Command Palette

Search for a command to run...

GitHub Profile
Blog

shadcn/ui 위에 KRDS를 얹는 래퍼 전략

krdscn/ui
Published on
  • KRDS
  • shadcn/ui
  • 아키텍처
  • RSC

shadcn base를 불가침으로 두고 KRDS 래퍼 계층을 쌓는 krdscn/ui의 구조를 정리합니다. 3계층 설계, 배럴을 지운 이유, 런타임 디스패처까지.

krdscn/ui의 핵심 설계 결정은 하나입니다: KRDS를 처음부터 만들지 않고, shadcn/ui 위에 얹는다. 이번 글은 그 "얹는 방법"의 구체적인 규칙들입니다.

3계층 구조

components/ui/           # shadcn 원본 56개 — 불가침
components/ui/krds/      # KRDS 래퍼 42개
components/ui/dynamic/   # 런타임 디스패처
  • ui/는 불가침 계층입니다. 수정·리네임·확장·이동 전부 금지. shadcn CLI로 재설치하거나 업데이트해도 KRDS 커스텀이 덮어써질 일이 없습니다.
  • ui/krds/가 래퍼 계층입니다. 원칙은 재구현이 아니라 감싸기 — 42개 래퍼 파일이 shadcn base를 조합하고 KRDS 토큰을 덧입힙니다.
  • ui/dynamic/은 디스패처입니다. 같은 <Button>을 shadcn 룩과 KRDS 룩으로 런타임 전환합니다.

래퍼가 base를 어떻게 감싸는지는 Modal이 잘 보여줍니다. 포커스 트랩·포탈·ESC 닫기는 전부 Dialog primitive의 것이고, 래퍼가 하는 일은 KRDS 토큰을 입히는 것뿐입니다.

components/ui/krds/(layout)/modal.tsx
<DialogPrimitive.Content
  className={cn(
    "fixed top-[50%] left-[50%] ...",
    // KRDS 토큰만 덧입힘
    "bg-krds-surface border-krds-border rounded-[12px] border p-6",
    variantClass,
    className
  )}
>

"이미 검증된 동작을 다시 만들지 않는다"는 원칙은 실전에서 공수를 크게 줄였습니다. 메인 메뉴(공공 사이트의 메가메뉴)도 순수 커스텀 구현을 준비해뒀었지만, shadcn NavigationMenu로 충분해서 폴백이 필요 없었습니다.

그룹 폴더 — 괄호는 라우트 그룹이 아니다

래퍼는 KRDS 공식 분류대로 (action), (selection), (layout) 같은 그룹 폴더에 삽니다. Next.js의 라우트 그룹 문법처럼 보이지만 app/ 밖이라 라우팅 의미는 없습니다. import 경로가 곧 카테고리가 되도록 한 폴더 컨벤션일 뿐입니다.

import { Button } from "@/components/ui/krds/(action)/button"
import { Checkbox } from "@/components/ui/krds/(selection)/checkbox"

배럴을 만들었다가 지웠다

처음엔 krds/index.ts 배럴에서 전부 export했습니다. 지금은 배럴이 없습니다. 의도적으로 지웠습니다.

  • 배럴은 하나의 import가 42개 파일을 잠재적으로 끌어들입니다. 트리셰이킹이 되더라도 번들러 분석 비용과 순환 참조 위험이 남습니다.
  • 소유 파일에서 직접 import하면 diff가 안정적입니다 — 새 컴포넌트 추가가 배럴 파일 수정을 유발하지 않습니다.

대신 Modal처럼 여러 부분으로 이뤄진 compound 컴포넌트는 같은 파일에서 sub-part를 모두 named export합니다 (ModalRoot, ModalTrigger, ModalContent, …). "파일 하나 = import 문 하나" 규칙입니다.

런타임 디스패처

ui/dynamic/의 디스패처는 UI 시스템 설정에 따라 두 구현 중 하나를 렌더합니다.

components/ui/dynamic/button.tsx
export function Button(props: ButtonProps) {
  const system = useUISystem()
  if (system === "krds") return <KrdsButton {...props} />
 
  const { variant, size, ...rest } = props
  return (
    <ShadcnButton
      variant={VARIANT_FALLBACK[variant ?? "default"]}
      size={SIZE_FALLBACK[size ?? "default"]}
      {...rest}
    />
  )
}

KRDS에만 있는 variant는 폴백 매핑으로 shadcn에 흡수됩니다 (tertiary → outline, text → ghost, xl → lg). 카탈로그의 199개 예제도 전부 이 디스패처를 통해 import해서, 예제 자체가 두 시스템 토글의 대상이 됩니다.

규칙은 문서가 아니라 코드에 가깝게

래퍼 계층의 규칙은 AGENTS.md에 명문화돼 있습니다. 몇 가지만 추리면:

  • shadcn-first — Radix를 직접 잡지 말고 shadcn base를 조합
  • 본문에서 var(--krds-*) 직접 사용 금지, 토큰은 Tailwind 유틸 클래스로만
  • hex 하드코딩 금지 — 라이트/다크 자동 전환되는 시맨틱 토큰 사용
  • named export만, 절대경로 import만
  • 모든 래퍼 파일 첫 줄에 // rsc:safe 또는 // rsc:client 마커 — 서버 컴포넌트 호환 여부를 파일이 스스로 선언

마지막 규칙 덕에 "use client"가 습관적으로 번지는 걸 막을 수 있었습니다. 순수 표현 컴포넌트는 RSC 호환으로 남습니다.

예외도 규칙의 일부다

"항상 shadcn을 감싼다"가 절대 규칙은 아닙니다. shadcn과 깔끔하게 대응되는 래퍼는 감싸고, 그렇지 않으면 승격합니다. 대표적인 예외가 Button인데 — variant×color 2차원 매트릭스를 다루려면 cva를 완전히 통제해야 해서 native <button> + cva로 직접 구현했습니다. 이 이야기는 시리즈 뒤쪽의 Button 딥다이브에서 자세히 다룹니다.

참고로 Radix에 없는 primitive는 다른 라이브러리로 보완합니다. Combobox가 그 경우로, 저장소에서 유일하게 @base-ui/react를 쓰는 곳입니다.

정리

  • shadcn 원본은 불가침, KRDS는 별도 래퍼 계층, 전환은 디스패처
  • 배럴 대신 소유 파일 직접 import — 트리셰이킹과 안정적 diff
  • 규칙(AGENTS.md)과 마커(// rsc:safe)로 구조가 무너지지 않게 강제
  • 감쌀 수 없으면 승격 — 예외까지 규칙으로 관리

다음 글에서는 이 래퍼들이 입는 옷 — KRDS 디자인 토큰을 Tailwind CSS v4로 옮긴 과정을 다룹니다.

Command Palette

Search for a command to run...