Gu Doyoon

확장 불가능한 Flat 컴포넌트를 Compound 패턴으로 리팩터링하기

1/23/2026 작성

요구사항이 늘어날수록 비대해지는 Props

초기에는 개발 과정의 트러블 슈팅을 기록하기 위해 단순한 형태의 TroubleShooting 컴포넌트를 만들었습니다. 모든 데이터를 하나의 props 객체로 내려받아 렌더링하는 전형적인 Flat 구조였습니다.

// Before: 모든 케이스를 하나의 Props로 처리 (유연성 부족) <TroubleShooting title="제목" problem="문제 상황 설명" solution="해결 방법" code={codeSnippet} // 코드가 없는 글이라면? null? videoSrc={...} // 갑자기 비디오 설명이 필요해진다면? />

해당 컴포넌트의 문제점?

처음에는 잘 작동했지만 프로젝트가 진행되면서 다양한 형태의 기록이 필요해졌습니다.

결국 새로운 타입의 콘텐츠가 생길 때마다 컴포넌트 내부 로직을 수정해야 하는 상황에 이르렀습니다.

Compound Component 패턴의 도입

(상태 공유 로직 없이 서브 컴포넌트만 묶었기에 'Namespaced Component' 패턴에 가깝지만, 합성 구조를 취한다는 점에서 넓은 의미의 Compound 패턴을 따랐습니다.)

이 패턴의 핵심은 "자식 요소를 어떻게 구성할지의 제어권을 부모에게 넘겨주는 것" 입니다. 거대한 하나의 덩어리였던 컴포넌트를 Header, Section, Code 등 작은 블록으로 쪼갰습니다.

Refactoring

기존의 TroubleShooting을 더 범용적인 이름인 CaseStudy로 변경하고, 내부 요소들을 조립 가능한 형태로 분리했습니다.

// After: 필요한 요소만 선택적으로 조합하여 사용 <CaseStudy> <CaseStudy.Header> 확장 불가능한 구조 개선하기 </CaseStudy.Header> <CaseStudy.Body> <CaseStudy.Section title="문제"> Props가 너무 많아져서 관리가 힘듭니다. </CaseStudy.Section> {/* 코드가 필요하면 넣고, 없으면 생략하면 됨 (Props null 처리 불필요) */} <CaseStudy.Code> {codeSnippet} </CaseStudy.Code> {/* 새로운 요구사항(예: 팁 박스)이 생겨도 컴포넌트 내부 수정 없이 조합만으로 해결 */} <CaseStudy.Tip> Compound 패턴을 쓰면 유연해집니다! </CaseStudy.Tip> </CaseStudy.Body> </CaseStudy>

저는 공유할 state가 없어 Context API는 사용하지 않았지만 이 패턴을 구현할 때는 주로 React의 Context API를 활용하여 부모 컴포넌트와 자식 컴포넌트들 간의 상태를 공유하곤 합니다.

// 구현 예시 const CaseStudy = ({ children }: Props) => { return <div className="case-study-container">{children}</div>; }; const Header = ({ children }: Props) => <h3>{children}</h3>; const Code = ({ children }: Props) => <pre><code>{children}</code></pre>; // 서브 컴포넌트 할당 CaseStudy.Header = Header; CaseStudy.Code = Code; export default CaseStudy;

이렇게 하면 사용처에서는 CaseStudy.Header와 같이 직관적으로 컴포넌트를 가져다 쓸 수 있습니다.

최종 회고

저는 이 패턴이 무조건적인 정답은 아니다라는 결론을 내렸습니다. 명확한 장점이 있는 만큼 단점도 존재했기 때문입니다.

👍 좋았던 점

높은 유연성: 더 이상 코드가 없는 경우를 위해 props에 null을 넘기거나 내부에서 불필요한 분기 처리를 할 필요가 없어졌습니다.
확장성: 만약 참고 링크 박스가 필요하다면 기존 CaseStudy 코드를 건드릴 필요 없이 <CaseStudy.Link> 컴포넌트 하나만 추가해서 조립하면 끝입니다.

👎 안 좋았던 점

