Dialog.Root는 정말 번들 크기를 키울까?

작성일:2026.07.09|수정일:2026.07.09|조회수:1

Dialog.Root 같은 API를 볼 때마다 약간 궁금했다. 이거, 번들 크기에는 괜찮을까?

겉으로 보기에는 별문제 없어 보인다. Dialog.Root, Dialog.Trigger, Dialog.Content처럼 이름이 잘 묶여 있고, 사용하는 쪽에서도 읽기 쉽다. React 컴포넌트 API로는 꽤 자연스럽다.

그런데 번들러 관점에서는 조금 다른 질문이 생긴다. 내가 실제로 쓰는 것은 RootTrigger뿐인데, 같은 객체에 매달린 Content, Overlay, Close까지 같이 따라오는 건 아닐까? 특히 Dialog처럼 조각이 많은 컴포넌트라면 이 차이가 생각보다 커질 수도 있다.

궁금하면 재봐야 한다. 그래서 작은 실험을 했다.

무엇을 확인했나

질문은 단순했다.

Dialog.Root, Dialog.Trigger 같은 API를 쓰면, 사용하지 않는 Dialog.Content, Dialog.Overlay, Dialog.Close까지 client chunk에 들어갈까?

실험은 Next 앱 안에 /bundle-exp route를 하나 추가해서 진행했다. isolated bundler 실험이 아니라 실제 production build 결과를 보고 싶었다. 번들러가 이론적으로 무엇을 할 수 있는지보다, 내가 쓰는 환경에서 실제 결과물이 어떻게 나오는지가 더 궁금했기 때문이다.

환경은 다음과 같았다.

TXT
Next.js 16.2.10
React 19.2.4
Turbopack production build
pnpm build

각 variant는 같은 route, 같은 client component, 같은 JSX 구조를 사용했다. 바뀐 것은 import/export API 모양뿐이다. 그리고 사용하지 않는 컴포넌트에는 일부러 긴 marker string을 넣었다.

빌드 후에는 .next/static/chunks 안에서 이 marker가 남는지 검색했다. marker가 남아 있으면 “사용하지 않는 컴포넌트가 client chunk에 포함됐다”고 볼 수 있고, marker가 없으면 적어도 이 조건에서는 제거됐다고 볼 수 있다. 완벽한 증명은 아니지만, 번들 크기 숫자만 보는 것보다 더 직접적인 신호다.

비교한 API 모양

비교한 것은 네 가지다.

TSX
// 1. named export
import { Root, Trigger } from './named-api'

// 2. object API
import { Dialog } from './static-api'

;<Dialog.Root>
  <Dialog.Trigger />
</Dialog.Root>

// 3. namespace import
import * as Dialog from './namespace-api'

// 4. Object.assign statics
export const Dialog = Object.assign(DialogRoot, {
  Close,
  Content,
  Overlay,
  Trigger,
})

처음에는 Dialog.Root라는 표기 자체가 조금 의심스러웠다. 객체의 property로 접근하니까, 번들러가 이것을 잘 털어낼 수 있을까 싶었다.

하지만 React에서 흔히 보는 compound component 패턴은 네 번째에 더 가깝다. 루트 컴포넌트가 <Dialog>이고, 거기에 Dialog.Trigger 같은 속성을 붙이는 방식이다. 이 둘은 보기에는 비슷하지만, 번들러 입장에서는 꽤 다른 코드일 수 있다.

결과

결과는 예상보다 미묘했다.

방식사용 형태전체 static chunks gzipmarker chunk gzipunused marker
named exportRoot, Trigger 직접 import196,516 B544 B없음
static objectDialog.Root, Dialog.Trigger196,517 B545 B없음
namespace importimport * as Dialog196,522 B550 B없음
Object.assign statics<Dialog><Dialog.Trigger /></Dialog>196,647 B675 B있음

첫 세 가지는 거의 차이가 없었다. named export가 가장 작기는 했지만, Dialog.Root object API나 namespace import에서도 unused marker가 남지 않았다.

그러니까 적어도 이 실험에서는 Dialog.Root라는 형태 자체가 문제는 아니었다. Next 16/Turbopack은 정적인 object property access를 꽤 잘 추적했다. 이건 내가 생각했던 것보다 똑똑한 결과였다. 좋은 방향으로 틀린 셈이다.

