Tech
공용 컴포넌트와 Compound 패턴
공용 컴포넌트가 늘어날수록 무거워지는 props를 Compound 패턴으로 풀기
컴파운드 패턴
암묵적 상태 공유와 자유로운 조합
// 사용하는 쪽: 상태를 넘기지 않아도 부품들이 알아서 동작한다
<Select onChange={handleChange}>
<Select.Trigger>과일 선택</Select.Trigger>
<Select.Options>
<Select.Option value='apple'>사과</Select.Option>
<Select.Option value='banana'>바나나</Select.Option>
</Select.Options>
</Select>// 사용하는 쪽: 상태를 넘기지 않아도 부품들이 알아서 동작한다
<Select onChange={handleChange}>
<Select.Trigger>과일 선택</Select.Trigger>
<Select.Options>
<Select.Option value='apple'>사과</Select.Option>
<Select.Option value='banana'>바나나</Select.Option>
</Select.Options>
</Select>- 컴파운드 패턴
- 여러 컴포넌트가 하나의 작업 단위를 이루면서, 부모가 가진 상태를 자식들이 암묵적으로 공유하는 구조
<Select>안에<Select.Trigger />,<Select.Option />을 배치- 열림/닫힘 상태나 선택값을 props로 일일이 내려주지 않아도 알아서 동작한다.
- 조합이 자유로워서 다양한 레이아웃 구성에 유리하다.
- 여러 컴포넌트가 하나의 작업 단위를 이루면서, 부모가 가진 상태를 자식들이 암묵적으로 공유하는 구조
- 사용
- 컴포넌트 제공자는 부품만 잘 만들면 되고
- 소비자는 화면 요구에 맞게 조립을 잘하면 된다.
React.Children.map + cloneElement ← 기존 구현방식
// 부모가 자식을 순회하며 props를 강제로 주입
function Flyout(props) {
const [open, setOpen] = useState(false);
const toggle = () => setOpen(!open);
// 1. props.children으로 들어온 자식들을 하나씩 순회
return React.Children.map(props.children, (child) => {
// 2. 자식 컴포넌트를 복제하면서 부모의 상태와 함수를 주입
return React.cloneElement(child, { open, toggle });
});
}
<Flyout>
<Flyout.Toggle />
<Flyout.List />
</Flyout>;// 부모가 자식을 순회하며 props를 강제로 주입
function Flyout(props) {
const [open, setOpen] = useState(false);
const toggle = () => setOpen(!open);
// 1. props.children으로 들어온 자식들을 하나씩 순회
return React.Children.map(props.children, (child) => {
// 2. 자식 컴포넌트를 복제하면서 부모의 상태와 함수를 주입
return React.cloneElement(child, { open, toggle });
});
}
<Flyout>
<Flyout.Toggle />
<Flyout.List />
</Flyout>;- 초기 컴파운드 패턴
- 부모가
React.Children.map으로 자식을 순회하며cloneElement로 상태를 주입하는 방식
- 부모가
기존 구현 방식의 한계
// 개발자는 이렇게 조립했지만
<Flyout>
<Flyout.Toggle />
<Flyout.List />
</Flyout>
// 문제1 : 사용하지 않는 props 까지 넘긴다.
<Flyout>
<Flyout.Toggle open={open} toggle={toggle} />
<Flyout.List open={open} toggle={toggle} />
</Flyout>
// 문제2 : 레이아웃용 div 하나만 감싸도 주입이 끊긴다.
<Flyout>
<div className="wrapper">
<Flyout.Toggle />
<Flyout.List />
</div>
</Flyout>// 개발자는 이렇게 조립했지만
<Flyout>
<Flyout.Toggle />
<Flyout.List />
</Flyout>
// 문제1 : 사용하지 않는 props 까지 넘긴다.
<Flyout>
<Flyout.Toggle open={open} toggle={toggle} />
<Flyout.List open={open} toggle={toggle} />
</Flyout>
// 문제2 : 레이아웃용 div 하나만 감싸도 주입이 끊긴다.
<Flyout>
<div className="wrapper">
<Flyout.Toggle />
<Flyout.List />
</div>
</Flyout>- 직계 자식에게만 상태를 전달할 수 있어서
div하나만 중간에 끼워도 상태 주입이 끊겨 컴포넌트가 깨진다. - 주입한 props와 사용자가 넘긴 props가 충돌
- 데이터가 어디서 오는지 코드만 봐서는 추적하기 어려워 타입 안정성과 디버깅 모두 취약
- 이런 이유로 React 공식 문서도
Children과cloneElement를 legacy API로 분류하고 사용 자제를 권고한다.
현대의 해결법: Context API
const SelectContext = createContext(null);
function Select({ children, onChange }) {
const [selected, setSelected] = useState(null);
return (
<SelectContext.Provider value={{ selected, setSelected, onChange }}>
{children}
</SelectContext.Provider>
);
}
// 중간에 어떤 래퍼가 있어도 동작한다
Select.Option = function Option({ value, children }) {
const { setSelected, onChange } = useContext(SelectContext);
return (
<li
onClick={() => {
setSelected(value);
onChange(value);
}}
>
{children}
</li>
);
};const SelectContext = createContext(null);
function Select({ children, onChange }) {
const [selected, setSelected] = useState(null);
return (
<SelectContext.Provider value={{ selected, setSelected, onChange }}>
{children}
</SelectContext.Provider>
);
}
// 중간에 어떤 래퍼가 있어도 동작한다
Select.Option = function Option({ value, children }) {
const { setSelected, onChange } = useContext(SelectContext);
return (
<li
onClick={() => {
setSelected(value);
onChange(value);
}}
>
{children}
</li>
);
};- 부모가 Provider로 상태를 감싸고 자식들이 각자
useContext로 꺼내 쓴다. - 자식의 깊이, 순서, 중간 래퍼에 아무런 제약이 없어서 마크업 구조를 완전히 자유롭게 결정할 수 있다.
- Radix UI 같은 주요 라이브러리들이 전부 Context 기반 컴파운드 구조를 채택
실제 사용사례 : 어드민 페이지
어드민이라는 환경의 특수성