코드의 길이: 가장 먼저 체감되는 단점입니다. 기존 Flat 구조에서는 Props만 넘기면 한 줄로 끝났던 코드가 이제는 모든 구조를 모두 명시해야 하므로 JSX의 길이가 늘어났습니다.

반복문 제어의 까다로움: 데이터 배열을 map으로 돌려 렌더링해야 하는 경우 부모 컴포넌트가 제어권을 갖고 있기 때문에 Flat 컴포넌트보다 구현이 번거로울 수 있습니다.

왜 이 프로젝트에 적합했나?

그럼에도 불구하고 이 패턴을 선택한 이유는
저는 MDX를 통해 기술 문서를 작성할 때 아래와 같이 마크업 하듯이 직관적으로 글을 작성하는 경험이 중요했습니다.

또한 AI도구를 적극 활용하여 문서를 작성한다면 수작업으로 작성해야한다는 단점도 사라지기 때문에 다양한 케이스를 커버해야했던 이러한 상황에는 적합하다고 판단하여 적용했습니다.

그런데, 패턴이 안정되자 새로운 문제가 생겼다

컴파운드 패턴 도입 후 한동안은 만족스러웠습니다. MDX구조로 관리혐

그런데 시간이 지나면서 다른 불편함이 느껴지기 시작했습니다.

// 이게 데이터인가, UI인가? <CaseStudy> <CaseStudy.Header>네트워크 워터폴 현상 개선</CaseStudy.Header> <CaseStudy.Body> <CaseStudy.Section title="문제 상황" dotColor="bg-red-400"> API 구조상 **[위치 정보 → 상세 정보 → 이미지 URL]** 로 이어지는... </CaseStudy.Section> <CaseStudy.Section title="해결 과정" dotColor="bg-blue-400"> ... </CaseStudy.Section> </CaseStudy.Body> </CaseStudy>

컴포넌트 조합은 유연해졌지만, 콘텐츠가 여전히 JSX 안에 박혀 있었습니다. 글 하나를 수정하려면 JSX 트리를 파고들어야 했고, 어떤 프로젝트 카드가 어떤 내용을 담고 있는지 파악하려면 마크업 구조를 눈으로 읽어야 했습니다.

컴파운드 패턴이 "UI의 합성 방식" 문제는 해결해줬지만, "데이터와 UI의 결합" 문제는 여전히 남아있었던 거죠.

렌더링 구조와 데이터를 분리하기

해결 방향은 명확했습니다. 컴파운드 패턴은 유지하되, 데이터만 별도의 .ts 파일로 분리하는 것입니다.

1단계: 콘텐츠 블록을 타입으로 정의하기

가장 먼저 "어떤 종류의 콘텐츠가 존재하는가"를 타입으로 정의했습니다.

// types/project.ts type SectionContent = | { type: 'text'; value: string } | { type: 'code'; value: string; language?: string } | { type: 'metrics'; items: MetricItem[] } | { type: 'figure'; src: string; caption?: string } | { type: 'blogLink'; title: string; href: string }; interface Section { title: string; dotColor?: string; contents: SectionContent[]; } interface ProjectDetailData { id: string; header: string; link?: string; sections: Section[]; result: string; }

SectionContent를 유니온 타입으로 정의한 것이 핵심입니다. 기존 컴파운드 패턴에서 "어떤 블록을 넣을지"를 JSX에서 선택했다면, 이제는 타입 시스템이 그 선택지를 보장해줍니다.

2단계: 데이터를 순수한 객체로 작성하기

export const PPickPerformanceData: ProjectDetailData = { id: 'network-waterfall', header: '네트워크 워터폴 현상 개선', sections: [ { title: '문제 상황', dotColor: 'bg-red-400', contents: [ { type: 'text', value: `API 구조상 **[위치 정보 → 상세 정보 → 이미지 URL]** 로 이어지는 구조 변경이 불가능했습니다.`, }, ], }, { title: '성과 지표', dotColor: 'bg-green-400', contents: [ { type: 'metrics', items: [ { name: '초기 API 요청', before: '10회', after: '3회', rate: '초기 로딩 단축' }, { name: '초기 로드 이미지', before: '30개+', after: '4개', rate: '86% 감소' }, ], }, ], }, ], result: '점진적 프리패칭 기술로 Waterfall 대기 시간을 은폐하여 끊김 없는 탐색 경험을 제공했습니다.', };

