Directive와 플랫폼 경계

I
Inkyu Oh

Front-End2025.11.24

TanStack 블로그 포스트 번역
2025년 10월 23일




JavaScript 생태계의 조용한 트렌드

수년 동안 JavaScript에는 정확히 하나의 의미 있는 directive가 있었습니다. 바로 "use strict"입니다. 이것은 표준화되어 있고, runtime에 의해 강제되며, 모든 환경에서 동일하게 동작합니다. 이는 언어, 엔진, 그리고 개발자 간의 명확한 계약을 나타냅니다.
하지만 이제 우리는 새로운 트렌드가 등장하는 것을 목격하고 있습니다. Framework들이 자체적인 top level directive를 만들어내고 있습니다. use client, use server, use cache, use workflow 등이 생태계 전반에 걸쳐 나타나고 있습니다. 이들은 언어 기능처럼 보입니다. 실제 언어 기능이 위치하는 곳에 자리 잡고 있습니다. 코드가 해석되고, 번들링되고, 실행되는 방식에 영향을 미칩니다.
여기에는 중요한 차이점이 있습니다: 이것들은 표준화된 JavaScript 기능이 아닙니다. Runtime은 이들을 이해하지 못하고, 관리하는 명세도 없으며, 각 framework는 자체적인 의미, 규칙, edge case를 자유롭게 정의할 수 있습니다.
이것은 오늘날 ergonomic하게 느껴질 수 있지만, 혼란을 증가시키고, 디버깅을 복잡하게 만들며, tooling과 이식성에 비용을 부과합니다. 우리가 이전에 본 패턴들입니다.



Directive가 플랫폼처럼 보일 때, 개발자들은 그것을 플랫폼처럼 취급합니다

파일 상단의 directive는 권위 있어 보입니다. 이것은 framework hint가 아닌 언어 수준의 진실이라는 인상을 줍니다. 이는 인식 문제를 만듭니다:
  • 개발자들은 directive가 공식적이라고 가정합니다
  • 생태계는 이들을 공유 API surface로 취급하기 시작합니다
  • 새로운 학습자들은 JavaScript와 framework magic을 구별하는 데 어려움을 겪습니다
  • 플랫폼과 vendor 간의 경계가 흐려집니다
  • 디버깅 가능성이 저하되고 tooling은 동작을 특별하게 처리해야 합니다
우리는 이미 혼란을 목격했습니다. 많은 개발자들이 이제 use clientuse server가 현대 JavaScript가 작동하는 방식이라고 믿고 있으며, 이들이 특정 build pipeline과 server component 의미론 내에서만 존재한다는 것을 인식하지 못합니다. 이러한 오해는 더 깊은 문제를 나타냅니다.



공로를 인정할 부분: use serveruse client

일부 directive는 여러 도구가 단일하고 간단한 조정 지점을 필요로 했기 때문에 존재합니다. 실제로 use serveruse client는 RSC 세계에서 코드가 실행될 수 있는 위치를 bundler와 runtime에 알려주는 실용적인 shim입니다. 이들은 bundler 전반에 걸쳐 비교적 광범위한 지원을 받았는데, 정확히 범위가 좁기 때문입니다: 실행 위치.
그렇긴 하지만, 이들조차도 실제 요구사항이 나타나면 directive의 한계를 보여줍니다. 규모가 커지면 정확성과 보안에 깊이 관련된 parameter와 policy가 필요한 경우가 많습니다: HTTP method, header, middleware, auth context, tracing, caching 동작 등. Directive는 이러한 옵션을 전달할 자연스러운 장소가 없으며, 이는 종종 무시되거나, 다른 곳에 덧붙여지거나, 새로운 directive 변형으로 다시 인코딩됨을 의미합니다.

Directive가 부담스러워지기 시작하는 지점: 옵션과 directive 인접 API

Directive가 생성 직후 또는 곧바로 옵션을 필요로 하거나 형제를 생성하는 경우(예: 'use cache:remote') 그리고 cacheLife(...)와 같은 helper 호출이 있다면, 이는 종종 해당 기능이 파일 상단의 문자열이 아닌 API가 되기를 원한다는 신호입니다. 어차피 함수가 필요하다는 것을 안다면, 모든 것에 대해 함수를 사용하세요.
예시:
'use cache:remote'
const fn = () => 'value'
// explicit API with provenance and options
import { cache } from 'next/cache'
export const fn = cache(() => 'value', {
strategy: 'remote',
ttl: 60,
})
그리고 세부사항이 중요한 server 동작의 경우:
import { server } from '@acme/runtime'

export const action = server(
async (req) => {
return new Response('ok')
},
{
method: 'POST',
headers: { 'x-foo': 'bar' },
middleware: [requireAuth()],
}
)
API는 provenance(import), versioning(package), composition(함수), 그리고 testability를 전달합니다. Directive는 일반적으로 그렇지 않으며, 옵션을 인코딩하려고 시도하면 빠르게 안좋은 설계가 될 수 있습니다.



공유 명세 없는 공유 구문은 취약한 기반이 될 수 있습니다

