timsquad 2.0.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 (181) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +347 -0
  3. package/bin/tsq.js +6 -0
  4. package/dist/commands/feedback.d.ts +3 -0
  5. package/dist/commands/feedback.d.ts.map +1 -0
  6. package/dist/commands/feedback.js +142 -0
  7. package/dist/commands/feedback.js.map +1 -0
  8. package/dist/commands/full.d.ts +3 -0
  9. package/dist/commands/full.d.ts.map +1 -0
  10. package/dist/commands/full.js +87 -0
  11. package/dist/commands/full.js.map +1 -0
  12. package/dist/commands/git/commit.d.ts +3 -0
  13. package/dist/commands/git/commit.d.ts.map +1 -0
  14. package/dist/commands/git/commit.js +88 -0
  15. package/dist/commands/git/commit.js.map +1 -0
  16. package/dist/commands/git/index.d.ts +5 -0
  17. package/dist/commands/git/index.d.ts.map +1 -0
  18. package/dist/commands/git/index.js +5 -0
  19. package/dist/commands/git/index.js.map +1 -0
  20. package/dist/commands/git/pr.d.ts +3 -0
  21. package/dist/commands/git/pr.d.ts.map +1 -0
  22. package/dist/commands/git/pr.js +138 -0
  23. package/dist/commands/git/pr.js.map +1 -0
  24. package/dist/commands/git/release.d.ts +3 -0
  25. package/dist/commands/git/release.d.ts.map +1 -0
  26. package/dist/commands/git/release.js +158 -0
  27. package/dist/commands/git/release.js.map +1 -0
  28. package/dist/commands/git/sync.d.ts +3 -0
  29. package/dist/commands/git/sync.d.ts.map +1 -0
  30. package/dist/commands/git/sync.js +132 -0
  31. package/dist/commands/git/sync.js.map +1 -0
  32. package/dist/commands/init.d.ts +3 -0
  33. package/dist/commands/init.d.ts.map +1 -0
  34. package/dist/commands/init.js +150 -0
  35. package/dist/commands/init.js.map +1 -0
  36. package/dist/commands/log.d.ts +3 -0
  37. package/dist/commands/log.d.ts.map +1 -0
  38. package/dist/commands/log.js +271 -0
  39. package/dist/commands/log.js.map +1 -0
  40. package/dist/commands/metrics.d.ts +3 -0
  41. package/dist/commands/metrics.d.ts.map +1 -0
  42. package/dist/commands/metrics.js +299 -0
  43. package/dist/commands/metrics.js.map +1 -0
  44. package/dist/commands/quick.d.ts +3 -0
  45. package/dist/commands/quick.d.ts.map +1 -0
  46. package/dist/commands/quick.js +136 -0
  47. package/dist/commands/quick.js.map +1 -0
  48. package/dist/commands/retro.d.ts +3 -0
  49. package/dist/commands/retro.d.ts.map +1 -0
  50. package/dist/commands/retro.js +280 -0
  51. package/dist/commands/retro.js.map +1 -0
  52. package/dist/commands/status.d.ts +3 -0
  53. package/dist/commands/status.d.ts.map +1 -0
  54. package/dist/commands/status.js +127 -0
  55. package/dist/commands/status.js.map +1 -0
  56. package/dist/commands/watch.d.ts +3 -0
  57. package/dist/commands/watch.d.ts.map +1 -0
  58. package/dist/commands/watch.js +213 -0
  59. package/dist/commands/watch.js.map +1 -0
  60. package/dist/index.d.ts +3 -0
  61. package/dist/index.d.ts.map +1 -0
  62. package/dist/index.js +50 -0
  63. package/dist/index.js.map +1 -0
  64. package/dist/lib/config.d.ts +34 -0
  65. package/dist/lib/config.d.ts.map +1 -0
  66. package/dist/lib/config.js +108 -0
  67. package/dist/lib/config.js.map +1 -0
  68. package/dist/lib/project.d.ts +47 -0
  69. package/dist/lib/project.d.ts.map +1 -0
  70. package/dist/lib/project.js +191 -0
  71. package/dist/lib/project.js.map +1 -0
  72. package/dist/lib/template.d.ts +33 -0
  73. package/dist/lib/template.d.ts.map +1 -0
  74. package/dist/lib/template.js +151 -0
  75. package/dist/lib/template.js.map +1 -0
  76. package/dist/types/config.d.ts +75 -0
  77. package/dist/types/config.d.ts.map +1 -0
  78. package/dist/types/config.js +66 -0
  79. package/dist/types/config.js.map +1 -0
  80. package/dist/types/feedback.d.ts +59 -0
  81. package/dist/types/feedback.d.ts.map +1 -0
  82. package/dist/types/feedback.js +26 -0
  83. package/dist/types/feedback.js.map +1 -0
  84. package/dist/types/index.d.ts +4 -0
  85. package/dist/types/index.d.ts.map +1 -0
  86. package/dist/types/index.js +5 -0
  87. package/dist/types/index.js.map +1 -0
  88. package/dist/types/project.d.ts +89 -0
  89. package/dist/types/project.d.ts.map +1 -0
  90. package/dist/types/project.js +44 -0
  91. package/dist/types/project.js.map +1 -0
  92. package/dist/utils/colors.d.ts +30 -0
  93. package/dist/utils/colors.d.ts.map +1 -0
  94. package/dist/utils/colors.js +54 -0
  95. package/dist/utils/colors.js.map +1 -0
  96. package/dist/utils/date.d.ts +25 -0
  97. package/dist/utils/date.d.ts.map +1 -0
  98. package/dist/utils/date.js +65 -0
  99. package/dist/utils/date.js.map +1 -0
  100. package/dist/utils/fs.d.ts +49 -0
  101. package/dist/utils/fs.d.ts.map +1 -0
  102. package/dist/utils/fs.js +84 -0
  103. package/dist/utils/fs.js.map +1 -0
  104. package/dist/utils/prompts.d.ts +31 -0
  105. package/dist/utils/prompts.d.ts.map +1 -0
  106. package/dist/utils/prompts.js +95 -0
  107. package/dist/utils/prompts.js.map +1 -0
  108. package/dist/utils/yaml.d.ts +21 -0
  109. package/dist/utils/yaml.d.ts.map +1 -0
  110. package/dist/utils/yaml.js +40 -0
  111. package/dist/utils/yaml.js.map +1 -0
  112. package/package.json +71 -0
  113. package/templates/common/CLAUDE.md.template +254 -0
  114. package/templates/common/claude/agents/tsq-dba.md +290 -0
  115. package/templates/common/claude/agents/tsq-designer.md +304 -0
  116. package/templates/common/claude/agents/tsq-developer.md +118 -0
  117. package/templates/common/claude/agents/tsq-planner.md +90 -0
  118. package/templates/common/claude/agents/tsq-prompter.md +336 -0
  119. package/templates/common/claude/agents/tsq-qa.md +134 -0
  120. package/templates/common/claude/agents/tsq-retro.md +168 -0
  121. package/templates/common/claude/agents/tsq-security.md +190 -0
  122. package/templates/common/claude/skills/architecture/SKILL.md +123 -0
  123. package/templates/common/claude/skills/backend/node/SKILL.md +1015 -0
  124. package/templates/common/claude/skills/coding/SKILL.md +171 -0
  125. package/templates/common/claude/skills/database/prisma/SKILL.md +357 -0
  126. package/templates/common/claude/skills/frontend/nextjs/SKILL.md +279 -0
  127. package/templates/common/claude/skills/frontend/react/SKILL.md +1729 -0
  128. package/templates/common/claude/skills/methodology/bdd/SKILL.md +234 -0
  129. package/templates/common/claude/skills/methodology/ddd/SKILL.md +311 -0
  130. package/templates/common/claude/skills/methodology/tdd/SKILL.md +512 -0
  131. package/templates/common/claude/skills/planning/SKILL.md +90 -0
  132. package/templates/common/claude/skills/security/SKILL.md +234 -0
  133. package/templates/common/claude/skills/testing/SKILL.md +146 -0
  134. package/templates/common/claude/skills/typescript/SKILL.md +435 -0
  135. package/templates/common/config.template.yaml +131 -0
  136. package/templates/common/timsquad/architectures/clean/ARCHITECTURE.md +49 -0
  137. package/templates/common/timsquad/architectures/clean/backend.xml +210 -0
  138. package/templates/common/timsquad/architectures/clean/frontend.xml +148 -0
  139. package/templates/common/timsquad/architectures/fsd/ARCHITECTURE.md +67 -0
  140. package/templates/common/timsquad/architectures/fsd/frontend.xml +288 -0
  141. package/templates/common/timsquad/architectures/hexagonal/ARCHITECTURE.md +60 -0
  142. package/templates/common/timsquad/architectures/hexagonal/backend.xml +300 -0
  143. package/templates/common/timsquad/constraints/competency-framework.xml +501 -0
  144. package/templates/common/timsquad/constraints/ssot-schema.xml +433 -0
  145. package/templates/common/timsquad/feedback/feedback-router.sh +341 -0
  146. package/templates/common/timsquad/feedback/routing-rules.yaml +352 -0
  147. package/templates/common/timsquad/generators/data-design.xml +290 -0
  148. package/templates/common/timsquad/generators/prd.xml +280 -0
  149. package/templates/common/timsquad/generators/requirements.xml +220 -0
  150. package/templates/common/timsquad/generators/service-spec.xml +266 -0
  151. package/templates/common/timsquad/logs/_example.md +81 -0
  152. package/templates/common/timsquad/logs/_template.md +46 -0
  153. package/templates/common/timsquad/patterns/cqrs.xml +127 -0
  154. package/templates/common/timsquad/patterns/event-sourcing.xml +85 -0
  155. package/templates/common/timsquad/patterns/repository.xml +64 -0
  156. package/templates/common/timsquad/process/state-machine.xml +343 -0
  157. package/templates/common/timsquad/process/validation-rules.xml +308 -0
  158. package/templates/common/timsquad/process/workflow-base.xml +202 -0
  159. package/templates/common/timsquad/retrospective/cycle-report.template.md +205 -0
  160. package/templates/common/timsquad/retrospective/metrics/metrics-schema.json +203 -0
  161. package/templates/common/timsquad/retrospective/patterns/failure-patterns.md +199 -0
  162. package/templates/common/timsquad/retrospective/patterns/success-patterns.md +262 -0
  163. package/templates/common/timsquad/retrospective/retrospective-config.xml +294 -0
  164. package/templates/common/timsquad/retrospective/retrospective-state.xml +210 -0
  165. package/templates/common/timsquad/ssot/adr/ADR-000-template.md +121 -0
  166. package/templates/common/timsquad/ssot/adr/ADR-001-example.md +115 -0
  167. package/templates/common/timsquad/ssot/data-design.template.md +132 -0
  168. package/templates/common/timsquad/ssot/deployment-spec.template.md +384 -0
  169. package/templates/common/timsquad/ssot/env-config.template.md +346 -0
  170. package/templates/common/timsquad/ssot/error-codes.template.md +114 -0
  171. package/templates/common/timsquad/ssot/functional-spec.template.md +185 -0
  172. package/templates/common/timsquad/ssot/glossary.template.md +148 -0
  173. package/templates/common/timsquad/ssot/integration-spec.template.md +391 -0
  174. package/templates/common/timsquad/ssot/planning.template.md +94 -0
  175. package/templates/common/timsquad/ssot/prd.template.md +102 -0
  176. package/templates/common/timsquad/ssot/requirements.template.md +117 -0
  177. package/templates/common/timsquad/ssot/security-spec.template.md +309 -0
  178. package/templates/common/timsquad/ssot/service-spec.template.md +194 -0
  179. package/templates/common/timsquad/ssot/test-spec.template.md +264 -0
  180. package/templates/common/timsquad/ssot/ui-ux-spec.template.md +262 -0
  181. package/templates/common/timsquad/state/workspace.xml +217 -0
