Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Compound Component를 export하는 세 가지 방법 #987

SimYunSup started this conversation in 블로그
Discussion options

SimYunSup
Apr 19, 2026
Collaborator Sponsor

달레 UI의 Card 컴포넌트는 Object.assign 기반의 compound component 패턴을 사용하고 있었습니다. RSC 지원을 검토하면서 이 패턴이 RSC 환경에서 동작하지 않는다는 걸 발견했고, namespace 패턴으로 전환하면 해결될 거라 판단해서 이슈를 올리고 PR까지 작성했습니다. 기여자로 합류한 지 얼마 되지 않은 시점이었고, 기존에 어떤 설계 결정들이 쌓여왔는지 맥락을 충분히 파악하기 전이었습니다.

그런데 파고들수록 이야기가 달라졌습니다. namespace 패턴으로 바꾸면 API가 breaking change가 되고, Context를 쓰는 이상 RSC 이점은 제한적이고, tree-shaking도 dot-notation을 유지하는 한 보장되지 않았습니다. 하나를 고치면 다른 곳에서 문제점이 튀어나오는, 전형적인 트레이드오프 문제였습니다.

팀과 함께 여러 층위의 트레이드오프를 논의한 과정을 공유합니다.

문제 발견: Object.assign 기반 compound component가 RSC에서 동작하지 않는다

달레UI의 Card 컴포넌트는 아래와 같은 방식으로 compound component 패턴을 구현하고 있었습니다.

export const Card = Object.assign(Root, {
 Header,
 Body,
 Footer,
});

사용하는 쪽에서는 <Card>, <Card.Header />, <Card.Body /> 같은 깔끔한 dot-notation API를 쓸 수 있습니다. React 생태계에서 흔히 볼 수 있는 패턴이고, DX도 좋습니다.

그런데 이 패턴은 React Server Components(RSC) 환경에서 정상 동작하지 않습니다. 이슈와 PR을 작성하면서, 왜 깨지는지와 어떤 해결책이 있는지를 본격적으로 파악하게 되었습니다.

기술 분석: 파고들수록 복잡해지는 문제

Object.assign과 모듈 그래프의 충돌

RSC에서 Object.assign 패턴이 깨지는 근본 원인을 이해하려면, 번들러가 'use client'를 어떻게 처리하는지를 먼저 알아야 합니다.

RSC 환경에서 번들러는 모든 모듈의 의존 관계를 모듈 그래프(module graph) 로 구성합니다. 'use client' 디렉티브는 이 그래프 위에서 서버/클라이언트의 경계(boundary) 를 선언하는 역할을 합니다. 어떤 파일에 'use client'가 붙으면, 해당 파일과 그 파일이 import하는 모든 하위 모듈은 클라이언트 번들에 포함됩니다. 이 판별은 빌드 타임에 정적으로 이루어져야 합니다.

그런데 Object.assign은 런타임에 객체를 합성하는 동적 연산입니다. 번들러가 모듈 그래프를 구성하는 시점에서는 Card라는 객체에 어떤 프로퍼티가 붙어있는지, 각 프로퍼티가 어떤 모듈에서 온 것인지를 추적할 수 없습니다. import/export 구문이 아닌 런타임 코드로 연결되어 있기 때문에, 모듈 그래프 상에서 해당 연결이 보이지 않는 것입니다.

// 번들러의 모듈 그래프에서 Header, Body, Footer와 Card의 관계가 보이지 않음
export const Card = Object.assign(Root, {
 Header,
 Body,
 Footer,
});

