저는 React·Next.js 진영에서 일하며 AntD, MUI, HeroUI, shadcn/ui 같은 라이브러리를 끊임없이 갈아 끼워 왔습니다. 매번 이런 의문이 남았습니다. 이 라이브러리들이 풀어준 문제를, 한 번도 처음부터 끝까지 직접 설계해 본 적은 없었다는 것입니다.
이번에 'junui' 라는 1인 포트폴리오 모노레포를 시작하면서 그 빈자리를 채워보기로 했습니다. 이 글은 Phase 0 — 부트스트랩 단계의 결과를 단순 stack 나열이 아니라 결정의 배경과 빠졌던 함정 위주로 정리합니다.
1. 4단계 누적 계획
포트폴리오의 가장 큰 함정은 단발성 프로젝트로 끝나는 것입니다. 한 번 만들어 GitHub 에 올리고, 다음 프로젝트는 또 처음부터 시작하는 패턴은 일정 대비 남는 자산이 적습니다. 그래서 이번에는 한 단계의 산출물이 다음 단계의 토대가 되도록 설계했습니다.
Phase 1: React/Next.js 컴포넌트 라이브러리 (NPM 배포) Phase 2: 1을 자기 소비하는 실서비스 한 개 Phase 3: 2 위에 공공데이터 + WebGL 3D 인터랙티브 레이어 Phase 4: 3을 React Native 로 모바일 출시
도메인은 검색·필터·지도·디테일이 모두 자연스럽게 등장하는 실생활 영역으로 골랐습니다. 공공데이터 활용 가치가 높고, 컴포넌트 라이브러리의 거의 모든 surface 가 한 번씩 등장하는 영역이어야 라이브러리 입증과 실서비스 완성도 둘 다에 기여하기 때문입니다.
Phase 1 ~ 4 는 모두 단일 모노레포 안에서 자기 컴포넌트·자기 토큰·자기 비즈로직을 공유합니다. 외부 라이선스나 일회성 의존성에 묶이지 않은, 처음부터 끝까지 제 자산입니다.
AntD 퀄리티를 글자 그대로 추적하면 2년 이상이 걸립니다. 본 플랜은 AntD 의 큐레이트 서브셋(25~30개 컴포넌트)으로 정의해 추적 가능한 일정으로 묶었습니다.
2. 모노레포 결정 (pnpm + Turborepo)
처음 떠오른 갈림길은 "라이브러리 한 레포, 앱 한 레포로 분리할까" 였습니다. 결론은 단일 모노레포로 가닥을 잡았습니다.
도구 조합은 다음과 같이 정리했습니다.
@junui/ui, @junui/tokens, @junui/utils)만 ignore 리스트에서 제외해 의도를 명시했습니다.junui/ ├── packages/ │ ├── ui/ # @junui/ui — 컴포넌트 라이브러리 │ ├── tokens/ # @junui/tokens — 디자인 토큰 │ ├── utils/ # @junui/utils — cn 헬퍼 등 공통 │ └── ... # api 어댑터, RN 호환 서브셋 (이후 단계) └── apps/ ├── docs/ # Storybook + 도큐 ├── web/ # Phase 2~3 실서비스 └── mobile/ # Phase 4 RN/Expo
3. 디자인 토큰을 컴포넌트보다 먼저
흔한 실수는 첫 컴포넌트(Button, Card 같은) 부터 만들고 나중에 토큰을 추출하는 것입니다. 이 순서는 거의 매번 토큰이 컴포넌트 구현 디테일에 의존하는 결과로 끝납니다.
저는 반대로 갔습니다. 컴포넌트 한 줄도 짜지 않은 상태에서 @junui/tokens 부터 박았습니다.
:root { /* 색상 */ --junui-color-primary-500: #3b82f6; --junui-color-primary-600: #2563eb; /* 시맨틱 (라이트 테마) */ --junui-bg: var(--junui-color-white); --junui-fg: var(--junui-color-neutral-900); --junui-accent: var(--junui-color-primary-600); --junui-ring: var(--junui-color-primary-500); /* spacing, radius, shadow, motion ... */ } [data-theme='dark'] { --junui-bg: var(--junui-color-neutral-950); --junui-fg: var(--junui-color-neutral-50); }
다크모드 전략으로는 prefers-color-scheme 자동 추종 대신 data-theme="dark" 속성 토글을 선택했습니다. 사용자가 직접 선택할 수 있어야 하고, 향후 data-theme="luxury" 같은 브랜드 테마 확장도 동일한 메커니즘으로 가능하기 때문입니다.
JS 측에는 타입드 토큰 export 도 함께 제공합니다.
import { color, space } from '@junui/tokens'; <div style={{ background: color.bg, color: color.fg }} /> // color.bg === 'var(--junui-bg)'
CSS-first 토큰 시스템은 Tailwind v4 의 컨셉과 자연스럽게 정합합니다. 컴포넌트 측에서는 bg-(--junui-accent) 같은 임의 값 utility 로 토큰을 직접 참조합니다.
4. 첫 컴포넌트 Button — 그리고 'use client' 함정
라이브러리에 가장 흔한 컴포넌트인 Button 부터 만들었습니다. 6 variants × 4 sizes × block 옵션을 cva(class-variance-authority)로 깔끔하게 표현했습니다.
'use client'; import { cn } from '@junui/utils'; import { cva, type VariantProps } from 'class-variance-authority'; import { forwardRef, type ButtonHTMLAttributes } from 'react'; const buttonVariants = cva([...], { variants: { variant: { primary, secondary, outline, ghost, destructive, link }, size: { sm, md, lg, icon }, block: { true: 'w-full', false: '' }, }, defaultVariants: { variant: 'primary', size: 'md', block: false }, });
여기서 약 30분을 잡아먹은 함정이 있었습니다. tsup 으로 번들된 dist 파일에서 'use client' 디렉티브가 사라지는 문제.
Next.js App Router 는 클라이언트 컴포넌트를 식별할 때 import 되는 파일의 첫 줄에 "use client" 가 있는지 확인합니다. 하지만 tsup(esbuild + Rollup 조합)은 번들링 과정에서 module-level 디렉티브를 silently strip 합니다. banner: { js: '"use client";' } 도, source-level 디렉티브도, splitting 옵션도 — 셋 다 결과는 같았습니다.
해결 방법은 우회였습니다. tsup 의 onSuccess 훅에서 dist 의 모든 *.js, *.cjs 파일 최상단에 직접 "use client"; 를 prepend 하는 후처리.
// tsup.config.ts
const USE_CLIENT = '"use client";\n';
const prependUseClient = (file: string) => {
const src = readFileSync(file, 'utf8');
if (src.startsWith(USE_CLIENT)) return;
writeFileSync(file, USE_CLIENT + src);
};
export default defineConfig({
entry: ['src/index.ts', 'src/components/*/index.ts'],
format: ['esm', 'cjs'],
dts: true,
splitting: false,
external: ['react', 'react-dom', '@junui/tokens', '@junui/utils'],
onSuccess: async () => {
const dist = resolve(__dirname, 'dist');
for (const f of walk(dist)) prependUseClient(f);
},
});
splitting 을 끄고 entry 를 per-component 로 분리한 이유도 같은 맥락입니다. splitting 이 켜져 있으면 컴포넌트 코드가 공유 chunk 로 빠지면서 entry 파일이 단순 re-export 가 됩니다. 그러면 Next.js 가 client 모듈로 인식할 단서가 없어집니다.
세 줄짜리 후처리 로직이지만, 30분간의 디버깅과 Module level directives cause errors when bundled 라는 모호한 경고문구 사이를 헤매다 도착한 결론입니다. 작업 일지에 기록할 가치가 있는 함정이었습니다.
5. 검증 파이프라인
Phase 0 종료 시점에 다음이 모두 통과해야 다음 단계로 넘어간다는 기준을 박았습니다.
| 단계 | 결과 |
|---|---|
pnpm format:check | All matched files use Prettier code style |
pnpm lint | 12/12 tasks |
pnpm typecheck | 12/12 tasks |
pnpm build | 10/10 tasks (per-component dist + 'use client' 보존 검증) |
pnpm test | Button 6/6 (render / type / click / disabled / variant 클래스 / className 병합) |
pnpm storybook | Storybook 9.1.20 boot — manager 92ms + preview 1.4s |
CI 워크플로(GitHub Actions)도 동일 시퀀스를 돌립니다. 부트스트랩 단계에서 한 번이라도 깨지면 다음 컴포넌트 추가 비용이 매번 두 배가 되기 때문입니다.
마치며
Phase 0 부트스트랩은 "결과를 만드는 단계가 아니다" 라는 이유로 가볍게 넘기기 쉬운 구간입니다. 하지만 부트스트랩에서 결정한 도구 조합·빌드 파이프라인·디자인 토큰 구조는 이후 모든 컴포넌트·앱·릴리스의 비용을 결정합니다. 한 번 잘못 박힌 결정은 한참 지난 후에야 발견되고, 그때는 되돌리기 비용이 훨씬 큽니다.
'use client' 디렉티브가 tsup 번들에서 사라지는 30분짜리 함정은 이 사실을 가장 잘 보여준 사건이었습니다. 기술 스택이 최신이라는 것과 그 조합이 제 유즈케이스에서 정상 동작하는 것은 다른 문제입니다.
다음 단계는 Form 패밀리(Input · Label · FormField · RHF/Zod 통합)입니다. 라이브러리가 form-driven UI 를 한 번에 풀어낼 수 있는 시점부터 Phase 2 의 실서비스 구축을 병렬로 시작할 수 있게 됩니다.