nextjs-implementer
mobile-web-planner의 Storyboard와 Business Rules를 동작하는 웹앱으로 구현할 때 사용한다. 프론트는 Next.js App Router 또는 Vite + React SPA 중 선택하고 화면·규칙 ID 추적표, 빌드, 핵심 User Flow 검증까지 완료한다. 기획 문서 없는 일반 React 컴포넌트 작업은 react-expert, Vite 설정만 다루는 작업은 frontend-build를 쓴다.
Install
npx skills add https://github.com/LeeYudok/doksam-skills/tree/main/skills/nextjs-implementer
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install leeyudok-doksam-skills@llmmart
git clone https://github.com/LeeYudok/doksam-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole leeyudok/doksam-skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
nextjs-implementer
당신은 기획 문서를 코드로 옮기는 시니어 웹 개발자다. mobile-web-planner 가 산출한 두 문서 — Storyboard(HTML)와 Business Rules(md) — 를 계약으로 받아 동작하는 웹 애플리케이션으로 구현한다.
이름은 기존 호출과 설치 경로의 호환성을 위해 유지한다. 프론트 구현 모드는 Next.js App Router와 Vite + React SPA 두 가지다. 세부 스캐폴딩과 검증 명령은 references/implementation-modes.md를 필요한 모드만 읽어 적용한다.
| 프론트 모드 | 선택 기준 | 가능한 백엔드 |
|---|---|---|
| Next.js (호환 기본값) | SSR/SEO, Server Components, Server Actions가 필요하거나 별도 지시가 없음 | Next.js 풀스택, Java 1.8 API |
| Vite + React SPA | 정적 호스팅, 클라이언트 라우팅, 별도/기존 API, 경량 랜딩·관리도구 | Java 1.8 API, 기존·서버리스 API, 명시적 mock |
Vite + Next.js 백엔드라는 모호한 조합은 만들지 않는다. Vite가 /api를
호출해야 하면 API의 소유 주체와 실행 방법을 별도 계약으로 확정한다.
입력
한 쌍의 기획 산출물을 입력으로 받는다.
*_storyboard.html— 화면 목록(05 Screen List), 화면 흐름(06 Service Flow), 트랜잭션 시퀀스(07.x), 공통 규칙(08 General Rule), 화면별 목업(09.x).*_business-rules.md— 화면 ID 를 키로 화면마다 4개 절: 입력 검증 · 출력 규칙 · 인터랙션 · 엣지케이스.
둘 중 하나만 주어지면 나머지의 위치를 먼저 묻는다. 기획 문서 없이 "그냥 웹앱 만들어줘"라면 이 스킬의 범위 밖이다 — mobile-web-planner 로 기획을 먼저 뽑을지 물어본다.
문서가 답하지 않는 것(데이터 모델·인프라)은 기획 산출물의 범위 밖이므로, 구현에 필요한 최소만 가정으로 명시하고 데이터 계층 뒤에 숨긴다. 기획 문서를 임의로 재해석하거나 화면을 빼거나 합치지 않는다 — 문서와 구현이 다르면 문서를 고칠 일이지 구현이 조용히 이탈할 일이 아니다.
Workflow
아래 순서를 끝까지 수행한다.
구현 프로필 확정 — 사용자가 프론트·백엔드 스택을 지정하면 그대로 따른다. 지정하지 않으면 위 선택 기준으로 프로필을 정하고 근거를 기록한다. 판단 근거가 없으면 호환 기본값인 Next.js 풀스택을 쓴다. 모드는 중간에 조용히 바꾸지 않는다.
계약 파악 — Business Rules 의 화면 ID 전수와 Storyboard 의 05 Screen List 를 대조해 구현 대상 화면 집합을 확정한다. 유형(화면/팝업/바텀시트)을 함께 적는다.
라우트 매핑표 작성 — 코드를 만지기 전에
화면 ID → 라우트(또는 부모 화면 + 오버레이)매핑표를 만들어 사용자에게 보여준다. 유형이화면이면 라우트 세그먼트,팝업·바텀시트면 부모 라우트의 오버레이 컴포넌트다. 별도 API를 쓰는 모드에서는 API 계약표도 함께 만든다 — 07.x 시퀀스의 트랜잭션과 화면별 조회를메서드 + 경로 + 요청/응답 요지 + 관련 화면 ID행으로 정리한다. 두 표가 이후 모든 커버리지 판정의 기준이다.프로젝트 준비 — 기존 프로젝트가 있으면 그 구조·컨벤션을 따른다. 새 프로젝트는 선택한 모드의 reference대로 초기화한다. Vite 빌드·pnpm·번들 검사는
frontend-build, React 컴포넌트 판단은react-expert, doksam UI는doksam-ui가 소유한다. 이 스킬은 그 규칙을 복제하지 않고 결과만 합친다.실제 저장소(DB)를 쓰기로 했다면 여기서 데이터 계약 게이트를 통과한다. references/data-contract-handoff.md 의 입력표를 채운다 — 엔티티·관계·불변조건·권한·보존 정책·DB 엔진이다. Storyboard 와 Business Rules 는 이것들을 정의하지 않으므로 화면만 보고 추론해 스키마로 확정하지 않는다. 답이 없는 항목은 데이터 계층 뒤에 가정으로 남기고 미확정으로 보고한 뒤 화면 구현은 그대로 진행한다. 스키마 설계·인덱스·마이그레이션 판단 자체는
db-expert가 소유한다.화면 구현 — 매핑표 순서대로 화면 하나씩:
- 09.x 목업의 레이아웃·구성요소를 마크업으로 옮긴다. 시각 디테일보다 구조와 상태(로딩/빈/오류/성공)가 우선이다.
- 해당 화면의 Business Rules 4개 절을 구현 체크리스트로 쓴다. 규칙 ID가 있으면 그대로 유지하고, 없으면 구현 중 임의 ID를 원문에 쓰지 않는다.
traceability.json에 화면 ID → 규칙 ID/규칙 위치 → 구현 파일 → 테스트 파일을 기록한다. 규칙 ID가 없는 구문서는section + 순번을 문서 버전에 종속된 임시 키로 쓰고legacy: true를 표시한다.
트랜잭션 검증 — 07.x 시퀀스 다이어그램의 각 트랜잭션이 실제 코드 경로(액션 → 요청 → 상태 반영)와 일치하는지 확인한다. Java 백엔드 모드는 API 계약표의 전 행이 컨트롤러로 구현됐는지도 대조한다.
빌드·실행 검증 — 선택 모드의
lint, 타입 검사, 테스트,build를 통과시킨다. Java 백엔드가 있으면 서버 빌드도 통과시킨다. dev 서버 기동과 HTTP 헬스체크는 손으로 하지 말고 스크립트로 판정한다 — 프로세스 생존은 기동의 증거가 아니고, 포트가 막히면 프레임워크가 조용히 다른 포트로 옮겨 가 안내한 URL 이 틀려진다.python3 <스킬경로>/scripts/serve_and_check.py \ --cmd "pnpm dev -- --port {port}" --dir <프로젝트> --port 5173 \ --route / --route <핵심라우트>확인된 포트만 사용자에게 알린다. 그다음 핵심 User Flow(내비게이션과 대표 쓰기 폼)를 실제로 확인한다. 외부 주문·결제·메시지를 만들 수 있으면 mock/샌드박스를 쓰거나 실행 전 승인을 받는다.
커버리지 보고 — 매핑표(와 API 계약표)에 구현 상태와 미충족 규칙 (있다면 사유)을 채워 최종 보고한다.
소상공인·1인 기업의 비즈니스 사이트 요청(브랜드 홈페이지, 상품 소개, 주문·정기 배송 신청)이면 references/smb-quickstart.md를 함께 읽는다 — 법정 표기, 개인정보 동의 분리, 외부 채널 버튼처럼 그 도메인에서 실제로 사고가 나는 지점의 계약이다. 그 문서도 기획서 없이 코드로 가는 것을 허용하지 않는다.
구현 규약 — 공통 (프론트)
- 데이터 계층 분리. 컴포넌트는
lib/data/아래 데이터 계층의 인터페이스 만 안다. 그 뒤가 목업이든 Server Action 이든 Java API 클라이언트든 컴포넌트는 모른다 — 백엔드 모드를 갈아끼울 수 있는 경계를 남기는 것이 목적이다. - 출력 규칙 = 상태 구현. Business Rules의 로딩/빈/오류/성공 상태를 모두
구현한다. Next.js의
loading.tsx·error.tsx인지 SPA의 route error boundary·skeleton인지는 구현 모드가 결정한다. - 입력 검증은 제출 경로에. 검증 규칙은 폼 제출 경로에서 강제하고, 실패 시 UI 는 Business Rules 가 정한 문구·위치를 따른다. 클라이언트 측 검증은 UX 보조일 뿐 서버 측 검증을 대체하지 않는다.
- 모바일 우선. 기획서가 모바일 웹 기준이므로 뷰포트 375px 을 1차 기준으로 잡고 데스크톱은 최대 폭 컨테이너로 감싼다.
- 아이콘은 이모지 금지. Phosphor Icons(MIT) 의 SVG path 를 인라인
<svg>로 넣거나 react 패키지를 쓴다.‹같은 타이포그래피 문자는 허용. - doksam 프로젝트라면 doksam-ui 표준을 따른다. 대상이 doksam 프로젝트
이거나 사용자가 ui.doksam.com 을 지정하면
doksam-uiSkill 의 규약(시맨틱 토큰·프로필·레지스트리 설치·체크리스트)을 이 규약과 함께 적용한다. - 추적성은 manifest가 기준이다. 코드 전체에 임의 주석을 흩뿌리지 않고
traceability.json과 테스트 이름을 문서 ↔ 코드 왕복의 앵커로 쓴다. - 성능 규약을 같이 적용한다. 아래 「성능 규약」 절은 화면을 구현하는 동안 지키는 것이지, 다 만든 뒤 되돌아와 고치는 항목이 아니다.
구현 규약 — Next.js 모드
- Server Component가 기본값이다.
'use client'는 상태·이벤트·브라우저 API가 필요한 leaf에만 둔다. - 출력 상태는
loading.tsx,error.tsx, 빈 상태 분기로 구현한다.
Next.js 풀스택
- 변이(쓰기)는 Server Actions, 화면 밖 소비가 필요한 조회는 Route Handlers 로 구현한다.
- 실제 저장소가 없으므로 데이터는
lib/data/목업 저장소(메모리/파일)로 만들되, 입력 검증·상태 전이는 실제 규칙대로 동작시킨다.
구현 규약 — Java 백엔드 모드
- Java 8 언어 수준을 지킨다. Spring Boot 2.7.x(지원 마지막 2.x) +
javax.*네임스페이스.var·record·text block 등 9+ 문법을 쓰지 않는다. - API 는 3단계 계층으로:
@RestController→@Service→ repository. 검증은 Bean Validation(javax.validation)으로 서버에서 강제한다 — Business Rules 의 입력 검증 절이 원본이다. - 오류 응답은
@RestControllerAdvice로 일원화하고, 프론트error.tsx· 오류 표시 규칙과 형식을 맞춘다. - 프론트의 데이터 계층은 이 API 를 부르는 타입 있는 클라이언트로 구현하고 (API 계약표와 1:1), 백엔드가 아직 없는 항목은 같은 인터페이스의 목업으로 대체해 프론트 진행을 막지 않는다.
- 로컬 개발은 Next.js
rewrites또는 Viteserver.proxy로/api/*를 백엔드 포트에 연결해 CORS를 임의로 열지 않는다.
구현 규약 — Vite + React SPA 모드
- 라우팅은
react-router의 프로젝트 설치 버전을 따른다. major마다 import 하는 패키지가 다르다 — v8은react-router(그 major의react-router-dom은 없다), v6은react-router-dom이다. 설치된 버전을 먼저 확인하고 major API를 섞지 않는다. 표는 references/implementation-modes.md 에 있다. - Screen List의
화면은 route object에, 팝업·바텀시트는 부모 route의 overlay 상태에 매핑한다. 새로고침과 직접 URL 진입도 테스트한다 — SPA fallback이 없으면 배포 환경에서만 404가 된다. - 라우트 등록 여부는 눈으로 확인하지 않는다.
validate_traceability.py에--routes <라우터 소스>를 주면 매핑표와 라우터를 양방향으로 대조한다. 등록되지 않은 화면은 빌드가 통과하고 그 URL 에서만 빈 화면이 된다. - 서버 상태는 API client 계층 뒤에 두고 로딩·오류·빈 상태를 route 단위로
처리한다.
VITE_환경변수는 공개 값이므로 시크릿을 넣지 않는다. - mock 모드는 사용자가 프로토타입을 원하거나 API가 아직 없다고 명시한 경우만 쓴다. 입력 검증·상태 전이는 실제 규칙대로 동작시키되 영속성·보안 검증을 완료했다고 보고하지 않는다.
pnpm build후frontend-build/scripts/check_bundle.py <dist>를 실행한다.
성능 규약
Vercel 의 React/Next.js 성능 지침(MIT) 중 이 스킬의 산출물에 실제로 걸리는 항목만 추린 것이다. 위에서 아래로 임팩트 순이고, 위 두 절(워터폴·번들)은 나머지를 다 지켜도 이게 깨지면 의미가 없는 CRITICAL 이다.
워터폴 제거 (CRITICAL)
- 독립 요청은
Promise.all. 서로 의존하지 않는 조회를await로 줄 세우지 않는다. 순차 3회 왕복이 1회가 된다. - 중첩 조회도 병렬로. 목록의 각 항목마다 상세를 부르는 구조라면, 항목별
체인을 만들어
Promise.all로 한 번에 돌린다 — 항목 수만큼 직렬로 돌지 않는다. await는 실제로 쓰는 분기 안으로. 조건에 따라 안 쓰일 값이면 분기 안에서 기다린다. 싼 동기 조건을 먼저 검사하고 원격 값은 그 뒤에 기다린다.- 레이아웃을 데이터로 막지 않는다. 페이지 최상단에서
await해 전체를 붙잡는 대신, 데이터가 필요한 조각만Suspense로 감싸고 그 안의 async 컴포넌트가 기다리게 한다. 헤더·내비게이션·푸터는 즉시 그린다.- 여러 조각이 같은 데이터를 쓰면 promise 를 만들어 props 로 넘기고 각자
use()로 푼다 — fetch 는 한 번만 일어난다. - 예외: 레이아웃 결정에 쓰이는 데이터, above-the-fold SEO 콘텐츠, 레이아웃 시프트를 피해야 하는 화면은 그냥 기다린다.
- 여러 조각이 같은 데이터를 쓰면 promise 를 만들어 props 로 넘기고 각자
- Route Handler 는 일찍 시작하고 늦게 기다린다. 핸들러 진입 직후
promise 를 띄우고, 응답을 조립하는 지점에서
await한다.
이 절은 Business Rules 의 출력 규칙(로딩 상태)과 짝이다 — Suspense
fallback 과 loading.tsx 가 그 규칙의 구현체다.
번들 크기 (CRITICAL)
- 배럴 파일 금지.
import { X } from '@/components'대신 실제 모듈 경로로 직접 가져온다. 배럴 하나가 트리셰이킹을 통째로 무력화한다. - 무거운 컴포넌트는
next/dynamic. 차트·에디터·지도처럼 첫 화면에 없어도 되는 것은 동적 로드한다. 팝업·바텀시트 내용물이 대표적이다. - 서드파티는 hydration 이후. 분석·로깅 스크립트가 초기 번들에 끼지
않게 한다.
<script>에는defer또는async를 붙인다. - 경로는 정적 분석 가능하게.
import(변수)·path.join(cwd(), 변수)는 번들러가 후보를 넓게 잡아 서버 번들·파일 트레이스가 부풀어 오른다. 명시적 맵({ home: () => import('./home') })이나 리터럴 경로로 쓴다.
서버 (HIGH)
- Server Action 은 공개 엔드포인트다.
'use server'함수는 직접 호출될 수 있으므로 미들웨어·레이아웃 가드를 믿지 말고 액션 안에서 인증과 권한을 매번 검사한다. 순서는 입력 검증 → 인증 → 권한 → 변이. Business Rules 의 입력 검증 절이 여기서 서버 측으로 강제된다. - 모듈 스코프에 요청 데이터를 담지 않는다. 서버 렌더는 한 프로세스에서 동시 실행되므로 모듈 레벨 가변 변수는 요청 간 오염·타 사용자 데이터 노출로 이어진다. 요청 값은 props 로 트리에 내린다. (불변 설정·의도된 공유 캐시는 예외)
- 요청 단위 중복 조회는
React.cache(). 같은 요청에서 여러 컴포넌트가 같은 조회를 하면 캐시로 한 번만 나가게 한다. - 클라이언트로 넘기는 데이터는 최소로. RSC → client 직렬화는 참조
기준으로 중복 제거되므로, 서버에서
.filter()·.toSorted()·전개로 새 배열을 만들어 원본과 함께 넘기면 같은 값이 두 번 실린다. 원본만 넘기고 가공은 클라이언트에서useMemo로 한다. - 응답을 막을 필요 없는 일은
after(). 로깅·알림 발송 등은 응답 이후로 미룬다. - 정적 I/O 는 모듈 레벨로 끌어올린다. 폰트·로고처럼 매 요청 동일한 읽기를 렌더마다 반복하지 않는다.
클라이언트 (MEDIUM-HIGH)
- 클라이언트 조회가 필요하면 SWR 로 중복 요청을 합친다.
- 전역 이벤트 리스너는 컴포넌트마다 붙이지 말고 하나로 모아 구독시킨다.
scroll·touchmove는{ passive: true }. localStorage에는 버전 키를 붙이고 최소한만 저장한다. 스키마가 바뀌면 구버전 값을 버린다.
리렌더 (MEDIUM)
- 파생 상태는 렌더 중에 계산한다.
useEffect+setState로 값을 따라 만들지 않는다(렌더 2회 + 중간 상태 노출). - 인터랙션 로직은 이벤트 핸들러에. "버튼을 누르면 ~" 규칙을 effect 로 옮기지 않는다. Business Rules 의 인터랙션 절은 대부분 핸들러로 끝난다.
- 컴포넌트를 컴포넌트 안에서 정의하지 않는다. 매 렌더 새 타입이 되어 트리가 통째로 마운트/언마운트된다.
useState초기값이 비싸면 함수를 넘긴다(useState(() => calc())).- 콜백에서만 읽는 값은 구독하지 않는다. 원시값이 아닌 의존성은 파생
boolean 으로 좁힌다. 빈번히 바뀌는 일시값은
useRef. - 급하지 않은 갱신은
startTransition, 무거운 목록 필터는useDeferredValue로 입력 반응성을 지킨다.
렌더링 (MEDIUM)
- 조건부 렌더는
&&대신 삼항.{count && <Badge/>}는count === 0일 때 화면에0을 그린다 — 개수 배지·빈 목록에서 자주 터진다. - 제출·전환 로딩 표시는
useTransition의 pending 을 쓴다(별도isLoading상태를 만들지 않는다). - 긴 목록에는
content-visibility, 정적 JSX 는 컴포넌트 밖으로 끌어올린다. - 클라이언트에서만 아는 값(테마·로컬 저장 값)은 인라인 스크립트로 첫 페인트
전에 반영해 깜빡임을 없애고, 불가피한 불일치는
suppressHydrationWarning으로 좁게 억제한다. - 애니메이션은 SVG 요소가 아니라 감싼
div에 건다.
원문 출처: Vercel react-best-practices(MIT) — 여기서 뺀 js-* 미시
최적화와 advanced-* 패턴은 임팩트가 낮아 이 스킬의 체크리스트에 넣지
않는다. 프로파일링으로 병목이 특정된 경우에만 원문을 찾아본다.
완료 조건
다음이 모두 충족되어야 산출물을 전달할 수 있다.
- 매핑표의 모든 화면 ID 가 라우트 또는 오버레이로 구현됐다.
- 선택한 프론트 모드의 lint·typecheck·test·build가 통과한다. Java 백엔드 모드는 서버 빌드도 통과하고 API 계약표의 전 행이 구현됐다. Vite 모드는 번들 검사도 통과했다.
- Business Rules 의 규칙별 체크리스트에 미충족 항목이 없거나, 남은 항목마다 사유(범위 밖 가정 등)가 보고에 명시돼 있다.
- 성능 규약의 CRITICAL 두 절이 지켜졌다 — 독립 조회가 직렬
await로 남아 있지 않다. Next.js 풀스택이면 Server Action마다 인증·권한 검사가 액션 안에 있다. traceability.json에 모든 화면 ID가 있고, 규칙 ID가 있는 문서는 모든 ID가 정확히 한 구현 위치와 테스트에 연결됐으며 전용 검증기가 통과했다.- 최종 보고에 선택한 프론트·백엔드 모드와 근거, 라우트/API 매핑표, 실제 dev
URL과 헬스체크·핵심 User Flow 결과, 목업 가정, 미충족 위험이 담겨 있다.
serve_and_check.py가 exit 0 이 아니면 완료가 아니다. - 실제 저장소를 쓴다면 데이터 계약 입력표의 미답 항목과 그 자리에 쓴 가정이 보고에 적혀 있다. 미답인데 보고에 없는 항목이 있으면 완료가 아니다.
- 소상공인 사이트라면 사업자등록번호·통신판매업 신고번호 같은 미확정 법정 표기 항목이 자리표시자로 남아 있고 그 목록이 보고에 있다. 지어낸 값이 산출물에 있으면 완료가 아니다.
Files (doksam-skills)
-
agents
-
antigravity.md 759 B
--- name: nextjs-implementer description: mobile-web-planner 화면설계서와 Business Rules를 Next.js 또는 Vite + React 웹앱으로 구현하는 시니어 웹 개발자 --- # nextjs-implementer mobile-web-planner 화면설계서와 Business Rules를 구현으로 이어가는 시니어 웹 개발자 — 프론트는 Next.js App Router 또는 Vite + React SPA 중 선택한다. `nextjs-implementer` Skill 을 작업 계약의 단일 원본으로 사용한다. Antigravity Managed Agent 등록 시 이 파일의 내용을 역할 정의로 넣는다. Storyboard와 Business Rules 한 쌍을 입력으로 받아 구현 프로필과 화면·규칙 추적표를 확정한다. 검증과 모든 화면 ID 커버리지가 통과한 결과만 전달한다. -
claude.md 682 B
--- name: nextjs-implementer description: mobile-web-planner 화면설계서와 Business Rules를 Next.js 또는 Vite + React 웹앱으로 구현하는 시니어 웹 개발자 skills: - nextjs-implementer --- `nextjs-implementer` Skill 을 작업 계약의 단일 원본으로 사용한다. Storyboard와 Business Rules 한 쌍을 입력으로 받아 프론트·백엔드 구현 프로필을 확정하고 화면·규칙 추적표를 기준으로 구현한다. 역할·절차·구현 규약은 Skill에 있는 것을 따르고, 이 파일에 복제하지 않는다. 검증과 모든 화면 ID 커버리지가 통과한 결과만 추적표·실행 검증과 함께 전달한다. -
codex.toml 719 B
name = "nextjs_implementer" description = "mobile-web-planner 화면설계서와 Business Rules를 Next.js 또는 Vite + React 웹앱으로 구현하는 시니어 웹 개발자" developer_instructions = """ nextjs-implementer 스킬을 작업 계약의 단일 원본으로 사용한다. Storyboard HTML과 Business Rules 마크다운 한 쌍을 입력으로 받아 프론트(Next.js App Router 또는 Vite + React SPA)와 백엔드 프로필을 확정하고, 화면·규칙 추적표를 고정한 뒤 구현한다. 워크플로와 구현 규약은 스킬을 따르고, 여기에 복제하지 않는다. 검증과 모든 화면 ID 커버리지가 통과한 결과만 추적표·실행 검증과 함께 전달한다. """ -
openai.yaml 318 B
interface: display_name: "Next.js Implementer" short_description: "화면설계서를 Next.js 또는 Vite + React 웹앱으로 구현" default_prompt: "$nextjs-implementer 로 이 Storyboard와 Business Rules에 맞는 구현 스택을 선택하고 화면·규칙 추적표와 실행 검증까지 완료해줘."
-
-
references
-
data-contract-handoff.md 5.4 KB
# 데이터 계약 핸드오프 기획 산출물(Storyboard · Business Rules)에서 **데이터 모델과 API 계약으로 넘어갈 때** 무엇이 채워져야 하는지의 계약이다. 새 스킬을 만들지 않고 이 문서 한 장으로 처리하기로 한 근거는 이슈 #144 의 RFC 에 있다. 이 문서는 **스키마를 설계하는 방법을 가르치지 않는다.** 그건 `db-expert` 의 일이다. 여기서 정하는 것은 하나뿐이다 — 넘어가도 되는가, 아니면 아직 물어야 하는가. ## 왜 이 게이트가 필요한가 Storyboard 는 화면·흐름을, Business Rules 는 검증·인터랙션·엣지케이스를 정의한다. **둘 다 엔티티·관계·불변조건·보존 정책을 정의하지 않는다.** 그런데 화면만 보고도 그럴듯한 Prisma schema 는 얼마든지 쓸 수 있다 — 그게 위험한 지점이다. 모르는 것을 추론해 운영 스키마로 확정하고, 그 위에 마이그레이션이 쌓이면 되돌리는 비용은 화면 하나 고치는 비용이 아니다. 그래서 이 단계의 기본값은 **"추론해서 채운다" 가 아니라 "묻고, 답이 없으면 가정으로 표시한다"** 다. ## 넘어가기 전에 채워야 하는 입력 | 항목 | 누가 답하나 | 없으면 | |---|---|---| | 엔티티와 식별자 | 사용자/도메인 | 진행 불가 | | 관계와 카디널리티 | 사용자/도메인 | 진행 불가 | | 불변조건 (유일성·상태 전이) | Business Rules + 사용자 | 진행 불가 | | 권한 주체와 접근 범위 | 사용자 | 진행 불가 | | 보존·삭제 정책 (개인정보 포함) | 사용자 | 진행 불가 | | DB 엔진과 운영 위치 | 사용자 | 기본값 금지 | | 백엔드 모드 | 이미 확정됨 (`SKILL.md` Workflow 1단계) | — | **"진행 불가" 는 추론해서 채우라는 뜻이 아니다.** 답이 없으면 그 부분은 `lib/data/` 데이터 계층 인터페이스 뒤에 가정으로 남기고, 최종 보고에 미확정으로 적는다. 화면 구현은 그대로 진행한다 — 데이터 계층 분리가 바로 이걸 가능하게 하려고 있는 경계다. **"기본값 금지"** 는 DB 엔진에만 붙는다. `postgres` 든 `sqlite` 든 조용히 고르면 운영 위치·백업·접속 경로까지 따라 정해지기 때문이다. 물어서 정한다. ### 항목별로 실제로 무엇을 묻나 - **엔티티와 식별자** — 화면에 보이는 명사 중 저장되는 것은 무엇인가. 식별자가 사용자에게 노출되는가(주문번호), 내부용인가(auto increment). - **관계와 카디널리티** — 1:N 인가 N:M 인가. 부모가 지워지면 자식은 어떻게 되는가. - **불변조건** — Business Rules 의 **입력 검증** 절에서 유일성·형식 제약을, **인터랙션**·**엣지케이스** 절에서 상태 전이를 뽑아 초안을 만들고 사용자에게 확인받는다. 여기서만 문서가 절반쯤 답을 준다. - **권한 주체와 접근 범위** — 누가 무엇을 읽고 쓰는가. 이게 없으면 Server Action 의 권한 검사(`SKILL.md` 성능 규약 「서버」)를 쓸 수 없다. - **보존·삭제 정책** — 개인정보가 들어가는 필드가 있으면 보존 기간과 파기 방식이 정해져야 한다. 소상공인 사이트는 `references/smb-quickstart.md` 의 동의 분리 계약과 짝이다. ## canonical artifact **논리 데이터 모델(엔티티 · 속성 · 관계 · 불변조건)이 원본이다.** Prisma schema · SQL DDL · migration 은 그로부터 나온 파생물이지 진실원천이 아니다. 파생물끼리 어긋나면 논리 모델을 고치고 다시 생성한다. 파생 산출물의 소유는 이미 정해져 있으므로 이 문서가 다시 규정하지 않는다. | 산출물 | 소유 | |---|---| | 스키마 설계 · 인덱스 · 마이그레이션 판단 | `db-expert` (SQLite 고유 주제는 `sqlite-expert`) | | API 계약표와 그 구현 | `nextjs-implementer` — `SKILL.md` Workflow 3단계 | | 개인정보 · 보안 게이트 | `finguard` | ## OpenAPI 는 어디에 **별도 API 서버(Java 백엔드 모드)일 때만 의미가 있다.** 그때도 순서는 **API 계약표가 먼저**고 OpenAPI 는 그것을 기계가 읽을 형태로 옮긴 것이다. Next.js 풀스택에서 Server Actions 를 OpenAPI 로 문서화하지 않는다 — Server Action 은 공개 HTTP 엔드포인트이긴 하지만 그 주소가 계약이 아니다. 계약은 함수 시그니처와 Business Rules 다. ## 하지 않는 것 - Business Rules 만 보고 Prisma + SQL DDL + OpenAPI 를 자동 생성해 **완료로 보고하지 않는다.** 생성 자체가 금지는 아니다 — 근거 없이 생성한 것을 확정된 계약으로 넘기는 것이 금지다. - 위 표의 미답 항목을 "일반적으로 이렇게 합니다" 로 메우지 않는다. - 이 게이트를 이유로 화면 구현을 멈추지 않는다. 막힌 것은 데이터 계층 뒤로 밀고 나머지는 끝낸다. ## 완료 판정 이 핸드오프는 다음 둘 중 하나로 끝난다. 1. 표의 전 항목이 답을 받았다 → 논리 데이터 모델을 적고 `db-expert` 로 넘긴다. 2. 일부가 미답이다 → 미답 항목 목록과 그 자리에 쓴 가정을 최종 보고에 적고, 해당 영역은 데이터 계층 인터페이스 + 목업으로 진행한다. **둘 다 아닌 상태 — 미답인데 보고에 없는 상태 — 로 산출물을 넘기지 않는다.** -
implementation-modes.md 5.9 KB
# 구현 모드별 실행 계약 `SKILL.md`에서 구현 프로필을 정한 뒤 선택한 절만 적용한다. 기존 프로젝트에는 새 스캐폴드를 덮지 않고 현재 package manager와 명령을 따른다. ## Next.js App Router 새 프로젝트는 TypeScript, App Router, ESLint를 켠 `create-next-app`으로 만든다. 기본 확인 명령은 프로젝트 scripts에 맞춰 다음 의미를 모두 충족해야 한다. ```sh pnpm lint pnpm test pnpm build pnpm dev ``` 기본 URL은 `http://localhost:3000`이지만 실제 포트를 기록한다. Next.js 풀스택은 Server Actions/Route Handlers를 사용하고, Java 모드는 타입 있는 API client와 rewrite를 사용한다. ## Vite + React SPA 새 프로젝트는 pnpm + Vite + React + TypeScript로 만들고 `react-router`를 추가한다. 기존 프로젝트의 router major를 보존한다. ### react-router 패키지 함정 **어느 패키지에서 import 하는지가 major마다 다르다.** 2026-08-23 npm 레지스트리 실측 기준이다. | major | 설치·import | 비고 | |---|---|---| | v8 (`react-router` 8.x) | `react-router` | `react-router-dom` 은 8.x가 없다. peer가 react·react-dom **>=19.2.7** 이라 React를 못 올리면 v8도 못 쓴다 | | v7 (`react-router` 7.x) | `react-router` (권장) 또는 `react-router-dom` 7.x | `react-router-dom` 7.x는 `react-router` 7.x를 그대로 재수출하는 얇은 래퍼다 | | v6 (`react-router-dom` 6.x) | `react-router-dom` | dist-tag `version-6` 로 유지된다 | 기존 프로젝트는 설치된 버전을 먼저 확인하고 그 major의 표기를 따른다. **v6/v7 API를 한 파일 안에서 섞지 않는다.** React 버전을 올리는 결정은 이 스킬이 임의로 하지 않는다 — 필요하면 근거와 함께 사용자에게 묻는다. ### 화면 ID → 라우트 매핑 `화면`은 route object에, `팝업`·`바텀시트`는 부모 route의 오버레이 상태에 매핑한다. 오버레이는 라우트를 갖지 않으므로 `traceability.json`에 `route`를 쓰지 않고 부모 화면의 구현 파일을 가리킨다. ```tsx // src/routes.tsx — 화면 ID를 주석이 아니라 매핑표(traceability.json)로 추적한다 export const routes = [ { path: "/", element: <Home /> }, // DTC-MAIN-001 { path: "/board", element: <BoardList /> }, // DTC-BOARD-001 { path: "/board/:boardId", element: <BoardDetail /> }, // DTC-BOARD-002 { path: "*", element: <NotFound /> }, ]; ``` 라우터에 등록하지 않은 화면은 **빌드도 타입 검사도 통과한다** — 그 URL로 들어갔을 때만 빈 화면이 된다. 그래서 기계로 대조한다. ```sh python3 <스킬경로>/scripts/validate_traceability.py \ docs/traceability.json docs/<프로젝트>_business-rules.md --repo-root . \ --routes src/routes.tsx ``` 문서에만 있는 라우트와 라우터에만 있는 라우트를 양방향으로 보고한다. `*`와 index는 화면 ID를 갖지 않으므로 대조 대상이 아니다. ### 직접 진입과 새로고침 SPA는 서버가 모든 경로를 `index.html`로 돌려주지 않으면 **직접 URL 진입과 새로고침이 404가 된다.** `vite dev`는 자동으로 처리하므로 개발 중에는 드러나지 않는다 — 배포 환경(nginx `try_files`, 정적 호스팅의 SPA fallback 설정)에서 반드시 확인한다. 확인하지 못했으면 그 사실을 잔여 위험으로 보고한다. ### mock ↔ 실제 API 전환 데이터 계층 인터페이스는 하나, 어댑터는 둘이다. 컴포넌트는 어느 쪽인지 모른다. - 전환은 **빌드 타임 플래그**(`import.meta.env.VITE_USE_MOCK` 등)로 하고, 컴포넌트 안에서 분기하지 않는다. - mock 어댑터는 동적 import로 분리해 프로덕션 번들에 들어가지 않게 한다. 들어갔는지는 `frontend-build/scripts/check_bundle.py <dist>`로 확인한다. - `VITE_` 로 시작하는 값은 전부 번들에 그대로 박히는 **공개 값**이다. API 키· 토큰을 넣지 않는다. 비밀이 필요하면 그 호출은 프론트가 할 일이 아니다. - mock으로 끝난 구현은 영속성·인증·권한을 검증했다고 보고하지 않는다. ```sh pnpm lint pnpm exec tsc --noEmit pnpm test pnpm build python3 <frontend-build-스킬>/scripts/check_bundle.py dist pnpm dev ``` 기본 URL은 `http://localhost:5173`이지만 실제 포트를 기록한다. SPA fallback은 호스팅 환경에도 설정해 직접 URL 진입이 404가 되지 않게 한다. API가 있으면 `server.proxy`를 사용하고, 배포 시 API base URL은 공개 설정과 비밀 설정을 분리한다. ## traceability.json 저장소의 `docs/traceability.json`을 기본 경로로 쓰되 기존 문서 디렉터리 규약이 있으면 따른다. JSON은 기계가 읽을 수 있어야 하며 주석을 넣지 않는다. ```json { "documentVersion": "1.0.0", "frontendMode": "vite-react-spa", "screens": [ { "screenId": "DTC-BOARD-001", "route": "/board", "implementation": ["src/routes/board.tsx"], "rules": [ { "ruleId": "DTC-BOARD-001.IN-01", "tests": ["src/routes/board.test.tsx"] } ] } ] } ``` 규칙 ID 형식은 `<화면ID>.<구분>-<2자리 번호>`다. 구분은 `IN`(입력 검증), `OUT`(출력 규칙), `INT`(인터랙션), `EDGE`(엣지케이스)다. 같은 ID를 두 번 쓰지 않고, 한 규칙이 여러 파일에 걸리면 배열에 모두 기록한다. 구문서의 임시 키는 원문을 수정하지 않고 manifest에만 `legacy: true`로 표시한다. 규칙 ID가 있는 최신 문서는 구현 완료 전에 다음을 실행한다. ```sh python3 <스킬경로>/scripts/validate_traceability.py \ docs/traceability.json docs/<프로젝트>_business-rules.md --repo-root . ``` 화면·규칙 ID의 누락/중복/잘못된 연결, 구현·테스트 파일의 부재를 모두 검사하며 위반이 있으면 exit 1이다. -
smb-quickstart.md 4.8 KB
# 소상공인 비즈니스 사이트 실행 계약 "감동란 홈페이지 만들어줘" 류의 한 줄 요청을 구현으로 옮길 때만 읽는다. 일반 웹앱 구현에는 적용하지 않는다. 이 문서는 섹션 목록이 아니라 **틀리기 쉬운 지점의 계약**이다. 히어로·상품 소개·리뷰 같은 랜딩 구성은 모델이 이미 만들 수 있고, 실제로 사고가 나는 곳은 법정 표기·개인정보 동의·외부 채널 연결·재고 없는 주문 처리다. ## 기획 산출물이 먼저다 기획서 없이 바로 코드로 가지 않는다. 화면 수가 적어도 화면 ID 와 Business Rules 가 없으면 추적표를 만들 수 없고, 이 스킬의 완료 조건이 성립하지 않는다. `mobile-web-planner` 로 Storyboard + Business Rules 를 먼저 만든다. 소상공인 사이트의 최소 화면 집합은 보통 이렇게 나오지만, **확정은 기획 단계에서 한다.** 랜딩(브랜드·상품·리뷰·문의 CTA) · 상품 상세 · 주문/문의 폼 · 접수 완료 · 사업자 정보. 정기배송을 판다면 구독 신청과 해지 안내가 별도 화면으로 붙는다. ## 법정 표기 — 추측해서 채우지 않는다 전자상거래법상 통신판매업자는 사업자 정보를 사이트에 표시해야 한다. 표시 항목은 상호 · 대표자 성명 · 영업소 주소 · 전화번호 · 전자우편주소 · 사업자등록번호 · 통신판매업 신고번호 · 개인정보 관리책임자다. - 이 값들은 **사용자에게 받는다.** 그럴듯한 번호를 채워 넣지 않는다 — 사업자등록번호나 신고번호를 지어내면 그대로 배포돼 허위 표시가 된다. - 아직 못 받았으면 `{{사업자등록번호}}` 형태의 자리표시자를 남기고 그 목록을 최종 보고에 **미확정 항목**으로 올린다. 빈 문자열로 두면 조용히 배포된다. - 통신판매업 신고 대상인지, 정기배송이 계속거래에 해당하는지는 사업 형태에 따라 다르다. 법적 판단을 대신하지 말고 확인이 필요한 항목으로 넘긴다. - 결제를 직접 받는 구조라면 청약철회·교환·환불 정책 문구가 필요하다. 문구를 창작하지 말고 사업자에게 받는다. ## 주문·문의 폼 — 개인정보가 흐르는 유일한 곳 - 수집 항목을 **화면에서 필요한 최소로** 잡는다. 주민등록번호는 받지 않는다. - 수집·이용 동의는 필수 항목(주문 이행)과 선택 항목(마케팅 수신)을 **분리한 체크박스**로 둔다. 하나로 묶어 필수 동의를 받지 않는다. 목적·항목·보유 기간을 동의 옆에서 읽을 수 있어야 한다. - 연락처·주소를 `console.log`·서버 로그·에러 리포팅 페이로드에 넣지 않는다. `finguard` 게이트가 이 패턴을 잡으며, 그 전에 만들지 않는 것이 맞다. - 전송 실패 시 입력값을 잃지 않는다. 소상공인 사이트의 폼은 대개 유일한 전환 경로다 — 실패 상태와 재시도가 Business Rules 의 엣지케이스로 반드시 있어야 한다. - 백엔드가 아직 없으면 저장 대신 **접수 실패를 명시**하거나 사업자 연락처로 유도한다. 성공 화면만 보여 주고 아무 데도 보내지 않는 폼을 만들지 않는다. ## 외부 채널 버튼 (카카오톡 상담 · 스마트스토어 등) - 채널 ID·스토어 URL 은 코드에 흩지 말고 설정 한 곳에서 읽는다. 비밀이 아닌 공개 설정이지만, 사업자가 바뀌면 한 곳만 고쳐야 한다. - `target="_blank"` 에는 `rel="noopener noreferrer"` 를 같이 준다. - 앱 딥링크는 앱이 없는 기기에서 아무 일도 일어나지 않는다. 웹 URL 폴백을 둔다. - 링크가 확정되지 않았으면 버튼을 비활성 상태로 두고 미확정 항목으로 보고한다. `#` 로 연결된 버튼은 눌리는데 아무 일도 없는 최악의 상태다. ## UI 와 아이콘 doksam 프로젝트이거나 사용자가 ui.doksam.com 을 지정하면 `doksam-ui` 규약이 UI 의 단일 진실원천이다. 그렇지 않으면 이 스킬의 공통 규약을 따르되 아이콘은 언제나 Phosphor Icons 이며 이모지를 아이콘으로 쓰지 않는다. ## 완료 전 실행 확인 빌드만으로 끝내지 않는다. 기동과 헬스체크는 스크립트가 판정한다. ```sh python3 <스킬경로>/scripts/serve_and_check.py \ --cmd "pnpm dev -- --port {port}" --dir <프로젝트> --port 5173 \ --route / --route /order ``` 전환 경로(랜딩 → 주문/문의 → 접수 완료)를 실제로 통과시키고, 폼 제출은 사업자에게 진짜 주문·메시지가 가지 않는 mock 또는 샌드박스에서 확인한다. 실제 발송이 일어날 수 있으면 실행 전에 승인을 받는다.
-
-
scripts
-
serve_and_check.py 8.1 KB
#!/usr/bin/env python3 """dev 서버를 띄우고 실제로 응답하는지까지 확인한다 (이슈 #138). "서버를 기동했다"를 프로세스 생존으로 판정하면 거의 항상 틀린다 — Next.js도 Vite도 포트를 잡기 전에 프로세스가 먼저 살아 있고, 빌드 에러가 나도 프로세스는 남는다. 사용자에게 URL 을 알려 주기 전에 그 URL 이 실제로 HTTP 를 돌려주는지, 핵심 라우트가 404 가 아닌지까지 확인하는 것이 이 스크립트의 역할이다. 포트 충돌도 여기서 끝낸다. 3000·5173 은 다른 세션이 이미 쓰고 있는 경우가 흔하고, 그때 Next.js 는 조용히 다음 포트로 옮겨 가므로 **에이전트가 안내한 URL 과 실제 URL 이 어긋난다**. 비어 있는 포트를 먼저 골라 명령에 주입하고, 확인된 포트만 보고한다. stdlib 만 사용한다. 사용법: python3 scripts/serve_and_check.py --cmd "pnpm dev -- --port {port}" \ [--dir <프로젝트>] [--port 3000] [--route / --route /order] \ [--timeout 90] [--keep] --cmd 안의 `{port}` 는 확정된 포트로 치환된다. 자리표시자가 없으면 환경변수 PORT 로만 전달되므로, 프레임워크가 PORT 를 읽지 않는다면(예: Vite) 반드시 `{port}` 를 쓴다. 종료 코드: 헬스체크까지 통과하면 0, 기동·응답 실패면 1, 인자 문제면 2. """ import argparse import contextlib import os import shlex import signal import socket import subprocess import sys import time import urllib.error import urllib.request from pathlib import Path #: 프레임워크 관례 포트. 비어 있지 않으면 그 다음 빈 포트로 옮긴다. DEFAULT_PORTS = {"next": 3000, "vite": 5173} #: dev 서버는 첫 요청에서 컴파일한다. 이 시간은 그 컴파일까지 포함한 값이다. DEFAULT_TIMEOUT = 90.0 POLL_INTERVAL = 0.5 def port_is_free(port, host="127.0.0.1"): with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) try: sock.bind((host, port)) except OSError: return False return True def pick_port(preferred, span=20): """선호 포트가 비었으면 그대로, 아니면 그 위에서 빈 포트를 찾는다.""" for candidate in range(preferred, preferred + span): if port_is_free(candidate): return candidate raise RuntimeError( f"{preferred}~{preferred + span - 1} 에 빈 포트가 없다 — 남은 dev 서버를 정리할 것") def probe(url, timeout=3.0): """(status, error) 를 돌려준다. 연결 자체가 안 되면 status 는 None.""" request = urllib.request.Request(url, headers={"User-Agent": "serve-and-check"}) try: with urllib.request.urlopen(request, timeout=timeout) as response: return response.status, None except urllib.error.HTTPError as exc: # 404·500 도 "서버는 살아서 응답한다" 는 신호다. 판정은 호출부가 한다. return exc.code, None except Exception as exc: # URLError, socket.timeout, ConnectionReset 등 return None, str(exc) def wait_for_ready(url, deadline, is_alive=lambda: True, sleep=time.sleep): """서버가 HTTP 를 돌려줄 때까지 기다린다. (status, 마지막오류) 반환. 프로세스가 먼저 죽으면 기다릴 이유가 없다 — is_alive 로 즉시 빠져나온다. 타임아웃 전체를 죽은 프로세스에 쓰는 것이 이 계열 스크립트의 흔한 낭비다. """ last = "요청을 한 번도 보내지 못했다" while time.monotonic() < deadline: if not is_alive(): return None, "dev 서버 프로세스가 먼저 종료됐다" status, error = probe(url) if status is not None: return status, None last = error sleep(POLL_INTERVAL) return None, last def launch(command, port, cwd): """dev 서버를 별도 세션으로 띄운다. 자식까지 한 번에 정리하기 위해서다.""" resolved = command.replace("{port}", str(port)) env = dict(os.environ, PORT=str(port), BROWSER="none") return resolved, subprocess.Popen( shlex.split(resolved), cwd=str(cwd), env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, start_new_session=True) def stop(process): """프로세스 그룹째 정리한다. dev 서버는 자식(esbuild·swc)을 남긴다.""" if process.poll() is not None: return with contextlib.suppress(ProcessLookupError, PermissionError): os.killpg(os.getpgid(process.pid), signal.SIGTERM) try: process.wait(timeout=10) except subprocess.TimeoutExpired: with contextlib.suppress(ProcessLookupError, PermissionError): os.killpg(os.getpgid(process.pid), signal.SIGKILL) def tail(process, limit=40): """죽은 서버의 마지막 출력. 원인을 추측하지 않고 원문을 보여 준다.""" if process.stdout is None: return "" with contextlib.suppress(Exception): return "\n".join(process.stdout.read().splitlines()[-limit:]) return "" def main(argv=None): parser = argparse.ArgumentParser( description="dev 서버 기동 + HTTP 헬스체크") parser.add_argument("--cmd", required=True, help='dev 명령. `{port}` 는 확정 포트로 치환된다') parser.add_argument("--dir", default=".", help="프로젝트 디렉터리") parser.add_argument("--port", type=int, default=DEFAULT_PORTS["next"], help="선호 포트. 사용 중이면 위쪽 빈 포트로 옮긴다") parser.add_argument("--route", action="append", default=[], help="추가로 확인할 경로. 여러 번 줄 수 있다") parser.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT, help="첫 응답까지 기다릴 초") parser.add_argument("--keep", action="store_true", help="확인 후에도 서버를 남긴다 (PID 와 종료 방법을 출력)") args = parser.parse_args(argv) cwd = Path(args.dir).resolve() if not cwd.is_dir(): print(f"오류: 디렉터리가 없다 — {cwd}", file=sys.stderr) return 2 try: port = pick_port(args.port) except RuntimeError as exc: print(f"오류: {exc}", file=sys.stderr) return 1 if port != args.port: print(f"알림: {args.port} 가 사용 중이라 {port} 로 띄운다") base = f"http://127.0.0.1:{port}" resolved, process = launch(args.cmd, port, cwd) print(f"기동: {resolved} (cwd={cwd})") failed = False try: status, error = wait_for_ready( base + "/", time.monotonic() + args.timeout, is_alive=lambda: process.poll() is None) if status is None: print(f" X 헬스체크 실패: {error}") output = tail(process) if output: print(" --- dev 서버 출력 ---") print(" " + output.replace("\n", "\n ")) return 1 print(f" ok {base}/ → HTTP {status}") if status >= 500: print(" X 루트가 서버 오류를 돌려준다 — 빌드 에러를 먼저 확인할 것") failed = True for route in args.route: path = route if route.startswith("/") else "/" + route code, error = probe(base + path, timeout=15) if code is None: print(f" X {path} → 응답 없음 ({error})") failed = True elif code >= 400: print(f" X {path} → HTTP {code}") failed = True else: print(f" ok {path} → HTTP {code}") finally: if args.keep and not failed: print(f" 서버를 남긴다 — URL {base} · PID {process.pid} · " f"종료 `kill -TERM -{process.pid}`") else: stop(process) print(f" => 헬스체크 {'실패' if failed else '통과'} ({base})") return 1 if failed else 0 if __name__ == "__main__": sys.exit(main()) -
validate_traceability.py 7.6 KB
#!/usr/bin/env python3 """Business Rules와 traceability.json의 화면·규칙·파일 연결을 검증한다. `--routes` 를 주면 라우터 소스와도 대조한다 (이슈 #139). SPA 모드에서 가장 흔한 이탈이 "매핑표에는 있는데 라우터에 등록되지 않은 화면" 이다 — 빌드도 타입 검사도 통과하고, 그 URL 로 들어갔을 때만 빈 화면이 된다. 반대 방향(라우터에는 있는데 문서에 없는 라우트)도 같이 본다. """ import argparse import json import re import sys from collections import Counter from pathlib import Path SCREEN_RE = re.compile(r"(?m)^##\s+([A-Z]{2,6}-[A-Z]{2,12}-\d{3})\b") #: 라우터 소스에서 경로 리터럴을 뽑는다. route object(`path: "/x"`)와 JSX #: (`<Route path="/x">`) 두 표기를 모두 쓴다 — 프로젝트마다 다르고, 하나만 #: 지원하면 검사가 조용히 0건이 된다. ROUTE_OBJ_RE = re.compile(r"""\bpath\s*:\s*['"`]([^'"`]*)['"`]""") ROUTE_JSX_RE = re.compile(r"""<Route\b[^>]*\bpath\s*=\s*['"{`]+([^'"`}]*)['"`}]+""") #: 문서와 대조할 대상이 아닌 라우트. 404 와 index 는 화면 ID 를 갖지 않는다. ROUTE_IGNORED = {"*", "", "/*"} RULE_RE = re.compile( r"\b([A-Z]{2,6}-[A-Z]{2,12}-\d{3}\.(?:IN|OUT|INT|EDGE)-\d{2})\b") def duplicates(values): return sorted(value for value, count in Counter(values).items() if count > 1) def collect_routes(sources): """라우터 소스에서 선언된 경로 집합을 뽑는다.""" found = set() for text in sources: found.update(ROUTE_OBJ_RE.findall(text)) found.update(ROUTE_JSX_RE.findall(text)) return {route for route in found if route not in ROUTE_IGNORED} def normalize_route(route): """비교용 정규화. 끝 슬래시와 파라미터 이름 차이는 같은 라우트로 본다.""" route = route.strip() if len(route) > 1: route = route.rstrip("/") if not route.startswith("/"): route = "/" + route return re.sub(r":[A-Za-z_][A-Za-z0-9_]*", ":param", route) def validate(manifest, rules_md, repo_root, router_sources=None): violations = [] br_screens = SCREEN_RE.findall(rules_md) br_rules = RULE_RE.findall(rules_md) if duplicates(br_screens): violations.append(f"Business Rules 중복 화면 ID: {', '.join(duplicates(br_screens))}") if duplicates(br_rules): violations.append(f"Business Rules 중복 규칙 ID: {', '.join(duplicates(br_rules))}") screens = manifest.get("screens") if isinstance(manifest, dict) else None if not isinstance(screens, list): return violations + ["traceability.json의 screens는 배열이어야 한다"] manifest_screens = [] manifest_rules = [] for index, screen in enumerate(screens, 1): if not isinstance(screen, dict) or not screen.get("screenId"): violations.append(f"screens[{index}]에 screenId가 없다") continue screen_id = screen["screenId"] manifest_screens.append(screen_id) implementations = screen.get("implementation") if not isinstance(implementations, list) or not implementations: violations.append(f"{screen_id}에 implementation 파일이 없다") else: for path in implementations: if not isinstance(path, str) or not (repo_root / path).is_file(): violations.append(f"{screen_id} 구현 파일 없음: {path}") rules = screen.get("rules") if not isinstance(rules, list): violations.append(f"{screen_id}의 rules는 배열이어야 한다") continue for rule in rules: if not isinstance(rule, dict) or not rule.get("ruleId"): violations.append(f"{screen_id}에 ruleId 없는 규칙 연결이 있다") continue rule_id = rule["ruleId"] manifest_rules.append(rule_id) if not rule_id.startswith(screen_id + "."): violations.append(f"규칙 ID가 다른 화면에 연결됨: {screen_id} → {rule_id}") tests = rule.get("tests") if not isinstance(tests, list) or not tests: violations.append(f"{rule_id}에 테스트 파일이 없다") else: for path in tests: if not isinstance(path, str) or not (repo_root / path).is_file(): violations.append(f"{rule_id} 테스트 파일 없음: {path}") routes = [(screen.get("screenId"), screen.get("route")) for screen in screens if isinstance(screen, dict) and screen.get("route")] seen = {} for screen_id, route in routes: key = normalize_route(route) if key in seen: violations.append( f"라우트 중복: {seen[key]} 와 {screen_id} 가 모두 {route} 다") else: seen[key] = screen_id if router_sources is not None: declared = {normalize_route(route) for route in collect_routes(router_sources)} for screen_id, route in routes: if normalize_route(route) not in declared: violations.append( f"{screen_id}의 라우트가 라우터에 등록되지 않았다: {route}") for orphan in sorted(declared - set(seen)): violations.append(f"문서에 없는 라우트가 라우터에 있다: {orphan}") for label, values in (("화면", manifest_screens), ("규칙", manifest_rules)): dup = duplicates(values) if dup: violations.append(f"traceability.json 중복 {label} ID: {', '.join(dup)}") missing_screens = sorted(set(br_screens) - set(manifest_screens)) extra_screens = sorted(set(manifest_screens) - set(br_screens)) missing_rules = sorted(set(br_rules) - set(manifest_rules)) extra_rules = sorted(set(manifest_rules) - set(br_rules)) if missing_screens: violations.append(f"traceability.json 화면 ID 누락: {', '.join(missing_screens)}") if extra_screens: violations.append(f"Business Rules에 없는 화면 ID: {', '.join(extra_screens)}") if missing_rules: violations.append(f"traceability.json 규칙 ID 누락: {', '.join(missing_rules)}") if extra_rules: violations.append(f"Business Rules에 없는 규칙 ID: {', '.join(extra_rules)}") return violations def main(argv=None): parser = argparse.ArgumentParser(description="기획↔구현 traceability 검증") parser.add_argument("manifest", type=Path) parser.add_argument("business_rules", type=Path) parser.add_argument("--repo-root", type=Path, default=Path.cwd()) parser.add_argument("--routes", type=Path, nargs="*", default=None, help="라우터 소스 파일. 주면 라우트 등록 여부까지 대조한다") args = parser.parse_args(argv) try: manifest = json.loads(args.manifest.read_text(encoding="utf-8")) rules_md = args.business_rules.read_text(encoding="utf-8") except (OSError, json.JSONDecodeError) as exc: print(f"오류: 입력을 읽을 수 없음: {exc}", file=sys.stderr) return 2 sources = None if args.routes is not None: try: sources = [path.read_text(encoding="utf-8") for path in args.routes] except OSError as exc: print(f"오류: 라우터 소스를 읽을 수 없음: {exc}", file=sys.stderr) return 2 violations = validate(manifest, rules_md, args.repo_root, sources) for violation in violations: print(f"[위반] {violation}") if violations: print(f"traceability 위반 {len(violations)}건") return 1 print("traceability 통과") return 0 if __name__ == "__main__": raise SystemExit(main())
-
-
tests
-
test_contract.py 6.1 KB
"""nextjs-implementer 가 참조하는 외부 계약의 드리프트 검출 (stdlib only). 이 스킬의 SKILL.md 는 mobile-web-planner 산출물의 구조(Business Rules 4개 절) 를 구현 체크리스트로 쓴다고 약속한다. 기획 스킬이 절 이름을 바꾸면 이 약속이 조용히 낡으므로, 두 스킬 문서를 대조해 어긋남을 테스트로 잡는다. """ import re import unittest from pathlib import Path SKILL_ROOT = Path(__file__).resolve().parent.parent SKILLS_DIR = SKILL_ROOT.parent def flat(text): """줄바꿈으로 갈라진 구문도 잡히게 공백을 한 칸으로 정규화한다.""" return re.sub(r"\s+", " ", text) SKILL_MD = flat((SKILL_ROOT / "SKILL.md").read_text(encoding="utf-8")) # SKILL.md 가 "4개 절"로 약속하는 Business Rules 섹션 명칭. BR_SECTIONS = ("입력 검증", "출력 규칙", "인터랙션", "엣지케이스") class TestPlannerContract(unittest.TestCase): """mobile-web-planner 산출물 구조에 대한 참조가 실제와 일치해야 한다.""" @classmethod def setUpClass(cls): cls.planner = flat(( SKILLS_DIR / "mobile-web-planner" / "SKILL.md" ).read_text(encoding="utf-8")) def test_br_sections_exist_in_planner(self): """참조하는 4개 절 명칭이 기획 스킬 문서에도 그대로 있어야 한다.""" for section in BR_SECTIONS: with self.subTest(section=section): self.assertIn(section, self.planner) self.assertIn(section, SKILL_MD) def test_storyboard_slide_refs_exist_in_planner(self): """참조하는 슬라이드 번호(05/06/07.x/08/09.x)가 기획 스킬의 번호 체계에 존재해야 한다 — 체계가 바뀌면 여기서 드리프트가 잡힌다.""" for ref in ("05 Screen List", "06 Service Flow", "08 General Rule"): with self.subTest(ref=ref): self.assertIn(ref, SKILL_MD) self.assertIn(ref.split(" ", 1)[1], self.planner) self.assertIn("07.x", SKILL_MD) self.assertIn("09.x", SKILL_MD) def test_deliverable_filename_patterns_match_planner(self): """입력 파일명 패턴이 기획 스킬의 산출물 명명과 일치해야 한다.""" for pattern in ("_storyboard.html", "_business-rules.md"): with self.subTest(pattern=pattern): self.assertIn(pattern, SKILL_MD) self.assertIn(pattern, self.planner) class TestBackendModeConsistency(unittest.TestCase): """백엔드 모드의 기술 계약은 SKILL.md 한 곳에서 유지한다.""" def test_java_version_is_consistent(self): self.assertIn("Java 1.8", SKILL_MD) def test_spring_boot_line_is_java8_compatible(self): """Spring Boot 는 2.7 (Java 8 을 지원하는 마지막 라인)로 고정한다.""" self.assertIn("Spring Boot 2.7", SKILL_MD) self.assertNotRegex( SKILL_MD, r"Spring Boot 3", "Spring Boot 3 은 Java 17 필수 — Java 1.8 계약과 모순된다") def test_doksam_ui_link_targets_existing_skill(self): """doksam-ui 연계 문구가 있다면 그 스킬이 실제로 존재해야 한다.""" if "doksam-ui" not in SKILL_MD: self.skipTest("doksam-ui 연계 문구 없음") self.assertTrue( (SKILLS_DIR / "doksam-ui" / "SKILL.md").is_file(), "SKILL.md 가 doksam-ui Skill 을 참조하지만 스킬이 없다") class TestFrontendModeContract(unittest.TestCase): def test_two_frontend_modes_and_owners_are_explicit(self): for phrase in ("Next.js App Router", "Vite + React SPA", "frontend-build", "react-expert", "doksam-ui"): with self.subTest(phrase=phrase): self.assertIn(phrase, SKILL_MD) def test_mode_reference_and_traceability_contract_exist(self): reference = SKILL_ROOT / "references" / "implementation-modes.md" self.assertTrue(reference.is_file()) text = flat(reference.read_text(encoding="utf-8")) for phrase in ("http://localhost:3000", "http://localhost:5173", "traceability.json", "ruleId", "screenId"): with self.subTest(phrase=phrase): self.assertIn(phrase, text) self.assertTrue((SKILL_ROOT / "scripts" / "validate_traceability.py").is_file()) def test_adapters_name_both_frontend_families(self): for name in ("claude.md", "codex.toml", "antigravity.md"): with self.subTest(adapter=name): text = (SKILL_ROOT / "agents" / name).read_text(encoding="utf-8") self.assertIn("Next.js", text) self.assertIn("Vite + React", text) if __name__ == "__main__": unittest.main() class TestReferencedFiles(unittest.TestCase): """SKILL.md 가 가리키는 참조 문서와 스크립트가 실제로 있어야 한다. 링크만 남고 파일이 사라지면 에이전트는 그 절을 조용히 건너뛴다 — 실행 계약이 있다고 믿는 상태가 없는 상태보다 나쁘다. """ def test_linked_references_exist(self): for name in re.findall(r"\(references/([\w.-]+)\)", (SKILL_ROOT / "SKILL.md").read_text(encoding="utf-8")): with self.subTest(reference=name): self.assertTrue((SKILL_ROOT / "references" / name).is_file(), f"references/{name} 가 없다") def test_serve_and_check_is_wired(self): self.assertIn("serve_and_check.py", SKILL_MD, "기동·헬스체크가 스크립트로 연결돼 있지 않다") self.assertTrue((SKILL_ROOT / "scripts" / "serve_and_check.py").is_file()) def test_smb_reference_keeps_planner_first(self): """소상공인 경로가 기획 단계를 건너뛰지 않아야 한다.""" smb = flat((SKILL_ROOT / "references" / "smb-quickstart.md") .read_text(encoding="utf-8")) self.assertIn("mobile-web-planner", smb) for term in ("사업자등록번호", "통신판매업 신고번호", "개인정보"): with self.subTest(term=term): self.assertIn(term, smb) -
test_serve_and_check.py 4.4 KB
"""serve_and_check.py 계약 테스트 (이슈 #138). 실제 dev 서버 대신 stdlib http.server 를 `--cmd` 로 띄운다 — Next.js/Vite 를 설치하지 않고도 "포트 선택 → 기동 → 첫 응답 대기 → 라우트 판정 → 정리" 전 경로가 그대로 돈다. """ import socket import sys import textwrap import unittest from pathlib import Path SKILL_ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(SKILL_ROOT / "scripts")) import serve_and_check as sac # noqa: E402 SERVER = textwrap.dedent(""" import os, sys from http.server import BaseHTTPRequestHandler, HTTPServer class H(BaseHTTPRequestHandler): def do_GET(self): code = 200 if self.path in ("/", "/order") else 404 self.send_response(code) self.end_headers() self.wfile.write(b"ok") def log_message(self, *a): pass HTTPServer(("127.0.0.1", int(sys.argv[1])), H).serve_forever() """) def write_server(tmp): path = Path(tmp) / "fake_dev.py" path.write_text(SERVER, encoding="utf-8") return path class TestPortSelection(unittest.TestCase): def test_free_port_is_used_as_is(self): with socket.socket() as probe: probe.bind(("127.0.0.1", 0)) free = probe.getsockname()[1] self.assertEqual(sac.pick_port(free), free) def test_busy_port_moves_up(self): """Next.js 는 포트가 막히면 조용히 옮겨 간다 — 우리가 먼저 정한다.""" with socket.socket() as taken: taken.bind(("127.0.0.1", 0)) taken.listen(1) busy = taken.getsockname()[1] chosen = sac.pick_port(busy) self.assertNotEqual(chosen, busy) self.assertGreater(chosen, busy) def test_no_free_port_raises(self): with socket.socket() as taken: taken.bind(("127.0.0.1", 0)) taken.listen(1) busy = taken.getsockname()[1] with self.assertRaises(RuntimeError): sac.pick_port(busy, span=1) class TestWaitForReady(unittest.TestCase): def test_dead_process_stops_waiting_immediately(self): """죽은 프로세스에 타임아웃 전체를 쓰지 않는다.""" slept = [] status, error = sac.wait_for_ready( "http://127.0.0.1:1/", deadline=sac.time.monotonic() + 60, is_alive=lambda: False, sleep=slept.append) self.assertIsNone(status) self.assertIn("먼저 종료", error) self.assertEqual(slept, [], "죽은 프로세스를 기다리며 잠들었다") def test_timeout_reports_last_error(self): status, error = sac.wait_for_ready( "http://127.0.0.1:1/", deadline=sac.time.monotonic() - 1, sleep=lambda _: None) self.assertIsNone(status) self.assertTrue(error) class TestEndToEnd(unittest.TestCase): def setUp(self): import tempfile self.tmp = tempfile.TemporaryDirectory() self.addCleanup(self.tmp.cleanup) self.server = write_server(self.tmp.name) def cmd(self): return f"{sys.executable} {self.server} {{port}}" def test_healthy_server_passes_and_is_stopped(self): code = sac.main(["--cmd", self.cmd(), "--dir", self.tmp.name, "--port", "38210", "--route", "/order", "--timeout", "20"]) self.assertEqual(code, 0) self.assertTrue(sac.port_is_free(38210), "확인 후 서버를 정리하지 않았다") def test_missing_route_fails(self): """존재하지 않는 라우트는 404 다 — 프로세스 생존만 보면 통과했을 것이다.""" code = sac.main(["--cmd", self.cmd(), "--dir", self.tmp.name, "--port", "38220", "--route", "/nope", "--timeout", "20"]) self.assertEqual(code, 1) def test_server_that_never_listens_fails(self): code = sac.main([ "--cmd", f"{sys.executable} -c \"import sys;sys.exit(1)\"", "--dir", self.tmp.name, "--port", "38230", "--timeout", "20"]) self.assertEqual(code, 1) def test_port_placeholder_is_substituted(self): resolved, process = sac.launch(f"{sys.executable} -c \"import sys\" {{port}}", 4321, Path(self.tmp.name)) self.addCleanup(sac.stop, process) self.assertIn("4321", resolved) self.assertNotIn("{port}", resolved) if __name__ == "__main__": unittest.main() -
test_traceability.py 6.4 KB
import importlib.util import tempfile import unittest from pathlib import Path ROOT = Path(__file__).resolve().parent.parent SPEC = importlib.util.spec_from_file_location( "validate_traceability", ROOT / "scripts" / "validate_traceability.py") validator = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(validator) RULES = """# T Business Rules ## DTC-MAIN-001 홈 ### 출력 규칙 | 상태 | 표시 | |---|---| | DTC-MAIN-001.OUT-01 · 로딩 | 스켈레톤 | """ class TestTraceability(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory() self.root = Path(self.temp.name) (self.root / "src").mkdir() (self.root / "src" / "home.tsx").write_text("export {}", encoding="utf-8") (self.root / "src" / "home.test.tsx").write_text("export {}", encoding="utf-8") def tearDown(self): self.temp.cleanup() def manifest(self): return {"screens": [{ "screenId": "DTC-MAIN-001", "route": "/", "implementation": ["src/home.tsx"], "rules": [{ "ruleId": "DTC-MAIN-001.OUT-01", "tests": ["src/home.test.tsx"], }], }]} def test_valid_manifest_passes(self): self.assertEqual(validator.validate(self.manifest(), RULES, self.root), []) def test_missing_rule_and_file_are_reported(self): manifest = self.manifest() manifest["screens"][0]["implementation"] = ["src/missing.tsx"] manifest["screens"][0]["rules"] = [] joined = "\n".join(validator.validate(manifest, RULES, self.root)) self.assertIn("구현 파일 없음", joined) self.assertIn("규칙 ID 누락", joined) def test_wrong_screen_and_duplicate_rule_are_reported(self): manifest = self.manifest() rule = manifest["screens"][0]["rules"][0] rule["ruleId"] = "DTC-OTHER-001.OUT-01" manifest["screens"][0]["rules"].append(dict(rule)) joined = "\n".join(validator.validate(manifest, RULES, self.root)) self.assertIn("다른 화면", joined) self.assertIn("중복 규칙 ID", joined) class TestRouteRegistration(unittest.TestCase): """SPA 모드에서 매핑표와 라우터가 어긋나는 경우 (이슈 #139). 빌드도 타입 검사도 통과하고, 그 URL 로 들어갔을 때만 빈 화면이 된다 — 그래서 기계 대조가 필요하다. """ def setUp(self): self.temp = tempfile.TemporaryDirectory() self.addCleanup(self.temp.cleanup) self.root = Path(self.temp.name) (self.root / "src").mkdir() for name in ("home.tsx", "home.test.tsx", "board.tsx", "board.test.tsx"): (self.root / "src" / name).write_text("export {}", encoding="utf-8") def manifest(self, *screens): return {"screens": list(screens)} def screen(self, screen_id, route, stem): return { "screenId": screen_id, "route": route, "implementation": [f"src/{stem}.tsx"], "rules": [{"ruleId": f"{screen_id}.OUT-01", "tests": [f"src/{stem}.test.tsx"]}], } def rules_for(self, *screen_ids): return "\n".join( f"## {sid} 화면\n\n### 출력 규칙\n| {sid}.OUT-01 · 로딩 | 스켈레톤 |\n" for sid in screen_ids) def test_route_object_syntax_is_read(self): router = 'createBrowserRouter([{ path: "/", element: <Home/> }])' result = validator.validate( self.manifest(self.screen("DTC-MAIN-001", "/", "home")), self.rules_for("DTC-MAIN-001"), self.root, [router]) self.assertEqual(result, []) def test_jsx_route_syntax_is_read(self): router = '<Route path="/board" element={<Board/>} />' result = validator.validate( self.manifest(self.screen("DTC-BOARD-001", "/board", "board")), self.rules_for("DTC-BOARD-001"), self.root, [router]) self.assertEqual(result, []) def test_unregistered_route_is_reported(self): router = 'createBrowserRouter([{ path: "/", element: <Home/> }])' joined = "\n".join(validator.validate( self.manifest(self.screen("DTC-BOARD-001", "/board", "board")), self.rules_for("DTC-BOARD-001"), self.root, [router])) self.assertIn("라우터에 등록되지 않았다", joined) def test_orphan_route_in_router_is_reported(self): router = ('createBrowserRouter([{ path: "/", element: <Home/> },' '{ path: "/secret", element: <Secret/> }])') joined = "\n".join(validator.validate( self.manifest(self.screen("DTC-MAIN-001", "/", "home")), self.rules_for("DTC-MAIN-001"), self.root, [router])) self.assertIn("문서에 없는 라우트", joined) def test_catch_all_is_not_an_orphan(self): router = ('createBrowserRouter([{ path: "/", element: <Home/> },' '{ path: "*", element: <NotFound/> }])') self.assertEqual(validator.validate( self.manifest(self.screen("DTC-MAIN-001", "/", "home")), self.rules_for("DTC-MAIN-001"), self.root, [router]), []) def test_param_names_and_trailing_slash_do_not_split_routes(self): router = 'createBrowserRouter([{ path: "/board/:boardId" }])' self.assertEqual(validator.validate( self.manifest(self.screen("DTC-BOARD-001", "/board/:id/", "board")), self.rules_for("DTC-BOARD-001"), self.root, [router]), []) def test_duplicate_route_is_reported_without_router(self): """라우터 소스를 안 줘도 두 화면이 같은 라우트인 것은 잡는다.""" joined = "\n".join(validator.validate( self.manifest(self.screen("DTC-MAIN-001", "/", "home"), self.screen("DTC-BOARD-001", "/", "board")), self.rules_for("DTC-MAIN-001", "DTC-BOARD-001"), self.root)) self.assertIn("라우트 중복", joined) def test_overlay_screens_without_route_are_untouched(self): """팝업·바텀시트는 라우트가 없다 — 없다고 위반이 되면 안 된다.""" overlay = self.screen("DTC-POPUP-001", None, "board") overlay.pop("route") self.assertEqual(validator.validate( self.manifest(self.screen("DTC-MAIN-001", "/", "home"), overlay), self.rules_for("DTC-MAIN-001", "DTC-POPUP-001"), self.root, ['createBrowserRouter([{ path: "/" }])']), []) if __name__ == "__main__": unittest.main()
-
-
SKILL.md 20 KB
--- name: nextjs-implementer description: mobile-web-planner의 Storyboard와 Business Rules를 동작하는 웹앱으로 구현할 때 사용한다. 프론트는 Next.js App Router 또는 Vite + React SPA 중 선택하고 화면·규칙 ID 추적표, 빌드, 핵심 User Flow 검증까지 완료한다. 기획 문서 없는 일반 React 컴포넌트 작업은 react-expert, Vite 설정만 다루는 작업은 frontend-build를 쓴다. --- # nextjs-implementer 당신은 기획 문서를 코드로 옮기는 **시니어 웹 개발자**다. mobile-web-planner 가 산출한 두 문서 — Storyboard(HTML)와 Business Rules(md) — 를 계약으로 받아 동작하는 웹 애플리케이션으로 구현한다. 이름은 기존 호출과 설치 경로의 호환성을 위해 유지한다. 프론트 구현 모드는 **Next.js App Router**와 **Vite + React SPA** 두 가지다. 세부 스캐폴딩과 검증 명령은 [references/implementation-modes.md](references/implementation-modes.md)를 필요한 모드만 읽어 적용한다. | 프론트 모드 | 선택 기준 | 가능한 백엔드 | |---|---|---| | **Next.js** (호환 기본값) | SSR/SEO, Server Components, Server Actions가 필요하거나 별도 지시가 없음 | Next.js 풀스택, Java 1.8 API | | **Vite + React SPA** | 정적 호스팅, 클라이언트 라우팅, 별도/기존 API, 경량 랜딩·관리도구 | Java 1.8 API, 기존·서버리스 API, 명시적 mock | `Vite + Next.js 백엔드`라는 모호한 조합은 만들지 않는다. Vite가 `/api`를 호출해야 하면 API의 소유 주체와 실행 방법을 별도 계약으로 확정한다. # 입력 한 쌍의 기획 산출물을 입력으로 받는다. 1. **`*_storyboard.html`** — 화면 목록(05 Screen List), 화면 흐름(06 Service Flow), 트랜잭션 시퀀스(07.x), 공통 규칙(08 General Rule), 화면별 목업(09.x). 2. **`*_business-rules.md`** — 화면 ID 를 키로 화면마다 4개 절: **입력 검증 · 출력 규칙 · 인터랙션 · 엣지케이스**. 둘 중 하나만 주어지면 나머지의 위치를 먼저 묻는다. 기획 문서 없이 "그냥 웹앱 만들어줘"라면 이 스킬의 범위 밖이다 — mobile-web-planner 로 기획을 먼저 뽑을지 물어본다. 문서가 답하지 않는 것(데이터 모델·인프라)은 기획 산출물의 범위 밖이므로, 구현에 필요한 최소만 **가정으로 명시하고** 데이터 계층 뒤에 숨긴다. 기획 문서를 임의로 재해석하거나 화면을 빼거나 합치지 않는다 — 문서와 구현이 다르면 문서를 고칠 일이지 구현이 조용히 이탈할 일이 아니다. # Workflow 아래 순서를 끝까지 수행한다. 1. **구현 프로필 확정** — 사용자가 프론트·백엔드 스택을 지정하면 그대로 따른다. 지정하지 않으면 위 선택 기준으로 프로필을 정하고 근거를 기록한다. 판단 근거가 없으면 호환 기본값인 Next.js 풀스택을 쓴다. 모드는 중간에 조용히 바꾸지 않는다. 2. **계약 파악** — Business Rules 의 화면 ID 전수와 Storyboard 의 05 Screen List 를 대조해 구현 대상 화면 집합을 확정한다. 유형(화면/팝업/바텀시트)을 함께 적는다. 3. **라우트 매핑표 작성** — 코드를 만지기 전에 `화면 ID → 라우트(또는 부모 화면 + 오버레이)` 매핑표를 만들어 사용자에게 보여준다. 유형이 `화면`이면 라우트 세그먼트, `팝업`·`바텀시트`면 부모 라우트의 오버레이 컴포넌트다. **별도 API를 쓰는 모드에서는 API 계약표도 함께** 만든다 — 07.x 시퀀스의 트랜잭션과 화면별 조회를 `메서드 + 경로 + 요청/응답 요지 + 관련 화면 ID` 행으로 정리한다. 두 표가 이후 모든 커버리지 판정의 기준이다. 4. **프로젝트 준비** — 기존 프로젝트가 있으면 그 구조·컨벤션을 따른다. 새 프로젝트는 선택한 모드의 reference대로 초기화한다. Vite 빌드·pnpm·번들 검사는 `frontend-build`, React 컴포넌트 판단은 `react-expert`, doksam UI는 `doksam-ui`가 소유한다. 이 스킬은 그 규칙을 복제하지 않고 결과만 합친다. **실제 저장소(DB)를 쓰기로 했다면 여기서 데이터 계약 게이트를 통과한다.** [references/data-contract-handoff.md](references/data-contract-handoff.md) 의 입력표를 채운다 — 엔티티·관계·불변조건·권한·보존 정책·DB 엔진이다. Storyboard 와 Business Rules 는 이것들을 정의하지 않으므로 **화면만 보고 추론해 스키마로 확정하지 않는다.** 답이 없는 항목은 데이터 계층 뒤에 가정으로 남기고 미확정으로 보고한 뒤 화면 구현은 그대로 진행한다. 스키마 설계·인덱스·마이그레이션 판단 자체는 `db-expert` 가 소유한다. 5. **화면 구현** — 매핑표 순서대로 화면 하나씩: - 09.x 목업의 레이아웃·구성요소를 마크업으로 옮긴다. 시각 디테일보다 **구조와 상태**(로딩/빈/오류/성공)가 우선이다. - 해당 화면의 Business Rules 4개 절을 **구현 체크리스트**로 쓴다. 규칙 ID가 있으면 그대로 유지하고, 없으면 구현 중 임의 ID를 원문에 쓰지 않는다. - `traceability.json`에 화면 ID → 규칙 ID/규칙 위치 → 구현 파일 → 테스트 파일을 기록한다. 규칙 ID가 없는 구문서는 `section + 순번`을 문서 버전에 종속된 임시 키로 쓰고 `legacy: true`를 표시한다. 6. **트랜잭션 검증** — 07.x 시퀀스 다이어그램의 각 트랜잭션이 실제 코드 경로(액션 → 요청 → 상태 반영)와 일치하는지 확인한다. Java 백엔드 모드는 API 계약표의 전 행이 컨트롤러로 구현됐는지도 대조한다. 7. **빌드·실행 검증** — 선택 모드의 `lint`, 타입 검사, 테스트, `build`를 통과시킨다. Java 백엔드가 있으면 서버 빌드도 통과시킨다. dev 서버 기동과 HTTP 헬스체크는 **손으로 하지 말고 스크립트로 판정한다** — 프로세스 생존은 기동의 증거가 아니고, 포트가 막히면 프레임워크가 조용히 다른 포트로 옮겨 가 안내한 URL 이 틀려진다. ```sh python3 <스킬경로>/scripts/serve_and_check.py \ --cmd "pnpm dev -- --port {port}" --dir <프로젝트> --port 5173 \ --route / --route <핵심라우트> ``` 확인된 포트만 사용자에게 알린다. 그다음 핵심 User Flow(내비게이션과 대표 쓰기 폼)를 실제로 확인한다. 외부 주문·결제·메시지를 만들 수 있으면 mock/샌드박스를 쓰거나 실행 전 승인을 받는다. 8. **커버리지 보고** — 매핑표(와 API 계약표)에 구현 상태와 미충족 규칙 (있다면 사유)을 채워 최종 보고한다. 소상공인·1인 기업의 비즈니스 사이트 요청(브랜드 홈페이지, 상품 소개, 주문·정기 배송 신청)이면 [references/smb-quickstart.md](references/smb-quickstart.md)를 함께 읽는다 — 법정 표기, 개인정보 동의 분리, 외부 채널 버튼처럼 그 도메인에서 실제로 사고가 나는 지점의 계약이다. 그 문서도 기획서 없이 코드로 가는 것을 허용하지 않는다. # 구현 규약 — 공통 (프론트) - **데이터 계층 분리.** 컴포넌트는 `lib/data/` 아래 데이터 계층의 인터페이스 만 안다. 그 뒤가 목업이든 Server Action 이든 Java API 클라이언트든 컴포넌트는 모른다 — 백엔드 모드를 갈아끼울 수 있는 경계를 남기는 것이 목적이다. - **출력 규칙 = 상태 구현.** Business Rules의 로딩/빈/오류/성공 상태를 모두 구현한다. Next.js의 `loading.tsx`·`error.tsx`인지 SPA의 route error boundary·skeleton인지는 구현 모드가 결정한다. - **입력 검증은 제출 경로에.** 검증 규칙은 폼 제출 경로에서 강제하고, 실패 시 UI 는 Business Rules 가 정한 문구·위치를 따른다. 클라이언트 측 검증은 UX 보조일 뿐 서버 측 검증을 대체하지 않는다. - **모바일 우선.** 기획서가 모바일 웹 기준이므로 뷰포트 375px 을 1차 기준으로 잡고 데스크톱은 최대 폭 컨테이너로 감싼다. - **아이콘은 이모지 금지.** Phosphor Icons(MIT) 의 SVG path 를 인라인 `<svg>` 로 넣거나 react 패키지를 쓴다. `‹` 같은 타이포그래피 문자는 허용. - **doksam 프로젝트라면 doksam-ui 표준을 따른다.** 대상이 doksam 프로젝트 이거나 사용자가 ui.doksam.com 을 지정하면 `doksam-ui` Skill 의 규약(시맨틱 토큰·프로필·레지스트리 설치·체크리스트)을 이 규약과 함께 적용한다. - **추적성은 manifest가 기준이다.** 코드 전체에 임의 주석을 흩뿌리지 않고 `traceability.json`과 테스트 이름을 문서 ↔ 코드 왕복의 앵커로 쓴다. - **성능 규약을 같이 적용한다.** 아래 「성능 규약」 절은 화면을 구현하는 동안 지키는 것이지, 다 만든 뒤 되돌아와 고치는 항목이 아니다. # 구현 규약 — Next.js 모드 - Server Component가 기본값이다. `'use client'`는 상태·이벤트·브라우저 API가 필요한 leaf에만 둔다. - 출력 상태는 `loading.tsx`, `error.tsx`, 빈 상태 분기로 구현한다. ## Next.js 풀스택 - 변이(쓰기)는 **Server Actions**, 화면 밖 소비가 필요한 조회는 **Route Handlers** 로 구현한다. - 실제 저장소가 없으므로 데이터는 `lib/data/` 목업 저장소(메모리/파일)로 만들되, 입력 검증·상태 전이는 실제 규칙대로 동작시킨다. # 구현 규약 — Java 백엔드 모드 - **Java 8 언어 수준을 지킨다.** Spring Boot 2.7.x(지원 마지막 2.x) + `javax.*` 네임스페이스. `var`·record·text block 등 9+ 문법을 쓰지 않는다. - API 는 3단계 계층으로: `@RestController` → `@Service` → repository. 검증은 Bean Validation(`javax.validation`)으로 서버에서 강제한다 — Business Rules 의 입력 검증 절이 원본이다. - 오류 응답은 `@RestControllerAdvice` 로 일원화하고, 프론트 `error.tsx` · 오류 표시 규칙과 형식을 맞춘다. - 프론트의 데이터 계층은 이 API 를 부르는 **타입 있는 클라이언트**로 구현하고 (API 계약표와 1:1), 백엔드가 아직 없는 항목은 같은 인터페이스의 목업으로 대체해 프론트 진행을 막지 않는다. - 로컬 개발은 Next.js `rewrites` 또는 Vite `server.proxy`로 `/api/*`를 백엔드 포트에 연결해 CORS를 임의로 열지 않는다. # 구현 규약 — Vite + React SPA 모드 - 라우팅은 `react-router`의 프로젝트 설치 버전을 따른다. **major마다 import 하는 패키지가 다르다** — v8은 `react-router`(그 major의 `react-router-dom`은 없다), v6은 `react-router-dom`이다. 설치된 버전을 먼저 확인하고 major API를 섞지 않는다. 표는 references/implementation-modes.md 에 있다. - Screen List의 `화면`은 route object에, 팝업·바텀시트는 부모 route의 overlay 상태에 매핑한다. 새로고침과 직접 URL 진입도 테스트한다 — SPA fallback이 없으면 배포 환경에서만 404가 된다. - 라우트 등록 여부는 눈으로 확인하지 않는다. `validate_traceability.py` 에 `--routes <라우터 소스>` 를 주면 매핑표와 라우터를 양방향으로 대조한다. 등록되지 않은 화면은 빌드가 통과하고 그 URL 에서만 빈 화면이 된다. - 서버 상태는 API client 계층 뒤에 두고 로딩·오류·빈 상태를 route 단위로 처리한다. `VITE_` 환경변수는 공개 값이므로 시크릿을 넣지 않는다. - mock 모드는 사용자가 프로토타입을 원하거나 API가 아직 없다고 명시한 경우만 쓴다. 입력 검증·상태 전이는 실제 규칙대로 동작시키되 영속성·보안 검증을 완료했다고 보고하지 않는다. - `pnpm build` 후 `frontend-build/scripts/check_bundle.py <dist>`를 실행한다. # 성능 규약 Vercel 의 React/Next.js 성능 지침(MIT) 중 **이 스킬의 산출물에 실제로 걸리는 항목만** 추린 것이다. 위에서 아래로 임팩트 순이고, 위 두 절(워터폴·번들)은 나머지를 다 지켜도 이게 깨지면 의미가 없는 CRITICAL 이다. ## 워터폴 제거 (CRITICAL) - **독립 요청은 `Promise.all`.** 서로 의존하지 않는 조회를 `await` 로 줄 세우지 않는다. 순차 3회 왕복이 1회가 된다. - **중첩 조회도 병렬로.** 목록의 각 항목마다 상세를 부르는 구조라면, 항목별 체인을 만들어 `Promise.all` 로 한 번에 돌린다 — 항목 수만큼 직렬로 돌지 않는다. - **`await` 는 실제로 쓰는 분기 안으로.** 조건에 따라 안 쓰일 값이면 분기 안에서 기다린다. 싼 동기 조건을 먼저 검사하고 원격 값은 그 뒤에 기다린다. - **레이아웃을 데이터로 막지 않는다.** 페이지 최상단에서 `await` 해 전체를 붙잡는 대신, 데이터가 필요한 조각만 `Suspense` 로 감싸고 그 안의 async 컴포넌트가 기다리게 한다. 헤더·내비게이션·푸터는 즉시 그린다. - 여러 조각이 같은 데이터를 쓰면 **promise 를 만들어 props 로 넘기고** 각자 `use()` 로 푼다 — fetch 는 한 번만 일어난다. - 예외: 레이아웃 결정에 쓰이는 데이터, above-the-fold SEO 콘텐츠, 레이아웃 시프트를 피해야 하는 화면은 그냥 기다린다. - **Route Handler 는 일찍 시작하고 늦게 기다린다.** 핸들러 진입 직후 promise 를 띄우고, 응답을 조립하는 지점에서 `await` 한다. 이 절은 Business Rules 의 **출력 규칙**(로딩 상태)과 짝이다 — `Suspense` fallback 과 `loading.tsx` 가 그 규칙의 구현체다. ## 번들 크기 (CRITICAL) - **배럴 파일 금지.** `import { X } from '@/components'` 대신 실제 모듈 경로로 직접 가져온다. 배럴 하나가 트리셰이킹을 통째로 무력화한다. - **무거운 컴포넌트는 `next/dynamic`.** 차트·에디터·지도처럼 첫 화면에 없어도 되는 것은 동적 로드한다. 팝업·바텀시트 내용물이 대표적이다. - **서드파티는 hydration 이후.** 분석·로깅 스크립트가 초기 번들에 끼지 않게 한다. `<script>` 에는 `defer` 또는 `async` 를 붙인다. - **경로는 정적 분석 가능하게.** `import(변수)` · `path.join(cwd(), 변수)` 는 번들러가 후보를 넓게 잡아 서버 번들·파일 트레이스가 부풀어 오른다. 명시적 맵(`{ home: () => import('./home') }`)이나 리터럴 경로로 쓴다. ## 서버 (HIGH) - **Server Action 은 공개 엔드포인트다.** `'use server'` 함수는 직접 호출될 수 있으므로 미들웨어·레이아웃 가드를 믿지 말고 **액션 안에서** 인증과 권한을 매번 검사한다. 순서는 입력 검증 → 인증 → 권한 → 변이. Business Rules 의 **입력 검증** 절이 여기서 서버 측으로 강제된다. - **모듈 스코프에 요청 데이터를 담지 않는다.** 서버 렌더는 한 프로세스에서 동시 실행되므로 모듈 레벨 가변 변수는 요청 간 오염·타 사용자 데이터 노출로 이어진다. 요청 값은 props 로 트리에 내린다. (불변 설정·의도된 공유 캐시는 예외) - **요청 단위 중복 조회는 `React.cache()`.** 같은 요청에서 여러 컴포넌트가 같은 조회를 하면 캐시로 한 번만 나가게 한다. - **클라이언트로 넘기는 데이터는 최소로.** RSC → client 직렬화는 **참조** 기준으로 중복 제거되므로, 서버에서 `.filter()`·`.toSorted()`·전개로 새 배열을 만들어 원본과 함께 넘기면 같은 값이 두 번 실린다. 원본만 넘기고 가공은 클라이언트에서 `useMemo` 로 한다. - **응답을 막을 필요 없는 일은 `after()`.** 로깅·알림 발송 등은 응답 이후로 미룬다. - **정적 I/O 는 모듈 레벨로 끌어올린다.** 폰트·로고처럼 매 요청 동일한 읽기를 렌더마다 반복하지 않는다. ## 클라이언트 (MEDIUM-HIGH) - 클라이언트 조회가 필요하면 **SWR** 로 중복 요청을 합친다. - 전역 이벤트 리스너는 컴포넌트마다 붙이지 말고 하나로 모아 구독시킨다. `scroll`·`touchmove` 는 `{ passive: true }`. - `localStorage` 에는 **버전 키를 붙이고** 최소한만 저장한다. 스키마가 바뀌면 구버전 값을 버린다. ## 리렌더 (MEDIUM) - **파생 상태는 렌더 중에 계산한다.** `useEffect` + `setState` 로 값을 따라 만들지 않는다(렌더 2회 + 중간 상태 노출). - **인터랙션 로직은 이벤트 핸들러에.** "버튼을 누르면 ~" 규칙을 effect 로 옮기지 않는다. Business Rules 의 **인터랙션** 절은 대부분 핸들러로 끝난다. - **컴포넌트를 컴포넌트 안에서 정의하지 않는다.** 매 렌더 새 타입이 되어 트리가 통째로 마운트/언마운트된다. - `useState` 초기값이 비싸면 **함수를 넘긴다**(`useState(() => calc())`). - 콜백에서만 읽는 값은 구독하지 않는다. 원시값이 아닌 의존성은 파생 boolean 으로 좁힌다. 빈번히 바뀌는 일시값은 `useRef`. - 급하지 않은 갱신은 `startTransition`, 무거운 목록 필터는 `useDeferredValue` 로 입력 반응성을 지킨다. ## 렌더링 (MEDIUM) - **조건부 렌더는 `&&` 대신 삼항.** `{count && <Badge/>}` 는 `count === 0` 일 때 화면에 `0` 을 그린다 — 개수 배지·빈 목록에서 자주 터진다. - 제출·전환 로딩 표시는 `useTransition` 의 pending 을 쓴다(별도 `isLoading` 상태를 만들지 않는다). - 긴 목록에는 `content-visibility`, 정적 JSX 는 컴포넌트 밖으로 끌어올린다. - 클라이언트에서만 아는 값(테마·로컬 저장 값)은 인라인 스크립트로 첫 페인트 전에 반영해 깜빡임을 없애고, 불가피한 불일치는 `suppressHydrationWarning` 으로 좁게 억제한다. - 애니메이션은 SVG 요소가 아니라 감싼 `div` 에 건다. 원문 출처: Vercel `react-best-practices`(MIT) — 여기서 뺀 `js-*` 미시 최적화와 `advanced-*` 패턴은 임팩트가 낮아 이 스킬의 체크리스트에 넣지 않는다. 프로파일링으로 병목이 특정된 경우에만 원문을 찾아본다. # 완료 조건 다음이 모두 충족되어야 산출물을 전달할 수 있다. 1. 매핑표의 모든 화면 ID 가 라우트 또는 오버레이로 구현됐다. 2. 선택한 프론트 모드의 lint·typecheck·test·build가 통과한다. Java 백엔드 모드는 서버 빌드도 통과하고 API 계약표의 전 행이 구현됐다. Vite 모드는 번들 검사도 통과했다. 3. Business Rules 의 규칙별 체크리스트에 미충족 항목이 없거나, 남은 항목마다 사유(범위 밖 가정 등)가 보고에 명시돼 있다. 4. 성능 규약의 CRITICAL 두 절이 지켜졌다 — 독립 조회가 직렬 `await` 로 남아 있지 않다. Next.js 풀스택이면 Server Action마다 인증·권한 검사가 액션 안에 있다. 5. `traceability.json`에 모든 화면 ID가 있고, 규칙 ID가 있는 문서는 모든 ID가 정확히 한 구현 위치와 테스트에 연결됐으며 전용 검증기가 통과했다. 6. 최종 보고에 선택한 프론트·백엔드 모드와 근거, 라우트/API 매핑표, 실제 dev URL과 헬스체크·핵심 User Flow 결과, 목업 가정, 미충족 위험이 담겨 있다. `serve_and_check.py` 가 exit 0 이 아니면 완료가 아니다. 7. 실제 저장소를 쓴다면 데이터 계약 입력표의 미답 항목과 그 자리에 쓴 가정이 보고에 적혀 있다. 미답인데 보고에 없는 항목이 있으면 완료가 아니다. 8. 소상공인 사이트라면 사업자등록번호·통신판매업 신고번호 같은 **미확정 법정 표기 항목**이 자리표시자로 남아 있고 그 목록이 보고에 있다. 지어낸 값이 산출물에 있으면 완료가 아니다.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.