create-claude-pipeline 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/bin/cli.js +359 -0
  2. package/package.json +32 -0
  3. package/template/.claude/agents/be-developer.md +218 -0
  4. package/template/.claude/agents/designer.md +192 -0
  5. package/template/.claude/agents/fe-developer.md +175 -0
  6. package/template/.claude/agents/infra-developer.md +270 -0
  7. package/template/.claude/agents/planner.md +126 -0
  8. package/template/.claude/agents/pm.md +130 -0
  9. package/template/.claude/agents/qa-engineer.md +270 -0
  10. package/template/.claude/agents/security-reviewer.md +281 -0
  11. package/template/.claude/settings.json +5 -0
  12. package/template/.claude/skills/analyze-requirements/SKILL.md +166 -0
  13. package/template/.claude/skills/api-integration/SKILL.md +354 -0
  14. package/template/.claude/skills/assemble-context/SKILL.md +192 -0
  15. package/template/.claude/skills/db-migration/SKILL.md +228 -0
  16. package/template/.claude/skills/explore-be-codebase/SKILL.md +260 -0
  17. package/template/.claude/skills/explore-codebase/SKILL.md +190 -0
  18. package/template/.claude/skills/explore-design-system/SKILL.md +150 -0
  19. package/template/.claude/skills/explore-fe-codebase/SKILL.md +209 -0
  20. package/template/.claude/skills/explore-implementation/SKILL.md +147 -0
  21. package/template/.claude/skills/explore-infra/SKILL.md +242 -0
  22. package/template/.claude/skills/implement-api/SKILL.md +477 -0
  23. package/template/.claude/skills/implement-components/SKILL.md +217 -0
  24. package/template/.claude/skills/review-auth/SKILL.md +175 -0
  25. package/template/.claude/skills/scan-vulnerabilities/SKILL.md +200 -0
  26. package/template/.claude/skills/write-cicd/SKILL.md +293 -0
  27. package/template/.claude/skills/write-design-spec/SKILL.md +363 -0
  28. package/template/.claude/skills/write-dockerfile/SKILL.md +269 -0
  29. package/template/.claude/skills/write-plan-doc/SKILL.md +164 -0
  30. package/template/.claude/skills/write-plan-doc/assets/plan_template.html +251 -0
  31. package/template/.claude/skills/write-qa-report/SKILL.md +151 -0
  32. package/template/.claude/skills/write-security-report/SKILL.md +185 -0
  33. package/template/.claude/skills/write-test-cases/SKILL.md +234 -0
  34. package/template/.claude-pipeline/dashboard/.env.example +1 -0
  35. package/template/.claude-pipeline/dashboard/.eslintrc.json +3 -0
  36. package/template/.claude-pipeline/dashboard/README.md +36 -0
  37. package/template/.claude-pipeline/dashboard/next.config.mjs +6 -0
  38. package/template/.claude-pipeline/dashboard/package-lock.json +8148 -0
  39. package/template/.claude-pipeline/dashboard/package.json +36 -0
  40. package/template/.claude-pipeline/dashboard/postcss.config.mjs +8 -0
  41. package/template/.claude-pipeline/dashboard/server.ts +24 -0
  42. package/template/.claude-pipeline/dashboard/src/app/api/pipelines/[id]/checkpoint/route.ts +23 -0
  43. package/template/.claude-pipeline/dashboard/src/app/api/pipelines/[id]/outputs/[...filepath]/route.ts +18 -0
  44. package/template/.claude-pipeline/dashboard/src/app/api/pipelines/[id]/route.ts +10 -0
  45. package/template/.claude-pipeline/dashboard/src/app/api/pipelines/route.ts +64 -0
  46. package/template/.claude-pipeline/dashboard/src/app/favicon.ico +0 -0
  47. package/template/.claude-pipeline/dashboard/src/app/fonts/GeistMonoVF.woff +0 -0
  48. package/template/.claude-pipeline/dashboard/src/app/fonts/GeistVF.woff +0 -0
  49. package/template/.claude-pipeline/dashboard/src/app/globals.css +52 -0
  50. package/template/.claude-pipeline/dashboard/src/app/layout.tsx +33 -0
  51. package/template/.claude-pipeline/dashboard/src/app/page.tsx +49 -0
  52. package/template/.claude-pipeline/dashboard/src/app/pipeline/[id]/page.tsx +84 -0
  53. package/template/.claude-pipeline/dashboard/src/components/agent-card.tsx +40 -0
  54. package/template/.claude-pipeline/dashboard/src/components/agent-logs.tsx +65 -0
  55. package/template/.claude-pipeline/dashboard/src/components/artifact-viewer.tsx +130 -0
  56. package/template/.claude-pipeline/dashboard/src/components/checkpoint-banner.tsx +59 -0
  57. package/template/.claude-pipeline/dashboard/src/components/new-pipeline-modal.tsx +63 -0
  58. package/template/.claude-pipeline/dashboard/src/components/output-list.tsx +57 -0
  59. package/template/.claude-pipeline/dashboard/src/components/phase-dots.tsx +37 -0
  60. package/template/.claude-pipeline/dashboard/src/components/pipeline-card.tsx +53 -0
  61. package/template/.claude-pipeline/dashboard/src/components/resizable-panels.tsx +91 -0
  62. package/template/.claude-pipeline/dashboard/src/hooks/use-pipeline-detail.ts +65 -0
  63. package/template/.claude-pipeline/dashboard/src/hooks/use-pipelines.ts +60 -0
  64. package/template/.claude-pipeline/dashboard/src/hooks/use-websocket.ts +58 -0
  65. package/template/.claude-pipeline/dashboard/src/lib/agents.ts +30 -0
  66. package/template/.claude-pipeline/dashboard/src/lib/checkpoint.ts +37 -0
  67. package/template/.claude-pipeline/dashboard/src/lib/pipelines.ts +91 -0
  68. package/template/.claude-pipeline/dashboard/src/lib/watcher.ts +90 -0
  69. package/template/.claude-pipeline/dashboard/src/lib/ws-server.ts +123 -0
  70. package/template/.claude-pipeline/dashboard/src/types/pipeline.ts +61 -0
  71. package/template/.claude-pipeline/dashboard/tailwind.config.ts +31 -0
  72. package/template/.claude-pipeline/dashboard/tsconfig.json +26 -0
  73. package/template/CLAUDE.md +301 -0
  74. package/template/references/context-structure.md +34 -0
  75. package/template/references/pm-context-assembly.md +34 -0
  76. package/template/references/task-context-template.md +65 -0
