왜 RSC에서는 dot notation이 안 될까

·2026년 4월 12일

Next

목록 보기
3/3
post-thumbnail

왜 RSC에서는 dot notation이 안 될까

— Object.assign에서 re-export로 변경한 이유

디자인 시스템을 만들면서, Object.assign 기반 compound export에서 정적 re-export namespace 방식으로 바꿨습니다.

기존에는 컴파운드 패턴을 따르는 컴포넌트의 경우 ComponentRootItem을 런타임에서 조합하여 <Component.Item /> 형태를 만들었습니다. 이 패턴은 CSR에서는 자연스럽지만, Next.js App Router(RSC) 환경에서 이 디자인 시스템을 사용하려고 하자 문제가 발생했습니다. RSC에서는 client component가 런타임 객체가 아니라 모듈 경로와 export 이름 기준의 식별자(모듈#export)로 다뤄지기 때문에 그대로 통하지 않는 것이었습니다.

먼저 팀이 실제로 채택한 해결책을 보여주고, 그 뒤에 왜 Next.js App Router와 RSC 구조상 Object.assign이 깨질 수밖에 없는지를 Next.js 내부 코드를 따라갈 예정입니다.

이 글의 내용은 아래 환경을 기준으로 작성했습니다.

  • 디자인 시스템 : Vite 7.2.4 / React 19
  • 사용처: Next.js 14.2.4 (Webpack) / 16.0.10 (Trubopack)

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의 반환 타입 추론은 제네릭 오버로드에 의존하는데, ForwardRefExoticComponent처럼 복잡한 타입과 합쳐지면 .Item 같은 프로퍼티가 타입에 반영되지 않는 경우가 있었습니다.
  • 런타임 RSC 에러는 서버가 client module에 dot access하는 것을 RSC 레이어가 막으면서 발생한 문제였습니다.

이 글에서는 RSC 런타임 에러의 원인과 해결을 중심으로 다룹니다.

팀의 해결법: Object.assign 제거

기존 구현은 전형적인 compound component 패턴이었습니다. ListRoot를 메인 컴포넌트로 두고, ListItemObject.assign으로 붙여 하나의 객체처럼 export했습니다.

// 기존 방식
const List = Object.assign(ListRoot, {
  Item: ListItem,
});

export { List };

사실, 이 방식은 자주 사용하던 CSR 환경에서는 자연스럽습니다. 브라우저가 최종 번들을 실행할 때 List.Item 프로퍼티가 실제 객체(List)에 연결되기 때문입니다. 서버가 따로 이 코드를 해석할 필요도 없습니다.

하지만 App Router의 RSC 환경에서는 client component를 서버에서 일반적인 런타임 객체처럼 다룰 수 없습니다. 서버는 이 값을 일반 JS 객체처럼 자유롭게 탐색하지 못하고, React/Next.js가 관리하는 client reference로 취급합니다. 따라서 List.Item처럼 런타임에 연결된 프로퍼티는 서버 기준으로 안정적으로 추적할 수 없고, dot notation 접근이 깨질 수 있습니다.

선택한 방법은 정말 단순합니다. 디자인시스템 내부에서 RootItem을 각각 정적으로 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 이름 단위의 client reference를 유지할 수 있었습니다. 즉 이 선택은 가장 이상적인 해결방법이라기보다, 당시 팀 규모와 개발 속도, 사용 편의성을 함께 고려한 결정이었습니다.

왜 re-export는 되고 Object.assign은 안 될까?

요약하면 RSC는 client component를 일반 런타임 객체로 다루지 않고, 모듈 경로와 export 이름으로 식별되는 client reference로 추적합니다.

이건 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';

여기서 RootItem은 모듈의 정적 export 선언에 들어갑니다. 서버 빌드 시 이 목록이 수집되어 각각에 대한 client reference가 생성됩니다. 즉 서버가 '이 모듈에는 Root라는 export가 있고, 그건 클라이언트에서 로드해야 한다'는 주소를 만들 수 있게 됩니다.

반대로 말하면, RSC 파이프라인이 이해할 수 있는 주소는 빌드 시 수집된 정적 export뿐입니다. Object.assign으로 런타임에 붙인 .Item은 이 어디에도 들어가지 않기 때문에, 서버 기준으로는 존재하지 않는 값이 됩니다.

선택한 방법에는 한 가지 별도 이슈도 있습니다. export * as List 방식은 RSC 호환성 측면에서는 실용적이지만, 번들 최적화 결과는 번들러와 배럴 구조에 따라 달라질 수 있습니다. 특히 Next.js는 barrel file에서 tree-shaking 최적화가 제한될 수 있다고 안내하고 있습니다.

팀에서 별도로 번들 사이즈 영향을 측정하지는 않았지만, 이론적으로 번들러가 namespace 객체 내부의 개별 바인딩을 제거하기 어려울 수 있다는 점은 인지하고 있었습니다. 즉 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를 실행해 RSC Payload(Flight 스트림) 생성
  5) RSC Payload에 "어느 client export가 필요한가"라는 참조 토큰이 들어감

Browser (Client)
  6) 브라우저가 RSC Payload를 읽고 참조 토큰을 발견
  7) manifest를 보고 필요한 chunk를 로드
  8) 로드된 client component로 해당 위치를 렌더링/하이드레이션

이 흐름에서 서버가 필요로 하는 정보는 '이 노드는 클라이언트에서 어느 모듈의 어느 export로 렌더링해야 하는가?'입니다.

2. next-flight-loader: 'use client' 파일을 참조로 변환

Next.js는 Webpack 기반 빌드에서 next-flight-loader'use client' 모듈을 처리합니다.

