컴포넌트 조합 우회
이 사례의 핵심은 “컴포넌트를 잘 조합하자"가 아니라
허용되지 않은 조합은 아예 만들 수 없게 만드는 것이다.
개요
렌더링 경로를 고정해도 UI는 여전히 무너질 수 있다.
그 이유는 컴포넌트 조합 자체가 우회 경로가 되기 때문이다.
Glif에서는 다음과 같은 문제를 다룬다.
- renderer는 사용한다
- 상태도 Store를 거친다
- 하지만 컴포넌트 조합이 무제한으로 열려 있다
이 상태에서는 구조가 다시 흐트러진다.
- 잘못된 부모 아래에 컴포넌트 배치
- 컨텍스트 없는 사용
- slot 규칙을 무시한 children 삽입
- 표현 규칙을 깨는 임의 래핑
즉 문제는 컴포넌트가 아니라
컴포넌트가 결합되는 방식이 구조 밖으로 새는 것이다.
이 문서의 질문
이 문서는 하네스 개요에서 정리한 다섯 공통 패턴 중 contract flattening에 가장 가깝다.
핵심 질문은 다음과 같다.
어떤 컴포넌트가 어떤 자리에서만 성립하는지가 구조적으로 잠겨 있는가
문제
다음과 같은 코드가 반복적으로 등장한다.
ListItem을List없이 단독 사용ToolbarAction을Toolbar밖에서 사용Card.Header,Card.Body,Card.Footer순서를 무시한 조합- 허용되지 않은
children을 임의로 삽입
이 방식은 처음에는 “조금 유연한 조합"처럼 보인다.
하지만 실제로는 다음 문제를 만든다.
- 컴포넌트 트리 일관성 붕괴
- 컨텍스트 의존성 누락
- 스타일 규칙 파편화
- 접근성 구조 붕괴
- 같은 역할의 UI가 서로 다른 형태로 중복 생성
즉 문제는 재사용이 아니라
조합 규칙이 없는 재사용이다.
문제의 본질
사람과 AI는 컴포넌트를 구조 단위가 아니라
재료 조각처럼 다루는 경향이 있다.
기준은 대체로 단순하다.
- 눈에 보이는 결과를 가장 빨리 만든다
- 기존 계층 의미보다 현재 필요한 출력에 집중한다
- 부모-자식 계약보다 props만 맞으면 사용하려 한다
그래서 다음과 같은 우회가 쉽게 발생한다.
- 컨텍스트가 필요한 컴포넌트를 단독 사용
- slot 규칙이 있는 컴포넌트에 임의 children 삽입
- 레이아웃 책임을 leaf 컴포넌트에 밀어넣음
즉 사람과 AI는 조합 규칙을 보존하기보다
조합 규칙을 생략한 최소 경로를 만든다.
목표 구조
Glif에서는 컴포넌트 조합을 다음처럼 고정한다.
특정 컴포넌트는 특정 부모 아래에서만 사용된다
slot 구조가 있는 경우 허용된 자식만 받는다
조합 규칙을 벗어나면 타입 또는 런타임에서 실패한다
하네스 적용 위치
이 문제는 한 군데에서 막히지 않는다.
Glif에서는 네 층에서 동시에 닫는다.
구조 레벨
컴포넌트를 계층으로 나눈다.
Layout 컴포넌트
Container 컴포넌트
Slot 컴포넌트
Leaf 컴포넌트
그리고 각 계층이 어떤 하위를 가질 수 있는지 제한한다.
즉 “아무데서나 가져다 쓸 수 있는 재료"가 아니라
정해진 위치에서만 성립하는 부품으로 만든다.
타입 레벨
허용된 조합만 타입으로 통과시킨다.
특정 slot만 children으로 허용
특정 부모 안에서만 생성 가능한 타입 정의
필요한 context 없이는 생성 자체가 불가능한 props 설계
검증 레벨
정적 분석으로 잘못된 조합을 탐지한다.
제한된 subcomponent 단독 import 금지
지정된 parent 없이 사용 시 lint error
금지된 children 구조 탐지
실행 레벨
런타임에서도 최종 검증한다.
provider 없는 사용은 즉시 throw
잘못된 slot 조합은 render 실패
fallback 없이 실패를 노출
실제 구현
조합 진입점을 하나로 고정한다
compound component 패턴의 핵심은 루트와 하위 컴포넌트의 관계를 이름이 아니라 구조로 고정하는 것이다.
export const Card = Object.assign(CardRoot, {
Header: CardHeader,
Body: CardBody,
Footer: CardFooter,
});중요한 점은 CardHeader 같은 하위 컴포넌트를 개별 진입점으로 열지 않고, 외부에서는 Card.Header처럼 승인된 경로로만 접근하게 만드는 것이다.
허용된 children만 통과시키는 타입을 둔다
type CardChild =
| React.ReactElement<typeof Card.Header>
| React.ReactElement<typeof Card.Body>
| React.ReactElement<typeof Card.Footer>;
type StrictCardProps = {
children: CardChild | CardChild[];
};이렇게 해야 div, button, 임의 custom component를 Card 아래에 바로 꽂는 경로가 줄어든다.
context 없는 사용은 즉시 실패해야 한다
const ToolbarContext = React.createContext<ToolbarContextValue | null>(null);
function ToolbarAction({ id, children }: { id: string; children: React.ReactNode }) {
const ctx = React.useContext(ToolbarContext);
if (!ctx) {
throw new Error("Toolbar.Action must be used within Toolbar");
}
ctx.registerAction(id);
return <button>{children}</button>;
}compound component가 의미를 가지려면, 루트 없이 하위 조각만 쓰는 경로도 조용히 허용되면 안 된다.
타입만으로 부족하면 런타임에서도 slot 구조를 닫는다
any, 동적 children, 외부 라이브러리 경유 우회 때문에 최종적으로는 런타임 검증도 유용하다. 핵심은 조합 규칙이 naming convention이 아니라 실제 contract로 남게 만드는 것이다.
테스트도 허용된 조합만 반복해야 한다
테스트가 CardHeader 단독 렌더나 ToolbarAction 단독 사용부터 허용하면 production 코드도 같은 우회를 배우게 된다. fixture와 helper도 정상 조합만 제공하는 편이 맞다.
위반 예시와 실패 결과
다음 코드는 반드시 실패해야 한다.
<Card>
<div>unexpected</div>
</Card>실패 결과:
Error: Card only accepts Card.Header, Card.Body, Card.Footer또는
<Toolbar.Action id="save">Save</Toolbar.Action>실패 결과:
Error: Toolbar.Action must be used within Toolbar또는 직접 import:
import { CardHeader } from "@/ui/card/CardHeader";실패 결과:
error: Use Card.Header instead of direct CardHeader import이 순간부터 조합 규칙은 문서가 아니라
실행 가능한 제약이 된다.
보조 강제 수단
이 케이스의 보조 강제 수단은 조합 surface를 export shape, 타입, runtime에서 동시에 닫는 데 있다.
subcomponent export만 막아도 children가 새고, 타입만 두면 direct import가 남고, runtime check가 없으면 잘못된 조합이 조용히 통과한다.
- export 구조 → 진입점 제한
- TypeScript 타입 → 허용된 children 제한
- ESLint → direct import 차단
- runtime assertion → context/slot 위반 차단
- test fixture → 우회 학습 차단
이 다섯 겹이 함께 있어야 compound component가 naming pattern이 아니라 실제 조합 contract로 남는다.
결과
하네스 적용 이후 변화는 명확하다.
- 루트와 subcomponent의 관계가 구조적으로 고정된다.
- 허용되지 않은 children이나 잘못된 조합 순서는 타입 또는 런타임에서 즉시 실패한다.
- 조합 규칙이 화면별 관습이 아니라 반복 가능한 contract로 남는다.
실무 적용 팁
하위 컴포넌트는 개별 export하지 않는다
문서를 써도 소용없다.
export가 열려 있으면 우회는 반드시 생긴다.
“유연한 children"은 기본값이 아니다
children 자유도는 편의가 아니라 우회 경로다.
slot 구조가 있으면 가능한 한 닫아야 한다.
fallback으로 복구하지 않는다
잘못된 조합을 조용히 보정하면
AI는 그 경로를 정상 사용법으로 학습한다.
요약
이 케이스에서 하네스는 다음과 같이 구성된다.
subcomponent는 제한된 진입점으로만 노출된다
허용된 children만 타입과 런타임에서 통과한다
context 없는 사용은 즉시 실패한다
direct import와 임의 조합은 lint로 차단된다
테스트도 정상 조합 fixture만 허용한다
결과적으로
컴포넌트 조합 경로는 제한되고
구조와 표현 규칙은 일관되게 유지되며
임의 조합이라는 우회 경로는 제거된다
즉
“컴포넌트를 올바르게 조합하라"가 아니라
“허용되지 않은 조합은 아예 만들 수 없다"가 하네스다.