Frontend Architecture
왜 RSC에서는 dot notation이 안 될까
왜 RSC에서는 dot notation이 안 될까
— Object.assign을 버리고 re-export를 택한 이유
디자인 시스템을 만들면서, Object.assign 기반 compound export를 버리고 정적 re-export namespace 방식으로 바꿨습니다.
기존에는 컴파운드 패턴을 따르는 컴포넌트의 경우 ComponentRoot에 Item을 런타임에서 조합하여 <Component.Item /> 형태를 만들었습니다. 이 패턴은 CSR에서는 자연스럽지만, Next.js App Router(RSC) 환경에서 이 디자인 시스템을 사용하려고 하자 문제가 터졌습니다. RSC에서는 client component가 런타임 객체가 아니라 “모듈#export” 기준의 참조로 다뤄지기 때문에 그대로 통하지 않는 것이었습니다.
먼저 팀이 실제로 채택한 해결책을 보여주고, 그 뒤에 왜 Next.js App Router와 RSC 구조상 Object.assign이 깨질 수밖에 없는지를 Next.js 내부 코드를 따라갈 예정입니다.
App Router에서 Compound API 깨짐
디자인 시스템 컴포넌트를 compound pattern으로 export했는데, Next.js App Router 환경에서 사용하려고 하자 두 가지 다른 종류의 에러를 만났습니다.
첫 번째: TypeScript 정적 에러
import { List } from '@design-system';
<List.Root titleText="리스트 제목">
<List.Item primaryText="리스트 아이템 1" />
</List.Root>;
// Property 'Item' does not exist on type
// 'ForwardRefExoticComponent<ListProps & RefAttributes<HTMLUListElement>>
두 번째: 런타임 에러
You cannot dot into a client module from a server component.
You can only pass the imported name through.
얼핏 같은 문제처럼 보이지만, 원인이 다르고 발생 시점도 다릅니다.
- TypeScript 에러는 선언 파일 생성/패키징 문제입니다.
Object.assign기반 compound API는 빌드 결과의.d.ts에서.Item같은 프로퍼티 타입이 누락된 상태 - 런타임 RSC 에러는 서버가 client module에 dot access하는 것을 RSC 레이어가 막는 문제.
RSC 런타임 에러의 원인과 해결을 중심으로 보았습니다.
팀의 해결 법: Object.assign 제거
기존 구현은 전형적인 compound component 패턴이었습니다. ListRoot를 메인 컴포넌트로 두고, ListItem을 Object.assign으로 붙여 하나의 객체처럼 export했습니다.
// 기존 방식
const List = Object.assign(ListRoot, {
Item: ListItem,
});
export { List };
사실, 이 방식은 자주 사용하던 CSR 환경에서는 자연스럽습니다. 브라우저가 최종 번들을 실행할 때 List.Item 프로퍼티가 실제 객체(List)에 연결되기 때문입니다. 서버가 따로 이 코드를 해석할 필요도 없습니다.
하지만 App Router의 RSC 환경에서는 client component가 런타임 객체 자체로 서버에 전달되지 않습니다. 서버는 이 실제 값을 비워두고 ‘실행 가능한 객체’가 아니라 ’클라이언트에서 나중에 로드할 참조’ 로만 다룹니다. 그래서 List.Item처럼 런타임에 연결된 프로퍼티는 서버 기준으로 추적할 수 없고, 결국 dotnotiation을 읽을 수 없게 됩니다.
선택한 방법은 정말 단순합니다. 디자인시스템 내부에서 Root와 Item을 각각 정적으로 export하고, barrel에서 다시 namespace처럼 묶었습니다.
// src/components/List/index.ts
export { ListRoot as Root } from './List';
export { ListItem as Item } from './ListItem';
// src/components/index.ts
export * as List from './List';
이 구조에서 List.Root, List.Item은 더 이상 런타임 객체 합성으로 이루지지 않습니다. 각각 정적 export이기 때문에, RSC 입장에서도 추적 가능한 주소를 갖게 됩니다. 디자인시스템을 사용처에서는 <List.Root />, <List.Item />을 유지하면서도, App Router가 이해할 수 있는 형태로 API를 다시 설계한 것입니다.
해당 방식 채택 이유
해결 방법은 하나만 있는 건 아니었습니다. react-router처럼 RSC에 더 최적화된 방향으로 server/client 엔트리를 세밀하게 분리하는 방법도 있었고, client 전용 API를 별도로 두는 방식도 가능했습니다.
다만 팀의 디자인 시스템을 다시 새로 구축하는 시기 이기 때문에 규모가 아직 크지 않았고, App Router 환경에서도 바로 쓸 수 있도록 빠르게 대응하면서 기존의 <List.Root />, <List.Item /> 사용성을 최대한 유지하는 것이 더 중요하게 여겼습니다.
그래서 팀과 협의하여 런타임 객체 합성(Object.assign) 대신, 정적 export를 다시 묶는 re-export 방식을 채택했습니다. 이 방식은 패키지 구조를 크게 복잡하게 만들지 않으면서도, RSC가 이해할 수 있는 "모듈#export" 형태를 유지할 수 있었습니다. 즉 이 선택은 가장 이상적인 이론 해법이라기보다, 당시 팀 규모와 개발 속도, 사용 편의성을 함께 고려한 가장 실용적인 결정이었습니다.
왜 re-export는 되고 Object.assign은 안 될까?
요악하면 RSC는 client component를 객체로 다루지 않고, “모듈#export” 주소로만 추적합니다**.**
이건 Next.js만의 제한은 아닙니다. 'use client'가 모듈 의존성 트리에서 server/client 경계를 만들고, 그 경계를 넘나드는 것은 ‘코드’가 아니라 ‘참조’가 된다는 점이 React Server Components 구조 자체의 제약입니다.
CSR에서는 Object.assign(ListRoot, { Item: ListItem })은 자연스럽습니다. 브라우저가 JS 번들을 통째로 실행하기 때문에, 런타임에 붙인 .Item도 그대로 접근 가능합니다.
하지만 App Router에서 'use client'를 붙이는 순간 규칙이 바뀝니다. 서버에서 모듈을 import할 때 가져오는 것은 구현이 아니라 참조이기 때문에 Object.assign으로 붙인 프로퍼티는 서버에서 주소를 만들고 접근할 수 없는 값이 됩니다.
반면 정적 re-export는 다릅니다.
export { ListRoot as Root } from './List';
export { ListItem as Item } from './ListItem';
여기서 Root와 Item은 모듈의 정적 export 선언에 들어갑니다. next-flight-loader는 이 목록을 파싱으로 수집하는데, 이렇게 수집된 export 이름 목록을 clientRefs라고 합니다. 그리고 이 clientRefs를 순회하면서 각각에 대한 client reference를 생성합니다. 즉 서버가 ‘이 모듈에는 Root라는 export가 있고, 그건 클라이언트에서 로드해야 한다’는 주소를 만들 수 있게 되는 것입니다. 반대로 말하면, RSC 파이프라인이 이해할 수 있는 주소는 next-flight-loader가 수집한 정적 export와 manifest가 매핑한 "모듈#export"뿐입니다. Object.assign으로 런타임에 붙인 .Item은 이 어디에도 들어가지 않기 때문에, 서버 기준으로는 존재하지 않는 값이 됩니다.
선택한 방법에는 한 가지 별도 이슈도 있습니다. export * as List는 RSC 직렬화 문제는 해결하지만, 트리셰이킹 관점에서는 namespace 접근(List.Root)이 개별 named import보다 불리할 수 있습니다. 즉 RSC 안정성과 번들 최적화는 별개의 문제입니다. 우리 팀은 디자인시스템이라는 특수성과 당시 사용성과 개발 비용을 우선했고, 그 결과 re-export namespace를 채택했습니다.
Next.js 내부에서 실제로 일어나는 일
‘왜 정적 export만 가능한가’를 Next.js 내부 흐름으로 봅니다.
1. 전체 프로세스 요약
**Build time**
1) 'use client' 경계를 기준으로 모듈 그래프를 server/client로 분리
2) server 번들에서는 client module의 export를 "참조(Client Reference)"로 치환
3) client reference -> chunk 로딩 정보로 연결한 manifest 생성
**Request time (Server)**
4) 서버가 Server Components를 실행해 Flight 스트림 생성
5) Flight 스트림에 "어느 client export가 필요한가"라는 참조 토큰이 들어감
**Browser (Client)**
6) 브라우저가 Flight를 읽고 참조 토큰을 발견
7) manifest를 보고 필요한 chunk를 로드
8) 로드된 client component로 해당 위치를 렌더링/하이드레이션
이 흐름에서 서버가 필요로 하는 정보는 ‘이 노드는 클라이언트에서 어느 모듈의 어느 export로 렌더링해야 하는가?’ 입니다.
이때의 주소 체계는 처음부터 끝까지 "모듈#export" 단위입니다.
2. next-flight-loader: 'use client' 파일을 참조로 변환
Next.js는 Webpack 기반 빌드에서 next-flight-loader로 'use client' 모듈을 처리합니다. 이 로더는 모듈의 소스를 분석해 정적 export 목록을 수집하고, 서버 번들에서는 원래 구현 대신 client reference stub을 생성합니다.
Turbopack에서는
ClientDirectiveTransformer라는 Rust 네이티브 트랜스폼이 같은 역할을 하며, 정적 export 단위로 참조를 만드는 원칙은 동일합니다.
ESM 모듈이라면 각 export를 registerClientReference()로 감싸는 식입니다. (next-flight-loader/index.ts)
import { registerClientReference } from "react-server-dom-webpack/server";
// registerClientReference(
// proxyImplementation,
// id, // 어떤 모듈인가
// exportName) // 그 모듈의 어떤 export인가
export const Root = registerClientReference(
function() { throw new Error("Attempted to call Root() from the server..."); },
"/path/to/List/index.ts",
"Root",
);
export const Item = registerClientReference(
function() { throw new Error("Attempted to call Item() from the server..."); },
"/path/to/List/index.ts",
"Item",
);
이 코드에서 중요한 건 함수 본문이 아니라 뒤의 두 값입니다.
- 어떤 모듈인가
- 그 모듈의 어떤 export인가
즉 next-flight-loader는 client component의 실제 구현이 아니라, “이 export는 클라이언트에 있다”는 참조를 서버 번들에 남깁니다. 그리고 이때 수집 대상은 어디까지나 정적 export 선언(export const, export function, export { X as Y })뿐이다. Object.assign으로 런타임에 합성한 프로퍼티는 이 목록에 들어갈 수 없습니다.
3. manifest: 참조를 실제 chunk로 연결하는 맵
loader가 client reference를 만들었다면, 다음 단계는 그 참조를 실제 브라우저 로딩 정보와 연결하는 것입니다. 이 역할을 하는 것이 client reference manifest입니다.
- client reference를 볼 때, 클라이언트에서 어떤 JS chunk와 CSS를 로드해야 하는지
서버 렌더 단계에서 Next.js는 이 manifest를 가져와 renderToFlightStream(...)에 넘깁니다. 서버는 client component를 직접 실행하지 않고, Flight 스트림 안에 ‘이 위치에서는 이 client export가 필요하다’는 참조만 기록합니다.
4. Flight: 서버가 브라우저로 보내는 RSC payload
요청 시점이 되면 서버는 Server Components를 실행해 Flight 스트림을 만듭니다. 이 스트림에는 완성된 HTML만 들어가는 것이 아니라, 중간중간 client reference도 함께 들어갑니다.
- client component 자리 여부
- 모듈에 따른 export 사용
- manifest로 필요한 파일 정보 확인
브라우저는 Flight를 읽고, 거기서 발견한 참조를 manifest와 대조한 뒤 필요한 chunk를 로드해 실제 client component를 합성합니다.
정리하면 이 흐름입니다.
loader: 참조 생성manifest: 생성한 참조를 파일과 연결Flight: 참조를 브라우저로 전달client: 전달 받은 참조를 실제 컴포넌트로 복원
5. "You cannot dot into a client module"은 어디서 온걸까?
Next.js가 사용하는 React Server DOM의 client module proxy 경로에서 나옵니다. Next.js는 module-proxy 경로를 통해 이 proxy를 사용하고, 서버가 client module을 객체처럼 탐색하려 할 때 dot notation 연결를 막습니다.
이 에러 메시지는 아래의 정보를 전달해 줍니다.
- 서버가 받은 client module은 실제 값이 아니라 참조 값
- 참조 값은 import 바인딩, 즉 정적 export 단위로만 추적 가능
.Item같은 dot 접근은 참조 내부를 객체처럼 탐색하는 런타임 동작이므로 사용 불가
에러 메시지는 구현체마다 다를 수 있어도, 제약 자체는 RSC 구조에서 옵니다.
고려했던 다른 해결 방법
참고로 Object.assign뿐 아니라 Card.Header = Header 같은 함수 직접 할당 패턴도 본질적으로 같습니다. 런타임에 프로퍼티를 붙이는 방식은 모두 RSC에서 문제가 될 수 있습니다.
방법 1: server/client 엔트리 분리
server-safe 컴포넌트와 client-only 컴포넌트를 별도 엔트리로 나누는 방식입니다.
react-router에서 자주봐왔던 방식으로 꽤나 친근합니다.
// Server Component
import { ListRoot } from '@langdy/design-system';
// Client Component
import { ListItem } from '@langdy/design-system/list/client';
RSC 최적화 측면에서는 가장 이상적이지만, import 경로와 설계가 복잡해지고 DX가 떨어질 수 있다고 판단했습니다.
방법 2: 전체를 client 전용 API로 묶기
client component 안에서만 사용할 수 있는 API를 별도로 두는 방식입니다.
import { ListUI } from '@langdy/design-system/list/list-ui';
<ListUI.Root>
<ListUI.Item />
</ListUI.Root>
서버에서 직접 import하는 순간 같은 문제가 다시 생깁니다. 즉 dot notation 자체를 완전히 안전하게 만드는 방식은 아니며, 실제 사용자가 혼란을 겪을 수 있다고 판단했습니다.
팀이 택한 방식: 정적 re-export namespace
서론에 말한 것과 같이 정적 export를 barrel에서 namespace처럼 다시 묶는 방식을 선택했습니다. 패키징 복잡도를 크게 늘리지 않으면서도 <List.Root />, <List.Item /> API를 유지할 수 있었고, 당시 팀 규모와 개발 속도를 고려했을 때 가장 실용적이었다고 판단합니다.
마무리
디자인시스템의 dot notation을 통해서 RSC는 “서버 컴포넌트 / 클라이언트 컴포넌트”를 구분하는 시스템을 넘어서 "모듈#export" 주소 체계로 동작하는 시스템인 것을 좀 더 자세하게 알게되었습니다.
Next.js는 빌드 타임에 'use client' 모듈을 client reference로 치환하고, manifest로 그 참조를 실제 로딩 정보와 연결한 뒤, 요청 시 Flight 스트림에 그 참조를 실어 보냅니다. 브라우저는 그 참조를 다시 해석해 실제 client component를 합성합니다.
그래서 정적 export로 주소가 잡히지 않는 런타임 합성은 서버에서는 알 수가 없습니다. Object.assign, 함수 프로퍼티 할당, dotting 기반 compound API가 App Router에서 깨지는 이유가 여기에 있습니다.
App Router에서 compound component pattern을 쓰고 싶다면, 핵심은 객체 합성이 아니라 정적 export 을 사용해야 합니다. 우리 팀은 그 기준에 맞춰 re-export namespace를 채택했고, 기존 사용성을 유지하면서도 문제를 해결할 수 있었습니다.
참고 자료
- Next.js — Server and Client Components
- React —
'use client' - Dan Abramov — How Imports Work in RSC
- Next.js
next-flight-loader - Next.js
module-proxy.ts - Next.js
app-render.tsx - Next.js Issue #51593 — Dot notation client component breaks RSC
- Next.js Issue #75192 — Namespace compound components in RSC
- React 19 —
react-serverexport condition - isBatak — Multipart Namespace Components: Addressing RSC and Dot Notation Issues
왜 RSC에서는 dot notation이 안 될까
최종