사이트 셋이 디자인과 코드를 나눠 쓰게

2025.12 ~ 2026.04 (운영 중)

블로그·포트폴리오·어드민 세 앱과 공유 패키지를 묶은 모노레포입니다.

과제

  • 세 앱이 API 타입·UI 컴포넌트·MDX 렌더링을 각자 중복 보유
  • 백엔드 저장소가 분리돼 API 스펙 변경을 프론트가 알 방법이 없음

담당 범위

  • 앱 3개 + 공유 패키지 3개 구조 설계: Turborepo, pnpm (1인)
  • OpenAPI 스펙 동기화와 openapi-typescript 코드젠 파이프라인
  • GitHub Actions 5종, 커밋 게이트, Dependabot 격리 정책

설계 판단

  • Turborepo 모노레포로 분할하고 앱별 FSD 레이어 적용 → 공용 코드 버전 동기화 비용 제거
  • 백엔드 스펙을 파일로 복사해 타입 생성 → 수동 타입이 숨기던 nullable을 컴파일 에러로 검출
  • 스키마 없는 응답을 고유 심볼로 브랜딩 → 대입 시점에 컴파일 실패 (대안: never)

결과 지표

  • OpenAPI 경로 51개 / 오퍼레이션 72개 / 스키마 80개, 생성 타입 4,997줄
  • 커밋 327건, 머지 커밋 57건
  • 공용 UI 컴포넌트 21개
원문 자세히 보기

과제

블로그·포트폴리오·어드민이 따로 놀면서 API 타입·UI 컴포넌트·MDX 렌더링이 각자 중복돼 있었고, 프런트와 백엔드 저장소가 갈린 탓에 API 스펙이 바뀌어도 프런트가 그것을 알 방법이 없었다. 세 앱을 한 저장소로 묶어 타입·디자인 시스템·캐시 무효화 규칙을 하나의 소스에서 공유하되, 빌드가 백엔드 가용성에 끌려가지 않게 만드는 것이 목표였다.

담당 범위

앱 3개 + 공유 패키지 3개 · 공용 컴포넌트 21개 · 워크플로 5종

1인 개발. (1) 단일 Next.js 블로그를 Turborepo + pnpm workspace로 재구성해 앱 3개 + 공유 패키지 3개 구조로 분할(커밋 f614877), (2) 공유 패키지 설계 — API 클라이언트·ENDPOINTS·OpenAPI 생성 타입(packages/shared), Radix 기반 공용 컴포넌트 21개(packages/ui), MDX 렌더러와 커스텀 컴포넌트(packages/mdx), (3) 백엔드 openapi.json을 바이트 그대로 복사·커밋하는 동기화 스크립트와 openapi-typescript 코드젠 파이프라인 구성, (4) 성공 응답 스키마가 없는 라우트를 고유 심볼로 브랜딩해 컴파일 시점에 잡는 MissingResponseSchema 타입 설계, (5) GitHub Actions 5종(CI, CodeQL, Lighthouse CI, OpenAPI drift 검사, release-please) 구성과 워크플로 YAML 파싱 검사 스텝 추가, (6) Biome + Husky + lint-staged 커밋 게이트, Dependabot 격리 브랜치 정책, (7) 각 앱 src/를 FSD 레이어로 정리하고 위반 4건을 문서로 채증한 뒤 해소.

보기

선택안 · 대안 · 근거 · 비용

단일 Next.js 블로그를 Turborepo + pnpm workspace 모노레포로 쪼개 앱 3개 + 공유 패키지 3개로 재구성하고(커밋 f614877), 각 앱 src/를 FSD 레이어(app → pages → widgets → features → entities → shared)로 나눴다

대안앱별로 저장소를 분리하고 공용 코드를 사내 npm 패키지로 배포하거나, 앱마다 자체 UI 라이브러리를 유지하는 안근거세 앱이 같은 API 타입·캐시 태그·MDX 렌더러를 쓰는데 저장소가 갈라지면 버전 동기화 비용이 상수로 든다. 패키지 배포를 끼우면 공용 코드를 한 줄 고치는 데 배포 사이클이 붙어, 1인 개발에서는 그 오버헤드가 이득을 넘는다.비용FSD 레이어 규칙이 도구로 강제되지 않는다 — Biome에 import 경계 룰이 없어 리뷰로만 잡는데, 실제로 shared가 entities를 import하는 등 위반 4건이 쌓여 별도 문서(docs/FSD-LAYER-VIOLATIONS.md)로 채증한 뒤에야 해소됐다. CI 한 번이 세 앱을 전부 빌드해 앱 하나만 고쳐도 전체가 돌고 PR 피드백이 느려진다.

백엔드가 커밋해 둔 openapi.json을 바이트 그대로 이 저장소에 복사·커밋하고(scripts/sync-openapi.mjs), openapi-typescript로 타입을 생성해 수동 API 타입을 전부 교체했다. ENDPOINTS의 정적 경로는 satisfies로 스펙의 keyof paths에 묶었다

대안수동으로 쓴 API 타입을 계속 유지하거나, 빌드 때마다 백엔드에서 스펙을 직접 fetch하거나, 백엔드 저장소를 이 모노레포로 합치는 안근거수동 타입이 nullable을 숨기고 있었다 — description·category가 응답에서 null일 수 있는데 string으로 선언돼 있어 런타임까지 안 드러났고, 생성 타입으로 바꾸자 컴파일 에러로 잡혔다. 빌드가 백엔드 네트워크에 의존하면 운영 배포가 백엔드 장애에 끌려가므로, 스펙은 파일로 커밋해 CI가 네트워크 없이 돌게 했다.비용스펙 동기화가 사람이 pnpm api:sync를 돌려야 일어나므로 안 돌리면 조용히 낡는다. 그래서 매일 도는 별도 워크플로가 업스트림과 diff를 떠 이슈를 열게 했지만, 그건 게이트가 아니라 알림이라 배포를 막지는 않는다. 같은 맥락에서 API 키도 스펙과 함께 프런트로 내려오는 값이라, 타입은 생성물로 검증되지만 접근 통제는 생성물이 보증해 주지 않는다.

성공 응답 스키마가 없는 라우트의 반환 타입을 never가 아니라 고유 심볼로 브랜딩한 MissingResponseSchema로 두어, 대입 시점에 컴파일이 터지게 했다 (커밋 c64faa4)

대안never를 반환하거나, unknown으로 두고 호출부에서 캐스팅하게 하는 안근거never는 모든 타입의 서브타입이라 어디에나 조용히 대입된다. 즉 '코드젠 타입을 붙였으니 안전하다'고 믿는 순간, 스키마가 없는 라우트만 아무 검사도 받지 않고 통과한다. 고유 심볼로 브랜딩하면 어떤 기대 타입에도 대입되지 않아 그 자리에서 컴파일이 멈춘다.비용스펙에 응답 스키마가 붙기 전까지는 해당 라우트를 쓰는 코드가 컴파일되지 않으므로, 프런트 작업이 백엔드의 @ApiOkResponse 선언 진도에 묶인다. 급할 때 우회하려면 명시적 캐스팅을 쓰게 되는데 그러면 브랜딩의 의미가 사라진다.

사용 기술

TypeScriptReactTailwind CSSshadcn/uinext-mdx-remoteTurborepo