JavaScript 생태계에서 프레임워크 지시문이 언어처럼 보이면서 생기는 혼란과 비용을 짚고, 지시문 대신 출처가 분명한 API와 명세 중심의 접근을 제안합니다. 옵션이 풍부한 기능, 도구 호환성, 이식성, 교육 관점에서 왜 API가 더 나은지 사례와 함께 설명합니다.
Tanner Linsley 작성, 2025년 10월 24일.
JavaScript 생태계의 조용한 흐름 ----------------------------------------- 수년간 JavaScript에는 의미 있는 지시문이 딱 하나뿐이었습니다. 바로 "use strict"입니다. 표준화되어 있고, 런타임이 강제하며, 모든 환경에서 동일하게 동작합니다. 언어, 엔진, 개발자 사이의 명확한 계약을 대표하죠.
하지만 이제 새로운 흐름이 나타나고 있습니다. 프레임워크들이 자체 최상위 지시문을 만들어내고 있습니다. use client, use server, use cache, use workflow 등등, 생태계 전반에 등장하고 있죠. 생김새는 언어 기능 같고, 실제 언어 기능이 자리하는 곳에 놓이며, 코드가 해석되고 번들되고 실행되는 방식에 영향을 줍니다.
중요한 차이가 있습니다. 이것들은 표준화된 JavaScript 기능이 아닙니다. 런타임은 이를 이해하지 못하고, 이를 관장하는 명세도 없으며, 각 프레임워크는 자신의 의미, 규칙, 엣지 케이스를 자유롭게 정의합니다.
이는 오늘은 인체공학적으로 느껴질 수 있지만, 동시에 혼란을 키우고, 디버깅을 복잡하게 만들며, 도구와 이식성에 비용을 부과합니다. 우리는 예전에 여러 번 보았던 패턴입니다.
### 지시문이 플랫폼처럼 보이면, 개발자는 플랫폼처럼 다룬다 파일 상단의 지시문은 권위 있어 보입니다. 프레임워크 힌트가 아니라 언어 차원의 진실처럼 보이게 하죠. 이로 인해 인식 문제가 생깁니다:
우리는 이미 혼란을 목격했습니다. 많은 개발자들이 이제 use client와 use server가 그저 현대 JavaScript의 방식이라고 믿습니다. 실제로는 특정 빌드 파이프라인과 서버 컴포넌트 의미론 안에서만 존재한다는 사실을 모른 채 말이죠. 그 오해는 더 깊은 문제를 신호합니다.
### 공로 인정: use server와 use client 일부 지시문은 여러 도구가 단일하고 단순한 조정 지점을 필요로 했기 때문에 존재합니다. 실제로 use server와 use client는 RSC 환경에서 코드가 어디에서 실행될 수 있는지를 번들러와 런타임에 알려주는 실용적인 얇은 계층(shim)입니다. 범위가 좁기 때문에, 즉 "실행 위치"라는 한정된 목적 덕분에 번들러 전반에서 비교적 널리 지원을 받았습니다.
그럼에도, 현실적인 요구가 나타나면 이들조차 지시문의 한계를 드러냅니다. 규모가 커지면 정합성과 보안에 깊이 관련된 매개변수와 정책이 자주 필요합니다: HTTP 메서드, 헤더, 미들웨어, 인증 컨텍스트, 트레이싱, 캐싱 동작 등. 지시문은 이러한 옵션을 담을 자연스러운 자리가 없습니다. 그래서 종종 무시되거나, 다른 곳에 덧대어 붙거나, 새로운 지시문 변형으로 다시 인코딩됩니다.
### 지시문이 버거워지는 지점: 옵션과 지시문 인접 API 지시문이 만들어지자마자 혹은 곧바로 옵션을 필요로 하거나, 'use cache:remote' 같은 형제 지시문과 cacheLife(...) 같은 보조 호출을 낳는다면, 이는 대개 그 기능이 파일 상단의 문자열이 아니라 API가 되길 원한다는 신호입니다. 어차피 함수가 필요하다는 걸 안다면, 전부 함수로 표현하세요.
예시:
js
'use cache:remote'
const fn = () => 'value'
'use cache:remote'
const fn = () => 'value'
js
// 출처와 옵션이 분명한 명시적 API
import { cache } from 'next/cache'
export const fn = cache(() => 'value', {
strategy: 'remote',
ttl: 60,
})
// 출처와 옵션이 분명한 명시적 API
import { cache } from 'next/cache'
export const fn = cache(() => 'value', {
strategy: 'remote',
ttl: 60,
})
그리고 세부가 중요한 서버 동작의 경우:
js
import { server } from '@acme/runtime'
export const action = server(
async (req) => {
return new Response('ok')
},
{
method: 'POST',
headers: { 'x-foo': 'bar' },
middleware: [requireAuth()],
}
)
import { server } from '@acme/runtime'
export const action = server(
async (req) => {
return new Response('ok')
},
{
method: 'POST',
headers: { 'x-foo': 'bar' },
middleware: [requireAuth()],
}
)
API는 출처(임포트), 버저닝(패키지), 합성(함수), 테스트 용이성을 지닙니다. 지시문은 대개 그렇지 않으며, 옵션을 지시문에 인코딩하려 들면 빠르게 설계 냄새가 나기 시작합니다.
### 공유 문법에 공유 명세가 없으면 토대가 취약해진다 여러 프레임워크가 지시문을 채택하기 시작하면, 최악의 상태에 도달합니다:
| 범주 | 공유 문법 | 공유 계약 | 결과 |
|---|---|---|---|
| ECMAScript | ✅ | ✅ | 안정적이고 보편적 |
| 프레임워크 API | ❌ | ❌ | 분리되어 있고 문제 없음 |
| 프레임워크 지시문 | ✅ | ❌ | 혼란스럽고 불안정 |
공유된 정의 없이 공유된 표면만 있으면 다음이 발생합니다:
우리가 이전에 겪었던 한 예는 데코레이터입니다. TypeScript가 비표준 의미론을 사실상 정규화했고, 커뮤니티가 그 위에 구축했지만, TC39는 다른 방향을 택했습니다. 이는 많은 이들에게 고통스러운 마이그레이션이었고 지금도 그렇습니다.
### “이거 그냥 문법만 다른 Babel 플러그인/매크로 아닌가요?” 기능적으로는 그렇습니다. 지시문도, 커스텀 트랜스폼도 컴파일 타임에 동작을 바꿀 수 있습니다. 문제는 능력이 아니라 표면과 외양입니다.
최선의 경우에도 지시문은 파일 맨 위에서 window.useCache() 같은 전역, 임포트 없는 함수를 호출하는 것과 동등합니다. 바로 그래서 위험합니다. 제공자를 숨기고, 프레임워크 의미론을 언어처럼 보이는 곳으로 옮기기 때문입니다.
예시:
js
'use cache'
const fn = () => 'value'
'use cache'
const fn = () => 'value'
js
// 명시적 API (임포트됨, 소유자 명확, 검색 가능)
import { createServerFn } from '@acme/runtime'
export const fn = createServerFn(() => 'value')
// 명시적 API (임포트됨, 소유자 명확, 검색 가능)
import { createServerFn } from '@acme/runtime'
export const fn = createServerFn(() => 'value')
js
// 전역 매직 (임포트 없음, 제공자 숨김)
window.useCache()
const fn = () => 'value'
// 전역 매직 (임포트 없음, 제공자 숨김)
window.useCache()
const fn = () => 'value'
왜 중요한가:
따라서 커스텀 Babel 플러그인이나 매크로가 동일한 기본 기능을 구현할 수는 있지만, 임포트 기반 API는 이를 분명히 프레임워크 영역에 둡니다. 지시문은 그 동일한 동작을 언어 영역처럼 보이는 곳으로 옮기며, 이 글의 핵심 우려가 바로 그것입니다.
### “네임스페이스를 붙이면 해결되나요?”(예: "use next.js cache") 네임스페이스는 인간이 알아보는 데 도움을 주지만, 핵심 문제를 해결하지는 못합니다:
예시:
js
'use next.js cache'
const fn = () => 'value'
'use next.js cache'
const fn = () => 'value'
js
// 출처와 버저닝이 분명한 명시적, 소유 가능한 API
import { cache } from 'next/cache'
export const fn = cache(() => 'value')
// 출처와 버저닝이 분명한 명시적, 소유 가능한 API
import { cache } from 'next/cache'
export const fn = cache(() => 'value')
목표가 출처 명시라면, 임포트가 이미 오늘날 생태계와 잘 작동하며 깔끔하게 해결해 줍니다. 목표가 프레임워크 간 공유 프리미티브라면, 벤더 문자열이 아니라 진짜 명세가 필요합니다.
### 지시문은 경쟁 구도를 촉발할 수 있다 지시문이 경쟁 표면이 되면, 인센티브가 바뀝니다:
이렇게 해서 다음과 같은 일이 벌어집니다:
tsx
'use server'
'use client'
'use cache'
'use cache:remote'
'use workflow'
'use streaming'
'use edge'
'use server'
'use client'
'use cache'
'use cache:remote'
'use workflow'
'use streaming'
'use edge'
지속 가능한 태스크, 캐싱 전략, 실행 위치 같은 것들조차 이제 지시문으로 인코딩되고 있습니다. 이는 문법의 의미론이 아니라 런타임 의미론입니다. 이를 지시문으로 인코딩하는 것은 표준 절차 바깥에서 방향을 정하는 것이며, 신중해야 합니다.
### 옵션이 풍부한 기능에는 지시문 대신 API를 고려하자 내구적 실행(durable execution)은 좋은 예입니다(예: 'use workflow', 'use step'). 하지만 요지는 일반적입니다. 지시문은 동작을 불리언으로 납작하게 만들 수 있지만, 많은 기능은 옵션과 진화 여지를 통해 더 큰 이점을 얻습니다. 컴파일러와 트랜스폼은 어느 표면이든 지원할 수 있습니다. 장기성과 명료성을 위해 올바른 표면을 고르는 문제가 중요합니다.
js
'use workflow'
'use step'
'use workflow'
'use step'
한 가지 선택지: 출처와 옵션이 분명한 명시적 API
js
import { workflow, step } from '@workflows/workflow'
export const sendEmail = workflow(
async (input) => {
/* ... */
},
{ retries: 3, timeout: '1m' }
)
export const handle = step(
'fetchUser',
async () => {
/* ... */
},
{ cache: 60 }
)
import { workflow, step } from '@workflows/workflow'
export const sendEmail = workflow(
async (input) => {
/* ... */
},
{ retries: 3, timeout: '1m' }
)
export const handle = step(
'fetchUser',
async () => {
/* ... */
},
{ cache: 60 }
)
함수 형태는 지시문만큼이나 AST/트랜스폼 친화적일 수 있으며, 출처(임포트)와 타입 안전성을 제공합니다.
또 다른 선택지는 전역을 한 번 주입하고 타입을 지정하는 것입니다:
ts
// 한 번 부트스트랩
globalThis.workflow = createWorkflow()
// 전역 타입(예: global.d.ts)
declare global {
var workflow: typeof import('@workflows/workflow').workflow
}
// 한 번 부트스트랩
globalThis.workflow = createWorkflow()
// 전역 타입(예: global.d.ts)
declare global {
var workflow: typeof import('@workflows/workflow').workflow
}
사용은 지시문 없이 API 형태를 유지합니다:
ts
export const task = workflow(
async () => {
/* ... */
},
{ retries: 5 }
)
export const task = workflow(
async () => {
/* ... */
},
{ retries: 5 }
)
편의성을 확장하는 컴파일러는 훌륭합니다. JSX가 좋은 선례죠! 다만 신중하고 책임감 있게 해야 합니다. 언어처럼 보이는 최상위 문자열이 아니라, 출처와 타입이 분명한 API로 확장합시다. 이는 처방이 아니라 선택지입니다.
### 미묘한 형태의 락인이 나타날 수 있다 악의적 의도가 없더라도, 지시문은 설계상 락인을 만들어냅니다:
지시문은 겉보기엔 독점적이지 않을 수 있지만, 생태계의 문법을 재구성하기 때문에 API보다 더 독점적인 기능처럼 행동할 수 있습니다.
### 공유 프리미티브가 필요하다면, 명세와 API로 협력하자 해결해야 할 실제 문제가 분명히 존재합니다:
하지만 이는 "통제되지 않은 유사 문법을 번들러로 밀어 넣는 것"이 아니라, 바로 "API, 역량, 그리고 미래 표준"의 문제입니다.
여러 프레임워크가 진정으로 공유 프리미티브를 원한다면, 책임 있는 경로는 다음과 같습니다:
지시문은 드물고, 안정적이며, 표준화되어야 하고, 특히 벤더 전반에 마구 늘려 쓰기보다 신중히 사용되어야 합니다.
### JSX/가상 DOM 시점과 무엇이 다른가 지시문에 대한 비판을 React의 초기 JSX나 가상 DOM에 대한 회의와 비교하고 싶을 수 있습니다. 실패 양상은 다릅니다. JSX와 VDOM은 언어 기능인 척하지 않았습니다. 명시적 임포트, 출처, 도구 경계를 갖고 왔죠. 반대로 지시문은 파일 최상위에 존재하고 플랫폼처럼 보이기 때문에, 공유 명세 없이도 생태계의 기대와 도구 부담을 만들어냅니다.
### 결론 프레임워크 지시문은 오늘은 DX 매직처럼 느껴질 수 있지만, 현재의 흐름은 표준이 아닌 도구가 정의한 방언으로 구성된 더욱 파편화된 미래로 이어질 위험이 있습니다.
우리는 더 명확한 경계를 지향할 수 있습니다.
프레임워크가 혁신하길 원한다면, 그래야 합니다. 다만 단기 채택을 위해 그 경계를 흐리기보다, 플랫폼 의미론과 프레임워크 동작을 명확히 구분해야 합니다. 더 명확한 경계는 생태계에 도움이 됩니다.