Slot 계약 우회

Slot 계약 우회

이 사례의 핵심은 “slot을 잘 쓰자"가 아니라
slot contract를 거치지 않으면 아무것도 끼워 넣을 수 없게 만드는 것이다.

개요

shared schema와 renderer contract까지 고정해도
UI는 여전히 구조를 무너뜨릴 수 있다.

그 이유는 최종 조립 단계에서
container가 허용한 slot 외의 내용을 다시 받아들일 수 있기 때문이다.

대표적으로 다음과 같은 방식이다.

  • children에 아무 노드나 넣기
  • named slot 대신 임의 prop으로 컴포넌트 주입
  • slot registry를 거치지 않고 raw element 전달
  • layout shell에 허용되지 않은 toolbar, panel, footer 삽입

즉 문제는 조합 자체가 아니라
어디에 무엇을 끼워 넣을 수 있는지가 구조 밖으로 새는 것이다.

Glif에서는 다음을 목표로 한다.

  • slot은 이름과 타입이 닫힌 contract를 가진다
  • container는 허용된 slot payload만 받는다
  • 위반은 코드 리뷰가 아니라 타입, 정적 분석, 런타임에서 실패한다

이 문서의 질문

이 문서는 하네스 개요에서 정리한 다섯 공통 패턴 중 contract flattening에 가장 가깝다. 핵심 질문은 다음과 같다.

container가 허용한 slot 외의 조합이 실제로 막혀 있는가

문제

다음과 같은 코드가 반복적으로 생성된다.

<Panel>
  <SearchBox />
  <DangerButton />
  <RandomWidget />
</Panel>

또는

<Shell
  header={<CustomHeader />}
  sidebar={<Anything />}
  footer={<UnknownFooter />}
/>

또는

const extra = config.extraComponent;
return <Toolbar>{React.createElement(extra)}</Toolbar>;

이 방식은 빠르게 화면을 만든다.
하지만 구조적으로는 다음 문제를 만든다.

  • container의 의미가 흐려짐

  • 같은 shell이 화면마다 전혀 다른 구조를 가짐

  • 접근성 및 레이아웃 contract가 깨짐

  • registry와 composition rule이 다시 무력화됨

  • AI가 slot contract를 생략한 가장 짧은 주입 경로를 계속 재생산함

즉 문제는 확장성이 아니라
slot surface가 열려 있는 것이다.

문제의 본질

AI는 slot을 구조적 계약보다
편하게 뭔가 꽂을 수 있는 구멍으로 보는 경향이 있다.

기준은 단순하다.

  • 필요한 컴포넌트를 넣기만 하면 된다

  • slot 이름과 허용 타입을 찾는 과정이 번거롭다

  • children은 아무거나 받으니 가장 빠르다

  • config에서 component reference를 바로 주입하면 쉽다

그래서 다음과 같은 우회가 쉽게 발생한다.

  • named slot 대신 generic children 사용

  • slot 전용 payload 대신 raw JSX 전달

  • registry key 대신 component reference 전달

  • 허용되지 않은 위치에 action/control 삽입

즉 사람과 AI는 slot contract를 보존하기보다
빈 칸에 바로 꽂는 경로를 만든다.

목표 구조

Glif에서는 slot 연결 경로를 다음처럼 고정한다.

D2 diagram
핵심 규칙은 단순하다.
  • slot은 이름과 허용 payload가 정해져 있다

  • container는 허용된 slot만 렌더한다

  • raw children과 raw component injection은 허용되지 않는다

하네스 적용 위치

이 문제는 네 레벨에서 동시에 막는다.

구조 레벨

slot contract를 schema로 끌어올린다.

  • header

  • toolbar.leading

  • toolbar.trailing

  • sidebar.primary

  • footer

같은 식으로 slot 이름을 닫는다.

그리고 각 slot이 받을 수 있는 payload도 제한한다.

즉 container는 임의 children을 먹는 박스가 아니라
정의된 slot surface를 가진 조립 프레임이 된다.

검증 레벨

정적 분석으로 slot 우회를 탐지한다.

  • 특정 container에서 unrestricted children 금지

  • raw JSX prop injection 금지

  • slot key 대신 component reference 전달 금지

  • 허용되지 않은 slot name literal 금지

실행 레벨

런타임에서도 slot contract를 검증한다.

  • unknown slot 즉시 throw

  • slot payload type 불일치 시 실패

  • registry에 없는 slot entry 주입 시 실패

테스트 레벨

테스트도 slot fixture를 통해서만 작성한다.

  • container direct children mount 금지

  • slot map fixture만 허용

  • 허용된 slot matrix 검증

실제 구현

slot 이름을 닫힌 surface로 정의한다

slot contract의 출발점은 container가 무엇을 받아줄지 먼저 닫는 것이다.

type ShellSlot =
  | "header"
  | "toolbar.leading"
  | "toolbar.trailing"
  | "sidebar.primary"
  | "footer";

이 순간부터 임의 slot 추가는 JSX 삽입이 아니라 schema 변경이 된다.

각 slot은 payload contract를 가진다

type SlotPayloadMap = {
  header: { type: "page_title"; title: string };
  "toolbar.leading": { type: "search_box"; placeholder: string };
  "toolbar.trailing": { type: "action_group"; actions: string[] };
  "sidebar.primary": { type: "nav_tree"; rootId: string };
  footer: { type: "status_bar"; compact?: boolean };
};