이제 글 내용을 수정할 때 JSX는 전혀 건드릴 필요가 없습니다. .ts 파일만 열면 됩니다.

3단계: 데이터를 컴파운드 컴포넌트로 연결하는 렌더러 만들기

마지막으로, 데이터 객체를 받아서 컴파운드 컴포넌트로 렌더링하는 렌더러 컴포넌트를 하나 만들었습니다.

// components/ProjectDetailRenderer.tsx const SectionContentRenderer = ({ content }: { content: SectionContent }) => { switch (content.type) { case 'text': return <MarkDownWrapper>{content.value}</MarkDownWrapper>; case 'code': return <ProjectDetail.Code language={content.language}>{content.value}</ProjectDetail.Code>; case 'metrics': return ( <ProjectDetail.Metrics> {content.items.map((item) => ( <ProjectDetail.MetricItem key={item.name} {...item} /> ))} </ProjectDetail.Metrics> ); case 'figure': return <ProjectDetail.Figure src={content.src} alt="" caption={content.caption} />; } }; export const ProjectDetailRenderer = ({ data }: { data: ProjectDetailData }) => ( <ProjectDetail> <ProjectDetail.Header link={data.link}>{data.header}</ProjectDetail.Header> <ProjectDetail.Body> {data.sections.map((section) => ( <ProjectDetail.Section key={section.title} title={section.title} dotColor={section.dotColor}> {section.contents.map((content, i) => ( <SectionContentRenderer key={i} content={content} /> ))} </ProjectDetail.Section> ))} <ProjectDetail.Result>{data.result}</ProjectDetail.Result> </ProjectDetail.Body> </ProjectDetail> );

switch 문으로 콘텐츠 타입을 분기하는 이 렌더러가, 기존에 JSX를 직접 작성했을 때의 유연성을 그대로 유지하면서 데이터와 UI를 완전히 분리해주는 연결고리 역할을 합니다.

사용하는 곳은 이렇게 바뀌었습니다

// Before: JSX에 데이터가 직접 박혀있음 <ProjectDetail> <ProjectDetail.Header>네트워크 워터폴 현상 개선</ProjectDetail.Header> <ProjectDetail.Body> <ProjectDetail.Section title="문제 상황" dotColor="bg-red-400"> ...긴 내용... </ProjectDetail.Section> </ProjectDetail.Body> </ProjectDetail> // After: 데이터만 꽂으면 끝 <ProjectDetailRenderer data={PPickPerformanceData} />

두 가지를 함께 쓰는 이유

이쯤에서 이런 의문이 생길 수 있습니다. "렌더러가 알아서 다 해주면 컴파운드 패턴은 왜 유지하나?"

역할이 서로 다르다고 생각합니다.

컴파운드 패턴은 UI 블록 하나하나의 렌더링 책임을 담당합니다. Section이 어떻게 생겼는지, MetricItem의 레이아웃이 어떤지는 여기서 결정됩니다. 디자인을 바꾸거나 스타일을 수정할 일이 생기면 이 컴포넌트들만 건드리면 됩니다.

데이터 + 렌더러"어떤 블록을 어떤 순서로 조합할지" 를 담당합니다. 콘텐츠가 바뀌는 건 여기서만 일어납니다.

결국 두 레이어가 함께 있을 때 각자의 역할이 더 명확해집니다.

담당
컴파운드 컴포넌트각 블록의 UI, 스타일
데이터 파일 (.ts)실제 콘텐츠 내용
렌더러데이터 → 컴파운드 컴포넌트 연결

위와같은 역할로서 컴포넌트를 분리하고

최종 구조 요약

컴파운드 패턴 : UI/스타일 담당 순수 컴포넌트
렌더러: 데이터 => 컴포넌트로 연결
데이터: 순수 객채로 데이터만 관리

확장 불가능한 Flat 컴포넌트를 Compound 패턴으로 리팩터링하기 | 구도윤 기술 블로그