@@ -0,0 +1,242 @@
1
+ ---
2
+ name: explore-infra
3
+ description: "인프라 엔지니어가 작업 전 기존 인프라 설정을 파악할 때 사용하는 skill. Dockerfile, docker-compose, CI/CD 파이프라인, 환경변수, 배포 플랫폼을 탐색해서 기존 설정과 일관된 작업이 가능하게 한다. Infra Agent가 04_task_INFRA.md를 받고 작업에 착수하기 전, 코드베이스를 처음 접하는 상황, 또는 기존 인프라에 새 서비스를 추가할 때 반드시 사용한다."
4
+ context: fork
5
+ agent: Explore
6
+ ---
7
+
8
+ # Explore Infra
9
+
10
+ Infra Agent가 작업을 시작하기 전에 기존 인프라 설정과 배포 환경을 파악하는 skill이다.
11
+
12
+ 기존 프로젝트에 인프라를 추가하거나 변경할 때, 기존 설정과 다른 방식으로 구성하면 환경이 일관성을 잃는다. 이 skill은 기존 인프라를 탐색해서 "이 프로젝트에서는 이렇게 배포한다"를 파악하고, Infra Agent가 동일한 패턴으로 작업할 수 있도록 현황을 정리한다.
13
+
14
+ 신규 프로젝트(기존 인프라 없음)의 경우에도 실행하되, "기존 인프라 없음 — 처음부터 구성"으로 보고한다.
15
+
16
+ ---
17
+
18
+ ## 탐색 절차
19
+
20
+ Explore agent를 사용하여 아래 항목들을 순서대로 탐색한다.
21
+
22
+ ### 1. Dockerfile 확인
23
+
24
+ ```
25
+ Glob: **/Dockerfile*
26
+ Glob: **/.dockerignore
27
+ ```
28
+
29
+ 확인할 항목:
30
+
31
+ | 항목 | 왜 중요한가 |
32
+ |------|-----------|
33
+ | Dockerfile 수 | 모노레포면 서비스별 Dockerfile이 있을 수 있다 |
34
+ | 베이스 이미지 | `node:20-alpine` vs `node:20` 등 — 새 Dockerfile도 동일 계열로 |
35
+ | 멀티 스테이지 여부 | 기존이 멀티 스테이지면 새 것도 맞춘다 |
36
+ | .dockerignore 내용 | 빠진 항목이 없는지 확인 |
37
+
38
+ ### 2. docker-compose 확인
39
+
40
+ ```
41
+ Glob: docker-compose*.yml
42
+ Glob: docker-compose*.yaml
43
+ Glob: compose*.yml
44
+ Glob: compose*.yaml
45
+ ```
46
+
47
+ 확인할 항목:
48
+ - 정의된 서비스 목록과 각 역할
49
+ - 네트워크 구성 (커스텀 네트워크 / 기본)
50
+ - 볼륨 설정 (DB 영속성, 로컬 마운트)
51
+ - 포트 매핑 현황
52
+ - 환경변수 주입 방식 (`environment` / `env_file`)
53
+ - 헬스체크 설정 여부
54
+ - 개발용 / 프로덕션용 분리 여부
55
+
56
+ ### 3. CI/CD 파이프라인 확인
57
+
58
+ ```
59
+ Glob: .github/workflows/*.yml
60
+ Glob: .github/workflows/*.yaml
61
+ Glob: .gitlab-ci.yml
62
+ Glob: Jenkinsfile
63
+ Glob: .circleci/**/*
64
+ Glob: bitbucket-pipelines.yml
65
+ ```
66
+
67
+ 확인할 항목:
68
+ - CI/CD 도구 (GitHub Actions / GitLab CI / Jenkins 등)
69
+ - 트리거 조건 (PR / push / tag)
70
+ - 파이프라인 단계 (lint → test → build → deploy)
71
+ - 시크릿 참조 방식 (`${{ secrets.XXX }}`)
72
+ - 배포 대상 환경 (staging / production)
73
+ - 캐싱 설정 (node_modules, Docker layer 등)
74
+
75
+ ### 4. 환경변수 확인
76
+
77
+ ```
78
+ Glob: .env.example
79
+ Glob: .env.sample
80
+ Glob: .env.template
81
+ Glob: .env.*.example
82
+ Grep: process\.env\.|os\.environ|import\.meta\.env
83
+ ```
84
+
85
+ 확인할 항목:
86
+ - 정의된 환경변수 목록과 용도
87
+ - FE용 / BE용 / 공통 분류
88
+ - 시크릿 변수 (DB 비밀번호, API 키 등)
89
+ - 환경별 분기 여부 (.env.development / .env.production)
90
+
91
+ ### 5. package.json scripts 확인
92
+
93
+ ```
94
+ Glob: **/package.json
95
+ ```
96
+
97
+ 확인할 항목 (scripts 섹션):
98
+ - 빌드 명령어 (`build`, `build:prod`)
99
+ - 개발 서버 (`dev`, `start:dev`)
100
+ - 테스트 (`test`, `test:e2e`)
101
+ - 린트 (`lint`, `lint:fix`)
102
+ - DB 관련 (`migrate`, `seed`, `db:push`)
103
+ - 배포 관련 (`deploy`, `docker:build`)
104
+
105
+ ### 6. 배포 플랫폼 설정 확인
106
+
107
+ ```
108
+ Glob: vercel.json
109
+ Glob: netlify.toml
110
+ Glob: railway.toml
111
+ Glob: fly.toml
112
+ Glob: app.yaml
113
+ Glob: appspec.yml
114
+ Glob: Procfile
115
+ Glob: render.yaml
116
+ Glob: **/terraform/**/*
117
+ Glob: **/k8s/**/*
118
+ Glob: **/kubernetes/**/*
119
+ ```
120
+
121
+ 확인할 항목:
122
+ - 사용 중인 배포 플랫폼
123
+ - 빌드 설정 (빌드 명령어, 출력 디렉토리)
124
+ - 리전/존 설정
125
+ - 스케일링 설정
126
+ - 도메인/라우팅 설정
127
+
128
+ ### 7. 외부 서비스 확인
129
+
130
+ ```
131
+ Grep: postgresql|mysql|mongodb|redis|elasticsearch|rabbitmq|kafka|s3|cloudfront|sentry
132
+ Glob: prisma/schema.prisma
133
+ ```
134
+
135
+ docker-compose, .env.example, package.json 의존성에서 외부 서비스를 추론한다:
136
+
137
+ | 단서 | 외부 서비스 |
138
+ |------|-----------|
139
+ | `pg`, `@prisma/client`, `DATABASE_URL` | PostgreSQL |
140
+ | `mysql2`, `MYSQL_` | MySQL |
141
+ | `mongoose`, `MONGODB_URI` | MongoDB |
142
+ | `ioredis`, `redis`, `REDIS_URL` | Redis |
143
+ | `@aws-sdk/client-s3`, `S3_BUCKET` | AWS S3 |
144
+ | `@sentry/node`, `SENTRY_DSN` | Sentry |
145
+ | `bull`, `bullmq` | Redis (큐) |
146
+
147
+ ---
148
+
149
+ ## 출력 형식
150
+
151
+ 탐색 결과를 아래 형식으로 정리하여 반환한다:
152
+
153
+ ```markdown
154
+ # 기존 인프라 현황
155
+
156
+ ## 컨테이너화
157
+
158
+ | 항목 | 상태 |
159
+ |------|------|
160
+ | Dockerfile | FE: Dockerfile / BE: Dockerfile.be |
161
+ | 베이스 이미지 | node:20-alpine |
162
+ | 멀티 스테이지 | 사용 (builder → runner) |
163
+ | docker-compose | 개발용: docker-compose.yml / 프로덕션: docker-compose.prod.yml |
164
+ | 서비스 | app, db (postgres:15), redis |
165
+ | 네트워크 | 커스텀 (app-network) |
166
+
167
+ ## CI/CD
168
+
169
+ | 항목 | 상태 |
170
+ |------|------|
171
+ | 도구 | GitHub Actions |
172
+ | CI | .github/workflows/ci.yml (PR → lint, test, build) |
173
+ | CD | .github/workflows/cd.yml (main → staging → production) |
174
+ | 캐싱 | node_modules 캐싱 설정됨 |
175
+ | 시크릿 | DEPLOY_KEY, DATABASE_URL 등 사용 |
176
+
177
+ ## 배포 플랫폼
178
+
179
+ | 항목 | 상태 |
180
+ |------|------|
181
+ | 플랫폼 | AWS ECS / Vercel / Railway 등 |
182
+ | 환경 | staging, production |
183
+ | 배포 방식 | 롤링 / 블루-그린 등 |
184
+
185
+ ## 환경변수
186
+
187
+ | 변수 | 분류 | 용도 |
188
+ |------|------|------|
189
+ | DATABASE_URL | BE | DB 연결 |
190
+ | JWT_SECRET | BE | 토큰 서명 |
191
+ | NEXT_PUBLIC_API_URL | FE | API 엔드포인트 |
192
+ | NODE_ENV | 공통 | 환경 구분 |
193
+ | PORT | 공통 | 서버 포트 |
194
+
195
+ ## 외부 서비스
196
+
197
+ | 서비스 | 종류 | 연결 방식 |
198
+ |--------|------|----------|
199
+ | PostgreSQL 15 | DB | docker-compose (로컬) / RDS (프로덕션) |
200
+ | Redis 7 | 캐시/큐 | docker-compose (로컬) |
201
+
202
+ ## 재사용 가능한 설정
203
+
204
+ - (기존 Dockerfile 패턴, CI/CD 워크플로우 등)
205
+ - 예: "기존 CI에 lint + test + build 3단계가 있으므로 새 서비스도 동일 구조로"
206
+ - 예: "docker-compose에 DB 서비스가 이미 있으므로 새로 추가하지 않아도 됨"
207
+
208
+ ## 새로 만들어야 할 것
209
+
210
+ - (04_task_INFRA.md 기준으로 기존에 없는 설정)
211
+ - 예: "CD 파이프라인 없음 — 새로 작성 필요"
212
+ - 예: "BE용 Dockerfile 없음 — 기존 FE Dockerfile 패턴 참고하여 작성"
213
+
214
+ ## 주의사항
215
+
216
+ - (기존 설정과 충돌 가능성, 따라야 할 규칙)
217
+ - 예: "포트 3000은 FE가 사용 중 — BE는 다른 포트를 써야 함"
218
+ - 예: "docker-compose 네트워크가 커스텀이므로 새 서비스도 같은 네트워크에 연결"
219
+ - 예: ".env 파일이 .gitignore에 없음 — 추가 필요"
220
+ ```
221
+
222
+ ---
223
+
224
+ ## 신규 프로젝트인 경우
225
+
226
+ 인프라 관련 파일이 전혀 없으면:
227
+
228
+ ```markdown
229
+ # 기존 인프라 현황
230
+
231
+ 기존 인프라 없음 — 처음부터 구성.
232
+
233
+ ## 프로젝트 설정 감지
234
+
235
+ - package.json: (있음/없음)
236
+ - 감지된 기술: (있으면 나열)
237
+ - .gitignore: (.env 포함 여부)
238
+
239
+ ## 권장사항
240
+
241
+ (04_task_INFRA.md의 인프라 요구사항에 따름)
242
+ ```
@@ -0,0 +1,477 @@
1
+ ---
2
+ name: implement-api
3
+ description: "BE 개발자가 API를 구현할 때 일관된 레이어 구조와 패턴을 유지하기 위해 참조하는 skill. Route → Controller → Service → Repository 4계층 구조와 입력값 검증(Zod), 에러 처리, 응답 형식 패턴을 제공한다. BE Agent가 API 엔드포인트를 구현하거나, 새 API를 추가하거나, 기존 API를 수정할 때 반드시 사용한다. 'API 구현', 'API 추가', '엔드포인트 작성', 'Controller 작성', 'Service 로직', 'DB 쿼리', 'Zod 검증', 'REST API 만들기' 등의 상황에서 트리거된다."
4
+ ---
5
+
6
+ # Implement API
7
+
8
+ BE 개발자가 API를 구현할 때 따르는 패턴이다.
9
+
10
+ API 구현은 "Route → Controller → Service → Repository" 4계층으로 나눈다. 각 레이어가 자기 역할만 하면 코드를 수정할 때 영향 범위가 해당 레이어로 한정된다. 예를 들어 DB를 Prisma에서 Drizzle로 바꿔도 Repository만 수정하면 되고, 검증 로직을 바꿔도 Controller만 수정하면 된다.
11
+
12
+ API 명세(`context/03_api_spec.md`)를 읽고 각 엔드포인트를 아래 순서로 구현한다.
13
+
14
+ ---
15
+
16
+ ## 폴더 구조
17
+
18
+ ```
19
+ src/
20
+ ├── routes/ # HTTP 경로 + 미들웨어 연결
21
+ │ ├── index.ts # 라우터 통합
22
+ │ └── orders.ts
23
+ ├── controllers/ # 요청 파싱 + 검증 + 응답 반환
24
+ │ └── orders.ts
25
+ ├── services/ # 비즈니스 로직
26
+ │ └── orders.ts
27
+ ├── repositories/ # DB 쿼리
28
+ │ └── orders.ts
29
+ ├── schemas/ # Zod 검증 스키마
30
+ │ └── orders.ts
31
+ ├── errors/ # 커스텀 에러 클래스
32
+ │ └── index.ts
33
+ ├── middleware/ # 인증, 권한, 에러 핸들링
34
+ │ ├── auth.ts
35
+ │ ├── errorHandler.ts
36
+ │ └── validate.ts
37
+ └── types/ # 공유 타입
38
+ └── orders.ts
39
+ ```
40
+
41
+ 기존 프로젝트에 이미 다른 구조가 있으면 **기존 구조를 따른다**. 이 구조는 신규 프로젝트이거나 구조가 없을 때의 기본값이다.
42
+
43
+ ---
44
+
45
+ ## STEP 1: Route 레이어
46
+
47
+ HTTP 메서드, 경로, 미들웨어를 선언하고 Controller 함수를 연결한다. Route에는 로직을 넣지 않는다. "이 URL로 요청이 오면 어떤 미들웨어를 거쳐 어떤 Controller가 처리하는지" 한눈에 보이게 하는 것이 목적이다.
48
+
49
+ ```ts
50
+ // routes/orders.ts
51
+ import { Router } from "express";
52
+ import { authenticate } from "@/middleware/auth";
53
+ import { validate } from "@/middleware/validate";
54
+ import { createOrderSchema, updateOrderSchema } from "@/schemas/orders";
55
+ import * as ordersController from "@/controllers/orders";
56
+
57
+ const router = Router();
58
+
59
+ router.get("/orders", authenticate, ordersController.list);
60
+ router.get("/orders/:id", authenticate, ordersController.getById);
61
+ router.post("/orders", authenticate, validate(createOrderSchema), ordersController.create);
62
+ router.put("/orders/:id", authenticate, validate(updateOrderSchema), ordersController.update);
63
+ router.delete("/orders/:id", authenticate, ordersController.remove);
64
+
65
+ export default router;
66
+ ```
67
+
68
+ **규칙:**
69
+ - 미들웨어 순서: 인증 → 권한 → 검증 → Controller
70
+ - RESTful 규칙을 따른다 (GET=조회, POST=생성, PUT=수정, DELETE=삭제)
71
+ - URL은 복수형 명사를 사용한다 (`/orders`, `/users`)
72
+
73
+ ---
74
+
75
+ ## STEP 2: Controller 레이어
76
+
77
+ 요청을 파싱하고, Service를 호출하고, 응답을 반환한다. Controller는 비즈니스 로직을 모른다. "요청에서 데이터를 꺼내고, Service에게 넘기고, 결과를 JSON으로 감싸서 돌려준다"가 Controller의 전부이다.
78
+
79
+ ```ts
80
+ // controllers/orders.ts
81
+ import { Request, Response, NextFunction } from "express";
82
+ import * as ordersService from "@/services/orders";
83
+
84
+ export async function list(req: Request, res: Response, next: NextFunction) {
85
+ try {
86
+ const { page = 1, limit = 20 } = req.query;
87
+ const result = await ordersService.getOrders({
88
+ page: Number(page),
89
+ limit: Number(limit),
90
+ });
91
+
92
+ res.json({
93
+ success: true,
94
+ data: result,
95
+ });
96
+ } catch (error) {
97
+ next(error);
98
+ }
99
+ }
100
+
101
+ export async function getById(req: Request, res: Response, next: NextFunction) {
102
+ try {
103
+ const order = await ordersService.getOrderById(req.params.id);
104
+
105
+ res.json({
106
+ success: true,
107
+ data: order,
108
+ });
109
+ } catch (error) {
110
+ next(error);
111
+ }
112
+ }
113
+
114
+ export async function create(req: Request, res: Response, next: NextFunction) {
115
+ try {
116
+ const order = await ordersService.createOrder(req.body);
117
+
118
+ res.status(201).json({
119
+ success: true,
120
+ data: order,
121
+ });
122
+ } catch (error) {
123
+ next(error);
124
+ }
125
+ }
126
+
127
+ export async function update(req: Request, res: Response, next: NextFunction) {
128
+ try {
129
+ const order = await ordersService.updateOrder(req.params.id, req.body);
130
+
131
+ res.json({
132
+ success: true,
133
+ data: order,
134
+ });
135
+ } catch (error) {
136
+ next(error);
137
+ }
138
+ }
139
+
140
+ export async function remove(req: Request, res: Response, next: NextFunction) {
141
+ try {
142
+ await ordersService.deleteOrder(req.params.id);
143
+
144
+ res.status(204).send();
145
+ } catch (error) {
146
+ next(error);
147
+ }
148
+ }
149
+ ```
150
+
151
+ **규칙:**
152
+ - 모든 Controller 함수는 `try/catch`로 감싸고, catch에서 `next(error)`로 에러 미들웨어에 넘긴다
153
+ - `req.body`는 이미 validate 미들웨어에서 검증된 상태이므로 Controller에서 재검증하지 않는다
154
+ - HTTP 상태 코드: 생성=201, 삭제=204, 그 외=200
155
+ - 성공 응답은 `{ success: true, data: ... }` 형식으로 통일한다
156
+
157
+ ---
158
+
159
+ ## STEP 3: 입력값 검증 (Zod)
160
+
161
+ Zod 스키마로 요청 데이터를 검증한다. 검증은 Controller가 아니라 미들웨어에서 처리하므로, Controller에 도달한 시점에는 데이터가 이미 유효하다.
162
+
163
+ ### 검증 스키마
164
+
165
+ ```ts
166
+ // schemas/orders.ts
167
+ import { z } from "zod";
168
+
169
+ export const createOrderSchema = z.object({
170
+ body: z.object({
171
+ productId: z.string().uuid("유효한 상품 ID가 필요합니다"),
172
+ quantity: z.number().int().min(1, "수량은 1 이상이어야 합니다"),
173
+ shippingAddress: z.string().min(1, "배송 주소를 입력해주세요"),
174
+ }),
175
+ });
176
+
177
+ export const updateOrderSchema = z.object({
178
+ params: z.object({
179
+ id: z.string().uuid(),
180
+ }),
181
+ body: z.object({
182
+ quantity: z.number().int().min(1).optional(),
183
+ shippingAddress: z.string().min(1).optional(),
184
+ }),
185
+ });
186
+
187
+ // 타입 추출 — 별도 타입 정의 없이 스키마에서 추론
188
+ export type CreateOrderInput = z.infer<typeof createOrderSchema>["body"];
189
+ export type UpdateOrderInput = z.infer<typeof updateOrderSchema>["body"];
190
+ ```
191
+
192
+ ### 검증 미들웨어
193
+
194
+ ```ts
195
+ // middleware/validate.ts
196
+ import { Request, Response, NextFunction } from "express";
197
+ import { AnyZodObject, ZodError } from "zod";
198
+
199
+ export function validate(schema: AnyZodObject) {
200
+ return (req: Request, res: Response, next: NextFunction) => {
201
+ try {
202
+ schema.parse({
203
+ body: req.body,
204
+ query: req.query,
205
+ params: req.params,
206
+ });
207
+ next();
208
+ } catch (error) {
209
+ if (error instanceof ZodError) {
210
+ res.status(422).json({
211
+ success: false,
212
+ error: {
213
+ code: "VALIDATION_ERROR",
214
+ message: "입력값이 올바르지 않습니다.",
215
+ details: error.errors.map((e) => ({
216
+ field: e.path.join("."),
217
+ message: e.message,
218
+ })),
219
+ },
220
+ });
221
+ return;
222
+ }
223
+ next(error);
224
+ }
225
+ };
226
+ }
227
+ ```
228
+
229
+ **규칙:**
230
+ - `body`, `params`, `query`를 하나의 스키마 객체로 묶어서 검증한다
231
+ - 에러 메시지는 한국어로 사용자 친화적으로 작성한다
232
+ - `z.infer`로 타입을 추출하여 별도 인터페이스 중복을 방지한다
233
+
234
+ ---
235
+
236
+ ## STEP 4: Service 레이어
237
+
238
+ 비즈니스 로직을 담당한다. Service는 HTTP를 모른다 — `req`, `res` 객체를 받지 않는다. 순수한 데이터 입출력만 처리하기 때문에 테스트하기 쉽고, 같은 로직을 REST API든 GraphQL이든 CLI든 어디서든 재사용할 수 있다.
239
+
240
+ ```ts
241
+ // services/orders.ts
242
+ import * as ordersRepo from "@/repositories/orders";
243
+ import { NotFoundError, BusinessError } from "@/errors";
244
+ import type { CreateOrderInput, UpdateOrderInput } from "@/schemas/orders";
245
+
246
+ export async function getOrders({ page, limit }: { page: number; limit: number }) {
247
+ const offset = (page - 1) * limit;
248
+ const [items, total] = await Promise.all([
249
+ ordersRepo.findMany({ offset, limit }),
250
+ ordersRepo.count(),
251
+ ]);
252
+
253
+ return {
254
+ items,
255
+ pagination: {
256
+ page,
257
+ limit,
258
+ total,
259
+ totalPages: Math.ceil(total / limit),
260
+ },
261
+ };
262
+ }
263
+
264
+ export async function getOrderById(id: string) {
265
+ const order = await ordersRepo.findById(id);
266
+ if (!order) {
267
+ throw new NotFoundError("주문을 찾을 수 없습니다.");
268
+ }
269
+ return order;
270
+ }
271
+
272
+ export async function createOrder(input: CreateOrderInput) {
273
+ // 비즈니스 규칙 검증
274
+ const product = await productsRepo.findById(input.productId);
275
+ if (!product) {
276
+ throw new NotFoundError("상품을 찾을 수 없습니다.");
277
+ }
278
+ if (product.stock < input.quantity) {
279
+ throw new BusinessError("INSUFFICIENT_STOCK", "재고가 부족합니다.");
280
+ }
281
+
282
+ // 트랜잭션이 필요한 경우
283
+ return await db.transaction(async (tx) => {
284
+ const order = await ordersRepo.create(input, tx);
285
+ await productsRepo.decrementStock(input.productId, input.quantity, tx);
286
+ return order;
287
+ });
288
+ }
289
+
290
+ export async function updateOrder(id: string, input: UpdateOrderInput) {
291
+ const existing = await ordersRepo.findById(id);
292
+ if (!existing) {
293
+ throw new NotFoundError("주문을 찾을 수 없습니다.");
294
+ }
295
+ return await ordersRepo.update(id, input);
296
+ }
297
+
298
+ export async function deleteOrder(id: string) {
299
+ const existing = await ordersRepo.findById(id);
300
+ if (!existing) {
301
+ throw new NotFoundError("주문을 찾을 수 없습니다.");
302
+ }
303
+ await ordersRepo.remove(id);
304
+ }
305
+ ```
306
+
307
+ **규칙:**
308
+ - `req`, `res`를 받지 않는다 — 순수 데이터 입출력만 처리
309
+ - 비즈니스 규칙 위반 시 커스텀 에러를 throw한다 (HTTP 상태 코드가 아니라 비즈니스 에러)
310
+ - 여러 Repository를 조합하여 하나의 유스케이스를 완성한다
311
+ - 트랜잭션이 필요하면 Service에서 관리한다
312
+
313
+ ---
314
+
315
+ ## STEP 5: Repository 레이어
316
+
317
+ DB 쿼리만 담당한다. Repository는 비즈니스 로직을 모른다 — "이 조건으로 데이터를 찾아줘", "이 데이터를 저장해줘"를 처리할 뿐이다. ORM을 바꿔도 Repository만 수정하면 나머지 코드는 영향을 받지 않는다.
318
+
319
+ ```ts
320
+ // repositories/orders.ts
321
+ import { db } from "@/lib/db"; // Prisma, Drizzle, 또는 프로젝트의 ORM
322
+ import type { CreateOrderInput, UpdateOrderInput } from "@/schemas/orders";
323
+
324
+ export async function findMany({ offset, limit }: { offset: number; limit: number }) {
325
+ return db.order.findMany({
326
+ skip: offset,
327
+ take: limit,
328
+ orderBy: { createdAt: "desc" },
329
+ });
330
+ }
331
+
332
+ export async function count() {
333
+ return db.order.count();
334
+ }
335
+
336
+ export async function findById(id: string) {
337
+ return db.order.findUnique({ where: { id } });
338
+ }
339
+
340
+ export async function create(input: CreateOrderInput, tx?: any) {
341
+ const client = tx || db;
342
+ return client.order.create({
343
+ data: {
344
+ ...input,
345
+ status: "pending",
346
+ },
347
+ });
348
+ }
349
+
350
+ export async function update(id: string, input: UpdateOrderInput) {
351
+ return db.order.update({
352
+ where: { id },
353
+ data: input,
354
+ });
355
+ }
356
+
357
+ export async function remove(id: string) {
358
+ return db.order.delete({ where: { id } });
359
+ }
360
+ ```
361
+
362
+ **규칙:**
363
+ - ORM 메서드만 사용한다 — raw SQL은 성능 최적화가 필요할 때만
364
+ - 트랜잭션 클라이언트를 선택적으로 받을 수 있도록 `tx` 파라미터를 지원한다
365
+ - 리턴 타입은 ORM이 추론하도록 두고, 명시적 타입이 필요하면 Service에서 처리한다
366
+
367
+ ---
368
+
369
+ ## 에러 처리
370
+
371
+ ### 커스텀 에러 클래스
372
+
373
+ 비즈니스 에러를 HTTP 상태 코드와 분리한다. Service는 "재고 부족"이라는 비즈니스 사실만 알리고, 이것을 HTTP 409로 변환하는 것은 에러 미들웨어의 역할이다.
374
+
375
+ ```ts
376
+ // errors/index.ts
377
+ export class AppError extends Error {
378
+ constructor(
379
+ public statusCode: number,
380
+ public code: string,
381
+ message: string,
382
+ public details?: any
383
+ ) {
384
+ super(message);
385
+ this.name = "AppError";
386
+ }
387
+ }
388
+
389
+ export class NotFoundError extends AppError {
390
+ constructor(message = "리소스를 찾을 수 없습니다.") {
391
+ super(404, "NOT_FOUND", message);
392
+ }
393
+ }
394
+
395
+ export class BusinessError extends AppError {
396
+ constructor(code: string, message: string, details?: any) {
397
+ super(409, code, message, details);
398
+ }
399
+ }
400
+
401
+ export class UnauthorizedError extends AppError {
402
+ constructor(message = "인증이 필요합니다.") {
403
+ super(401, "UNAUTHORIZED", message);
404
+ }
405
+ }
406
+
407
+ export class ForbiddenError extends AppError {
408
+ constructor(message = "접근 권한이 없습니다.") {
409
+ super(403, "FORBIDDEN", message);
410
+ }
411
+ }
412
+ ```
413
+
414
+ ### 에러 핸들링 미들웨어
415
+
416
+ 모든 에러를 한 곳에서 통일된 형식으로 변환한다.
417
+
418
+ ```ts
419
+ // middleware/errorHandler.ts
420
+ import { Request, Response, NextFunction } from "express";
421
+ import { AppError } from "@/errors";
422
+
423
+ export function errorHandler(err: Error, req: Request, res: Response, next: NextFunction) {
424
+ // 커스텀 에러 — 정의된 상태 코드와 코드 사용
425
+ if (err instanceof AppError) {
426
+ res.status(err.statusCode).json({
427
+ success: false,
428
+ error: {
429
+ code: err.code,
430
+ message: err.message,
431
+ ...(err.details && { details: err.details }),
432
+ },
433
+ });
434
+ return;
435
+ }
436
+
437
+ // 예상하지 못한 에러 — 내부 정보 노출 방지
438
+ console.error("Unhandled error:", err);
439
+ res.status(500).json({
440
+ success: false,
441
+ error: {
442
+ code: "INTERNAL_ERROR",
443
+ message: "서버 오류가 발생했습니다.",
444
+ },
445
+ });
446
+ }
447
+ ```
448
+
449
+ ### 응답 형식 정리
450
+
451
+ | 상황 | HTTP 상태 | 응답 형식 |
452
+ |------|-----------|-----------|
453
+ | 성공 (조회/수정) | 200 | `{ success: true, data: { ... } }` |
454
+ | 성공 (생성) | 201 | `{ success: true, data: { ... } }` |
455
+ | 성공 (삭제) | 204 | 빈 응답 |
456
+ | 입력값 오류 | 422 | `{ success: false, error: { code: "VALIDATION_ERROR", message, details } }` |
457
+ | 인증 실패 | 401 | `{ success: false, error: { code: "UNAUTHORIZED", message } }` |
458
+ | 권한 없음 | 403 | `{ success: false, error: { code: "FORBIDDEN", message } }` |
459
+ | 리소스 없음 | 404 | `{ success: false, error: { code: "NOT_FOUND", message } }` |
460
+ | 비즈니스 에러 | 409 | `{ success: false, error: { code: "INSUFFICIENT_STOCK", message } }` |
461
+ | 서버 에러 | 500 | `{ success: false, error: { code: "INTERNAL_ERROR", message } }` |
462
+
463
+ ---
464
+
465
+ ## 구현 순서 체크리스트
466
+
467
+ API 엔드포인트를 구현할 때 아래 순서로 진행한다:
468
+
469
+ 1. [ ] `context/03_api_spec.md`에서 구현할 엔드포인트 확인
470
+ 2. [ ] `schemas/`에 Zod 검증 스키마 + 타입 추출
471
+ 3. [ ] `repositories/`에 DB 쿼리 함수 작성
472
+ 4. [ ] `services/`에 비즈니스 로직 작성
473
+ 5. [ ] `controllers/`에 요청/응답 처리 작성
474
+ 6. [ ] `routes/`에 경로 + 미들웨어 연결
475
+ 7. [ ] `errors/`에 필요한 커스텀 에러 추가 (기존에 없는 경우)
476
+ 8. [ ] 에러 핸들링 미들웨어 확인 (이미 있으면 스킵)
477
+ 9. [ ] 응답 형식 확인 (success/error 구조 준수)