차이가 난 것은 Object.assign 패턴이었다. 루트 컴포넌트에 Content, Overlay, Close, Trigger를 static property로 붙인 경우에는 사용하지 않는 Content, Overlay, Close marker가 client chunk에 남았다. marker chunk 기준으로 gzip이 544 B에서 675 B로 늘었다.

숫자만 보면 작다. 하지만 이 fixture의 unused 컴포넌트는 일부러 작게 만든 장난감 코드다. 실제 Dialog라면 Content, Overlay, Portal, Title, Description, Close가 focus 관리, effect, style, ARIA 계산, 유틸 import를 같이 물고 있을 수 있다. 작은 예제의 +131 B gzip을 그대로 실무 크기로 일반화할 수는 없지만, 방향은 무시하기 어렵다.

Object.assign은 남았을까

정확한 내부 구현까지 단정할 수는 없다. 다만 결과 자체는 이해할 만하다.

named export는 번들러가 가장 다루기 쉬운 모양이다.

TSX
import { Root, Trigger } from './named-api'

여기서는 Content라는 export를 아예 import하지 않는다. 번들러 입장에서는 “이 모듈의 일부 export는 사용되지 않는다”고 판단하기 쉽다.

반면 Object.assign(DialogRoot, { ... })는 런타임에 함수 객체에 property를 붙인다.

TSX
export const Dialog = Object.assign(DialogRoot, {
  Close,
  Content,
  Overlay,
  Trigger,
})

사용자 입장에서는 예쁘다. <Dialog>가 root이고, <Dialog.Trigger>가 조각이다. API 모양도 자연스럽다. 하지만 번들러 입장에서는 함수 객체에 속성을 붙이는 side effect에 가깝게 보일 여지가 있다. 사용하지 않는 property를 안전하게 제거하려면 Object.assign 호출의 의미와 이후 property 접근 가능성을 꽤 넓게 증명해야 한다.

이번 Turbopack은 단순 object property access는 잘 추적했지만, 함수 객체에 static property를 붙이는 패턴에는 더 보수적으로 반응한 셈이다.

그래서 무엇을 선택해야 하나

이 실험만으로 “compound component는 번들 크기에 나쁘다”고 말하면 과장이다. 오히려 결과는 반대에 가깝다. Dialog.Root object API는 이번 조건에서 잘 제거됐다.

다만 Object.assign으로 루트 컴포넌트에 여러 조각을 붙이는 static compound 패턴은 주의할 만하다. 특히 shared UI primitive처럼 시간이 지나면서 조각이 늘어나는 컴포넌트에서는 차이가 쌓일 수 있다. 처음에는 Root, Trigger, Content 정도였던 것이 곧 Portal, Backdrop, Viewport, Title, Description, Close, Header, Footer로 늘어난다. 이때 사용하지 않는 조각이 정말 빠지는지는 꽤 중요한 질문이 된다.

내가 지금 고른다면, 라이브러리나 shared primitive에서는 named export를 기본값으로 둘 것 같다.

TSX
import {
  DialogRoot,
  DialogTrigger,
  DialogContent,
  DialogClose,
} from './dialog'

조금 지루하다. Dialog.Root보다 예쁘지도 않다. 하지만 어떤 조각을 쓰는지 import 문에서 바로 보이고, 번들러도 가장 단순한 형태로 사용 여부를 판단할 수 있다.

반대로 앱 내부에서 쓰는 작은 컴포넌트라면 API 취향을 더 우선해도 된다고 생각한다. 모든 컴포넌트가 라이브러리처럼 설계될 필요는 없다. 번들 크기가 실제로 문제가 되는 지점인지, 컴포넌트가 얼마나 커질지, 사용하지 않는 조각이 얼마나 무거운지를 같이 봐야 한다.

결국 중요한 것은 Dialog.Root가 무조건 나쁘냐가 아니다. 어떤 방식으로 묶고, 어떤 방식으로 소비하느냐다. 번들러는 내가 상상한 대로 일하지 않는다. 가끔은 더 똑똑하고, 가끔은 더 겁이 많다.

댓글

댓글을 불러오는 중...