컴포넌트를 설계하는 방식은 다양합니다. 최근에는 shadcn/ui, Chakra UI와 같은 오픈소스 디자인 시스템을 중심으로 합성 컴포넌트 패턴이 널리 사용되고 있습니다. 저 또한 이 패턴이 제공하는 높은 자유도와 확장성 덕분에 선호하고 있으며, 실제로 사내 디자인 시스템에서도 적극적으로 적용하고 있습니다.
이 과정에서 서브 컴포넌트 간의 응집도를 높이기 위해 점 표기법(Dot Notation) 컨벤션을 도입했습니다. 점 연산자를 활용하면 연관된 서브 컴포넌트를 구조적으로 묶어 표현할 수 있기 때문에, 사용자가 더 예측하기 쉬운 일관된 방식으로 컴포넌트를 사용할 수 있습니다.
하지만 서버 컴포넌트 환경에서는 이러한 점 표기법이 예상치 못한 문제를 일으킬 수 있습니다. 저 역시 디자인 시스템을 테스트하는 과정에서 같은 문제를 경험했습니다. 이번 글에서는 이 문제가 왜 발생하는지, 그리고 어떤 방식으로 해결할 수 있는지 정리해 보려고 합니다.
점 표기법
예를 들어, Card 컴포넌트를 다음과 같이 정의했다고 가정해 보겠습니다.
'use client';
// parts
const Root = () => { ... };
const Header = () => { ... };
const Body = () => { ... };
const Footer = () => { ... };
Root.Header = Header;
Root.Body = Body;
Root.Footer = Footer;
export { Root as Card };실무에서 <Card />는 훨씬 더 복잡합니다. 예를 들어, 서브 컴포넌트끼리 공유하는 상태(state)가 있을 수 있고, 상황에 따라서 특정 서브 컴포넌트는 이벤트 핸들러를 포함할 수도 있습니다.
그러나 다음과 같은 이유로, 두 경우 모두 서버 컴포넌트에서는 처리하기가 어렵습니다.
- 서버 컴포넌트는 요청이 들어오면 그 순간 서버에서 한 번만 실행되기 때문에, 지속성을 전제로 하는 상태를 가질 수 없습니다.
- 함수(이벤트 핸들러)는 선언 시점의 렉시컬 스코프를 기억합니다. 그래서 함수를 직렬화하려면 스코프 전체를 포함해야 하는데, 이는 구조적으로 복잡하고 비효율적입니다. 또 함수를 역 직렬화 하는 과정에서 보안 위험도 발생할 수 있습니다.
이런 이유로 많은 디자인 시스템 라이브러리들이 컴포넌트를 기본적으로 클라이언트 컴포넌트로 제공합니다. 저 역시 이번 예제에서 Card 컴포넌트를 클라이언트 컴포넌트로 정의했습니다.
아무튼, 이런 방식으로 컴포넌트를 정의한 후에는 루트 컴포넌트에 서브 컴포넌트를 할당하고 있습니다. 이는 자바스크립트의 함수가 일급 객체라는 점을 활용한 것으로, 이 패턴을 통해 <Card.Header>, <Card.Body>처럼 점 표기법으로 컴포넌트를 호출할 수 있습니다.
참고로 아래처럼 Object.assign을 사용하는 방식도 자주 사용됩니다.
export const Card = Object.assign(Root, {
Header,
Body,
Footer,
});두 방식 모두 일반적인 상황에서는 정상적으로 동작합니다. 그러나 문제는 서버 컴포넌트 환경에서 발생합니다. 위에서 생성한 Card라는 클라이언트 컴포넌트를 Next.js의 App Router에서 렌더링하면 이런 에러가 발생합니다.