slot은 위치가 아니라 허용된 의미를 가진 contract가 된다. 그래서 header에 footer payload를 넣거나 toolbar.trailing에 random widget을 넣는 경로가 사라진다.

실제 렌더링은 registry를 통해서만 연결한다

const slotRegistry = {
  header: (payload) => <PageTitle title={payload.title} />,
  "toolbar.leading": (payload) => <SearchBox placeholder={payload.placeholder} />,
  "toolbar.trailing": (payload) => <ActionGroup actions={payload.actions} />,
  "sidebar.primary": (payload) => <NavTree rootId={payload.rootId} />,
  footer: (payload) => <StatusBar compact={payload.compact} />,
};

container는 raw component를 받지 않고, slot payload만 받고, 실제 렌더러 선택은 registry가 맡는다.

unrestricted children와 raw JSX slot prop을 닫는다

type ShellProps = {
  slots: Partial<{
    [K in ShellSlot]: SlotPayloadMap[K];
  }>;
};

이 구조의 목적은 단순하다.

  • <Shell><Anything /></Shell> 금지
  • header={<CustomHeader />} 금지
  • component reference를 config에 직접 넣는 경로 금지

slot contract 하네스는 결국 children 자유도를 없애고 승인된 slot assembly만 남기는 것이다.

unknown slot은 즉시 실패한다

export function assertKnownSlot(slot: string): asserts slot is ShellSlot {
  const known: ShellSlot[] = ["header", "toolbar.leading", "toolbar.trailing", "sidebar.primary", "footer"];
  if (!known.includes(slot as ShellSlot)) {
    throw new Error(`Unknown slot: ${slot}`);
  }
}

fallback으로 다른 slot에 집어넣거나 빈 div로 넘기지 않는다. 조용한 복구는 slot contract 위반을 누적시키기 때문이다.

테스트도 slot fixture를 통해 조립한다

const shellFixture: ShellProps["slots"] = {
  header: { type: "page_title", title: "Overview" },
  "toolbar.leading": { type: "search_box", placeholder: "Search docs" },
  footer: { type: "status_bar", compact: true },
};

테스트가 render(<Shell><SearchBox /></Shell>) 같은 우회를 허용하면 production도 같은 습관을 다시 배운다.

위반 예시와 실패 결과

다음 코드는 반드시 실패해야 한다.

<Shell>
  <DangerButton />
</Shell>

실패 결과:

error: Shell does not accept unrestricted children. Use typed slots.

또는

<Shell header={<CustomHeader />} />

실패 결과:

error: Use slot payload contracts instead of raw ReactNode slot injection.

또는 unknown slot 주입:

Error: Unknown slot: toolbar.center

이 순간부터 slot 조립 규칙은 문서가 아니라
실행 가능한 제약이 된다.

보조 강제 수단

이 케이스의 보조 강제 수단은 slot을 위치가 아니라 이름과 payload를 가진 contract surface로 잠그는 데 있다. slot name만 막으면 raw JSX 주입이 남고, payload map만 두면 renderer 연결이 새고, runtime guard가 없으면 unknown slot이 조용히 무시된다.

  • slot name union → slot surface 고정
  • payload map → 허용된 의미 타입 고정
  • slot registry → renderer 연결 권한 집중
  • ESLint / 타입 제한 → unrestricted children, raw node slot 차단
  • runtime slot guard → unknown slot 즉시 실패

이 장치들은 children 조합을 generic bucket에서 승인된 slot assembly로 바꾼다.

결과

하네스 적용 이후 변화는 명확하다.

  • slot이 이름과 payload를 가진 명시적 surface로 고정된다.
  • 임의 children 삽입이 막히고 container 조합이 schema-driven 경로로 돌아온다.
  • 화면 조립에서 무엇이 어디에 들어갈 수 있는지가 다시 추론 가능해진다.

실무 적용 팁

children는 기본값이 아니라 마지막 수단이다

children를 열어두는 순간
slot contract는 거의 항상 무력화된다.

slot은 위치가 아니라 의미를 가져야 한다

left, right만으로 끝내면
곧 random widget 주입 경로가 된다.

container는 renderer가 아니라 frame이다

frame이 무엇이든 받아주기 시작하면
registry와 composition rule은 금방 장식이 된다.

fallback으로 unknown slot을 삼키지 않는다

모르는 slot을 조용히 무시하면
구조 붕괴는 눈에 띄지 않게 누적된다.

요약

이 케이스에서 하네스는 다음과 같이 구성된다.

  • slot 이름은 닫힌 union으로 고정된다

  • 각 slot은 허용된 payload contract만 받는다

  • container는 unrestricted children이나 raw JSX slot injection을 허용하지 않는다

  • 실제 렌더링은 slot registry를 통해서만 연결된다

  • unknown slot과 contract 위반은 타입 또는 런타임에서 즉시 실패한다

결과적으로

  • 조립 surface는 slot contract 중심으로 고정되고

  • container 의미는 일관되게 유지되며

  • direct children injection과 raw component slotting이라는 우회 경로는 제거된다

“slot을 잘 사용하라"가 아니라
“slot contract를 거치지 않으면 아무것도 끼워 넣을 수 없다"가 하네스다.