- 어드민은 사용자가 내부 관리자이기 때문에 심미성보다 기능 완성 속도가 우선순위가 높다.
- 요구사항의 변주가 크지 않고, 목록, 검색, 폼, 상세와 같이 반복되는 UI 패턴으로 화면간 통일성이 강하다.
- 따라서, 비슷한 컴포넌트의 조합으로 화면이 구성되는, 컴파운드 패턴 기반 공용 컴포넌트 체계에 적합하다.
실제로도 조합만으로 새 기능 완성
function PlanListView() {
return (
<ListView>
<ListView.Header as={<ListViewHeader />} />
<ListView.Filter>
<PlanListFilter />
</ListView.Filter>
<ListView.Menu>
<ListView.Menu.Left>
<PlanListMenu />
</ListView.Menu.Left>
<ListView.Menu.Right>
<SelectListSize />
</ListView.Menu.Right>
</ListView.Menu>
<ListView.Table>
<PlanListTable />
<ListView.Table.Pagination>
<PlanListPagination />
</ListView.Table.Pagination>
</ListView.Table>
</ListView>
);
}
export default PlanListView;function PlanListView() {
return (
<ListView>
<ListView.Header as={<ListViewHeader />} />
<ListView.Filter>
<PlanListFilter />
</ListView.Filter>
<ListView.Menu>
<ListView.Menu.Left>
<PlanListMenu />
</ListView.Menu.Left>
<ListView.Menu.Right>
<SelectListSize />
</ListView.Menu.Right>
</ListView.Menu>
<ListView.Table>
<PlanListTable />
<ListView.Table.Pagination>
<PlanListPagination />
</ListView.Table.Pagination>
</ListView.Table>
</ListView>
);
}
export default PlanListView;- 새 기능 요구가 들어오면 별도의 디자인 시안 없이, 기획 문서만 가지고 이미 만들어진 컴포넌트를 조합해 화면을 완성한다.
- 어떤 컬럼이 들어갈지, 요소의 순서나 배치, 해당 정보 구성만 결정되면 빠르게 개발이 가능하다.
패턴 유지하기
잘 관리되는 팀의 경우,, 패턴 유지 및 관리 비용
![]()
- 공용 컴포넌트 체계의 통제력을 유지하려면 체계 밖의 커스텀 컴포넌트를 극도로 지양해야 하고, 이는 자연스럽게 빡센 컨벤션으로 이어진다.
- 어드민 서비스 관리팀은 이 컨벤션을 린트 규칙, 문서로만 두지 않고, 룰과 함께 에이전트 스킬 형태로 촘촘하게 관리해서 코드 작성 시점에 강제되도록 하고 있다.
- 빠른 개발을 얻은 만큼 컨벤션 문서화, 리뷰, 스킬 등의 지속적인 관리가 필수적이다.
- 주기적으로 마이그레이션 → 에이전트 스킬 활용
우리팀의 경우,, 공용 컴포넌트를 못 건드리는 딜레마
- 공용 컴포넌트에서 불편한 점을 발견해 개선하려 하면, 그 컴포넌트를 사용하는 모든 화면의 영향 범위를 전수 점검해야 한다.