@@ -0,0 +1,1015 @@
1
+ ---
2
+ name: node
3
+ description: Node.js 백엔드 개발 가이드라인 (Hono Framework)
4
+ user-invocable: false
5
+ ---
6
+
7
+ <skill name="node">
8
+ <purpose>Node.js 기반 백엔드 서비스 개발 가이드라인 (Hono 프레임워크 중심)</purpose>
9
+
10
+ <philosophy>
11
+ <principle>비동기 우선 - 블로킹 작업 피하기</principle>
12
+ <principle>에러는 명시적으로 처리</principle>
13
+ <principle>환경 분리 - 설정은 환경변수로</principle>
14
+ <principle>타입 안전한 API - Zod로 검증</principle>
15
+ <principle>레이어 분리 - Clean Architecture</principle>
16
+ </philosophy>
17
+
18
+ <project-structure>
19
+ <reference>
20
+ 프로젝트 구조는 아키텍처 설정에 따라 결정됩니다.
21
+ - Clean Architecture: architectures/clean/backend.xml
22
+ - Hexagonal Architecture: architectures/hexagonal/backend.xml
23
+ </reference>
24
+ </project-structure>
25
+
26
+ <hono-framework>
27
+ <pattern name="기본 앱 설정">
28
+ <example>
29
+ <![CDATA[
30
+ // src/app.ts
31
+ import { Hono } from 'hono';
32
+ import { cors } from 'hono/cors';
33
+ import { logger } from 'hono/logger';
34
+ import { secureHeaders } from 'hono/secure-headers';
35
+ import { timing } from 'hono/timing';
36
+ import { userRoutes } from './interface/routes/user.routes';
37
+ import { orderRoutes } from './interface/routes/order.routes';
38
+ import { errorHandler } from './interface/middleware/error-handler';
39
+ import { authMiddleware } from './interface/middleware/auth';
40
+
41
+ const app = new Hono();
42
+
43
+ // 글로벌 미들웨어
44
+ app.use('*', logger());
45
+ app.use('*', timing());
46
+ app.use('*', secureHeaders());
47
+ app.use('*', cors({
48
+ origin: ['http://localhost:3000', 'https://example.com'],
49
+ allowMethods: ['GET', 'POST', 'PUT', 'DELETE'],
50
+ allowHeaders: ['Content-Type', 'Authorization'],
51
+ credentials: true,
52
+ }));
53
+
54
+ // 헬스 체크
55
+ app.get('/health', (c) => c.json({ status: 'ok', timestamp: new Date().toISOString() }));
56
+
57
+ // 라우트 마운트
58
+ app.route('/api/v1/users', userRoutes);
59
+ app.route('/api/v1/orders', orderRoutes);
60
+
61
+ // 에러 핸들러
62
+ app.onError(errorHandler);
63
+
64
+ // 404 핸들러
65
+ app.notFound((c) => {
66
+ return c.json({ success: false, error: { code: 'NOT_FOUND', message: 'Route not found' } }, 404);
67
+ });
68
+
69
+ export default app;
70
+ ]]>
71
+ </example>
72
+ </pattern>
73
+
74
+ <pattern name="라우트 정의 (타입 안전)">
75
+ <example>
76
+ <![CDATA[
77
+ // src/interface/routes/user.routes.ts
78
+ import { Hono } from 'hono';
79
+ import { zValidator } from '@hono/zod-validator';
80
+ import { z } from 'zod';
81
+ import { authMiddleware } from '../middleware/auth';
82
+ import { UserService } from '../../domain/user/service';
83
+ import { createUserSchema, updateUserSchema } from '../validators/user.validator';
84
+
85
+ // 타입 정의 (Hono 컨텍스트 확장)
86
+ type Variables = {
87
+ userId: string;
88
+ };
89
+
90
+ const userRoutes = new Hono<{ Variables: Variables }>();
91
+
92
+ // 인증이 필요한 라우트
93
+ userRoutes.use('*', authMiddleware);
94
+
95
+ // GET /api/v1/users - 목록 조회
96
+ userRoutes.get('/', async (c) => {
97
+ const page = Number(c.req.query('page') || '1');
98
+ const limit = Number(c.req.query('limit') || '10');
99
+
100
+ const result = await UserService.findAll({ page, limit });
101
+
102
+ return c.json({
103
+ success: true,
104
+ data: result.users,
105
+ pagination: {
106
+ page,
107
+ limit,
108
+ total: result.total,
109
+ totalPages: Math.ceil(result.total / limit),
110
+ },
111
+ });
112
+ });
113
+
114
+ // GET /api/v1/users/:id - 단일 조회
115
+ userRoutes.get('/:id', async (c) => {
116
+ const id = c.req.param('id');
117
+ const user = await UserService.findById(id);
118
+
119
+ if (!user) {
120
+ return c.json({
121
+ success: false,
122
+ error: { code: 'USER_NOT_FOUND', message: 'User not found' },
123
+ }, 404);
124
+ }
125
+
126
+ return c.json({ success: true, data: user });
127
+ });
128
+
129
+ // POST /api/v1/users - 생성
130
+ userRoutes.post(
131
+ '/',
132
+ zValidator('json', createUserSchema),
133
+ async (c) => {
134
+ const data = c.req.valid('json');
135
+ const user = await UserService.create(data);
136
+
137
+ return c.json({ success: true, data: user }, 201);
138
+ }
139
+ );
140
+
141
+ // PUT /api/v1/users/:id - 수정
142
+ userRoutes.put(
143
+ '/:id',
144
+ zValidator('json', updateUserSchema),
145
+ async (c) => {
146
+ const id = c.req.param('id');
147
+ const data = c.req.valid('json');
148
+ const user = await UserService.update(id, data);
149
+
150
+ return c.json({ success: true, data: user });
151
+ }
152
+ );
153
+
154
+ // DELETE /api/v1/users/:id - 삭제
155
+ userRoutes.delete('/:id', async (c) => {
156
+ const id = c.req.param('id');
157
+ await UserService.delete(id);
158
+
159
+ return c.json({ success: true, message: 'User deleted' });
160
+ });
161
+
162
+ export { userRoutes };
163
+ ]]>
164
+ </example>
165
+ </pattern>
166
+
167
+ <pattern name="Zod 검증 스키마">
168
+ <example>
169
+ <![CDATA[
170
+ // src/interface/validators/user.validator.ts
171
+ import { z } from 'zod';
172
+
173
+ // 생성 스키마
174
+ export const createUserSchema = z.object({
175
+ email: z.string().email('올바른 이메일 형식이 아닙니다'),
176
+ name: z.string().min(2, '이름은 2자 이상이어야 합니다').max(50),
177
+ password: z.string()
178
+ .min(8, '비밀번호는 8자 이상이어야 합니다')
179
+ .regex(/[A-Z]/, '대문자를 포함해야 합니다')
180
+ .regex(/[0-9]/, '숫자를 포함해야 합니다'),
181
+ role: z.enum(['USER', 'ADMIN']).default('USER'),
182
+ });
183
+
184
+ // 수정 스키마 (모든 필드 선택적)
185
+ export const updateUserSchema = createUserSchema.partial().omit({ password: true });
186
+
187
+ // 비밀번호 변경 스키마
188
+ export const changePasswordSchema = z.object({
189
+ currentPassword: z.string().min(1, '현재 비밀번호를 입력하세요'),
190
+ newPassword: z.string()
191
+ .min(8, '비밀번호는 8자 이상이어야 합니다')
192
+ .regex(/[A-Z]/, '대문자를 포함해야 합니다')
193
+ .regex(/[0-9]/, '숫자를 포함해야 합니다'),
194
+ confirmPassword: z.string(),
195
+ }).refine((data) => data.newPassword === data.confirmPassword, {
196
+ message: '비밀번호가 일치하지 않습니다',
197
+ path: ['confirmPassword'],
198
+ });
199
+
200
+ // 타입 추출
201
+ export type CreateUserDto = z.infer<typeof createUserSchema>;
202
+ export type UpdateUserDto = z.infer<typeof updateUserSchema>;
203
+ export type ChangePasswordDto = z.infer<typeof changePasswordSchema>;
204
+ ]]>
205
+ </example>
206
+ </pattern>
207
+ </hono-framework>
208
+
209
+ <authentication-patterns>
210
+ <pattern name="JWT 인증 미들웨어">
211
+ <example>
212
+ <![CDATA[
213
+ // src/interface/middleware/auth.ts
214
+ import { Context, Next } from 'hono';
215
+ import { jwt } from 'hono/jwt';
216
+ import { HTTPException } from 'hono/http-exception';
217
+ import { config } from '../../config';
218
+
219
+ // JWT 페이로드 타입
220
+ interface JWTPayload {
221
+ sub: string; // userId
222
+ email: string;
223
+ role: 'USER' | 'ADMIN';
224
+ iat: number;
225
+ exp: number;
226
+ }
227
+
228
+ // 기본 JWT 미들웨어
229
+ export const authMiddleware = jwt({
230
+ secret: config.JWT_SECRET,
231
+ });
232
+
233
+ // 역할 기반 접근 제어
234
+ export function requireRole(...roles: Array<'USER' | 'ADMIN'>) {
235
+ return async (c: Context, next: Next) => {
236
+ const payload = c.get('jwtPayload') as JWTPayload;
237
+
238
+ if (!payload || !roles.includes(payload.role)) {
239
+ throw new HTTPException(403, {
240
+ message: 'Insufficient permissions',
241
+ });
242
+ }
243
+
244
+ // 사용자 ID를 컨텍스트에 저장
245
+ c.set('userId', payload.sub);
246
+ c.set('userRole', payload.role);
247
+
248
+ await next();
249
+ };
250
+ }
251
+
252
+ // 토큰 생성 유틸리티
253
+ import { sign } from 'hono/jwt';
254
+
255
+ export async function generateTokens(user: { id: string; email: string; role: string }) {
256
+ const now = Math.floor(Date.now() / 1000);
257
+
258
+ const accessToken = await sign(
259
+ {
260
+ sub: user.id,
261
+ email: user.email,
262
+ role: user.role,
263
+ iat: now,
264
+ exp: now + 60 * 15, // 15분
265
+ },
266
+ config.JWT_SECRET
267
+ );
268
+
269
+ const refreshToken = await sign(
270
+ {
271
+ sub: user.id,
272
+ type: 'refresh',
273
+ iat: now,
274
+ exp: now + 60 * 60 * 24 * 7, // 7일
275
+ },
276
+ config.JWT_REFRESH_SECRET
277
+ );
278
+
279
+ return { accessToken, refreshToken };
280
+ }
281
+ ]]>
282
+ </example>
283
+ </pattern>
284
+
285
+ <pattern name="로그인 라우트">
286
+ <example>
287
+ <![CDATA[
288
+ // src/interface/routes/auth.routes.ts
289
+ import { Hono } from 'hono';
290
+ import { setCookie, getCookie, deleteCookie } from 'hono/cookie';
291
+ import { zValidator } from '@hono/zod-validator';
292
+ import { z } from 'zod';
293
+ import { UserService } from '../../domain/user/service';
294
+ import { generateTokens } from '../middleware/auth';
295
+ import { AppError } from '../../shared/errors/base-error';
296
+
297
+ const authRoutes = new Hono();
298
+
299
+ const loginSchema = z.object({
300
+ email: z.string().email(),
301
+ password: z.string().min(1),
302
+ });
303
+
304
+ authRoutes.post('/login', zValidator('json', loginSchema), async (c) => {
305
+ const { email, password } = c.req.valid('json');
306
+
307
+ // 사용자 검증
308
+ const user = await UserService.verifyCredentials(email, password);
309
+ if (!user) {
310
+ throw new AppError('AUTH_001', 'Invalid credentials', 401);
311
+ }
312
+
313
+ // 토큰 생성
314
+ const { accessToken, refreshToken } = await generateTokens(user);
315
+
316
+ // Refresh 토큰은 HttpOnly 쿠키로
317
+ setCookie(c, 'refreshToken', refreshToken, {
318
+ httpOnly: true,
319
+ secure: process.env.NODE_ENV === 'production',
320
+ sameSite: 'Strict',
321
+ maxAge: 60 * 60 * 24 * 7, // 7일
322
+ path: '/api/v1/auth',
323
+ });
324
+
325
+ return c.json({
326
+ success: true,
327
+ data: {
328
+ accessToken,
329
+ user: {
330
+ id: user.id,
331
+ email: user.email,
332
+ name: user.name,
333
+ role: user.role,
334
+ },
335
+ },
336
+ });
337
+ });
338
+
339
+ authRoutes.post('/logout', (c) => {
340
+ deleteCookie(c, 'refreshToken', {
341
+ path: '/api/v1/auth',
342
+ });
343
+
344
+ return c.json({ success: true, message: 'Logged out' });
345
+ });
346
+
347
+ authRoutes.post('/refresh', async (c) => {
348
+ const refreshToken = getCookie(c, 'refreshToken');
349
+
350
+ if (!refreshToken) {
351
+ throw new AppError('AUTH_003', 'Refresh token required', 401);
352
+ }
353
+
354
+ // 토큰 검증 및 갱신 로직
355
+ const tokens = await UserService.refreshTokens(refreshToken);
356
+
357
+ setCookie(c, 'refreshToken', tokens.refreshToken, {
358
+ httpOnly: true,
359
+ secure: process.env.NODE_ENV === 'production',
360
+ sameSite: 'Strict',
361
+ maxAge: 60 * 60 * 24 * 7,
362
+ path: '/api/v1/auth',
363
+ });
364
+
365
+ return c.json({
366
+ success: true,
367
+ data: { accessToken: tokens.accessToken },
368
+ });
369
+ });
370
+
371
+ export { authRoutes };
372
+ ]]>
373
+ </example>
374
+ </pattern>
375
+ </authentication-patterns>
376
+
377
+ <error-handling>
378
+ <pattern name="커스텀 에러 클래스">
379
+ <example>
380
+ <![CDATA[
381
+ // src/shared/errors/base-error.ts
382
+ export class AppError extends Error {
383
+ constructor(
384
+ public readonly code: string,
385
+ message: string,
386
+ public readonly statusCode: number = 500,
387
+ public readonly details?: Record<string, unknown>,
388
+ ) {
389
+ super(message);
390
+ this.name = 'AppError';
391
+ Error.captureStackTrace(this, this.constructor);
392
+ }
393
+
394
+ toJSON() {
395
+ return {
396
+ code: this.code,
397
+ message: this.message,
398
+ ...(this.details && { details: this.details }),
399
+ };
400
+ }
401
+ }
402
+
403
+ // src/shared/errors/not-found.ts
404
+ export class NotFoundError extends AppError {
405
+ constructor(resource: string, id?: string) {
406
+ super(
407
+ `${resource.toUpperCase()}_NOT_FOUND`,
408
+ id ? `${resource} with id ${id} not found` : `${resource} not found`,
409
+ 404
410
+ );
411
+ }
412
+ }
413
+
414
+ // src/shared/errors/validation.ts
415
+ export class ValidationError extends AppError {
416
+ constructor(message: string, details?: Record<string, unknown>) {
417
+ super('VALIDATION_ERROR', message, 400, details);
418
+ }
419
+ }
420
+
421
+ // src/shared/errors/unauthorized.ts
422
+ export class UnauthorizedError extends AppError {
423
+ constructor(message = 'Unauthorized') {
424
+ super('UNAUTHORIZED', message, 401);
425
+ }
426
+ }
427
+
428
+ // src/shared/errors/forbidden.ts
429
+ export class ForbiddenError extends AppError {
430
+ constructor(message = 'Forbidden') {
431
+ super('FORBIDDEN', message, 403);
432
+ }
433
+ }
434
+ ]]>
435
+ </example>
436
+ </pattern>
437
+
438
+ <pattern name="글로벌 에러 핸들러">
439
+ <example>
440
+ <![CDATA[
441
+ // src/interface/middleware/error-handler.ts
442
+ import { Context } from 'hono';
443
+ import { HTTPException } from 'hono/http-exception';
444
+ import { ZodError } from 'zod';
445
+ import { AppError } from '../../shared/errors/base-error';
446
+
447
+ export function errorHandler(err: Error, c: Context) {
448
+ console.error('[Error]', {
449
+ name: err.name,
450
+ message: err.message,
451
+ stack: process.env.NODE_ENV === 'development' ? err.stack : undefined,
452
+ });
453
+
454
+ // 커스텀 AppError
455
+ if (err instanceof AppError) {
456
+ return c.json({
457
+ success: false,
458
+ error: err.toJSON(),
459
+ }, err.statusCode as any);
460
+ }
461
+
462
+ // Hono HTTPException
463
+ if (err instanceof HTTPException) {
464
+ return c.json({
465
+ success: false,
466
+ error: {
467
+ code: 'HTTP_ERROR',
468
+ message: err.message,
469
+ },
470
+ }, err.status);
471
+ }
472
+
473
+ // Zod 검증 에러
474
+ if (err instanceof ZodError) {
475
+ return c.json({
476
+ success: false,
477
+ error: {
478
+ code: 'VALIDATION_ERROR',
479
+ message: 'Request validation failed',
480
+ details: err.errors.map((e) => ({
481
+ path: e.path.join('.'),
482
+ message: e.message,
483
+ })),
484
+ },
485
+ }, 400);
486
+ }
487
+
488
+ // Prisma 에러
489
+ if (err.name === 'PrismaClientKnownRequestError') {
490
+ const prismaError = err as any;
491
+ if (prismaError.code === 'P2002') {
492
+ return c.json({
493
+ success: false,
494
+ error: {
495
+ code: 'DUPLICATE_ENTRY',
496
+ message: 'Resource already exists',
497
+ details: { fields: prismaError.meta?.target },
498
+ },
499
+ }, 409);
500
+ }
501
+ if (prismaError.code === 'P2025') {
502
+ return c.json({
503
+ success: false,
504
+ error: {
505
+ code: 'NOT_FOUND',
506
+ message: 'Resource not found',
507
+ },
508
+ }, 404);
509
+ }
510
+ }
511
+
512
+ // 알 수 없는 에러 (프로덕션에서는 상세 정보 숨김)
513
+ return c.json({
514
+ success: false,
515
+ error: {
516
+ code: 'INTERNAL_ERROR',
517
+ message: process.env.NODE_ENV === 'production'
518
+ ? 'Internal server error'
519
+ : err.message,
520
+ },
521
+ }, 500);
522
+ }
523
+ ]]>
524
+ </example>
525
+ </pattern>
526
+ </error-handling>
527
+
528
+ <async-patterns>
529
+ <pattern name="Promise.all로 병렬 처리">
530
+ <description>독립적인 작업은 병렬로 실행</description>
531
+ <example type="bad">
532
+ <![CDATA[
533
+ // Bad: Sequential - 3 round trips
534
+ async function getDashboardData(userId: string) {
535
+ const user = await userService.findById(userId);
536
+ const orders = await orderService.findByUserId(userId);
537
+ const notifications = await notificationService.getUnread(userId);
538
+
539
+ return { user, orders, notifications };
540
+ }
541
+ ]]>
542
+ </example>
543
+ <example type="good">
544
+ <![CDATA[
545
+ // Good: Parallel - 1 round trip
546
+ async function getDashboardData(userId: string) {
547
+ const [user, orders, notifications] = await Promise.all([
548
+ userService.findById(userId),
549
+ orderService.findByUserId(userId),
550
+ notificationService.getUnread(userId),
551
+ ]);
552
+
553
+ return { user, orders, notifications };
554
+ }
555
+ ]]>
556
+ </example>
557
+ </pattern>
558
+
559
+ <pattern name="API 라우트에서 Waterfall 방지">
560
+ <example type="bad">
561
+ <![CDATA[
562
+ // Bad: config waits for auth, data waits for both
563
+ app.get('/dashboard', async (c) => {
564
+ const session = await auth();
565
+ const config = await fetchConfig();
566
+ const data = await fetchData(session.user.id);
567
+ return c.json({ data, config });
568
+ });
569
+ ]]>
570
+ </example>
571
+ <example type="good">
572
+ <![CDATA[
573
+ // Good: auth and config start immediately
574
+ app.get('/dashboard', async (c) => {
575
+ const sessionPromise = auth();
576
+ const configPromise = fetchConfig();
577
+
578
+ const session = await sessionPromise;
579
+ const [config, data] = await Promise.all([
580
+ configPromise,
581
+ fetchData(session.user.id),
582
+ ]);
583
+
584
+ return c.json({ data, config });
585
+ });
586
+ ]]>
587
+ </example>
588
+ </pattern>
589
+
590
+ <pattern name="트랜잭션 처리">
591
+ <example>
592
+ <![CDATA[
593
+ // src/application/order/create-order.ts
594
+ import { prisma } from '../../infrastructure/database/prisma';
595
+ import { AppError } from '../../shared/errors/base-error';
596
+
597
+ interface CreateOrderInput {
598
+ userId: string;
599
+ items: Array<{ productId: string; quantity: number }>;
600
+ }
601
+
602
+ export async function createOrder(input: CreateOrderInput) {
603
+ return prisma.$transaction(async (tx) => {
604
+ // 1. 사용자 확인
605
+ const user = await tx.user.findUnique({ where: { id: input.userId } });
606
+ if (!user) {
607
+ throw new AppError('USER_NOT_FOUND', 'User not found', 404);
608
+ }
609
+
610
+ // 2. 재고 확인 및 차감
611
+ let totalAmount = 0;
612
+ for (const item of input.items) {
613
+ const product = await tx.product.findUnique({
614
+ where: { id: item.productId },
615
+ });
616
+
617
+ if (!product || product.stock < item.quantity) {
618
+ throw new AppError('INSUFFICIENT_STOCK', `Insufficient stock for ${item.productId}`, 400);
619
+ }
620
+
621
+ await tx.product.update({
622
+ where: { id: item.productId },
623
+ data: { stock: { decrement: item.quantity } },
624
+ });
625
+
626
+ totalAmount += product.price * item.quantity;
627
+ }
628
+
629
+ // 3. 주문 생성
630
+ const order = await tx.order.create({
631
+ data: {
632
+ userId: input.userId,
633
+ totalAmount,
634
+ status: 'PENDING',
635
+ items: {
636
+ create: input.items.map((item) => ({
637
+ productId: item.productId,
638
+ quantity: item.quantity,
639
+ })),
640
+ },
641
+ },
642
+ include: { items: true },
643
+ });
644
+
645
+ return order;
646
+ });
647
+ }
648
+ ]]>
649
+ </example>
650
+ </pattern>
651
+ </async-patterns>
652
+
653
+ <config-pattern>
654
+ <pattern name="환경변수 검증 (Zod)">
655
+ <example>
656
+ <![CDATA[
657
+ // src/config/index.ts
658
+ import { z } from 'zod';
659
+
660
+ const envSchema = z.object({
661
+ // Server
662
+ NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
663
+ PORT: z.coerce.number().default(3000),
664
+ HOST: z.string().default('0.0.0.0'),
665
+
666
+ // Database
667
+ DATABASE_URL: z.string().url(),
668
+
669
+ // JWT
670
+ JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'),
671
+ JWT_REFRESH_SECRET: z.string().min(32),
672
+
673
+ // External Services (선택적)
674
+ REDIS_URL: z.string().url().optional(),
675
+ SENTRY_DSN: z.string().url().optional(),
676
+
677
+ // Feature Flags
678
+ ENABLE_SWAGGER: z.coerce.boolean().default(false),
679
+ });
680
+
681
+ // 환경변수 파싱 (앱 시작 시 검증)
682
+ const parseResult = envSchema.safeParse(process.env);
683
+
684
+ if (!parseResult.success) {
685
+ console.error('❌ Invalid environment variables:');
686
+ console.error(parseResult.error.format());
687
+ process.exit(1);
688
+ }
689
+
690
+ export const config = parseResult.data;
691
+
692
+ // 타입 안전하게 사용
693
+ // config.PORT → number
694
+ // config.JWT_SECRET → string
695
+ // config.REDIS_URL → string | undefined
696
+ ]]>
697
+ </example>
698
+ </pattern>
699
+ </config-pattern>
700
+
701
+ <middleware-patterns>
702
+ <pattern name="Rate Limiting">
703
+ <example>
704
+ <![CDATA[
705
+ // src/interface/middleware/rate-limit.ts
706
+ import { Context, Next } from 'hono';
707
+ import { rateLimiter } from 'hono-rate-limiter';
708
+
709
+ // 일반 API용 (분당 100회)
710
+ export const apiRateLimiter = rateLimiter({
711
+ windowMs: 60 * 1000, // 1분
712
+ limit: 100,
713
+ standardHeaders: 'draft-6',
714
+ keyGenerator: (c) => c.req.header('x-forwarded-for') || c.req.header('x-real-ip') || 'unknown',
715
+ message: { success: false, error: { code: 'RATE_LIMIT', message: 'Too many requests' } },
716
+ });
717
+
718
+ // 인증용 (분당 5회)
719
+ export const authRateLimiter = rateLimiter({
720
+ windowMs: 60 * 1000,
721
+ limit: 5,
722
+ keyGenerator: (c) => {
723
+ const ip = c.req.header('x-forwarded-for') || 'unknown';
724
+ return `auth:${ip}`;
725
+ },
726
+ message: { success: false, error: { code: 'AUTH_RATE_LIMIT', message: 'Too many login attempts' } },
727
+ });
728
+ ]]>
729
+ </example>
730
+ </pattern>
731
+
732
+ <pattern name="Request Logging">
733
+ <example>
734
+ <![CDATA[
735
+ // src/interface/middleware/logger.ts
736
+ import { Context, Next } from 'hono';
737
+
738
+ export async function requestLogger(c: Context, next: Next) {
739
+ const start = Date.now();
740
+ const requestId = crypto.randomUUID();
741
+
742
+ // Request ID 설정
743
+ c.set('requestId', requestId);
744
+ c.header('X-Request-ID', requestId);
745
+
746
+ // 요청 로깅
747
+ console.log(JSON.stringify({
748
+ type: 'request',
749
+ requestId,
750
+ method: c.req.method,
751
+ path: c.req.path,
752
+ query: c.req.query(),
753
+ userAgent: c.req.header('user-agent'),
754
+ ip: c.req.header('x-forwarded-for') || 'unknown',
755
+ }));
756
+
757
+ await next();
758
+
759
+ // 응답 로깅
760
+ const duration = Date.now() - start;
761
+ console.log(JSON.stringify({
762
+ type: 'response',
763
+ requestId,
764
+ method: c.req.method,
765
+ path: c.req.path,
766
+ status: c.res.status,
767
+ duration,
768
+ }));
769
+ }
770
+ ]]>
771
+ </example>
772
+ </pattern>
773
+ </middleware-patterns>
774
+
775
+ <testing-patterns>
776
+ <pattern name="API 테스트 (Vitest + Hono)">
777
+ <example>
778
+ <![CDATA[
779
+ // src/interface/routes/user.routes.test.ts
780
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
781
+ import app from '../../app';
782
+
783
+ describe('User Routes', () => {
784
+ beforeEach(() => {
785
+ vi.clearAllMocks();
786
+ });
787
+
788
+ describe('GET /api/v1/users/:id', () => {
789
+ it('사용자를 조회한다', async () => {
790
+ const res = await app.request('/api/v1/users/user-123', {
791
+ method: 'GET',
792
+ headers: {
793
+ Authorization: 'Bearer valid-token',
794
+ },
795
+ });
796
+
797
+ expect(res.status).toBe(200);
798
+ const json = await res.json();
799
+ expect(json.success).toBe(true);
800
+ expect(json.data).toHaveProperty('id', 'user-123');
801
+ });
802
+
803
+ it('존재하지 않는 사용자는 404를 반환한다', async () => {
804
+ const res = await app.request('/api/v1/users/not-exist', {
805
+ method: 'GET',
806
+ headers: {
807
+ Authorization: 'Bearer valid-token',
808
+ },
809
+ });
810
+
811
+ expect(res.status).toBe(404);
812
+ const json = await res.json();
813
+ expect(json.success).toBe(false);
814
+ expect(json.error.code).toBe('USER_NOT_FOUND');
815
+ });
816
+ });
817
+
818
+ describe('POST /api/v1/users', () => {
819
+ it('유효한 데이터로 사용자를 생성한다', async () => {
820
+ const res = await app.request('/api/v1/users', {
821
+ method: 'POST',
822
+ headers: {
823
+ 'Content-Type': 'application/json',
824
+ Authorization: 'Bearer valid-token',
825
+ },
826
+ body: JSON.stringify({
827
+ email: 'test@example.com',
828
+ name: 'Test User',
829
+ password: 'Password123',
830
+ }),
831
+ });
832
+
833
+ expect(res.status).toBe(201);
834
+ const json = await res.json();
835
+ expect(json.success).toBe(true);
836
+ expect(json.data.email).toBe('test@example.com');
837
+ });
838
+
839
+ it('유효하지 않은 이메일은 400을 반환한다', async () => {
840
+ const res = await app.request('/api/v1/users', {
841
+ method: 'POST',
842
+ headers: {
843
+ 'Content-Type': 'application/json',
844
+ Authorization: 'Bearer valid-token',
845
+ },
846
+ body: JSON.stringify({
847
+ email: 'invalid-email',
848
+ name: 'Test User',
849
+ password: 'Password123',
850
+ }),
851
+ });
852
+
853
+ expect(res.status).toBe(400);
854
+ const json = await res.json();
855
+ expect(json.error.code).toBe('VALIDATION_ERROR');
856
+ });
857
+ });
858
+ });
859
+ ]]>
860
+ </example>
861
+ </pattern>
862
+
863
+ <pattern name="Service 테스트 (유닛 테스트)">
864
+ <example>
865
+ <![CDATA[
866
+ // src/domain/user/service.test.ts
867
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
868
+ import { UserService } from './service';
869
+ import { UserRepository } from './repository';
870
+ import { NotFoundError } from '../../shared/errors/not-found';
871
+
872
+ // Repository Mock
873
+ const mockRepository: UserRepository = {
874
+ findById: vi.fn(),
875
+ findByEmail: vi.fn(),
876
+ create: vi.fn(),
877
+ update: vi.fn(),
878
+ delete: vi.fn(),
879
+ };
880
+
881
+ describe('UserService', () => {
882
+ let service: UserService;
883
+
884
+ beforeEach(() => {
885
+ vi.clearAllMocks();
886
+ service = new UserService(mockRepository);
887
+ });
888
+
889
+ describe('findById', () => {
890
+ it('존재하는 사용자를 반환한다', async () => {
891
+ const mockUser = { id: '1', email: 'test@example.com', name: 'Test' };
892
+ vi.mocked(mockRepository.findById).mockResolvedValue(mockUser);
893
+
894
+ const result = await service.findById('1');
895
+
896
+ expect(result).toEqual(mockUser);
897
+ expect(mockRepository.findById).toHaveBeenCalledWith('1');
898
+ });
899
+
900
+ it('존재하지 않으면 NotFoundError를 던진다', async () => {
901
+ vi.mocked(mockRepository.findById).mockResolvedValue(null);
902
+
903
+ await expect(service.findById('not-exist')).rejects.toThrow(NotFoundError);
904
+ });
905
+ });
906
+
907
+ describe('create', () => {
908
+ it('새 사용자를 생성한다', async () => {
909
+ const input = { email: 'new@example.com', name: 'New User', password: 'Password123' };
910
+ const mockUser = { id: '1', ...input };
911
+ vi.mocked(mockRepository.create).mockResolvedValue(mockUser);
912
+
913
+ const result = await service.create(input);
914
+
915
+ expect(result).toEqual(mockUser);
916
+ expect(mockRepository.create).toHaveBeenCalledWith(expect.objectContaining({
917
+ email: input.email,
918
+ name: input.name,
919
+ }));
920
+ });
921
+ });
922
+ });
923
+ ]]>
924
+ </example>
925
+ </pattern>
926
+ </testing-patterns>
927
+
928
+ <deployment>
929
+ <pattern name="서버 시작 (Graceful Shutdown)">
930
+ <example>
931
+ <![CDATA[
932
+ // src/index.ts
933
+ import { serve } from '@hono/node-server';
934
+ import app from './app';
935
+ import { config } from './config';
936
+ import { prisma } from './infrastructure/database/prisma';
937
+
938
+ const server = serve({
939
+ fetch: app.fetch,
940
+ port: config.PORT,
941
+ hostname: config.HOST,
942
+ }, (info) => {
943
+ console.log(`🚀 Server running at http://${info.address}:${info.port}`);
944
+ });
945
+
946
+ // Graceful Shutdown
947
+ async function shutdown(signal: string) {
948
+ console.log(`\n${signal} received. Shutting down gracefully...`);
949
+
950
+ // 새 요청 받지 않기
951
+ server.close(() => {
952
+ console.log('HTTP server closed');
953
+ });
954
+
955
+ // DB 연결 종료
956
+ await prisma.$disconnect();
957
+ console.log('Database disconnected');
958
+
959
+ process.exit(0);
960
+ }
961
+
962
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
963
+ process.on('SIGINT', () => shutdown('SIGINT'));
964
+ ]]>
965
+ </example>
966
+ </pattern>
967
+ </deployment>
968
+
969
+ <rules>
970
+ <category name="비동기">
971
+ <must>async/await 사용</must>
972
+ <must>독립 작업은 Promise.all로 병렬 처리</must>
973
+ <must>에러는 명시적으로 처리</must>
974
+ <must>Waterfall 방지 (Promise 먼저 시작)</must>
975
+ <must-not>동기 파일 I/O (fs.readFileSync)</must-not>
976
+ <must-not>콜백 패턴 사용</must-not>
977
+ </category>
978
+ <category name="보안">
979
+ <must>환경변수로 설정 관리 (Zod 검증)</must>
980
+ <must>입력 검증 (Zod + zValidator)</must>
981
+ <must>에러 메시지에 민감 정보 제외</must>
982
+ <must>JWT는 HttpOnly 쿠키로 Refresh Token 관리</must>
983
+ <must>Rate Limiting 적용</must>
984
+ <must-not>하드코딩된 시크릿</must-not>
985
+ <must-not>검증 없이 사용자 입력 사용</must-not>
986
+ </category>
987
+ <category name="구조">
988
+ <must>레이어 분리 (Clean Architecture)</must>
989
+ <must>의존성 주입 (Repository 패턴)</must>
990
+ <must>글로벌 에러 핸들러 사용</must>
991
+ <must>커스텀 에러 클래스 사용</must>
992
+ </category>
993
+ <category name="Hono 프레임워크">
994
+ <must>타입 안전한 라우트 정의</must>
995
+ <must>zValidator로 요청 검증</must>
996
+ <must>미들웨어로 공통 로직 처리</must>
997
+ <must>일관된 응답 형식 { success, data, error }</must>
998
+ </category>
999
+ </rules>
1000
+
1001
+ <checklist>
1002
+ <item priority="critical">async/await 사용</item>
1003
+ <item priority="critical">입력 검증 (Zod + zValidator)</item>
1004
+ <item priority="critical">글로벌 에러 핸들러</item>
1005
+ <item priority="critical">환경변수 Zod 검증</item>
1006
+ <item priority="critical">Waterfall 제거 (Promise.all)</item>
1007
+ <item priority="high">레이어 분리 (Clean Architecture)</item>
1008
+ <item priority="high">커스텀 에러 클래스</item>
1009
+ <item priority="high">JWT 인증 (HttpOnly Refresh Token)</item>
1010
+ <item priority="high">Rate Limiting</item>
1011
+ <item priority="medium">병렬 처리 최적화</item>
1012
+ <item priority="medium">Request Logging</item>
1013
+ <item priority="medium">Graceful Shutdown</item>
1014
+ </checklist>
1015
+ </skill>