컴파일 타임 타입 강제, 멱등 스캐폴딩, react-docgen-typescript까지 — krdscn/ui 문서 카탈로그가 코드와 어긋날 수 없게 만든 장치들을 정리합니다.
krdscn/ui의 문서 카탈로그에는 41개 컴포넌트, 199개 예제, 42개 Props 테이블이 있습니다. 이 규모의 문서에서 제일 무서운 건 조용한 드리프트입니다 — 컴포넌트는 바뀌었는데 문서는 그대로인 상태. 그래서 문서가 코드와 어긋나면 빌드가 깨지도록 설계했습니다.
백본: 컴파일 타임 타입 강제
모든 정합성의 중심에 examples-config.ts가 있습니다. 사이드바 항목마다 어떤 예제가 있어야 하는지 선언하는 레지스트리인데, 타입이 이 선언을 강제합니다.
import type { SIDEBAR_GROUPS } from "@/lib/sidebar-nav"
type SidebarItemId = (typeof SIDEBAR_GROUPS)[number]["items"][number]["id"]
export const EXAMPLES_CONFIG = {
masthead: ["MastheadDefault"],
header: ["HeaderDefault", "HeaderLoggedIn"],
// ... 41개 slug
} as const satisfies Record<SidebarItemId, readonly [string, ...string[]]>satisfies Record<SidebarItemId, ...> 한 줄이 하는 일:
- 사이드바에 컴포넌트를 추가하면 이 레지스트리에 키가 없어서 컴파일 에러
- 사이드바에서 빼면 여분 키가 남아서 컴파일 에러
- 값이
[string, ...string[]]튜플이라 예제 0개인 컴포넌트도 컴파일 에러
한국어 설명 레지스트리(component-copy.ts)도 같은 타입으로 강제됩니다. "새 컴포넌트를 추가했는데 설명 쓰는 걸 깜빡했다"가 타입 에러로 잡힙니다.
스캐폴딩은 멱등하게
선언과 실제 파일의 간극은 scaffold-examples.ts가 메웁니다. EXAMPLES_CONFIG의 모든 (slug, 이름) 쌍을 돌며 파일이 없으면 TODO 스텁을 만들고, 있는 파일은 절대 덮지 않습니다. 재실행하면 변경 0건 — 멱등입니다. config가 곧 할 일 목록이고, 스캐폴딩이 파일 존재를 보장하고, 타입이 삼자 정합을 지킵니다.
정적 import 199개 — RSC가 가르쳐준 것
199개 예제는 example-registry.ts에서 전부 정적 import됩니다. 처음엔 당연히 () => import(...) 지연 로딩을 썼는데, 파일 상단 주석에 그 교훈이 남아 있습니다.
// Uses static ESM imports (not lazy `() => import(...)`) so that the Next.js
// bundler fully resolves each "use client" module as a client reference at
// compile time. Lazy dynamic imports preserved the "use client" boundary
// incorrectly when called from a Server Component, causing RSC serialization
// errors for examples with interactive event handlers (e.g. TagClosable).서버 컴포넌트에서 지연 import를 호출하면 "use client" 경계가 잘못 유지되어, 이벤트 핸들러가 있는 예제에서 RSC 직렬화 오류가 났습니다. 정적 import여야 번들러가 컴파일 타임에 client reference로 확정합니다. 199줄의 import가 못생겨 보여도, 그게 정답인 이유가 있는 겁니다.
미리보기와 소스는 같은 파일에서
문서 페이지의 "실행되는 미리보기"와 "소스 코드 표시"는 흔히 따로 관리되다 어긋납니다. 여기선 구조적으로 어긋날 수 없습니다 — 둘 다 같은 예제 파일 하나에서 나오거든요.
- 실행: 클라이언트 컴포넌트가 레지스트리에서 예제를 꺼내 렌더
- 소스: 서버 컴포넌트가
server-only+fs.readFileSync로 같은.tsx원본을 읽어 코드블록으로 렌더
서버가 소스 텍스트를 미리 렌더해 prop으로 내리고, 클라이언트는 실행만 담당하는 분리입니다. 예제를 고치면 미리보기와 소스가 동시에 바뀝니다.
Props 테이블은 타입에서 생성
Props 문서는 손으로 쓰지 않습니다. react-docgen-typescript가 래퍼의 TypeScript 타입에서 추출합니다.
const parser = docgen.withCustomConfig(TSCONFIG, {
componentNameResolver: (_exp, source) => fileToComponentName(source.fileName),
propFilter: (prop) => {
if (!prop.parent) return true
if (KEEP_HTML_PROPS.has(prop.name)) return true // className, disabled 등
if (prop.parent.fileName.includes("@types/react")) return false
return true
},
})포인트 두 가지:
- propFilter로 노이즈 제거 — React 기본 prop 수백 개를 다 실으면 테이블이 쓰레기가 됩니다.
@types/react출신은 버리되, 문서에 유용한className·disabled같은 것만 화이트리스트로 살립니다. - 개수 단언 — 생성된 컴포넌트 수가 래퍼 파일 수와 다르면
process.exit(1). 파싱이 조용히 실패해서 문서가 비는 사고를 CI 단계에서 잡습니다.
출력 JSON은 컴포넌트명·prop명 모두 알파벳 정렬해서 diff가 결정론적입니다.
전부 정적으로 굽는다
문서 라우트는 force-static + dynamicParams: false로 42개 MDX를 전부 사전 렌더링하고, 예제 199개는 각각 독립 정적 라우트(/preview/...)로 구워 iframe으로 격리해 보여줍니다. 런타임 서버 의존이 없으니 문서 사이트 전체가 CDN에서 끝납니다.
정리
satisfies한 줄로 사이드바·예제·한국어 설명의 3자 정합을 컴파일 타임에 강제- 스캐폴딩은 멱등, 레지스트리는 정적 import(RSC 직렬화 교훈)
- 미리보기와 소스는 같은 파일에서 — 어긋날 수 없는 구조
- Props는 타입에서 생성 + 개수 단언으로 조용한 실패 차단
다음이 시리즈 마지막 글입니다. "KRDS를 구현했다"를 증명하는 문제 — 공식 KRDS Storybook과의 시각 비교 파이프라인을 다룹니다.