에러 로그에 따르면, 서버는 Card.Header가 문자열, 클래스, 함수 중 하나라고 예상했지만 실제로는 undefined를 전달받았다고 합니다. 분명 코드에서 Header를 Card의 속성으로 지정해 두었는데, 존재하지 않는 것처럼 인식되는 것입니다.
왜 이런 문제가 발생할까요?
RSC의 직렬화 문제
이 문제를 이해하려면 React Server Component(RSC, 서버 컴포넌트)의 기본적인 동작 방식을 알아야 합니다. 여기서는 핵심만 간단히 짚어보겠습니다. 자세한 내용이 궁금하다면 Josh Comeau의 글을 참고해 보세요.
Next.js App Router에서 페이지는 기본적으로 서버에서 먼저 렌더링됩니다. 사용자가 페이지에 접근하면, 브라우저는 자바스크립트를 다운로드하기 전에 서버가 만든 HTML을 우선 전달받습니다. 이후 자바스크립트가 로드되면 하이드레이션을 거쳐 상호작용이 가능한 상태가 됩니다.
그런데 서버 컴포넌트가 클라이언트 컴포넌트를 포함하고 있을 때는 조금 다르게 동작합니다.
- 클라이언트 컴포넌트는 이벤트 처리, 상태 관리 등 DOM 환경이 필요한 API를 사용합니다.
- 하지만 서버에는 DOM이 없기 때문에 실제로 컴포넌트를 실행할 수 없습니다.
- 그래서 RSC는 클라이언트 컴포넌트를 직접 실행하지 않고, 그 컴포넌트에 대한 메타데이터를 직렬화하여 브라우저로 전달합니다.
문제는 이 직렬화 과정에서 발생합니다.
서버는 클라이언트 컴포넌트를 실행조차 하지 않기 때문에, Card.Header = Header처럼 동적으로 객체에 추가되는 속성은 직렬화 대상에 포함되지 않습니다.
그 결과 브라우저는 Card 컴포넌트가 존재한다는 정보만 전달받고, Card.Header 같은 서브 컴포넌트에 대한 정보는 전달받지 못합니다. 브라우저 입장에서는 Card.Header가 존재하지 않는 속성이기 때문에 undefined 에러를 발생시킵니다.
Namespace Module
이 문제는 아래와 같이 ESM의 네임스페이스 모듈(Namespace Module) 방식으로 해결할 수 있습니다.
// card.tsx
'use client';
export const Root = () => { ... };
export const Header = () => { ... };
export const Body = () => { ... };
export const Footer = () => { ... };
// index.ts
export * as Card from './card';네임스페이스 모듈은 특정 파일의 모든 export를 하나의 네임스페이스로 묶고, 이를 단일 객체로 가져올 수 있는 문법입니다.
이 방법이 동작하는 이유는 ESM의 동작 방식에 있습니다.
ESM은 export/import 키워드를 통해 코드 실행 전에 구문을 정적으로 분석합니다. Card 컴포넌트에서는 모든 서브 컴포넌트를 export 하고 있기 때문에 서버는 코드를 실행하지 않고도 Card 안에 어떤 서브 컴포넌트가 있는지 알 수 있는 것입니다. 따라서 RSC 직렬화 시에 서브 컴포넌트에 대한 참조를 정상적으로 생성할 수 있습니다.
실제로 Next.js App Router에서 Card 컴포넌트를 렌더링하고, 개발자 도구의 Elements 탭을 보면 다음과 같은 RSC Payload를 확인할 수 있습니다.
// 가독성을 위해 불필요한 내용은 모두 제거했습니다.
["$","div",null,{"children":["$","$L4",null,{"children":[
["$","$L5",null,{"children":"Card Header"}],
["$","$L6",null,{"children":"Card Body"}],
["$","$L7",null,{"children":"Card Footer"}]
]}]}]$L4, $L5, $L6, $L7은 각각 Card.Root, Card.Header, Card.Body, Card.Footer에 대한 클라이언트 컴포넌트 참조입니다. 서버는 컴포넌트를 직접 실행하는 대신, 이 위치에 이 컴포넌트를 렌더링하라는 참조만 전달합니다.
마침내 브라우저는 Card 컴포넌트를 정상적으로 렌더링할 수 있고, 사용자들은 서버 사이드 환경에서도 문제없이 점 표기법을 사용할 수 있게 되었습니다.
Trade-offs
이 방식을 통해 RSC 직렬화 문제는 해결할 수 있지만, 한 가지 트레이드오프가 있습니다.
<Card> 더 이상 컴포넌트가 아니게 되었고, 그 대신 Card의 속성 중 하나인 <Card.Root> 컴포넌트를 사용해야 합니다.
// not a <Card>
<Card.Root>
<Card.Header>Header</Card.Header>
<Card.Body>Body</Card.Body>
<Card.Footer>Footer</Card.Footer>
</Card.Root>불필요하게 .Root를 추가해야 한다는 점이 번거롭고, 어색하게 느껴질 수는 있습니다. 다만, 환경에 구애받지 않고 항상 동일한 문법을 사용할 수 있다는 점을 고려했을 때, 이는 충분히 가치 있는 선택이라고 생각합니다.
맺음
점 표기법은 단순한 네이밍 컨벤션처럼 보일 수 있습니다. 하지만 이런 작은 규칙과 일관성이 결국 사용자의 예측 가능성을 높이고, 디자인 시스템을 안정적으로 만드는 출발점이 될 수 있습니다. 그런 의미에서 기존 방식에서 벗어나 새로운 설계 패턴을 도입한 것은 충분히 가치 있는 변화였다고 생각합니다.
또, 이번 문제를 해결하는 과정에서 RSC의 동작 방식을 좀 더 깊이 공부하기도 했습니다. 사실 이제까지는 서버 컴포넌트와 클라이언트 컴포넌트의 관계에 대해서만 대략적으로 알고 있었는데, 이번 기회를 통해 직렬화 과정이나 클라이언트 컴포넌트 처리 방식 등에 대한 이해가 한 층 깊어진 것 같습니다. 그런 점에서 개인적으로도 되게 의미 있고, 또 즐거운 시간이었습니다.
이 과정들이 저에게 의미가 있었던 만큼, 다른 누군가에게도 도움이 되길 바라며 마무리하겠습니다. 읽어주셔서 감사합니다.