byuckchon-frontend-cli 1.7.0 → 1.9.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.
package/README.md CHANGED
@@ -156,6 +156,36 @@ bc › Auto layout 이 row 였고 padding 12/16 이었어요. MemberCard 만들
156
156
  > Figma 응답은 자동으로 압축됩니다 (자식 60개, 깊이 8 까지). 너무 큰 프레임은 더 작은
157
157
  > 자식 frame URL 을 줘서 분할 정복하세요.
158
158
 
159
+ ### 팀 컨벤션 문서(.md) 자동 주입 (v1.8+)
160
+
161
+ FE 전반의 규칙(폴더 구조, 네이밍, 스웨거 → 코드 변환 규칙 등)을 `.md` 로 적어두면
162
+ **매 chat 세션에 시스템 프롬프트로 자동 주입**됩니다. AI 는 기존 코드 패턴보다 이 문서를 우선합니다.
163
+
164
+ **파일명은 고정이 아닙니다.** 아무 경로나 `bc.config.json` 의 `docs` 에 적으면 됩니다.
165
+ 적지 않으면 관례 파일명(`bc.md`, `.bc/conventions.md`, `AGENTS.md`, `FRONTEND.md`, `docs/frontend.md`)을 자동 탐지합니다.
166
+
167
+ ```json
168
+ {
169
+ "docs": ["docs/fe-conventions.md", "docs/api-guide.md"]
170
+ }
171
+ ```
172
+
173
+ - 문서당 최대 24KB, 전체 48KB 까지 (토큰 폭발 방지). 헤더에 `docs` 줄로 로드된 파일이 표시됩니다.
174
+ - (고급) 항목을 `{ "path": "...", "when": { "framework": "next" } }` 형태로 적으면 프레임워크별 조건부 주입도 가능합니다.
175
+
176
+ #### API 코드 컨벤션 .md 자동 포함
177
+
178
+ `bc init` / `bc adopt` 를 실행하면 **API 코드 생성 가이드(`api-codegen.md`)가 프레임워크에 맞는 위치에 자동으로 깔립니다.**
179
+
180
+ | 프레임워크 | 위치 |
181
+ | --- | --- |
182
+ | React (Vite/CRA 등) | `src/api/api-codegen.md` |
183
+ | Next.js | `lib/api/api-codegen.md` |
184
+
185
+ - 이 파일은 `bc.config.json` 의 `docs` 에 자동 등록되어 **chat 시작 시 주입**됩니다.
186
+ - 하나의 .md 로 React/Next 를 모두 다루며(차이는 위치뿐), 팀 규칙에 맞게 직접 다듬어 쓰면 됩니다.
187
+ - 이미 파일이 있으면 덮어쓰지 않습니다.
188
+
159
189
  ### OpenAPI / 코드 컨텍스트 — 자동 주입 (v1.4+)
160
190
 
161
191
  `bc.config.json` 의 `api.openapi` 와 코드 인덱스는 **chat 시작할 때 알아서 준비됩니다.**
package/bin/index.js CHANGED
@@ -28,7 +28,7 @@ const program = new Command();
28
28
  program
29
29
  .name('bc')
30
30
  .description('Byuckchon Frontend Workbench — 프로젝트 스타터 + AI 어시스턴트')
31
- .version('1.7.0');
31
+ .version('1.9.0');
32
32
 
33
33
  program
34
34
  .command('init')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "byuckchon-frontend-cli",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "Byuckchon Frontend Workbench — project starter + AI chat + codebase RAG + OpenAPI codegen",
5
5
  "type": "module",