여러 framework가 directive를 채택하기 시작하면, 우리는 가능한 최악의 상태에 놓이게 됩니다:
카테고리
공유 구문
공유 계약
결과
ECMAScript
안정적이고 보편적
Framework API
격리되어 있고 괜찮음
Framework Directive
혼란스럽고 불안정
공유 정의 없는 공유 surface area는 다음을 만듭니다:
  • 해석 drift, 각 framework가 자체 의미론을 정의합니다
  • 이식성 문제, 보편적으로 보이지만 그렇지 않은 코드
  • Tooling 부담, bundler, linter, IDE가 동작을 추측하거나 쫓아야 합니다
  • 플랫폼 마찰, 표준 기구가 생태계 기대에 갇히게 됩니다
우리가 이전에 이러한 어려움을 겪은 예는 decorator입니다. TypeScript가 비표준 의미론을 정규화했고, 커뮤니티가 그 위에 구축했으며, 그 다음 TC39가 다른 방향으로 갔습니다. 이것은 많은 사람들에게 고통스러운 마이그레이션이었고 지금도 계속되고 있습니다.



"이것은 다른 구문을 가진 Babel plugin/macro일 뿐 아닌가요?"

기능적으로는 그렇습니다. Directive와 custom transform 모두 compile time에 동작을 변경할 수 있습니다. 문제는 능력이 아니라 surface와 광학입니다.
  • Directive는 플랫폼처럼 보입니다. import도 없고, 소유자도 없고, 명시적인 출처도 없습니다. "이것은 JavaScript입니다"라고 신호를 보냅니다.
  • API/macro는 소유자를 가리킵니다. Import는 provenance, versioning, 그리고 발견 가능성을 제공합니다.
기껏해야 directive는 파일 상단에서 window.useCache()와 같은 global, importless 함수를 호출하는 것과 동등합니다. 그것이 정확히 위험한 이유입니다: provider를 숨기고 framework 의미론을 언어처럼 보이는 것으로 이동시킵니다.
예시:
'use cache'
const fn = () => 'value'
// explicit API (imported, ownable, discoverable)
import { createServerFn } from '@acme/runtime'
export const fn = createServerFn(() => 'value')
// global magic (importless, hidden provider)
window.useCache()
const fn = () => 'value'
이것이 중요한 이유:
  • 소유권과 provenance: import는 누가 동작을 제공하는지 알려줍니다; directive는 그렇지 않습니다.
  • Tooling ergonomics: API는 package 공간에 존재합니다; directive는 생태계 전반의 특별 처리를 요구합니다.
  • 이식성과 마이그레이션: import된 API를 교체하는 것은 간단합니다; 파일 전반에 걸쳐 directive 의미론을 풀어내는 것은 비용이 많이 들고 모호합니다.
  • 교육과 기대: directive는 플랫폼 경계를 흐립니다; API는 경계를 명시적으로 만듭니다.
따라서 custom Babel plugin이나 macro가 동일한 기본 기능을 구현할 수 있지만, import 기반 API는 이를 명확하게 framework 공간에 유지합니다. Directive는 동일한 동작을 언어 공간처럼 보이는 곳으로 이동시키며, 이것이 이 글의 핵심 관심사입니다.

"네임스페이싱이 이를 해결하나요?" (예: "use next.js cache")

네임스페이싱은 사람의 발견 가능성을 돕지만, 핵심 문제를 해결하지는 못합니다:
  • 여전히 플랫폼처럼 보입니다. Top-level 문자열 리터럴은 library가 아닌 언어를 암시합니다.
  • 여전히 module 수준에서 provenance와 versioning이 부족합니다. Import는 둘 다 인코딩합니다; 문자열은 그렇지 않습니다.
  • 여전히 일반 import resolution을 활용하는 대신 toolchain(bundler, linter, IDE) 전반에 걸쳐 특별 처리가 필요합니다.
  • 여전히 명세 없이 구문의 pseudo-standardization을 장려하며, 단지 vendor prefix만 있을 뿐입니다.
  • 여전히 import된 API를 교체하는 것에 비해 마이그레이션 비용이 증가합니다.
예시:
'use next.js cache'
const fn = () => 'value'
// explicit, ownable API with provenance and versioning
import { cache } from 'next/cache'
export const fn = cache(() => 'value')
목표가 provenance라면, import가 이미 깔끔하게 해결하며 오늘날의 생태계와 작동합니다. 목표가 공유 cross-framework primitive라면, vendor 문자열이 아닌 실제 명세가 필요합니다.



Directive는 경쟁 역학을 주도할 수 있습니다

Directive가 경쟁 surface가 되면, 인센티브가 바뀝니다:
  1. 한 vendor가 새로운 directive를 출시합니다
  1. 이것이 눈에 보이는 기능이 됩니다
  1. 개발자들은 모든 곳에서 이를 기대합니다
  1. 다른 framework들이 이를 채택해야 한다는 압박을 느낍니다
  1. 구문이 명세 없이 퍼집니다