같은 이유로 함수에 직접 프로퍼티를 할당하는 패턴(Card.Header = function Header() {})도 동일한 문제를 가집니다. 이 문제는 Next.js에서 실제로 보고되었고(#51593, #58776), 여러 디자인 시스템 라이브러리에서 공통적으로 나타나는 문제입니다.

해결책: namespace 패턴

Object.assign 없이도 dot-notation을 제공하는 방법이 있습니다. namespace 패턴입니다. 구현체를 별도 파일에 분리하고, namespace 파일에서 정적 re-export로 묶는 구조입니다.

// Card.part.tsx - 개별 컴포넌트 구현
export function CardRoot(props: CardRootProps) { /* ... */ }
export function CardHeader(props: CardHeaderProps) { /* ... */ }
export function CardBody(props: CardBodyProps) { /* ... */ }
// namespace.ts - dot-notation을 위한 namespace 구성
export {
 CardRoot as Root,
 CardHeader as Header,
 CardBody as Body,
} from "./Card.part";
// index.ts - 최종 export
export * as Card from "./namespace";

모든 import/export가 ESM의 정적 구문이기 때문에, 번들러가 모듈 그래프 상에서 각 컴포넌트의 경계를 정확히 추적할 수 있습니다. 'use client'의 전파도 모듈 단위로 올바르게 동작합니다.

여기까지만 보면 깔끔한 해결책 같지만, 실제로 적용하려 하면 세 가지 문제점이 하나씩 드러납니다. 처음에는 "namespace 패턴으로 바꾸면 끝"이라고 생각했는데, 문제점을 하나씩 발견할 때마다 문제의 성격이 달라졌습니다.

문제점 1: API가 바뀐다 - <Card>에서 <Card.Root>

Object.assign 방식에서는 Card 자체가 루트 컴포넌트였습니다. 하지만 namespace 방식에서 Card는 하위 컴포넌트들을 담는 객체이므로, 루트도 그 안의 한 멤버(Root)가 됩니다.

// Object.assign 방식 - Card 자체가 루트
<Card>
 <Card.Header />
</Card>
// namespace 방식 - Card는 namespace, Root가 루트
<Card.Root>
 <Card.Header />
</Card.Root>

이는 소비자 입장에서 breaking change 입니다. 실제로 Chakra UI v3, Base UI는 처음부터 이 변화를 수용하여 <Card.Root>, <Dialog.Root> 형태의 API를 사용합니다. namespace 패턴을 채택한다면 이 API 변경을 받아들일 것인지, 기존 <Card>를 유지할 방법을 찾을 것인지를 결정해야 합니다.

문제점 2: Context를 쓰면 RSC 이점이 제한적이다

namespace 패턴으로 전환하면 RSC 호환이 된다고 생각하고 PR을 올렸는데, 한 발 더 들어가보니 이야기가 달랐습니다.

Compound component 패턴의 핵심 가치는 부모와 자식 컴포넌트 간의 암묵적 상태 공유입니다. <RadioGroup>이 선택 상태를 <RadioGroup.Item>에 전달하거나, <Accordion>이 열린 패널 정보를 <Accordion.Content>에 전달하는 것이 대표적이며, React에서 이를 구현하는 표준 방법이 Context API입니다.

문제는 createContextuseContext클라이언트 전용 API라는 점입니다. Context를 사용하는 순간 해당 컴포넌트는 'use client'를 선언해야 하고, 서버 컴포넌트로 동작할 수 없습니다.

즉, namespace 패턴으로 전환해서 모듈 그래프 수준의 정적 분석이 가능해지더라도, Context를 사용하는 compound component는 결국 클라이언트 컴포넌트로 동작합니다. RSC 호환성의 실질적 이점은 Context를 사용하지 않는 순수 조합형 컴포넌트(예: 단순 레이아웃용 Card)에서만 발휘됩니다. "모듈 그래프 레벨에서는 문제를 풀었지만, 런타임 레벨에서는 여전히 클라이언트"라는 걸 깨닫는 데 시간이 걸렸습니다.

실제로 많은 UI 컴포넌트 라이브러리들이 대부분의 컴포넌트에 'use client'를 선언하는 것도 이 때문입니다. 상태를 공유하는 compound component라면 어떤 디자인 시스템에서든 마주하게 되는 본질적 제약입니다.

문제점 3: Tree-shaking도 보장되지 않는다

namespace 패턴의 또 다른 기대 효과는 tree-shaking이었습니다. 각 컴포넌트가 named export로 분리되니, 사용하지 않는 컴포넌트는 번들에서 제거될 거라는 기대였죠.

그런데 iIvica Batinić의 분석에 따르면, dot-notation(namespace) 문법을 사용하는 한 Webpack에서는 tree-shaking이 동작하지 않는 것으로 확인됩니다. import { Card } from './card'<Card.Body />만 사용하더라도, Card namespace 아래의 모든 컴포넌트가 번들에 포함됩니다. (Rollup 등 다른 번들러에서는 상황이 다를 수 있지만, Webpack 기반 프로젝트가 많은 현실을 감안하면 무시하기 어려운 제약입니다.)

tree-shaking의 이점을 실제로 얻으려면 dot-notation을 포기하고 import { CardBody } from './card'처럼 개별 named import를 사용해야 합니다. dot-notation의 DX와 tree-shaking은 현재 번들러 환경에서 양립하기 어려운 것입니다. 디자인 시스템의 규모가 커질수록 이 제약은 더 무거워질 수 있기 때문에, named export를 함께 제공하는 것은 미래를 위한 투자이기도 합니다.

라이브러리들은 이 문제점을 어떻게 감수하고 있는가

이 세 가지 문제점을 어떻게 다루느냐에 따라 라이브러리들의 선택이 갈립니다.

Chakra UI v3, Radix Primitives, Base UI, Ark UI는 대부분의 컴포넌트가 클라이언트 전용이므로 RSC 제약(문제점 2)을 인정하고, 각 파일이나 index.ts'use client'를 직접 선언합니다. 반면 Headless UI, Ant Design, Mantine은 아직 Object.assign 패턴을 유지하고 있습니다.

결국 namespace 패턴으로 전환하더라도, API는 breaking change가 되고, RSC 호환성은 Context 의존성 때문에 제한적이고, tree-shaking은 dot-notation을 유지하는 한 Webpack에서 동작하지 않습니다. 문제를 파고들수록 "어느 하나를 깔끔하게 해결하면 끝"이 아니라, 서로 얽힌 트레이드오프를 어디서 끊을지를 결정해야 하는 상황이었습니다.

이 기술적 배경을 바탕으로, 팀에서는 두 가지를 결정해야 했습니다.

의사결정 1: <Card> vs <Card.Root>

먼저 namespace 패턴을 적용할 경우 피할 수 없는 API 변경 문제를 다뤄야 했습니다. PR에서 <Card><Card.Root>로 변경하는 것으로 구현했지만, 리뷰 과정에서 기존에 이미 합의된 설계 결정이 있었다는 것을 알게 되었습니다(PR #80).

.Root를 붙이지 않기로 한 이유는 실용적이었습니다. 추후 Nested compound pattern을 제공하게 되면 <Card.Root>, <Card.BodyRoot> 같은 식으로 "Root"가 반복적으로 붙게 되어 API가 지저분해진다는 것이었습니다.

// Root를 붙이는 경우 - Nested compound pattern에서 지저분해짐
<Card.Root>
 <Card.Header />
 <Card.Body.Root>
 <Card.Body.Content />
 <Card.Body.Footer />
 </Card.Body.Root>
</Card.Root>
// Root를 붙이지 않는 경우
<Card>
 <Card.Header />
 <Card.Body>
 <Card.Body.Content />
 <Card.Body.Footer />
 </Card.Body>
</Card>

팀은 사용자 경험을 우선한다는 원칙 아래, <Card>를 최상위로 유지하기로 했습니다. 다만 이 결정에는 기술적 함의가 있습니다. <Card>를 컴포넌트로 직접 렌더링하려면 Card가 호출 가능한 함수여야 하는데, namespace 패턴의 Card는 모듈 객체이지 함수가 아닙니다. 결국 <Card>를 유지하는 한 Object.assign이나 직접 프로퍼티 할당 같은 런타임 바인딩이 필수적이며, 이는 다음 단계인 export 방식 선택에도 영향을 미치는 제약이 됩니다.

의사결정 2: export 방식을 어떻게 할 것인가

dot-notation을 유지할 것인가, named export로 전환할 것인가, 아니면 둘 다 제공할 것인가. 팀 앞에 세 가지 선택지가 놓였습니다.

선택지 1: dot-notation + named export 병행 제공

// dot-notation 방식 - DX 우선
<Card>
 <Card.Header />
 <Card.Body />
</Card>
// named import 방식 - RSC 호환 + tree-shaking
<CardRoot>
 <CardHeader />
 <CardBody />
</CardRoot>

두 가지를 함께 제공하면 모든 사용자를 배려할 수 있습니다. dot-notation으로 자동완성과 생산성을 원하는 사용자, RSC 호환이나 tree-shaking이 필요한 프로젝트 모두 만족시킬 수 있고, 실제로 많은 Headless Component 라이브러리들이 이 전략을 취하고 있어 검증된 접근이기도 합니다. named import 경로를 통해 RSC 환경에서의 모듈 그래프 추적과 번들 최적화를 동시에 열어둘 수 있다는 장점도 있습니다.

반면 두 가지 방식이 공존하면서 "어떤 걸 써야 하지?"라는 혼선을 줄 수 있고, 공식 문서의 예제를 어떤 방식으로 작성할지부터 결정해야 합니다. 문서화, 예제, 테스트 관리 비용도 사실상 두 배가 됩니다.

선택지 2: named export만 제공 (dot-notation 제거)

<Card>
 <CardHeader />
 <CardBody />
</Card>

RSC 호환과 tree-shaking이 자동으로 보장되는 방식입니다. 소비자가 별도로 최적화를 신경 쓸 필요가 없고, import 경로만 보고도 어떤 컴포넌트를 사용하는지 명확하게 파악할 수 있습니다. API 표면이 하나로 통일되어 문서화와 예제 관리도 단순해집니다.

하지만 Card.을 입력해서 하위 컴포넌트를 탐색하는 자동완성이 불가능해집니다. 처음 사용하는 개발자는 CardHeader, CardBody, CardFooter 등의 이름을 문서에서 찾아봐야 합니다. compound component 간의 부모-자식 관계가 import문에서 드러나지 않는 것도 단점인데, import { CardHeader, CardBody, Button }처럼 관련 없는 컴포넌트와 섞여 나열되면 어떤 컴포넌트끼리 조합해서 써야 하는지 직관적으로 알기 어렵습니다. Prefix 네이밍(CardHeader, CardBody)이 장황하게 느껴질 수도 있고, 네이밍 충돌 가능성도 있습니다.

선택지 3: dot-notation만 제공 (현행 유지)

<Card>
 <Card.Header />
 <Card.Body />
</Card>

<Card>를 루트 컴포넌트로 유지하면서 dot-notation을 제공하려면 Object.assign이 필수적이므로, 사실상 기존 구현 방식을 그대로 유지하는 선택지입니다.

Card.만 입력하면 하위 컴포넌트가 자동완성으로 나열되어 탐색이 쉽고, 처음 사용하는 개발자도 문서 없이 API를 탐색할 수 있습니다. 네임스페이스로 묶여 있어 compound component 간의 부모-자식 관계가 import문에서 명확하게 드러나고, import문이 import { Card } from 'daleui' 한 줄로 깔끔합니다. API 표면이 하나로 통일되어 사용자가 "어떤 방식을 써야 하지?"라고 고민할 필요도 없습니다.

다만 앞서 분석했듯이 dot-notation을 사용하는 한 Webpack에서 tree-shaking이 동작하지 않을 수 있습니다. 라이브러리 규모가 커지면 이 제약이 부담이 될 수 있고, Context를 사용하지 않는 컴포넌트에서만 RSC 이점이 발휘됩니다. 또한 앞서 <Card>를 루트 컴포넌트로 유지하기로 한 이상, 순수한 namespace 패턴만으로는 구현할 수 없습니다. Card 자체가 호출 가능한 컴포넌트여야 하므로 Object.assign이나 직접 프로퍼티 할당이 여전히 필요하며, 이는 앞서 분석한 RSC 호환성 문제를 그대로 안고 가게 됩니다.

어떤 상황에서 어떤 방식을 선택하는가

세 가지 선택지 중 모든 축에서 최선인 방식은 없습니다. 팀의 상황에 따라 우선하는 축이 달라지고, 그에 따라 선택이 갈립니다.

DX와 API 탐색성이 가장 중요하고 라이브러리 규모가 크지 않다면, dot-notation만 제공하는 것이 가장 단순한 선택입니다. RSC 환경의 소비자를 지원해야 하거나 번들 사이즈에 민감한 프로젝트가 많다면, named export를 통해 RSC 호환과 tree-shaking을 확보하는 것이 실질적인 이점을 줍니다. 두 유형의 소비자를 모두 지원해야 하거나, 현재는 DX를 우선하되 RSC 호환 경로를 열어두고 싶다면, dot-notation을 primary API로 두고 named export를 함께 제공하는 병행 방식이 균형 잡힌 선택입니다.

dot-notation 적용 범위에 대한 논의

세 가지 선택지와 별개로, dot-notation을 어디까지 적용할 것인가에 대한 논의도 있었습니다. 모든 컴포넌트에 일괄 적용하면 오히려 복잡도가 올라간다는 의견이 나왔고, 두 가지 선정 기준이 제시되었습니다.

첫째, 슬롯이 많은가 - Card처럼 Title, Body, Description 등 다양한 하위 컴포넌트가 있으면 dot-notation의 이점이 크지만, 하위 컴포넌트가 하나뿐이면 이점이 적습니다.

둘째, 상태를 부모와 공유하는가 - CheckboxGroup, RadioGroup처럼 부모와 상태를 공유하는 경우에 적합하지만, Grid처럼 단순 CSS 컨테이너인 경우에는 부적합합니다.

결과: dot-notation을 유지하되, named export도 함께 제공한다

최종적으로 팀은 dot-notation을 primary API로 유지하면서 named export도 함께 제공하는 것으로 결론을 내렸습니다 (선택지 1).

핵심 근거는 세 가지였습니다.

첫째, <Card>를 루트 컴포넌트로 유지하기로 한 이상 Object.assign을 걷어낼 수 없어, 순수한 namespace 패턴으로의 전환은 불가능하다는 점. dot-notation은 Object.assign 기반으로 그대로 유지합니다.

둘째, named export를 함께 제공하면 RSC 환경에서도 각 컴포넌트를 모듈 그래프 상에서 개별 추적할 수 있다는 점. import { CardHeader, CardBody } from 'daleui' 형태로 import하면 번들러가 'use client' 경계를 정확히 판별할 수 있고, tree-shaking도 함께 보장됩니다.

셋째, dot-notation을 공식 문서와 예제의 primary API로 문서화함으로써 "어떤 방식을 써야 하지?"라는 혼선을 줄일 수 있다는 점. named export는 RSC 호환이나 번들 최적화가 필요한 소비자를 위한 대안 경로로 안내합니다.

// primary API - 공식 문서와 예제에서 사용
import { Card } from 'daleui';
<Card>
 <Card.Header />
 <Card.Body />
</Card>
// 대안 경로 - RSC 호환이 필요한 경우
import { Card, CardHeader, CardBody } from 'daleui';
<Card>
 <CardHeader />
 <CardBody />
</Card>

결과적으로 기존 DX를 깨뜨리지 않으면서, RSC 환경의 소비자에게도 호환 가능한 경로를 제공하는 구조를 갖추게 되었습니다. Context 기반 compound component의 RSC 제약은 여전히 남아 있지만, 이는 namespace 패턴으로 전환하더라도 풀리지 않는 본질적 제약이므로 별도의 문제가 아닙니다.

이 논의 과정에서 몇 가지를 배웠습니다.

기존 결정에는 이유가 있다. namespace 패턴을 적용하면서 <Card.Root>로 API를 변경했다가, 기존에 .Root를 붙이지 않기로 한 합의가 있었다는 것을 뒤늦게 알게 되었습니다. "왜 안 고쳤지?"가 아니라 "왜 지금 이 모양인가"를 먼저 파악했어야 했다는 걸 느꼈습니다. 코드를 변경하기 전에 기존 결정의 맥락을 확인하는 습관이 필요합니다.

트레이드오프는 층위가 있다. 처음에는 "RSC 호환이 안 되니 namespace 패턴으로 전환하자"는 단순한 문제였지만, Context의 존재, 번들러의 tree-shaking 한계, DX와 API 일관성 등을 고려하면서 문제의 층위가 깊어졌습니다. 한 가지 축만 보고 결정하면 다른 축에서 예상치 못한 문제가 발생합니다.

만약 여러분의 디자인 시스템에서도 비슷한 RSC 호환성 이슈를 겪고 있다면, 아래 표가 팀 내 논의의 출발점이 되었으면 합니다.

RSC 호환 Tree-shaking DX (자동완성/탐색) 적합한 상황
Object.assign, Function export ✅ (Card.으로 탐색) DX 우선, 소규모 라이브러리
namespace (dot-notation) ✅* ** ❌ (Webpack 기준) ✅ (Card.으로 탐색) RSC 호환이 필수이고 API 변경을 수용할 수 있는 경우
named export ❌ (이름 암기 필요, 자식 컴포넌트까지 임포트) RSC 호환 또는 번들 최적화가 중요한 경우

*Context를 사용하는 경우 실질적 RSC 이점 제한

**<Card.Root> 형태의 API 변경을 수용해야 함. <Card>를 유지하려면 Object.assign이 필요하므로 RSC 호환 이점을 얻을 수 없음

세 가지 축에서 모두 ✅인 방식은 없습니다. 달레UI는 dot-notation의 DX를 유지하면서 named export로 RSC 호환 경로를 함께 제공하는 방식을 선택했습니다. 어떤 축을 우선할지는 팀의 상황인 라이브러리의 규모, RSC 도입 정도, 소비자의 번들러 환경에 따라 달라질 수밖에 없고, 그 판단을 내리기 위해서는 각 축의 트레이드오프를 팀 전체가 이해하고 있어야 합니다.


참고 자료:

You must be logged in to vote

Replies: 1 comment

Comment options

DaleSeo
Apr 20, 2026
Maintainer Sponsor

@SimYunSup 님, 첫 블로그 글 발행을 축하드립니다! 🎉

Object.assign으로 시작한 문제가 어떻게 세 가지 축의 트레이드오프로 확장되고 팀에서 여러 수차례 논의 끝에 내린 의사 결정 과정을 심도있게 다뤄주셔서 감사합니다. RSC 지원을 위해서 비슷한 고민을 하고 있는 디자인 시스템 팀에게 실질적인 도움이 될 거라고 생각합니다. 글 초안 리뷰하면서 덕분에 저도 많이 배웠습니다. 😃

You must be logged in to vote
0 replies
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet

AltStyle によって変換されたページ (->オリジナル) /