6
6
  "engines": {
@@ -34,6 +34,7 @@
34
34
  "files": [
35
35
  "bin",
36
36
  "src",
37
+ "templates",
37
38
  "README.md"
38
39
  ],
39
40
  "keywords": [
@@ -7,6 +7,10 @@ import inquirer from 'inquirer';
7
7
  import { detectProjectContext, summarizeContext } from '../context/detect.js';
8
8
  import { CONFIG_PATHS } from '../config/index.js';
9
9
  import { modelChoices, DEFAULT_MODEL_ID } from '../ai/models.js';
10
+ import {
11
+ apiRootForFramework,
12
+ scaffoldApiConventionDoc,
13
+ } from '../generators/apiConventionDoc.js';
10
14
 
11
15
  /**
12
16
  * `bc adopt`
@@ -119,6 +123,9 @@ export async function adoptCommand(opts = {}) {
119
123
  exclude: ['**/*.test.*', '**/__mocks__/**', 'node_modules/**', 'dist/**', '.next/**'],
120
124
  maxFiles: 20,
121
125
  },
126
+ docs: existing?.docs ?? [
127
+ path.posix.join(apiRootForFramework(ctx.framework), 'api-codegen.md'),
128
+ ],
122
129
  framework: ctx.framework,
123
130
  detected: {
124
131
  language: ctx.language,
@@ -136,7 +143,23 @@ export async function adoptCommand(opts = {}) {
136
143
  await fs.writeFile(targetFile, JSON.stringify(next, null, 2) + '\n', 'utf8');
137
144
 
138
145
  console.log(chalk.green(`\n ✓ ${CONFIG_PATHS.projectFileName} 작성 완료.`));
139
- console.log(chalk.dim(` ${targetFile}\n`));
146
+ console.log(chalk.dim(` ${targetFile}`));
147
+
148
+ // API 코드 컨벤션 .md 를 API 루트(src/api | lib/api)에 깐다 (이미 있으면 유지).
149
+ try {
150
+ const { relPath, written } = await scaffoldApiConventionDoc({
151
+ projectRoot: cwd,
152
+ framework: ctx.framework,
153
+ });
154
+ if (written) {
155
+ console.log(chalk.green(` ✓ API 코드 컨벤션 문서 생성: ${relPath}`));
156
+ } else {
157
+ console.log(chalk.dim(` API 코드 컨벤션 문서 유지: ${relPath} (이미 존재)`));
158
+ }
159
+ } catch {
160
+ /* 문서 스캐폴드 실패는 치명적이지 않음 */
161
+ }
162
+ console.log();
140
163
  console.log(chalk.dim(' 다음:'));
141
164
  if (!process.env.ANTHROPIC_API_KEY) {
142
165
  console.log(chalk.dim(' bc config set-key anthropic # API 키 등록'));
@@ -63,12 +63,37 @@ export async function chatCommand(opts = {}) {
63
63
  process.exit(1);
64
64
  }
65
65
 
66
- const baseSystem = buildSystemPrompt({
66
+ let baseSystem = buildSystemPrompt({
67
67
  effective: cfg.effective,
68
68
  paths: cfg.paths,
69
69
  project: cfg.project,
70
70
  });
71
71
 
72
+ // 컨벤션 문서(.md) 자동 주입 — FE 전반 규칙, 스웨거→코드 변환 규칙 등.
73
+ // bc.config.json 의 docs:[...] 또는 bc.md/AGENTS.md 등 관례 파일을 읽는다.
74
+ let conventionFiles = [];
75
+ if (cfg.paths.projectFile) {
76
+ try {
77
+ const { loadConventionDocs } = await import('../context/conventions.js');
78
+ const projectRoot = path.dirname(cfg.paths.projectFile);
79
+ const conv = await loadConventionDocs({
80
+ projectRoot,
81
+ docs: cfg.effective.docs,
82
+ framework: cfg.project?.framework ?? cfg.project?.detected?.framework,
83
+ });
84
+ if (conv.text) {
85
+ conventionFiles = conv.files;
86
+ baseSystem +=
87
+ '\n\n---\n## 팀 컨벤션 문서 (반드시 우선 준수)\n' +
88
+ '아래는 이 프로젝트/팀의 프론트엔드 컨벤션이다. 코드 생성·수정 시 여기 규칙을 ' +
89
+ '기존 코드 패턴보다 우선 적용한다. 충돌하면 이 문서를 따른다.\n\n' +
90
+ conv.text;
91
+ }
92
+ } catch {
93
+ /* 문서 로딩 실패는 무시 */
94
+ }
95
+ }
96
+
72
97
  // OpenAPI 자동 주입 — bc.config.json 의 api.openapi 가 있으면 fetch 후 요약을
73
98
  // 시스템 프롬프트에 박는다. 1시간 캐시. 실패해도 chat 은 그대로 동작.
74
99
  let openapiInfo = null;
@@ -145,10 +170,10 @@ export async function chatCommand(opts = {}) {
145
170
  const isTTY = process.stdin.isTTY && process.stdout.isTTY;
146
171
  const wantPlain = opts.plain || cfg.global?.ui?.mode === 'plain';
147
172
  if (!isTTY || wantPlain) {
148
- return runReadlineFallback({ cfg, resolved, system, session, openapiInfo });
173
+ return runReadlineFallback({ cfg, resolved, system, session, openapiInfo, conventionFiles });
149
174
  }
150
175
 
151
- return runInkApp({ cfg, resolved, system, session, openapiInfo });
176
+ return runInkApp({ cfg, resolved, system, session, openapiInfo, conventionFiles });
152
177
  }
153
178
 
154
179
  async function printHistoryList() {
@@ -172,14 +197,14 @@ async function printHistoryList() {
172
197
 
173
198
  /* ───────────────────────── ink 모드 ───────────────────────── */
174
199
 
175
- async function runInkApp({ cfg, resolved, system, session, openapiInfo }) {
200
+ async function runInkApp({ cfg, resolved, system, session, openapiInfo, conventionFiles = [] }) {
176
201
  // ink/React 는 무겁고 비-TTY 환경에서 import 만으로도 종종 문제 일으키므로
177
202
  // 여기서 늦게 import 한다 (--once / pipe 모드에 영향 없도록).
178
203
  const { render } = await import('ink');
179
204
  const { ChatApp } = await import('../ui/ChatApp.js');
180
205
  const React = (await import('react')).default;
181
206
 
182
- const initialConfig = { ...cfg, system, openapiInfo };
207
+ const initialConfig = { ...cfg, system, openapiInfo, conventionFiles };
183
208
 
184
209
  const onSessionUpdate = async (messages) => {
185
210
  session.messages = messages;
@@ -247,7 +272,7 @@ async function runOnce({ cfg, resolved, system, prompt }) {
247
272
 
248
273
  /* ──────────────────── 비-TTY / --plain 폴백 ──────────────────── */
249
274
 
250
- async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo }) {
275
+ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo, conventionFiles = [] }) {
251
276
  const readline = await import('node:readline');
252
277
  const meter = new TokenMeter(resolved.meta, cfg.effective.limits);
253
278
 
@@ -259,6 +284,9 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
259
284
  chalk.dim(' openapi: ' + openapiInfo.source + (openapiInfo.cached ? ' (cached)' : ' (live)')),
260
285
  );
261
286
  }
287
+ if (conventionFiles.length) {
288
+ console.log(chalk.dim(' 컨벤션 문서: ' + conventionFiles.join(', ')));
289
+ }
262
290
  if (session?.messages?.length) {
263
291
  console.log(chalk.dim(` 세션: ${session.id} (${session.messages.length} turns 이어가기)`));
264
292
  }
@@ -65,6 +65,11 @@ const DEFAULT_PROJECT = {
65
65
  exclude: ['**/*.test.*', '**/__mocks__/**', 'node_modules/**', 'dist/**'],
66
66
  maxFiles: 20,
67
67
  },
68
+ /**
69
+ * FE 전반 컨벤션을 적은 .md 경로들. 매 chat 세션에 시스템 프롬프트로 주입된다.
70
+ * 비우면 bc.md / .bc/conventions.md / AGENTS.md 등을 자동 탐지.
71
+ */
72
+ docs: [],
68
73
  /** bc adopt 가 채워준다. systemPrompt 가 읽어 모델에 알린다. */
69
74
  framework: null,
70
75
  detected: null,
@@ -181,6 +186,7 @@ export async function loadEffectiveConfig(startDir = process.cwd()) {
181
186
  design: project.design,
182
187
  api: project.api,
183
188
  context: project.context,
189
+ docs: project.docs ?? [],
184
190
  },
185
191
  };
186
192
  }
@@ -0,0 +1,114 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * 프로젝트의 "컨벤션 문서(.md)" 를 찾아 읽어서 시스템 프롬프트에 주입할 텍스트로 만든다.
6
+ *
7
+ * 우선순위:
8
+ * 1) bc.config.json 의 `docs: [...]` 에 명시된 항목 (순서대로)
9
+ * - 항목은 문자열(경로) 이거나, 조건부 객체일 수 있다:
10
+ * "docs/common.md"
11
+ * { "path": "docs/api-codegen.md", "when": { "frameworkNot": "next" } }
12
+ * { "path": "docs/api-next.md", "when": { "framework": "next" } }
13
+ * - when 의 framework / frameworkNot 는 문자열 또는 문자열 배열.
14
+ * 2) 명시가 없으면 프로젝트 루트의 관례적 파일명 자동 탐지
15
+ * - bc.md, .bc/conventions.md, AGENTS.md, FRONTEND.md, docs/frontend.md
16
+ *
17
+ * 토큰 폭발 방지: 문서당 최대 bytes, 전체 합계 최대 bytes 로 컷.
18
+ */
19
+ const AUTO_NAMES = [
20
+ 'bc.md',
21
+ '.bc/conventions.md',
22
+ 'AGENTS.md',
23
+ 'FRONTEND.md',
24
+ 'docs/frontend.md',
25
+ 'docs/FRONTEND.md',
26
+ ];
27
+
28
+ const PER_DOC_MAX = 24 * 1024;
29
+ const TOTAL_MAX = 48 * 1024;
30
+
31
+ async function readIfExists(abs) {
32
+ try {
33
+ const stat = await fs.stat(abs);
34
+ if (!stat.isFile()) return null;
35
+ let text = await fs.readFile(abs, 'utf8');
36
+ if (Buffer.byteLength(text, 'utf8') > PER_DOC_MAX) {
37
+ text = text.slice(0, PER_DOC_MAX) + '\n... (이하 생략 — 문서가 너무 깁니다)';
38
+ }
39
+ return text;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ function asArray(v) {
46
+ if (v == null) return [];
47
+ return Array.isArray(v) ? v : [v];
48
+ }
49
+
50
+ /** docs 항목의 when 조건이 현재 framework 에 맞는지. */
51
+ function matchesFramework(when, framework) {
52
+ if (!when || typeof when !== 'object') return true;
53
+ const fw = framework || 'unknown';
54
+
55
+ const only = asArray(when.framework);
56
+ if (only.length && !only.includes(fw)) return false;
57
+
58
+ const not = asArray(when.frameworkNot);
59
+ if (not.length && not.includes(fw)) return false;
60
+
61
+ return true;
62
+ }
63
+
64
+ /** docs 항목(문자열 | 객체)을 { path, when } 로 정규화. */
65
+ function normalizeEntry(entry) {
66
+ if (typeof entry === 'string') return { path: entry, when: null };
67
+ if (entry && typeof entry === 'object' && typeof entry.path === 'string') {
68
+ return { path: entry.path, when: entry.when ?? null };
69
+ }
70
+ return null;
71
+ }
72
+
73
+ /**
74
+ * @param {object} args
75
+ * @param {string} args.projectRoot bc.config.json 이 있는 디렉터리
76
+ * @param {Array<string|object>} [args.docs] config 의 docs (문자열 또는 조건부 객체)
77
+ * @param {string} [args.framework] detect.js 의 framework (조건부 docs 판별용)
78
+ * @returns {Promise<{ text: string|null, files: string[] }>}
79
+ */
80
+ export async function loadConventionDocs({ projectRoot, docs, framework }) {
81
+ if (!projectRoot) return { text: null, files: [] };
82
+
83
+ const explicit = Array.isArray(docs) && docs.length > 0;
84
+ const rawEntries = explicit ? docs : AUTO_NAMES;
85
+
86
+ const entries = rawEntries
87
+ .map(normalizeEntry)
88
+ .filter(Boolean)
89
+ .filter((e) => matchesFramework(e.when, framework));
90
+
91
+ const collected = [];
92
+ const files = [];
93
+ let total = 0;
94
+
95
+ for (const { path: rel } of entries) {
96
+ const abs = path.resolve(projectRoot, rel);
97
+ // 루트 밖 경로는 무시 (안전)
98
+ const within = !path.relative(projectRoot, abs).startsWith('..');
99
+ if (!within) continue;
100
+
101
+ const content = await readIfExists(abs);
102
+ if (!content) continue;
103
+
104
+ const bytes = Buffer.byteLength(content, 'utf8');
105
+ if (total + bytes > TOTAL_MAX) break;
106
+ total += bytes;
107
+
108
+ files.push(rel);
109
+ collected.push(`### 문서: ${rel}\n${content}`);
110
+ }
111
+
112
+ if (collected.length === 0) return { text: null, files: [] };
113
+ return { text: collected.join('\n\n'), files };
114
+ }
@@ -0,0 +1,49 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ const TEMPLATE_PATH = path.resolve(
6
+ path.dirname(fileURLToPath(import.meta.url)),
7
+ '../../templates/conventions/api-codegen.md',
8
+ );
9
+
10
+ /**
11
+ * 프레임워크에 맞는 API 루트 폴더.
12
+ * - Next.js → lib/api
13
+ * - 그 외 React 계열 → src/api
14
+ */
15
+ export function apiRootForFramework(framework) {
16
+ return framework === 'next' ? 'lib/api' : 'src/api';
17
+ }
18
+
19
+ /**
20
+ * API 코드 컨벤션 .md 를 프로젝트의 API 루트(`src/api` 또는 `lib/api`)에 깐다.
21
+ *
22
+ * @param {object} args
23
+ * @param {string} args.projectRoot
24
+ * @param {string} args.framework 'react' | 'next' | detect.js 의 framework 값
25
+ * @param {boolean} [args.force] 이미 있으면 덮어쓸지
26
+ * @returns {Promise<{ relPath: string, written: boolean }>}
27
+ */
28
+ export async function scaffoldApiConventionDoc({ projectRoot, framework, force = false }) {
29
+ const apiRoot = apiRootForFramework(framework);
30
+ const relPath = path.join(apiRoot, 'api-codegen.md');
31
+ const absPath = path.join(projectRoot, relPath);
32
+
33
+ let exists = false;
34
+ try {
35
+ await fs.access(absPath);
36
+ exists = true;
37
+ } catch {
38
+ /* not there */
39
+ }
40
+
41
+ if (exists && !force) {
42
+ return { relPath, written: false };
43
+ }
44
+
45
+ const template = await fs.readFile(TEMPLATE_PATH, 'utf8');
46
+ await fs.mkdir(path.dirname(absPath), { recursive: true });
47
+ await fs.writeFile(absPath, template, 'utf8');
48
+ return { relPath, written: true };
49
+ }
@@ -1,6 +1,8 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
 
4
+ import { apiRootForFramework } from './apiConventionDoc.js';
5
+
4
6
  /**
5
7
  * 새 프로젝트 루트에 `bc.config.json` 을 만든다.
6
8
  *
@@ -28,6 +30,8 @@ export async function createBcConfig(rootDir, config) {
28
30
  exclude: ['**/*.test.*', '**/__mocks__/**', 'node_modules/**', 'dist/**'],
29
31
  maxFiles: 20,
30
32
  },
33
+ // chat 시작 시 자동 주입되는 팀 컨벤션 문서. API 코드 가이드가 기본 포함.
34
+ docs: [path.posix.join(apiRootForFramework(config.framework), 'api-codegen.md')],
31
35
  framework: config.framework,
32
36
  // init 단계에선 사용자가 React/Next 중 골랐고 TS/Tailwind 가 항상 들어가니
33
37
  // 감지 결과를 미리 채워둔다 (bc adopt 의 detected 와 같은 모양).
@@ -8,6 +8,7 @@ import { createBcConfig } from './createBcConfig.js';
8
8
  import { createFolders } from './createFolders.js';
9
9
  import { createPackageJson } from './createPackageJson.js';
10
10
  import { createReadme } from './createReadme.js';
11
+ import { scaffoldApiConventionDoc } from './apiConventionDoc.js';
11
12
 
12
13
  const exec = promisify(execCallback);
13
14
  const BYUCKCHON_PACKAGES = [
@@ -25,6 +26,8 @@ export async function createProject(config) {
25
26
  await createPackageJson(rootDir, config);
26
27
  await createBaseFiles(rootDir, config);
27
28
  await createReadme(rootDir, config);
29
+ // API 코드 컨벤션 .md 를 프레임워크에 맞는 API 루트(src/api | lib/api)에 깐다.
30
+ await scaffoldApiConventionDoc({ projectRoot: rootDir, framework: config.framework });
28
31
  await createBcConfig(rootDir, config);
29
32
 
30
33
  // 최신 버전(latest 포함) 의존성을 실제로 설치해 lockfile까지 생성
package/src/ui/ChatApp.js CHANGED
@@ -115,7 +115,7 @@ async function pasteClipboardImage() {
115
115
  * 슬래시 명령은 Input 컴포넌트의 onSubmit 에서 가로채서 처리.
116
116
  */
117
117
 
118
- function Header({ modelMeta, projectFile, gateway, ragOn, hasIndex, openapiInfo }) {
118
+ function Header({ modelMeta, projectFile, gateway, ragOn, hasIndex, openapiInfo, conventionFiles }) {
119
119
  let ragLabel;
120
120
  if (!hasIndex) ragLabel = '(준비 중 — 자동 빌드 또는 /index)';
121
121
  else if (ragOn) ragLabel = 'on (관련 코드 자동 주입)';
@@ -167,6 +167,14 @@ function Header({ modelMeta, projectFile, gateway, ragOn, hasIndex, openapiInfo
167
167
  h(Text, null, openapiLabel),
168
168
  )
169
169
  : null,
170
+ conventionFiles?.length
171
+ ? h(
172
+ Box,
173
+ null,
174
+ h(Text, { dimColor: true }, 'docs '),
175
+ h(Text, null, conventionFiles.join(', ')),
176
+ )
177
+ : null,
170
178
  gateway
171
179
  ? h(
172
180
  Box,
@@ -870,6 +878,7 @@ export function ChatApp({ initialConfig, initialResolved, session, onSessionUpda
870
878
  ragOn: ragEnabled,
871
879
  hasIndex,
872
880
  openapiInfo: cfg.openapiInfo,
881
+ conventionFiles: cfg.conventionFiles,
873
882
  }),
874
883
  h(
875
884
  Box,
@@ -0,0 +1,400 @@
1
+ # 📑 API 코드 생성 가이드 (for AI)
2
+
3
+ > 이 문서는 **Swagger(OpenAPI) JSON 을 기반으로 API 코드를 생성**할 때 따라야 하는 컨벤션이다.
4
+ > 사용자가 Swagger JSON(또는 엔드포인트)을 제공하면, AI 는 이 문서의 규칙에 맞춰
5
+ > `api / zod / type / service / index` 파일을 생성한다.
6
+ >
7
+ > 스택: `@tanstack/react-query` + `axios`. **React / Next.js 공용**이며, 차이는 §0 의 "위치"뿐이다.
8
+
9
+ ---
10
+
11
+ ## ⛳ 사전 규칙 — 코드 짜기 전에
12
+
13
+ 1. **추측 금지.** Swagger 에 없는 필드/엔드포인트를 임의로 만들지 않는다.
14
+ 스키마가 모호하면 기존 리소스 폴더(예: `user/`)를 먼저 `read_file` 로 확인하고,
15
+ 그래도 불명확하면 사용자에게 한 번 묻는다.
16
+ 2. **기존 코드 우선.** 공용 유틸(`cacheConfig`, `queryKey`, `captureSentryError`, `metaSchema`,
17
+ `PaginationParams` 등)은 새로 만들지 말고 그대로 가져다 쓴다. import 경로가 확실치 않으면
18
+ `search_code` 로 실제 export 위치를 확인한다.
19
+ 3. **5파일 세트는 항상 함께.** `api / zod / type / service / index` 를 한 번에 생성한다.
20
+
21
+ ---
22
+
23
+ ## 0. 위치 & 폴더 구조 ⚠️ (React vs Next 차이는 여기뿐)
24
+
25
+ API 코드의 **루트 위치는 프레임워크마다 다르다.**
26
+
27
+ | 프레임워크 | API 루트 |
28
+ | --- | --- |
29
+ | **React** (Vite/CRA 등) | `src/api` |
30
+ | **Next.js** | `lib/api` |
31
+
32
+ > 그 외 폴더/파일 구조와 규칙은 **완전히 동일**하다. 아래 예시는 `src/api` 기준이며,
33
+ > Next 면 `src/api` 를 `lib/api` 로 바꿔 읽으면 된다.
34
+
35
+ 리소스(도메인) 하나당 폴더 하나. 폴더명은 **소문자**(여러 단어는 kebab-case: `favorite-stores`).
36
+
37
+ ```
38
+ src/api/ # (Next: lib/api/)
39
+ ├── instance.ts # axios 인스턴스 (interceptor 포함)
40
+ ├── index.ts # 모든 API 모듈 export
41
+ └── user/ # 리소스 폴더 (소문자)
42
+ ├── user.api.ts # axios 호출 함수 (순수 함수)
43
+ ├── user.zod.ts # 응답/요청 zod 스키마
44
+ ├── user.type.ts # zod 로부터 추론한 타입 + 입력 타입
45
+ ├── user.service.ts # react-query 훅 (use~) — 비즈니스 로직
46
+ └── index.ts # 외부 노출 (service, type 만)
47
+ ```
48
+
49
+ - 파일 prefix 는 폴더명과 같다 (`user/` → `user.api.ts`, `user.zod.ts` ...).
50
+ - `RESOURCE` 상수는 `/api/<path>` 형태로 `.api.ts` 상단에 둔다.
51
+
52
+ ---
53
+
54
+ ## 1. 가장 먼저 — "next(무한스크롤)" 인지 판단하라
55
+
56
+ > 여기서 말하는 "next" 는 **프레임워크 Next.js 가 아니라**, **커서 기반 무한스크롤 패턴**을 가리킨다.
57
+
58
+ 코드를 짜기 전에 **해당 엔드포인트가 커서 기반 무한스크롤 API 인지** 먼저 판단한다.
59
+
60
+ ### "next" 로 판단하는 기준 (아래 중 하나라도 해당하면 next)
61
+
62
+ - 요청 파라미터에 `cursor`, `limit` (또는 `page`, `size` 등 페이지네이션 파라미터) 가 있다.
63
+ - 응답 본문에 `items`(배열) + `meta`(`hasNextPage`, `nextCursor`) 구조가 있다.
64
+ - "목록을 스크롤하며 더 불러오는" 리스트 조회 엔드포인트다.
65
+
66
+ | 판단 | 사용 훅 | 참고 |
67
+ | --- | --- | --- |
68
+ | **next O** (무한스크롤) | `useInfiniteQuery` | [§5](#5-next무한스크롤-템플릿) |
69
+ | **next X** (일반) | `useQuery` / `useMutation` | [§4](#4-일반next-x-템플릿) |
70
+
71
+ > 단건 조회·생성·수정·삭제, 페이지네이션 없는 전체 목록은 모두 **next X (일반)**.
72
+
73
+ ---
74
+
75
+ ## 2. 각 파일 작성 규칙
76
+
77
+ ### 2-1. `user.api.ts` — 순수 호출 함수
78
+
79
+ - `import baseInstance from '../instance';` 사용 (axios 인스턴스).
80
+ - 함수는 `async`, 내부에서 `const { data } = await baseInstance.X(...)` 후 `return data;`.
81
+ - 응답 본문이 없는 경우(`204 No Content`) 는 `return data` 생략 가능.
82
+ - 쿼리스트링은 `{ params }`, path 파라미터는 템플릿 리터럴.
83
+ - **여기서는 zod 파싱을 하지 않는다.** (파싱은 service 의 queryFn 책임)
84
+
85
+ ```ts
86
+ import baseInstance from '../instance';
87
+
88
+ const RESOURCE = '/api/user';
89
+
90
+ export const getUserList = async () => {
91
+ const { data } = await baseInstance.get(RESOURCE);
92
+
93
+ return data;
94
+ };
95
+
96
+ export const getUser = async (userId: string) => {
97
+ const { data } = await baseInstance.get(`${RESOURCE}/${userId}`);
98
+
99
+ return data;
100
+ };
101
+
102
+ export const deleteUser = async (userId: string) => {
103
+ const { data } = await baseInstance.delete(`${RESOURCE}/${userId}`);
104
+
105
+ return data;
106
+ };
107
+ ```
108
+
109
+ ### 2-2. `user.zod.ts` — 스키마
110
+
111
+ - `import { z } from 'zod';`
112
+ - **단일 아이템 스키마**(`userItemSchema`)를 먼저 정의하고, 리스트는 `z.array(...)` 로 조합한다.
113
+ - 재사용 가능한 작은 스키마는 별도 `const` 로 분리.
114
+ - Swagger 타입 → zod 매핑:
115
+ - `string` → `z.string()`, `integer/number` → `z.number()`, `boolean` → `z.boolean()`
116
+ - `nullable: true` → `.nullable()` / `required` 에 없으면 `.optional()` (둘 다면 `.nullable().optional()`)
117
+ - `enum` → `z.enum([...] as const)` (숫자 enum 은 `z.union([z.literal(1), ...])`)
118
+ - `format: date-time` → `z.string().datetime()` (값은 ISO 문자열 유지)
119
+ - 제약(min/max/length) 이 명시되면 반영
120
+ - **`$ref` / `allOf` / `oneOf`**:
121
+ - `$ref` → 참조 대상 스키마를 먼저 정의 후 재사용
122
+ - `allOf` → `baseSchema.merge(extraSchema)` 또는 `.and(...)`
123
+ - `oneOf`/`anyOf` → `z.union([...])`, discriminator 있으면 `z.discriminatedUnion(...)`
124
+ - **next 응답**은 공통 `metaSchema` 사용: `import { metaSchema } from '@/lib';`
125
+
126
+ ```ts
127
+ import { z } from 'zod';
128
+
129
+ export const userItemSchema = z.object({
130
+ userId: z.string(),
131
+ name: z.string(),
132
+ email: z.string().nullable(),
133
+ isActive: z.boolean(),
134
+ });
135
+
136
+ export const userListSchema = z.array(userItemSchema);
137
+ ```
138
+
139
+ ### 2-3. `user.type.ts` — 타입
140
+
141
+ - 응답 타입은 **zod 스키마에서 추론**: `export type X = z.infer<typeof xSchema>;`
142
+ - 입력(요청) 타입은 직접 정의. 인자가 2개 이상인 변경은 객체 입력 타입으로 묶는다.
143
+
144
+ ```ts
145
+ import { z } from 'zod';
146
+ import { userItemSchema } from './user.zod';
147
+
148
+ export type UserItem = z.infer<typeof userItemSchema>;
149
+
150
+ export type UpdateUser = {
151
+ userId: string;
152
+ name: string;
153
+ };
154
+ ```
155
+
156
+ ### 2-4. `user.service.ts` — react-query 훅 (비즈니스 로직)
157
+
158
+ 공통 import:
159
+
160
+ ```ts
161
+ import { cacheConfig, captureSentryError, queryKey } from '@/lib';
162
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
163
+ ```
164
+
165
+ 규칙:
166
+
167
+ - 훅 이름: 조회 `useGetUser` / `useGetUserList`, 변경 `useCreateUser` / `useUpdateUser` / `useDeleteUser`.
168
+ - **조회(useQuery)** 의 `queryFn` 에서 zod `safeParse` 로 검증.
169
+ - 실패 시 `console.error(...)` 후 **원본 `data` 그대로 반환**(throw 금지).
170
+ - 성공 시 `parsed.data` 반환.
171
+ - **변경(useMutation)** 은 `onSuccess` 에서 관련 `queryKey` 를 `invalidateQueries`.
172
+ - 모든 훅의 `onError` 에서 `captureSentryError(error, { location, action })`.
173
+ - `location` = 훅 이름(`'useDeleteUser'`), `action` = 호출 함수명(`'deleteUser'`).
174
+ - 조회 훅은 마지막에 `...cacheConfig.<tier>` + `...options` 펼침.
175
+ - 캐시 tier: 자주 바뀜 `realtime`/`shortLived`, 보통 `mediumLived`, 잘 안 바뀜 `longLived`, 불변 `immutable`.
176
+
177
+ ```ts
178
+ export const useGetUserList = (options?: Record<string, any>) => {
179
+ return useQuery({
180
+ queryKey: queryKey.user.list,
181
+ queryFn: async () => {
182
+ const data = await getUserList();
183
+ const parsed = userListSchema.safeParse(data);
184
+
185
+ if (!parsed.success) {
186
+ console.error('User list validation error:', parsed.error);
187
+
188
+ return data;
189
+ }
190
+
191
+ return parsed.data;
192
+ },
193
+ ...cacheConfig.longLived,
194
+ ...options,
195
+ });
196
+ };
197
+
198
+ export const useDeleteUser = () => {
199
+ const queryClient = useQueryClient();
200
+
201
+ return useMutation({
202
+ mutationFn: (userId: string) => deleteUser(userId),
203
+ onSuccess: () => {
204
+ queryClient.invalidateQueries({ queryKey: queryKey.user.all });
205
+ },
206
+ onError: (error) => {
207
+ captureSentryError(error, { location: 'useDeleteUser', action: 'deleteUser' });
208
+ },
209
+ });
210
+ };
211
+ ```
212
+
213
+ ### 2-5. `index.ts` (리소스) — 노출
214
+
215
+ - **`service` 와 `type` 만** 재노출 (`api`, `zod` 는 노출하지 않음).
216
+
217
+ ```ts
218
+ export * from './user.service';
219
+ export * from './user.type';
220
+ ```
221
+
222
+ ### 2-6. `src/api/index.ts` (루트) — 모든 모듈 export
223
+
224
+ - 새 리소스를 추가하면 한 줄 추가한다.
225
+
226
+ ```ts
227
+ export * from './user';
228
+ // export * from './order';
229
+ ```
230
+
231
+ ### 2-7. `src/api/instance.ts` — axios 인스턴스
232
+
233
+ - 이미 있으면 **건드리지 않는다.** baseURL/interceptor 설정이 여기 모여 있다.
234
+ - 새 리소스는 항상 이 `baseInstance` 를 import 해서 쓴다.
235
+
236
+ ---
237
+
238
+ ## 3. queryKey 등록 규칙
239
+
240
+ 공용 `queryKey` 객체에 리소스 항목을 추가한다.
241
+
242
+ - `all` 은 무효화(invalidate) 기준 최상위 키. 변경 훅은 보통 `queryKey.<resource>.all` 무효화.
243
+ - 하위 키는 `['<resource>', '<scope>']`. 파라미터가 들어가면 함수형으로.
244
+
245
+ ```ts
246
+ user: Object.freeze({
247
+ all: ['user'],
248
+ list: ['user', 'list'],
249
+ detail: (id: string) => ['user', 'detail', id],
250
+ }),
251
+ ```
252
+
253
+ ---
254
+
255
+ ## 4. 일반(next X) 템플릿
256
+
257
+ ```ts
258
+ // api
259
+ export const getUser = async (params?: SomeParams) => {
260
+ const { data } = await baseInstance.get(RESOURCE, { params });
261
+ return data;
262
+ };
263
+
264
+ // service
265
+ export const useGetUser = (options?: Record<string, any>) => {
266
+ return useQuery({
267
+ queryKey: queryKey.user.list,
268
+ queryFn: async () => {
269
+ const data = await getUser();
270
+ const parsed = userSchema.safeParse(data);
271
+ if (!parsed.success) {
272
+ console.error('User validation error:', parsed.error);
273
+ return data;
274
+ }
275
+ return parsed.data;
276
+ },
277
+ ...cacheConfig.mediumLived,
278
+ ...options,
279
+ });
280
+ };
281
+
282
+ // 변경
283
+ export const useCreateUser = () => {
284
+ const queryClient = useQueryClient();
285
+
286
+ return useMutation({
287
+ mutationFn: (payload: CreateUserPayload) => createUser(payload),
288
+ onSuccess: () => {
289
+ queryClient.invalidateQueries({ queryKey: queryKey.user.all });
290
+ },
291
+ onError: (error) => {
292
+ captureSentryError(error, { location: 'useCreateUser', action: 'createUser' });
293
+ },
294
+ });
295
+ };
296
+ ```
297
+
298
+ > 인자가 2개 이상이면 객체로 묶어 `*.type.ts` 에 입력 타입을 정의하고 구조분해로 받는다.
299
+ > 예: `mutationFn: ({ userId, name }: UpdateUser) => updateUser(userId, name)`
300
+
301
+ ---
302
+
303
+ ## 5. next(무한스크롤) 템플릿
304
+
305
+ 판단 결과가 **next** 일 때만 사용. 핵심은 `useInfiniteQuery` + 공통 `metaSchema`.
306
+
307
+ ```ts
308
+ // api (cursor 기반)
309
+ import { PaginationParams } from '@/lib';
310
+
311
+ export const getUser = async (params: PaginationParams) => {
312
+ const { data } = await baseInstance.get(RESOURCE, { params });
313
+ return data;
314
+ };
315
+
316
+ // zod (items + meta)
317
+ import { metaSchema } from '@/lib';
318
+
319
+ export const userItemSchema = z.object({ /* ... */ });
320
+ export const getUserResponseSchema = z.object({
321
+ items: z.array(userItemSchema),
322
+ meta: metaSchema, // { hasNextPage, nextCursor }
323
+ });
324
+
325
+ // type
326
+ export type GetUserResponse = z.infer<typeof getUserResponseSchema>;
327
+
328
+ // service
329
+ import { useInfiniteQuery } from '@tanstack/react-query';
330
+
331
+ export const useGetUser = (options?: Record<string, any>) => {
332
+ const defaultParams: PaginationParams = { limit: 10, cursor: undefined };
333
+
334
+ return useInfiniteQuery<GetUserResponse, Error, GetUserResponse['items']>({
335
+ queryKey: queryKey.user.list,
336
+ queryFn: async ({ pageParam }) => {
337
+ const params: PaginationParams = { ...defaultParams, ...(pageParam || {}) };
338
+ const data = await getUser(params);
339
+ const parsed = getUserResponseSchema.safeParse(data);
340
+ if (!parsed.success) {
341
+ console.error('User validation error:', parsed.error);
342
+ return data;
343
+ }
344
+ return parsed.data;
345
+ },
346
+ getNextPageParam: (lastPage) =>
347
+ lastPage?.meta?.hasNextPage
348
+ ? { ...defaultParams, cursor: lastPage.meta.nextCursor }
349
+ : undefined,
350
+ select: (data) => data.pages.flatMap((page) => page?.items ?? []),
351
+ initialPageParam: defaultParams,
352
+ ...cacheConfig.longLived,
353
+ ...options,
354
+ });
355
+ };
356
+ ```
357
+
358
+ - `select` 로 `pages` 를 평탄화해 컴포넌트는 평평한 배열만 받는다.
359
+ - `meta.hasNextPage` 가 falsy 면 `getNextPageParam` 은 `undefined`(다음 페이지 없음).
360
+
361
+ ---
362
+
363
+ ## 6. 네이밍 & 스타일 요약
364
+
365
+ - 폴더: 소문자 / kebab-case (`user`, `favorite-stores`). 파일 prefix = 폴더명.
366
+ - 함수: 동사 + 리소스 (`getUserList`, `createUser`, `updateUser`).
367
+ - 훅: `use` + 함수 의미 (`useGetUserList`, `useCreateUser`).
368
+ - `RESOURCE` 상수로 baseURL 경로 관리, 동적 경로는 템플릿 리터럴.
369
+ - 조회는 zod 검증(실패 시 원본 반환), 변경은 invalidate + Sentry.
370
+ - `index.ts` 는 service/type 만 노출, 루트 `index.ts` 에 리소스 한 줄 추가.
371
+ - 들여쓰기 2칸, 세미콜론 사용, import 그룹: 외부 → `@/...` → 상대경로.
372
+
373
+ ---
374
+
375
+ ## 7. 생성 시 체크리스트 ✅
376
+
377
+ 1. [ ] 위치를 맞췄다 (React `src/api` / Next `lib/api`).
378
+ 2. [ ] 엔드포인트가 **next(무한스크롤)** 인지 판단했다. (§1)
379
+ 3. [ ] `api / zod / type / service / index` 5파일을 모두 만들었다.
380
+ 4. [ ] Swagger 응답을 zod 로 정확히 매핑(nullable/optional/enum/date/$ref).
381
+ 5. [ ] 타입은 `z.infer` 로 추론.
382
+ 6. [ ] 조회 훅 queryFn 에서 `safeParse` 후 실패 시 원본 반환.
383
+ 7. [ ] 변경 훅에 `invalidateQueries` + `captureSentryError`.
384
+ 8. [ ] `queryKey` 에 리소스 키(`all` 포함) 추가.
385
+ 9. [ ] 리소스 `index.ts` + 루트 `index.ts` 노출 추가.
386
+ 10. [ ] next 면 `useInfiniteQuery` + `metaSchema` + `select` 평탄화 적용.
387
+ 11. [ ] 공용 유틸을 재사용하고, Swagger 에 없는 필드를 추측으로 만들지 않았다.
388
+
389
+ ---
390
+
391
+ ## 8. 자주 하는 실수 (하지 말 것) 🚫
392
+
393
+ - ❌ `*.api.ts` 에서 zod 파싱 (파싱은 service 의 queryFn 책임).
394
+ - ❌ `index.ts` 에서 `api`/`zod` 노출 (service/type 만).
395
+ - ❌ 조회 훅에서 검증 실패 시 throw (원본 반환이 규칙).
396
+ - ❌ `captureSentryError` / `invalidateQueries` 누락.
397
+ - ❌ Swagger 의 `nullable` 무시하고 필수로 선언.
398
+ - ❌ 일반 목록인데 `useInfiniteQuery` 사용 (또는 그 반대).
399
+ - ❌ 공용 타입/유틸(`metaSchema`, `PaginationParams`)을 중복 재정의.
400
+ - ❌ React 인데 `lib/api`, Next 인데 `src/api` 에 만드는 위치 실수.