이것이 다음과 같은 결과를 낳는 방법입니다:
'use server'
'use client'
'use cache'
'use cache:remote'
'use workflow'
심지어 durable task, caching 전략, 실행 위치까지도 이제 directive로 인코딩되고 있습니다. 이것들은 runtime 의미론이지 구문 의미론이 아닙니다. 이들을 directive로 인코딩하는 것은 표준 프로세스 외부에서 방향을 설정하며 주의가 필요합니다.



옵션이 풍부한 기능에 대해 directive 대신 API 고려하기

Durable execution은 좋은 예입니다(예: 'use workflow', 'use step'), 하지만 요점은 일반적입니다: directive는 동작을 boolean으로 축소할 수 있지만, 많은 기능은 옵션과 진화할 여지로부터 이익을 얻습니다. Compiler와 transform은 어느 surface든 지원할 수 있습니다; 이것은 수명과 명확성을 위해 올바른 것을 선택하는 것에 관한 것입니다.
'use workflow'
'use step'
한 가지 옵션: provenance와 옵션을 가진 명시적 API:
import { workflow, step } from '@workflows/workflow'

export const sendEmail = workflow(
async (input) => {
/* ... */
},
{ retries: 3, timeout: '1m' }
)

export const handle = step(
'fetchUser',
async () => {
/* ... */
},
{ cache: 60 }
)
함수 형태는 directive만큼 AST/transform 친화적일 수 있으며, provenance(import)와 type-safety를 전달합니다.
또 다른 옵션은 global을 한 번 주입하고 타입을 지정하는 것입니다:
// bootstrap once
globalThis.workflow = createWorkflow()
// global types (e.g., global.d.ts)
declare global {
var workflow: typeof import('@workflows/workflow').workflow
}
사용법은 directive 없이 API 형태로 유지됩니다:
export const task = workflow(
async () => {
/* ... */
},
{ retries: 5 }
)
Ergonomics를 확장하는 compiler는 훌륭합니다. JSX를 보세요. 유용한 선례입니다! 우리는 단지 신중하고 책임감 있게 해야 합니다: 언어처럼 보이는 top-level 문자열이 아닌, 명확한 provenance와 타입을 가진 API를 통해 확장하세요. 이것들은 옵션이지 처방이 아닙니다.



미묘한 형태의 lock-in이 나타날 수 있습니다

나쁜 의도가 없더라도, directive는 설계상 lock-in을 만듭니다:
  • 정신적 lock-in, 개발자들은 vendor의 directive 의미론에 대한 근육 기억을 형성합니다
  • Tooling lock-in, IDE, bundler, compiler가 특정 runtime을 대상으로 해야 합니다
  • 코드 lock-in, directive는 구문 수준에 위치하여 제거하거나 마이그레이션하는 데 비용이 많이 듭니다
Directive는 독점적으로 보이지 않을 수 있지만, 생태계의 문법을 재구성하기 때문에 API보다 독점 기능처럼 동작할 수 있습니다.



공유 primitive를 원한다면, 명세와 API에 대해 협력해야 합니다

해결해야 할 실제 문제들이 분명히 있습니다:
  • Server 실행 경계
  • Streaming과 async workflow
  • 분산 runtime primitive
  • Durable task
  • Caching 의미론
하지만 이것들은 API, capability, 그리고 미래 표준을 위한 문제이지, bundler를 통해 밀어붙이는 관리되지 않는 pseudo 구문을 위한 문제가 아닙니다.
여러 framework가 진정으로 공유 primitive를 원한다면, 책임 있는 경로는:
  • Cross framework 명세에 대해 협력하기
  • 적절할 때 TC39에 primitive 제안하기
  • 비표준 기능을 언어 공간이 아닌 API 공간에 명확하게 범위 지정하기
Directive는 드물고, 안정적이며, 표준화되어야 하고, 특히 vendor 전반에 걸쳐 확산되기보다는 신중하게 사용되어야 합니다.



이것이 JSX/virtual DOM의 등장과 다른 이유

Directive에 대한 비판을 React의 JSX나 virtual DOM에 대한 초기 회의론과 비교하고 싶은 유혹이 있습니다. 실패 모드가 다릅니다. JSX와 VDOM은 언어 기능으로 가장하지 않았습니다; 명시적 import, provenance, tooling 경계와 함께 왔습니다. 반면 directive는 파일의 top-level에 존재하고 플랫폼처럼 보이며, 이는 공유 명세 없이 생태계 기대와 tooling 부담을 만듭니다.



결론

Framework directive는 오늘날 DX magic처럼 느껴질 수 있지만, 현재 트렌드는 표준이 아닌 도구에 의해 정의된 방언으로 구성된 더 분열된 미래를 위험에 빠뜨립니다.
우리는 더 명확한 경계를 목표로 할 수 있습니다.
Framework가 혁신하고 싶다면 그래야 하지만, 단기 채택을 위해 그 경계를 흐리는 대신 framework 동작플랫폼 의미론을 명확하게 구별해야 합니다. 더 명확한 경계는 생태계에 도움이 됩니다.

0
15

댓글

?

아직 댓글이 없습니다.

첫 번째 댓글을 작성해보세요!

Inkyu Oh님의 다른 글

더보기

유사한 내용의 글