Webpack 경로에서 next-flight-loader는 client boundary의 메타정보에 들어 있는 clientRefs를 바탕으로, 서버 번들에서 각 export를 registerClientReference(..., resourceKey, exportName) 형태의 참조 코드로 바꿉니다. 이때 'use client'가 선언된 파일 자체에서 bare export *를 사용하면 에러가 나는데, 이는 팀이 사용한 export * as List와는 다릅니다 — 후자는 'use client'가 없는 barrel 파일에서의 namespace re-export이므로 이 제한에 해당하지 않습니다.

Turbopack에서는 ClientDirectiveTransformer라는 Rust 네이티브 트랜스폼이 같은 역할을 하며, 정적 export 단위로 참조를 만드는 원칙은 동일합니다.

ESM 모듈이라면 각 export를 registerClientReference()로 감싸는 식입니다. (next-flight-loader/index.ts)

import { registerClientReference } from "react-server-dom-webpack/server";

// registerClientReference(
//    placeholder,   // 서버에서 실수로 호출 시 에러를 던지는 더미 함수
//    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는 클라이언트에 있다"는 참조를 서버 번들에 남깁니다. 그리고 이때 clientRefs에 포함되는 대상은 어디까지나 정적 export 선언(export const, export function, export { X as Y })뿐입니다. Object.assign으로 런타임에 합성한 프로퍼티는 이 목록에 들어갈 수 없습니다.

3. manifest: 참조를 실제 chunk로 연결하는 맵

loader가 client reference를 만들었다면, 다음 단계는 그 참조를 실제 브라우저 로딩 정보와 연결하는 것입니다. 이 역할을 하는 것이 client reference manifest입니다.

Webpack 빌드에서는 ClientReferenceManifestPlugin이 이 manifest를 생성하며, client reference를 볼 때 클라이언트에서 어떤 JS chunk와 CSS를 로드해야 하는지를 매핑합니다.

Turbopack에서도 동일한 개념의 manifest가 존재하며, Rust 구조체(ClientReferenceManifest)로 구현되어 있습니다.

서버 렌더 단계에서 Next.js는 client reference manifest에서 client modules 정보를 꺼내 Flight stream renderer에 전달합니다. 서버는 client component를 직접 실행하지 않고, RSC Payload 안에 '이 위치에서는 이 client export가 필요하다'는 참조만 기록합니다.

4. Flight: 서버가 브라우저로 보내는 RSC Payload

요청 시점이 되면 서버는 Server Components를 실행해 RSC Payload(Flight 스트림)를 만듭니다. RSC Payload는 React Server Components 트리를 표현하는 직렬화된 데이터이며, 서버 컴포넌트의 결과와 Client Component 자리 표시자, 그리고 props 정보를 담습니다.

  • client component가 필요한 위치 표시
  • 해당 위치에서 사용할 모듈과 export 이름
  • manifest와 대조해 필요한 chunk 파일 정보 확인

브라우저는 RSC Payload를 읽고, 거기서 발견한 참조를 manifest와 대조한 뒤 필요한 chunk를 로드해 실제 client component를 합성합니다.

정리하면 이 흐름입니다.

  • loader: 참조 생성
  • manifest: 생성한 참조를 파일과 연결
  • RSC Payload: 참조를 브라우저로 전달
  • 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>

이 방식은 client component 내부에서 사용하는 한 문제가 없지만, 해당 barrel 파일에 'use client'가 빠져 있거나 사용자가 서버 컴포넌트에서 직접 import하는 순간 같은 dot notation 문제가 다시 발생합니다. 즉 dot notation 자체를 구조적으로 안전하게 만드는 방식은 아니며, 실제 사용자가 혼란을 겪을 수 있다고 판단했습니다.

팀이 택한 방식: 정적 re-export namespace

서론에서 말한 것과 같이 정적 export를 barrel에서 namespace처럼 다시 묶는 방식을 선택했습니다. 패키징 복잡도를 크게 늘리지 않으면서도 <List.Root />, <List.Item /> API를 유지할 수 있었고, 당시 팀 규모와 개발 속도를 고려했을 때 가장 실용적이었다고 판단합니다.

마무리

디자인시스템의 dot notation 문제를 통해서 RSC가 단순히 "서버 컴포넌트 / 클라이언트 컴포넌트"를 구분하는 시스템이 아니라, 모듈 경로와 export 이름으로 식별되는 client reference 체계로 동작한다는 것을 좀 더 자세하게 알게 되었습니다.

Next.js는 빌드 타임에 'use client' 모듈을 client reference로 치환하고, manifest로 그 참조를 실제 로딩 정보와 연결한 뒤, 요청 시 RSC Payload에 그 참조를 실어 보냅니다. 브라우저는 그 참조를 다시 해석해 실제 client component를 합성합니다.

그래서 정적 export로 주소가 잡히지 않는 런타임 합성은 서버에서는 알 수가 없습니다. Object.assign, 함수 프로퍼티 할당, dotting 기반 compound API가 App Router에서 깨지는 이유가 여기에 있습니다.

저희 팀의 경우에는 런타임 합성 대신 정적 export 기반으로 구조를 바꾸는 것이 실용적인 해결책이었습니다. re-export namespace를 채택해 기존 사용성을 유지하면서도 RSC 호환성 문제를 해결할 수 있었지만, 이것이 유일한 정답은 아닙니다. Next.js에는 namespace/compound components의 RSC 호환에 관한 이슈가 아직 열려 있으며, 팀의 규모와 구조, 번들 전략에 따라 다른 접근이 더 적합할 수도 있습니다.


참고 자료

profile
성실하게

0개의 댓글