byuckchon-frontend-cli 1.9.1 → 1.9.3
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 +6 -2
- package/bin/index.js +1 -1
- package/package.json +8 -8
- package/src/ai/tools.js +117 -6
- package/src/commands/adopt.js +1 -1
- package/src/commands/chat.js +15 -0
- package/src/generators/apiConventionDoc.js +3 -3
- package/src/generators/createProject.js +1 -1
- package/src/openapi/cache.js +7 -2
- package/src/ui/ChatApp.js +12 -0
- package/templates/conventions/api-codegen.md +99 -21
package/README.md
CHANGED
|
@@ -180,10 +180,10 @@ FE 전반의 규칙(폴더 구조, 네이밍, 스웨거 → 코드 변환 규칙
|
|
|
180
180
|
| 프레임워크 | 위치 |
|
|
181
181
|
| --- | --- |
|
|
182
182
|
| React (Vite/CRA 등) | `src/api/api-codegen.md` |
|
|
183
|
-
| Next.js | `lib/api/api-codegen.md` |
|
|
183
|
+
| Next.js | `src/lib/api/api-codegen.md` |
|
|
184
184
|
|
|
185
185
|
- 이 파일은 `bc.config.json` 의 `docs` 에 자동 등록되어 **chat 시작 시 주입**됩니다.
|
|
186
|
-
- 하나의
|
|
186
|
+
- 하나의 문서에 React의 axios 규칙과 Next.js의 fetch 및 Server/Client 경계 규칙이 함께 들어갑니다.
|
|
187
187
|
- 이미 파일이 있으면 덮어쓰지 않습니다.
|
|
188
188
|
|
|
189
189
|
### OpenAPI / 코드 컨텍스트 — 자동 주입 (v1.4+)
|
|
@@ -193,6 +193,10 @@ FE 전반의 규칙(폴더 구조, 네이밍, 스웨거 → 코드 변환 규칙
|
|
|
193
193
|
|
|
194
194
|
- **OpenAPI**: chat 시작 시 자동 fetch + 1시간 디스크 캐시 → 엔드포인트 요약을 시스템 프롬프트에 박음.
|
|
195
195
|
- 헤더에 `openapi` 줄로 표시. 캐시 hit 면 `(cached)`, fresh fetch 면 `(live)`.
|
|
196
|
+
- **세션 중 서버가 스펙을 바꿔도 자동 대응 (v1.10+)**: `search_openapi` / `get_openapi_endpoint` 가
|
|
197
|
+
캐시에서 엔드포인트를 못 찾으면 **딱 한 번 최신본을 다시 받아 재검색**합니다 (`🔄 OpenAPI 스펙 새로고침`).
|
|
198
|
+
남용 방지를 위해 세션당 횟수·간격이 제한됩니다. "방금 스웨거 업데이트했어, 다시 읽어줘" 라고 하면
|
|
199
|
+
즉시 강제 새로고침(`refresh_openapi`)합니다.
|
|
196
200
|
- **코드 인덱스**: chat 시작 시 인덱스 파일이 없으면 **백그라운드에서 자동 빌드**.
|
|
197
201
|
- 빌드 중에는 화면에 `📚 인덱싱 중 ...` 진행 표시. 끝나면 `✓` 메시지 한 줄.
|
|
198
202
|
- OpenAI 키가 없으면 빌드를 건너뛰고 도움 메시지를 띄움 (Anthropic 은 임베딩 API 미제공).
|
package/bin/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "byuckchon-frontend-cli",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.3",
|
|
4
4
|
"description": "Byuckchon Frontend Workbench — project starter + AI chat + codebase RAG + OpenAPI codegen",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -10,12 +10,6 @@
|
|
|
10
10
|
"byuckchon-frontend-cli": "./bin/index.js",
|
|
11
11
|
"bc": "./bin/index.js"
|
|
12
12
|
},
|
|
13
|
-
"scripts": {
|
|
14
|
-
"start": "node bin/index.js",
|
|
15
|
-
"chat": "node bin/index.js chat",
|
|
16
|
-
"init": "node bin/index.js init",
|
|
17
|
-
"config": "node bin/index.js config show"
|
|
18
|
-
},
|
|
19
13
|
"dependencies": {
|
|
20
14
|
"@ai-sdk/anthropic": "^3.0.85",
|
|
21
15
|
"@ai-sdk/openai": "^3.0.73",
|
|
@@ -65,5 +59,11 @@
|
|
|
65
59
|
"repository": {
|
|
66
60
|
"type": "git",
|
|
67
61
|
"url": "git+https://github.com/RevolutionaryWarrior/byuckchon-frontend-cli.git"
|
|
62
|
+
},
|
|
63
|
+
"scripts": {
|
|
64
|
+
"start": "node bin/index.js",
|
|
65
|
+
"chat": "node bin/index.js chat",
|
|
66
|
+
"init": "node bin/index.js init",
|
|
67
|
+
"config": "node bin/index.js config show"
|
|
68
68
|
}
|
|
69
|
-
}
|
|
69
|
+
}
|
package/src/ai/tools.js
CHANGED
|
@@ -44,6 +44,39 @@ export function buildTools({ projectRoot, effective, onEvent = () => {}, openapi
|
|
|
44
44
|
return _openapiDocPromise;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// ── live refetch (서버가 세션 도중 스펙을 바꾼 경우 대비) ──
|
|
48
|
+
// 남용 방지: 세션당 최대 횟수 + 최소 간격 throttle.
|
|
49
|
+
const REFRESH_MAX = 6;
|
|
50
|
+
const REFRESH_MIN_INTERVAL_MS = 10_000;
|
|
51
|
+
let _refreshCount = 0;
|
|
52
|
+
let _lastRefreshAt = 0;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 캐시를 무시하고 OpenAPI 스펙을 다시 fetch 한다.
|
|
56
|
+
* @returns {Promise<{ doc: object|null, refreshed: boolean, reason?: string }>}
|
|
57
|
+
*/
|
|
58
|
+
async function refreshOpenApiDoc() {
|
|
59
|
+
if (!openapiSource) return { doc: null, refreshed: false, reason: 'no-source' };
|
|
60
|
+
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
if (_refreshCount >= REFRESH_MAX) {
|
|
63
|
+
return { doc: await loadOpenApiDoc(), refreshed: false, reason: 'limit' };
|
|
64
|
+
}
|
|
65
|
+
if (now - _lastRefreshAt < REFRESH_MIN_INTERVAL_MS) {
|
|
66
|
+
return { doc: await loadOpenApiDoc(), refreshed: false, reason: 'throttled' };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
_refreshCount += 1;
|
|
70
|
+
_lastRefreshAt = now;
|
|
71
|
+
_openapiDocPromise = getCachedOpenApi(openapiSource, { force: true })
|
|
72
|
+
.then((res) => res.doc ?? null)
|
|
73
|
+
.catch(() => null);
|
|
74
|
+
|
|
75
|
+
const doc = await _openapiDocPromise;
|
|
76
|
+
onEvent({ kind: 'openapi_refreshed', ok: !!doc });
|
|
77
|
+
return { doc, refreshed: true };
|
|
78
|
+
}
|
|
79
|
+
|
|
47
80
|
function safePath(p) {
|
|
48
81
|
if (!p || typeof p !== 'string') {
|
|
49
82
|
throw new Error('path 가 비어있습니다');
|
|
@@ -188,7 +221,11 @@ export function buildTools({ projectRoot, effective, onEvent = () => {}, openapi
|
|
|
188
221
|
// ─────────── OpenAPI 툴 ───────────
|
|
189
222
|
|
|
190
223
|
async function searchOpenApi({ query, limit = 40 }) {
|
|
191
|
-
|
|
224
|
+
let doc = await loadOpenApiDoc();
|
|
225
|
+
if (!doc) {
|
|
226
|
+
// 첫 로드 실패면 한 번 live refetch 시도.
|
|
227
|
+
({ doc } = await refreshOpenApiDoc());
|
|
228
|
+
}
|
|
192
229
|
if (!doc) {
|
|
193
230
|
return {
|
|
194
231
|
ok: false,
|
|
@@ -197,11 +234,25 @@ export function buildTools({ projectRoot, effective, onEvent = () => {}, openapi
|
|
|
197
234
|
'(NestJS 는 보통 /api/docs 가 아니라 /api/docs-json).',
|
|
198
235
|
};
|
|
199
236
|
}
|
|
200
|
-
|
|
237
|
+
|
|
238
|
+
let hits = searchEndpoints(doc, query, { limit });
|
|
239
|
+
let refreshed = false;
|
|
240
|
+
|
|
241
|
+
// 캐시된 스펙에서 못 찾으면 → 서버에서 방금 추가됐을 수 있으니 딱 한 번 live refetch 후 재검색.
|
|
242
|
+
if (hits.length === 0) {
|
|
243
|
+
const r = await refreshOpenApiDoc();
|
|
244
|
+
if (r.refreshed && r.doc) {
|
|
245
|
+
refreshed = true;
|
|
246
|
+
doc = r.doc;
|
|
247
|
+
hits = searchEndpoints(doc, query, { limit });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
201
251
|
return {
|
|
202
252
|
ok: true,
|
|
203
253
|
query,
|
|
204
254
|
count: hits.length,
|
|
255
|
+
refreshed,
|
|
205
256
|
endpoints: hits.map((e) => ({
|
|
206
257
|
method: e.method,
|
|
207
258
|
path: e.path,
|
|
@@ -210,17 +261,63 @@ export function buildTools({ projectRoot, effective, onEvent = () => {}, openapi
|
|
|
210
261
|
})),
|
|
211
262
|
hint:
|
|
212
263
|
hits.length === 0
|
|
213
|
-
?
|
|
214
|
-
|
|
264
|
+
? (refreshed
|
|
265
|
+
? '최신 스펙을 다시 받아왔는데도 매치가 없습니다. 다른 키워드로 재시도하거나 query 를 비워 전체 목록을 확인하세요.'
|
|
266
|
+
: '매치 없음. 다른 키워드로 재시도하거나, query 를 비워 전체 목록을 받아 path 를 직접 고르세요.')
|
|
267
|
+
: (refreshed
|
|
268
|
+
? '캐시엔 없던 항목을 최신 스펙에서 찾았습니다. 상세는 get_openapi_endpoint(path, method).'
|
|
269
|
+
: '상세 스키마가 필요하면 get_openapi_endpoint(path, method) 를 호출하세요.'),
|
|
215
270
|
};
|
|
216
271
|
}
|
|
217
272
|
|
|
218
273
|
async function getOpenApiEndpoint({ path: epPath, method }) {
|
|
219
|
-
|
|
274
|
+
let doc = await loadOpenApiDoc();
|
|
275
|
+
if (!doc) {
|
|
276
|
+
({ doc } = await refreshOpenApiDoc());
|
|
277
|
+
}
|
|
220
278
|
if (!doc) {
|
|
221
279
|
return { ok: false, error: 'OpenAPI 스펙을 불러올 수 없습니다.' };
|
|
222
280
|
}
|
|
223
|
-
|
|
281
|
+
|
|
282
|
+
let result = getEndpoint(doc, epPath, method);
|
|
283
|
+
|
|
284
|
+
// 못 찾으면 → 최신 스펙으로 한 번 더.
|
|
285
|
+
if (!result.ok) {
|
|
286
|
+
const r = await refreshOpenApiDoc();
|
|
287
|
+
if (r.refreshed && r.doc) {
|
|
288
|
+
const retry = getEndpoint(r.doc, epPath, method);
|
|
289
|
+
if (retry.ok) result = { ...retry, refreshed: true };
|
|
290
|
+
else result = { ...retry, refreshed: true };
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return result;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function refreshOpenApi() {
|
|
297
|
+
if (!openapiSource) {
|
|
298
|
+
return { ok: false, error: 'bc.config.json 에 api.openapi 가 설정되어 있지 않습니다.' };
|
|
299
|
+
}
|
|
300
|
+
const r = await refreshOpenApiDoc();
|
|
301
|
+
if (!r.doc) {
|
|
302
|
+
return {
|
|
303
|
+
ok: false,
|
|
304
|
+
refreshed: r.refreshed,
|
|
305
|
+
error:
|
|
306
|
+
r.reason === 'throttled'
|
|
307
|
+
? '방금 새로고침했습니다. 잠시 후 다시 시도하세요.'
|
|
308
|
+
: '스펙을 다시 받아오지 못했습니다 (네트워크/URL 확인).',
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
const count = (r.doc.paths ? Object.keys(r.doc.paths).length : 0);
|
|
312
|
+
return {
|
|
313
|
+
ok: true,
|
|
314
|
+
refreshed: r.refreshed,
|
|
315
|
+
reason: r.refreshed ? undefined : r.reason,
|
|
316
|
+
paths: count,
|
|
317
|
+
message: r.refreshed
|
|
318
|
+
? `최신 OpenAPI 스펙을 다시 받아왔습니다 (path ${count}개).`
|
|
319
|
+
: '최근에 이미 새로고침되어 캐시를 재사용했습니다.',
|
|
320
|
+
};
|
|
224
321
|
}
|
|
225
322
|
|
|
226
323
|
// ─────────── Figma 툴 ───────────
|
|
@@ -381,6 +478,20 @@ export function buildTools({ projectRoot, effective, onEvent = () => {}, openapi
|
|
|
381
478
|
}),
|
|
382
479
|
execute: getOpenApiEndpoint,
|
|
383
480
|
}),
|
|
481
|
+
refresh_openapi: tool({
|
|
482
|
+
description:
|
|
483
|
+
'연결된 OpenAPI(Swagger) 스펙을 캐시 무시하고 서버에서 다시 받아온다. ' +
|
|
484
|
+
'사용자가 "방금 스웨거(백엔드 API) 를 업데이트했다 / 다시 읽어라" 라고 하거나, ' +
|
|
485
|
+
'search_openapi 가 분명히 있어야 할 엔드포인트를 못 찾을 때 호출. ' +
|
|
486
|
+
'(search_openapi / get_openapi_endpoint 는 못 찾으면 자동으로 한 번 새로고침하므로, ' +
|
|
487
|
+
'명시적 요청이 있을 때만 직접 부르면 된다.)',
|
|
488
|
+
inputSchema: jsonSchema({
|
|
489
|
+
type: 'object',
|
|
490
|
+
properties: {},
|
|
491
|
+
additionalProperties: false,
|
|
492
|
+
}),
|
|
493
|
+
execute: refreshOpenApi,
|
|
494
|
+
}),
|
|
384
495
|
write_file: tool({
|
|
385
496
|
description:
|
|
386
497
|
'새 파일을 만들거나 기존 파일을 통째로 덮어쓴다. 새 파일을 만들기 전에 반드시 1) 비슷한 기존 파일을 read_file 로 보고 2) 같은 폴더 컨벤션(barrel 파일, 네이밍, import 순서) 을 따른다.',
|
package/src/commands/adopt.js
CHANGED
|
@@ -145,7 +145,7 @@ export async function adoptCommand(opts = {}) {
|
|
|
145
145
|
console.log(chalk.green(`\n ✓ ${CONFIG_PATHS.projectFileName} 작성 완료.`));
|
|
146
146
|
console.log(chalk.dim(` ${targetFile}`));
|
|
147
147
|
|
|
148
|
-
// API 코드 컨벤션 .md 를 API 루트(src/api | lib/api)에 깐다 (이미 있으면 유지).
|
|
148
|
+
// API 코드 컨벤션 .md 를 API 루트(src/api | src/lib/api)에 깐다 (이미 있으면 유지).
|
|
149
149
|
try {
|
|
150
150
|
const { relPath, written } = await scaffoldApiConventionDoc({
|
|
151
151
|
projectRoot: cwd,
|
package/src/commands/chat.js
CHANGED
|
@@ -121,6 +121,9 @@ export async function chatCommand(opts = {}) {
|
|
|
121
121
|
'가져와서 zod/타입/요청 함수를 만든다. ' +
|
|
122
122
|
'예: 사용자가 "inquiries" 라고 하면 search_openapi("inquiries") 로 ' +
|
|
123
123
|
'`/api/admin/inquiries` 같은 실제 경로를 찾아낸다. ' +
|
|
124
|
+
'스펙은 캐시(최대 1시간)라 서버가 방금 바꿨으면 오래됐을 수 있다 — ' +
|
|
125
|
+
'search_openapi/get_openapi_endpoint 는 못 찾으면 자동으로 한 번 최신본을 다시 받아온다. ' +
|
|
126
|
+
'사용자가 "방금 스웨거 업데이트했어/다시 읽어" 라고 하면 `refresh_openapi()` 를 먼저 호출한다. ' +
|
|
124
127
|
'이미 `*.gen.ts` 가 있으면 그걸 import 해서 쓰는 것도 좋다.';
|
|
125
128
|
}
|
|
126
129
|
} catch {
|
|
@@ -264,6 +267,12 @@ async function runOnce({ cfg, resolved, system, prompt }) {
|
|
|
264
267
|
effective: cfg.effective,
|
|
265
268
|
openapiSource: cfg.effective.api?.openapi ?? null,
|
|
266
269
|
onEvent: (ev) => {
|
|
270
|
+
if (ev.kind === 'openapi_refreshed') {
|
|
271
|
+
console.log(
|
|
272
|
+
chalk.dim(ev.ok ? ' 🔄 OpenAPI 스펙 새로고침' : ' ⚠️ OpenAPI 새로고침 실패'),
|
|
273
|
+
);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
267
276
|
const label =
|
|
268
277
|
ev.kind === 'write_created'
|
|
269
278
|
? '🆕'
|
|
@@ -489,6 +498,12 @@ async function runReadlineFallback({ cfg, resolved, system, session, openapiInfo
|
|
|
489
498
|
effective: cfg.effective,
|
|
490
499
|
openapiSource: cfg.effective.api?.openapi ?? null,
|
|
491
500
|
onEvent: (ev) => {
|
|
501
|
+
if (ev.kind === 'openapi_refreshed') {
|
|
502
|
+
console.log(
|
|
503
|
+
chalk.dim(ev.ok ? '\n 🔄 OpenAPI 스펙 새로고침' : '\n ⚠️ OpenAPI 새로고침 실패'),
|
|
504
|
+
);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
492
507
|
const label =
|
|
493
508
|
ev.kind === 'write_created'
|
|
494
509
|
? '🆕 생성'
|
|
@@ -9,15 +9,15 @@ const TEMPLATE_PATH = path.resolve(
|
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* 프레임워크에 맞는 API 루트 폴더.
|
|
12
|
-
* - Next.js → lib/api
|
|
12
|
+
* - Next.js → src/lib/api
|
|
13
13
|
* - 그 외 React 계열 → src/api
|
|
14
14
|
*/
|
|
15
15
|
export function apiRootForFramework(framework) {
|
|
16
|
-
return framework === 'next' ? 'lib/api' : 'src/api';
|
|
16
|
+
return framework === 'next' ? 'src/lib/api' : 'src/api';
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
|
-
* API 코드 컨벤션 .md 를 프로젝트의 API 루트(`src/api` 또는 `lib/api`)에 깐다.
|
|
20
|
+
* API 코드 컨벤션 .md 를 프로젝트의 API 루트(`src/api` 또는 `src/lib/api`)에 깐다.
|
|
21
21
|
*
|
|
22
22
|
* @param {object} args
|
|
23
23
|
* @param {string} args.projectRoot
|
|
@@ -26,7 +26,7 @@ export async function createProject(config) {
|
|
|
26
26
|
await createPackageJson(rootDir, config);
|
|
27
27
|
await createBaseFiles(rootDir, config);
|
|
28
28
|
await createReadme(rootDir, config);
|
|
29
|
-
// API 코드 컨벤션 .md 를 프레임워크에 맞는 API 루트(src/api | lib/api)에 깐다.
|
|
29
|
+
// API 코드 컨벤션 .md 를 프레임워크에 맞는 API 루트(src/api | src/lib/api)에 깐다.
|
|
30
30
|
await scaffoldApiConventionDoc({ projectRoot: rootDir, framework: config.framework });
|
|
31
31
|
await createBcConfig(rootDir, config);
|
|
32
32
|
|
package/src/openapi/cache.js
CHANGED
|
@@ -23,8 +23,13 @@ function keyFor(input) {
|
|
|
23
23
|
*
|
|
24
24
|
* - 네트워크 실패 시: 만료된 캐시라도 있으면 그걸로 폴백 (offline-friendly).
|
|
25
25
|
* - 캐시는 .bc/cache/openapi-<hash>.json 에 저장.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} input OpenAPI URL 또는 파일 경로
|
|
28
|
+
* @param {object} [opts]
|
|
29
|
+
* @param {boolean} [opts.force] true 면 TTL 무시하고 무조건 live refetch (캐시 갱신).
|
|
30
|
+
* 서버가 세션 도중 스펙을 바꿨을 때 사용.
|
|
26
31
|
*/
|
|
27
|
-
export async function getCachedOpenApi(input) {
|
|
32
|
+
export async function getCachedOpenApi(input, { force = false } = {}) {
|
|
28
33
|
const dir = await getCacheDir();
|
|
29
34
|
const file = path.join(dir, `openapi-${keyFor(input)}.json`);
|
|
30
35
|
|
|
@@ -39,7 +44,7 @@ export async function getCachedOpenApi(input) {
|
|
|
39
44
|
/* miss */
|
|
40
45
|
}
|
|
41
46
|
|
|
42
|
-
if (cachedFresh && cached) {
|
|
47
|
+
if (!force && cachedFresh && cached) {
|
|
43
48
|
return { doc: cached, cached: true, source: input };
|
|
44
49
|
}
|
|
45
50
|
|
package/src/ui/ChatApp.js
CHANGED
|
@@ -871,6 +871,18 @@ export function ChatApp({
|
|
|
871
871
|
|
|
872
872
|
// 툴 실행 이벤트는 채팅에 시스템 메시지로 표시 (사용자가 무엇이 일어났는지 보게).
|
|
873
873
|
const onToolEvent = (ev) => {
|
|
874
|
+
if (ev.kind === 'openapi_refreshed') {
|
|
875
|
+
setMessages((m) => [
|
|
876
|
+
...m,
|
|
877
|
+
{
|
|
878
|
+
role: 'system-info',
|
|
879
|
+
text: ev.ok
|
|
880
|
+
? '🔄 OpenAPI 스펙 새로고침 (서버에서 최신본 다시 받음)'
|
|
881
|
+
: '⚠️ OpenAPI 새로고침 실패 (네트워크/URL 확인)',
|
|
882
|
+
},
|
|
883
|
+
]);
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
874
886
|
const labels = {
|
|
875
887
|
write_created: '🆕 생성',
|
|
876
888
|
write_overwritten: '✏️ 덮어씀',
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
> 사용자가 Swagger JSON(또는 엔드포인트)을 제공하면, AI 는 이 문서의 규칙에 맞춰
|
|
5
5
|
> `api / zod / type / service / index` 파일을 생성한다.
|
|
6
6
|
>
|
|
7
|
-
>
|
|
7
|
+
> 대상: React(Vite/CRA 등), Next.js App Router
|
|
8
|
+
> 스택: `@tanstack/react-query` + React는 `axios`, Next.js는 native `fetch`.
|
|
8
9
|
|
|
9
10
|
---
|
|
10
11
|
|
|
@@ -16,30 +17,25 @@
|
|
|
16
17
|
2. **기존 코드 우선.** 공용 유틸(`cacheConfig`, `queryKey`, `captureSentryError`, `metaSchema`,
|
|
17
18
|
`PaginationParams` 등)은 새로 만들지 말고 그대로 가져다 쓴다. import 경로가 확실치 않으면
|
|
18
19
|
`search_code` 로 실제 export 위치를 확인한다.
|
|
19
|
-
3.
|
|
20
|
+
3. **계약 파일은 항상 함께.** React는 `api / zod / type / service / index`를 한 번에 생성한다.
|
|
21
|
+
Next.js는 공용 `zod / type`, 서버 기본 호출 파일, 필요한 경우 Client 호출 파일과 service를 생성한다.
|
|
22
|
+
이미 같은 리소스가 있으면 기존 파일 구성과 이름을 우선한다.
|
|
20
23
|
|
|
21
24
|
---
|
|
22
25
|
|
|
23
|
-
## 0. 위치 & 폴더 구조
|
|
26
|
+
## 0. 위치 & 폴더 구조
|
|
24
27
|
|
|
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` 로 바꿔 읽으면 된다.
|
|
28
|
+
API 루트는 React에서 `src/api`, Next.js에서 `src/lib/api`다.
|
|
34
29
|
|
|
35
30
|
리소스(도메인) 하나당 폴더 하나. 폴더명은 **소문자**(여러 단어는 kebab-case: `favorite-stores`).
|
|
36
31
|
|
|
37
32
|
```
|
|
38
|
-
src/api/ #
|
|
39
|
-
├── instance.ts # axios 인스턴스
|
|
33
|
+
src/api/ # Next.js: src/lib/api/
|
|
34
|
+
├── instance.ts # React: axios 인스턴스
|
|
40
35
|
├── index.ts # 모든 API 모듈 export
|
|
41
36
|
└── user/ # 리소스 폴더 (소문자)
|
|
42
|
-
├── user.api.ts # axios
|
|
37
|
+
├── user.api.ts # React: axios / Next.js: Server fetch 호출
|
|
38
|
+
├── user.api.client.ts # Next.js: Client 호출이 필요할 때만 생성
|
|
43
39
|
├── user.zod.ts # 응답/요청 zod 스키마
|
|
44
40
|
├── user.type.ts # zod 로부터 추론한 타입 + 입력 타입
|
|
45
41
|
├── user.service.ts # react-query 훅 (use~) — 비즈니스 로직
|
|
@@ -51,6 +47,67 @@ src/api/ # (Next: lib/api/)
|
|
|
51
47
|
|
|
52
48
|
---
|
|
53
49
|
|
|
50
|
+
## Next.js 전용 규칙 — Server/Client 경계
|
|
51
|
+
|
|
52
|
+
> 이 섹션의 **Next.js**는 프레임워크다. 문서의 **next(무한스크롤)**와는 관련이 없다.
|
|
53
|
+
|
|
54
|
+
Next.js App Router는 API 코드를 생성하기 전에 실행 환경을 분류한다.
|
|
55
|
+
|
|
56
|
+
| 환경 | 사용처 | 허용 의존성 |
|
|
57
|
+
| --- | --- | --- |
|
|
58
|
+
| Server | Server Component, Route Handler, Server Action | `cookies`, `headers`, 서버 토큰, `serverAuthHttp` |
|
|
59
|
+
| Client | `'use client'`, React Query hook | 브라우저 API, `clientAuthHttp` |
|
|
60
|
+
| 공용 | type, 순수 zod schema, 직렬화 가능한 상수 | 서버/브라우저 전용 의존성 없음 |
|
|
61
|
+
|
|
62
|
+
### 공용 barrel에서 Server 모듈을 내보내지 않는다
|
|
63
|
+
|
|
64
|
+
Client Component가 공용 `index.ts`를 가져올 때 그 barrel이 Server 모듈까지 export하면 다음 오류가 발생할 수 있다.
|
|
65
|
+
|
|
66
|
+
```text
|
|
67
|
+
This module cannot be imported from a Client Component module.
|
|
68
|
+
It should only be used from a Server Component.
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
// src/lib/api/http/index.ts
|
|
73
|
+
export * from './httpBase';
|
|
74
|
+
export * from './publicHttp';
|
|
75
|
+
export * from './clientAuthHttp';
|
|
76
|
+
// 금지: export * from './serverAuthHttp';
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`serverAuthHttp`와 Server API는 barrel을 거치지 않고 절대경로로 직접 import한다.
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
import { serverAuthHttp } from '@/lib/api/http/serverAuthHttp';
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
- Server 파일은 가능하면 `import 'server-only';`로 경계를 표시한다.
|
|
86
|
+
- 인증 호출은 Server를 기본으로 하고 Client에서도 호출해야 할 때만 `*.client.ts`를 만든다.
|
|
87
|
+
- Client service는 Client 호출 파일만 import하며 Server 파일을 간접 참조하지 않는다.
|
|
88
|
+
- `useQuery`/`useMutation`을 export하는 service는 Client 전용이다.
|
|
89
|
+
- type과 zod schema만 Server/Client가 공유한다.
|
|
90
|
+
|
|
91
|
+
### HTTP와 인증 컨텍스트를 분리한다
|
|
92
|
+
|
|
93
|
+
- `serverAuthHttp`: 서버 쿠키·헤더·비공개 토큰 사용. 공용 barrel export 금지.
|
|
94
|
+
- `clientAuthHttp`: 브라우저에 노출 가능한 인증 상태만 사용.
|
|
95
|
+
- `publicHttp`: 양쪽에서 안전할 때만 공용 사용.
|
|
96
|
+
- 기존 fetch wrapper를 먼저 찾는다. 없으면 자동 생성하지 말고 fetch/auth 방식을 사용자에게 묻는다.
|
|
97
|
+
- `cookies()`/`headers()`는 모듈 최상위가 아니라 요청 함수 내부에서 호출한다.
|
|
98
|
+
- 서버 비밀값에 `NEXT_PUBLIC_`을 붙이지 않는다.
|
|
99
|
+
- 기존 Route Handler/BFF, base URL, CORS, Edge Runtime 정책을 확인하고 재사용한다.
|
|
100
|
+
|
|
101
|
+
### 캐시와 전달 값을 구분한다
|
|
102
|
+
|
|
103
|
+
- React Query 무효화는 Client 캐시만 갱신한다.
|
|
104
|
+
- Next.js의 `fetch` cache/`next`, `revalidatePath`/`revalidateTag`, Cache Components는 기존 정책을 따른다.
|
|
105
|
+
- 사용자별 인증 응답을 전역 캐시하지 않는다.
|
|
106
|
+
- Server에서 Client로 응답 wrapper, `Headers`, `Error`, 함수 등을 전달하지 않고 JSON 직렬화 가능한 data만 전달한다.
|
|
107
|
+
- Server Action은 요청받았거나 기존 프로젝트가 같은 패턴을 사용할 때만 만든다.
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
54
111
|
## 1. 가장 먼저 — "next(무한스크롤)" 인지 판단하라
|
|
55
112
|
|
|
56
113
|
> 여기서 말하는 "next" 는 **프레임워크 Next.js 가 아니라**, **커서 기반 무한스크롤 패턴**을 가리킨다.
|
|
@@ -76,13 +133,17 @@ src/api/ # (Next: lib/api/)
|
|
|
76
133
|
|
|
77
134
|
### 2-1. `user.api.ts` — 순수 호출 함수
|
|
78
135
|
|
|
79
|
-
- `
|
|
80
|
-
-
|
|
81
|
-
|
|
82
|
-
-
|
|
136
|
+
- React는 `baseInstance`(axios)를 사용한다.
|
|
137
|
+
- Next.js는 Server 파일에서 기존 `serverAuthHttp`를 절대경로로 import한다. Client 호출이 필요할 때만
|
|
138
|
+
`*.client.ts`를 만들고 기존 `clientAuthHttp`를 사용한다. wrapper가 없으면 자동 생성하지 않는다.
|
|
139
|
+
- React는 `const { data } = await baseInstance.X(...)` 후 `data`를 반환한다.
|
|
140
|
+
- Next.js는 fetch wrapper의 generic에 응답 타입을 전달하고 JSON data를 반환받는다.
|
|
141
|
+
- 쿼리스트링은 기존 wrapper 규칙(axios의 `{ params }`, fetch wrapper의 `searchParams` 등)을 따른다.
|
|
142
|
+
path 파라미터는 템플릿 리터럴을 사용한다.
|
|
83
143
|
- **여기서는 zod 파싱을 하지 않는다.** (파싱은 service 의 queryFn 책임)
|
|
84
144
|
|
|
85
145
|
```ts
|
|
146
|
+
// React
|
|
86
147
|
import baseInstance from '../instance';
|
|
87
148
|
|
|
88
149
|
const RESOURCE = '/api/user';
|
|
@@ -106,6 +167,18 @@ export const deleteUser = async (userId: string) => {
|
|
|
106
167
|
};
|
|
107
168
|
```
|
|
108
169
|
|
|
170
|
+
```ts
|
|
171
|
+
// Next.js Server
|
|
172
|
+
import { serverAuthHttp } from '@/lib/api/http/serverAuthHttp';
|
|
173
|
+
import type { UserItem } from './user.type';
|
|
174
|
+
|
|
175
|
+
const RESOURCE = '/api/user';
|
|
176
|
+
|
|
177
|
+
export const getUser = async (userId: string) => {
|
|
178
|
+
return serverAuthHttp<UserItem>(`${RESOURCE}/${userId}`);
|
|
179
|
+
};
|
|
180
|
+
```
|
|
181
|
+
|
|
109
182
|
### 2-2. `user.zod.ts` — 스키마
|
|
110
183
|
|
|
111
184
|
- `import { z } from 'zod';`
|
|
@@ -213,6 +286,7 @@ export const useDeleteUser = () => {
|
|
|
213
286
|
### 2-5. `index.ts` (리소스) — 노출
|
|
214
287
|
|
|
215
288
|
- **`service` 와 `type` 만** 재노출 (`api`, `zod` 는 노출하지 않음).
|
|
289
|
+
- Next.js에서는 Server 호출 파일을 Client가 접근 가능한 barrel에서 재노출하지 않는다.
|
|
216
290
|
|
|
217
291
|
```ts
|
|
218
292
|
export * from './user.service';
|
|
@@ -230,6 +304,7 @@ export * from './user';
|
|
|
230
304
|
|
|
231
305
|
### 2-7. `src/api/instance.ts` — axios 인스턴스
|
|
232
306
|
|
|
307
|
+
- React 전용 규칙이다.
|
|
233
308
|
- 이미 있으면 **건드리지 않는다.** baseURL/interceptor 설정이 여기 모여 있다.
|
|
234
309
|
- 새 리소스는 항상 이 `baseInstance` 를 import 해서 쓴다.
|
|
235
310
|
|
|
@@ -374,7 +449,7 @@ export const useGetUser = (options?: Record<string, any>) => {
|
|
|
374
449
|
|
|
375
450
|
## 7. 생성 시 체크리스트 ✅
|
|
376
451
|
|
|
377
|
-
1. [ ] 위치를 맞췄다 (React `src/api` / Next `lib/api`).
|
|
452
|
+
1. [ ] 위치를 맞췄다 (React `src/api` / Next.js `src/lib/api`).
|
|
378
453
|
2. [ ] 엔드포인트가 **next(무한스크롤)** 인지 판단했다. (§1)
|
|
379
454
|
3. [ ] `api / zod / type / service / index` 5파일을 모두 만들었다.
|
|
380
455
|
4. [ ] Swagger 응답을 zod 로 정확히 매핑(nullable/optional/enum/date/$ref).
|
|
@@ -385,6 +460,8 @@ export const useGetUser = (options?: Record<string, any>) => {
|
|
|
385
460
|
9. [ ] 리소스 `index.ts` + 루트 `index.ts` 노출 추가.
|
|
386
461
|
10. [ ] next 면 `useInfiniteQuery` + `metaSchema` + `select` 평탄화 적용.
|
|
387
462
|
11. [ ] 공용 유틸을 재사용하고, Swagger 에 없는 필드를 추측으로 만들지 않았다.
|
|
463
|
+
12. [ ] Next.js면 Server/Client 환경을 분류하고 Server 모듈을 공용 barrel에서 내보내지 않았다.
|
|
464
|
+
13. [ ] Next.js면 기존 fetch/auth wrapper와 캐시·runtime 정책을 확인했다.
|
|
388
465
|
|
|
389
466
|
---
|
|
390
467
|
|
|
@@ -397,4 +474,5 @@ export const useGetUser = (options?: Record<string, any>) => {
|
|
|
397
474
|
- ❌ Swagger 의 `nullable` 무시하고 필수로 선언.
|
|
398
475
|
- ❌ 일반 목록인데 `useInfiniteQuery` 사용 (또는 그 반대).
|
|
399
476
|
- ❌ 공용 타입/유틸(`metaSchema`, `PaginationParams`)을 중복 재정의.
|
|
400
|
-
- ❌ React
|
|
477
|
+
- ❌ React와 Next.js의 API 루트 또는 axios/fetch 규칙을 혼용.
|
|
478
|
+
- ❌ Next.js 공용 `index.ts`에서 Server 전용 API나 `serverAuthHttp`를 export.
|