- 모노레포를 사용하여, 공용 컴포넌트가 여러 프로젝트에 영향을 주는 경우
- 다른 프로젝트, 다른팀의 개발자들을 모두 태그하여 영향을 판단하고 리뷰를 받아야한다.
- 따라서 잘 건들이지 않게 되거나, 옵셔널 props를 추가하여 최대한 영향이 적도록 수정이 된다.
- 이것은 공용 컴포넌트로 통일성을 지킨다는 초기 취지에서 벗어나진다.
왜 이런일이 발생할까?
- 프론트엔드 코어팀의 부재
- 어드민 과 b2c의 차이
- 기획실, 디자인팀, 개발팀, 사업팀의 이해관계
- 어드민 → 기획 + 개발 + 피드백이 한곳에서 이루어짐
- b2c → 기획실에서 큰틀 → 개발팀이 개발 → 사업팀이 피드백
- 기획실, 디자인팀, 개발팀, 사업팀의 이해관계
그래서 B2C에서는 하이브리드 방식, 토큰은 강제 & 컴포넌트는 선택
- B2C 프로젝트에서는 어드민과 달리 전면적인 컴포넌트 공용화 대신 하이브리드 방식을 택했다.
- 폰트, 색상 같은 디자인 토큰 레이어는 반드시 공통 정의를 따르도록 강제한다.
- 컴포넌트 레이어는 버튼, 모달, GNB처럼 사용 빈도가 높은 소수만 공용으로 사용한다.
- 그 외의 컴포넌트는 해당 기능의 맥락에 맞게 자유롭게 구현
- 토큰을 쓰는 한 브랜드 일관성의 하한선은 유지된다.
- 즉 체계의 강제력을 레이어별로 다르게 가져가는 전략
마치며
패턴의 성패는 코드가 아니라 환경이 결정
- 같은 컴파운드 패턴이라도 어드민에서는 최고의 생산성 도구였고, B2C에서는 유지 비용이 효익을 넘어섰다.
- 요구사항의 변주가 적고 통일성이 강한 환경일수록 공용화의 경계를 넓게, 변형이 많은 환경일수록 좁게
일관성은 공짜가 아니다
- "조합만으로 빠른 개발"이라는 장점은 빡센 컨벤션, 린트 규칙, 에이전트 스킬, 주기적 마이그레이션이라는 지속적인 관리 비용 위에서만 성립한다.
- 이 비용을 지불할 주체(코어팀 또는 관리 조직)가 없다면, 공용 컴포넌트는 개선되지 못한 채 옵셔널 props만 쌓이며 초기 취지에서 멀어진다.
기술 문제처럼 보이는 것의 뿌리는 조직 구조
- 어드민은 기획 & 개발 & 피드백이 한곳에서 이루어지지만,
- B2C는 기획실 → 개발팀 → 사업팀으로 분리되어 아키텍처를 고려하지 않은 변형이 계속 주입된다.
- 공용 컴포넌트가 자꾸 깨지는 이유를 코드에서만 찾으면 답이 안 나오는 이유다.