두 번째 점검은 합류 지점이었다 — Admin Portal 2차에서 한 사이클에 잡힌 FE-BE 연동 버그 11건
📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (46편)
1차 점검(SC-A01~A17)이 *FE 단독·BE 단독*의 단위 검수였다면, 2차 점검은 *FE-BE를 처음 같이 돌려 보는 자리*였어요. 그날 자정부터 새벽까지 한 사이클에 11건이 터졌습니다. CORS PATCH·DTO interface→class·운영자 상태 변경 400·콘텐츠 FK 제약·dbVersion 동적 조회 다섯 자리(BE)에 모니터링 useCustom·진단평가 문제 추가 버튼 결정·이메일 인라인 에러·SUPER_ADMIN 활성화 가드·배치고사 상태 엔드포인트 분리·콘텐츠 목록 null 크래시 여섯 자리(FE)까지. 코드 변경은 한 줄짜리 자리가 많았는데, *어떤 자리가 깨질 수 있는지*를 미리 보지 못한 게 11건이 한꺼번에 몰린 이유였어요. 합류 지점이라는 디버깅 환경의 본질을 11개의 자리로 짚은 글입니다.
💡 Tip. 바쁜 현대인들을 위한 본문 요약
- 1차는 단위 검수, 2차는 합류 검수. #26 1차 점검(16/20)에서 잠근 자리는 FE 단독·BE 단독의 시나리오였어요. 모니터링 DB 상태, 콘텐츠 FK, 진단평가 상태 토글, 운영자 상태 토글, 정책 설정 저장 — 다섯 자리는 모두 FE/BE 한쪽에서만 통과했고, 두 쪽이 처음 같이 돌아가는 자정의 합류 지점에서야 11번 어긋난 자국이 보였습니다
- BE 5건 — 응답 헤더·DTO·FK·동적 SQL의 어긋남. ① CORS
allowedHeaders누락 + Vite 6 프록시 소문자 메서드(자세한 흐름은 #25) ② 콘텐츠 FK 제약 — Level 시드 비어 있는 채로 L1~L30 하드코딩이 부딪힘 ③ 정책 설정 DTOinterface→class변환(자세한 흐름은 #24) ④ 운영자 상태 변경 400 —UpdateAdminStatusDto데코레이터 누락 ⑤ dbVersion 요청 —'PostgreSQL 14.x'하드코딩 →SELECT version()동적 조회. 다섯 자리 모두 한쪽에서만 통과한 가정이 합류 지점에서 깨졌어요- FE 6건 — useList/useCustom·UX·권한 분기·null 가드의 자리들. ① 모니터링 DB 상태 — 단일 객체 응답에
useList를 쓰던 자리를useCustom으로 교체 ② 배치고사 문제 추가 버튼 — MVP 외 결정으로 disabled + “준비 중” 표시 ③ 이메일 유효성 —alert()→ 인라인<FormHelperText error>④ SUPER_ADMIN 활성화 가드 —disabled={admin.role==='SUPER_ADMIN'}을 본인 계정만 차단으로 ⑤ 배치고사 상태 변경 —PATCH /:id→PATCH /:id/status별도 엔드포인트로 ⑥ 콘텐츠 목록 크래시 —metricWeights/playableLevelIdsnull·빈배열 가드. 여섯 자리 모두 데이터가 비어 있는 상태를 한쪽에서 가정하지 않아 깨진 자리예요- PM 모자가 두 번 끼어든 자리. 배치고사 문제 추가 기능 — MVP 범위 외라는 판단을 코드로 막는 결정이 한 번 박혔고, 원장 계정 자동 생성 — Option A 유지가 #26 이후 한 번 더 박혔어요. 합류 지점에서 결정이 흔들리면 코드도 같이 흔들립니다. 11건 중 두 자리는 코드가 아니라 결정 잠금이 답이었어요
- 회귀 막는 두 줄 — LESSONS.md 동시 갱신. 11건을 잡은 다음 그날 안에
.claude/skills/role-frontend/LESSONS.md(FE 3줄)와.claude/skills/role-backend/LESSONS.md(BE 4줄)에 같이 박았어요. Vite 프록시는 메서드를 대문자로·단일 객체 API는 useCustom·alert() 대신 인라인·DTO는 반드시 class+데코레이터·CORS allowedHeaders 명시·FK 생성 전 참조 검증·findFirst()는 orderBy 명시 일곱 줄. 다음 사이클에서 같은 자리가 안 깨지게 박는 자리는 코드가 아니라 교훈 인덱스예요- 앵글 한 줄. 2차 점검은 11건을 잡는 자리가 아니라, 11건이 한 사이클에 몰리는 환경 자체가 합류 지점임을 인정하는 자리였어요. 1인 다역에서 FE/BE를 워크트리 둘로 나눠 작업하면 각각의 통과 기준은 따로 만들어집니다. 그 기준이 합류 지점에서 처음 만나는 한 사이클에 11번 어긋난 자국은 다음 사이클의 점검 매트릭스를 어디에 박을지 결정하는 좌표가 되었어요
🪪 재구성 안내. 이 편은 #24 DTO interface→class와 #25 CORS PATCH에서 자세히 다룬 두 자리를 11건 전체 매트릭스의 두 칸으로 다시 위치시키고, 세션 아카이브
2026-01-14_0100.md의 2차 점검 버그픽스 표 + 추가 버그픽스 섹션과2026-01-14_1215.md의 진단평가 상태 변경 / 콘텐츠 목록 크래시 두 자리를 교차해 한 시점의 사이클로 합본한 글이에요. 직접 인용한 코드는 당시 동작을 글로 표현하기 위한 재구성본이고, 변수명·로직 흐름은 커밋 정황과 정합하지만 라인 단위까지 동일하다고 보증하지 않습니다.
🚀 한 사이클에 11건 — 1차는 단위, 2차는 합류
#26 Admin Portal 1차 완료 점검(16/20)이 끝난 자정 무렵, 시나리오 표 위에서는 16개 자리가 초록불이었어요. SC-A01~A17 중 코드 변경이 끝난 자리 16개를 FE 단독·BE 단독으로 각각 통과시킨 자국이었습니다. 그 기준에서는 모두 통과인 상태였어요.
문제는 1차 점검의 통과 기준이 워크트리 둘로 나눈 단위에서만 잠겨 있었다는 점이에요. 1인 다역으로 모노레포를 운영하면 보통 FE 모자와 BE 모자를 따로 쓰면서 워크트리를 둘로 나눠 작업합니다. 같은 시나리오 SC-A03(콘텐츠 등록)도 FE는 mock REST 응답으로 폼이 잘 닫히는지만 보고, BE는 Postman/curl로 컨트롤러가 201을 돌려주는지만 봐요. 두 통과 기준이 합류 지점에서 처음 만나는 자리에서야 어긋남이 보입니다.
[1차 점검] [2차 점검 — 합류 지점]
FE 단독 통과 ───── ✅ ┌───────────────────────────┐
│ FE/BE 동시 기동 │
BE 단독 통과 ───── ✅ │ → 한 사이클에 11번 어긋난 │
│ → 5 BE + 6 FE │
└───────────────────────────┘
자정 23:30에 pnpm --filter api start:dev와 pnpm --filter admin-portal dev를 동시에 띄우고 처음 같이 돌려 본 사이클에서 11건이 줄줄이 터졌어요. 새벽 5:30까지 6시간이 깎였고, 다음 날 12:15·14:30에 콘텐츠 목록 크래시와 진단평가 상태 변경 엔드포인트 두 자리가 추가로 박혀서 한 사이클의 합류 검수가 마무리됐습니다.
📌 핵심: 2차 점검의 통과 기준은 1차의 합집합이 아니에요. 1차는 각자의 워크트리에서 닫히는 자리를 보는 검수이고, 2차는 두 워크트리가 합쳐진 다음에야 보이는 자리를 잡는 검수예요. 단위 11번 통과 ≠ 합류 1번 통과. 11건이 한 사이클에 몰리는 게 비정상이 아니라 2차 점검이라는 환경의 정의에 가깝습니다.
이 글은 그 11건을 어떻게 잡았는지보다 어떤 자리에서 어떻게 어긋났는지를 매트릭스로 펼치는 데 무게를 두려고 해요. CORS PATCH는 #25, DTO interface→class는 #24에서 한 편 분량으로 따로 다뤘고, 이 글에서는 그 두 자리를 11건 매트릭스의 두 칸으로 다시 위치시키는 그림이 본론입니다.
🌉 합류 지점이라는 디버깅 환경 — 왜 11건이 한 사이클에 몰리나
11건을 한 줄씩 적기 전에, 왜 이 자리에 11건이 한꺼번에 박혔는지부터 짚는 게 다음 사이클의 점검 매트릭스를 어디에 둘지 결정하는 데 더 중요해요. 결론부터 말하면 세 가지 환경 요소가 합류 지점에 모이면서 11건이 동시에 가시화된 거예요.
[합류 지점 = 환경 요소 × 셋]
(1) 모킹 막을 처음 거두는 자리 — Refine mock dataProvider → REST dataProvider
└── 응답 포맷 가정이 처음 부딪힘 (DTO·shape·null 처리)
(2) 브라우저 보안 모델이 처음 끼어드는 자리 — curl/Postman → 실제 origin
└── CORS·prefligh·credentials가 처음으로 강제됨
(3) DB 시드와 마이그레이션이 처음 같이 도는 자리 — 빈 DB → 시드 → 점검
└── FK 제약·기본값·동적 정보(`SELECT version()`)가 처음 노출됨
각 요소는 FE 단독에서도, BE 단독에서도 우회 가능했어요. FE는 mock dataProvider로 응답 포맷을 통제했고, BE는 curl/Postman으로 origin이 없는 요청만 받았고, DB는 Prisma Studio로 직접 채운 한 줄짜리 데이터로 점검을 돌렸으니까요. 합류 지점에서는 세 우회가 한꺼번에 사라집니다. 그래서 우회로 가려져 있던 가정이 11번 동시에 가시화되는 거예요.
| 자리 | 1차 점검에서 우회한 방법 | 2차 점검에서 처음 부딪힌 자리 |
|---|---|---|
| 응답 포맷 | mock dataProvider 응답 | REST 응답에서 id/dto 모양 어긋남 |
| 보안 모델 | curl로 직접 호출 | 브라우저 OPTIONS preflight |
| DB 상태 | Prisma Studio로 한 줄 입력 | 빈 시드 + FK 제약 |
| 권한 분기 | 단일 역할로 점검 | SUPER_ADMIN/ADMIN 동시 분기 |
| 시간 윈도우 | 단발 호출 | 오늘·이번 분기 슬라이스 |
합류 지점에서 11건이 보이는 자리는 코드의 결함이 아니라 우회의 그림자예요. 그래서 11건을 잡는 디버깅이 한 줄짜리 패치로 끝나는 자리가 많고, 그 한 줄을 박는 데 6시간이 깎이는 이유는 어떤 자리에서 어긋날 수 있는지를 미리 보지 못했기 때문이에요. 다음 사이클의 점검 매트릭스를 우회의 그림자가 가시화되는 자리에 박는 게 이 합류 지점이 남긴 가장 큰 자산이었습니다.
⚠️ 주의: FE·BE를 같이 띄우는 점검을 1차 점검 안에 넣고 싶은 유혹이 강하지만, 1인 다역에서는 워크트리 분리의 이득이 그것보다 훨씬 커요. 단위 검수의 빠른 사이클을 그대로 두고, 2차 점검을 별도 사이클로 분리하면서 11건이 한 자리에 몰리는 걸 받아들이는 게 더 빠릅니다. 합류 지점을 1차로 흡수하려 하면 워크트리의 벽이 흐려지고 PR 단위가 비대해져요.
🔧 BE 다섯 자리 — 응답 헤더·DTO·FK·동적 SQL의 어긋남
BE 5건을 시간 순으로 펼치면 합류 지점에서 가장 먼저 보이는 게 보안 모델, 그 다음이 DTO 형, 마지막이 데이터 일관성이에요.
① CORS PATCH — allowedHeaders 누락 + Vite 6 소문자 메서드 (#25에서 자세히)
#25에서 자정의 6시간 디버깅을 따로 풀었어요. GET·POST·PUT은 다 되는데 PATCH만 빨갛게라는 비대칭 신호에서 출발했고, 두 자리(apps/api/src/main.ts의 allowedHeaders 누락 + apps/admin-portal/src/providers/rest-data-provider.ts의 method.toUpperCase() 누락)가 동시에 어긋나 있었던 자리예요.
// ✅ apps/api/src/main.ts (after, 핵심만)
app.enableCors({
origin: 'http://localhost:5173',
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Authorization', 'Content-Type', 'X-Requested-With'],
credentials: true,
});
// ✅ apps/admin-portal/src/providers/rest-data-provider.ts (after)
const httpMethod = method.toUpperCase(); // "patch" → "PATCH"
const res = await fetch(url, { method: httpMethod, headers, body });
이 자리는 합류 지점이 보안 모델을 처음 강제하는 자리라는 환경 요소 (2)의 가시화예요. curl에는 origin이 없으니 preflight가 안 가고, mock dataProvider에는 origin이 있어도 응답이 mock에서 끝나니 preflight가 무의미했어요. 두 우회가 사라진 자정에 처음 진짜 OPTIONS 요청이 흘러간 거예요.
② 콘텐츠 FK 제약 — Level 시드 부재 + FE 하드코딩 충돌
이 자리는 환경 요소 (3) DB 시드와 마이그레이션의 가시화였어요. FE에서 콘텐츠 등록 폼은 L1~L30 30개 옵션을 하드코딩으로 보여주고 있었는데, BE의 Level 테이블은 비어 있었습니다. 합류 지점에서 콘텐츠를 등록하면 FK 제약 위반으로 즉시 깨졌어요.
-- ❌ 합류 직전 상태
SELECT COUNT(*) FROM "Level"; -- 0
-- FE는 levelId: 5를 보냄 → BE는 ER_NO_REFERENCED_ROW_2
해결은 두 자리에 동시에 박혔어요. (1) BE — Level 시드 데이터 L1~L31(31개) 추가, (2) FE — useList({ resource: 'levels' })로 동적 조회. FE 하드코딩을 지우는 자리가 더 비쌌습니다. 30개 라벨이 콘텐츠 등록·콘텐츠 검색·진단평가 매핑 세 자리에 흩어져 있어서, 동적 조회 훅 한 줄로 통일하는 패치 자체가 30분짜리였어요.
🔍 단서: FE 하드코딩과 BE 시드 부재가 같은 도메인에서 어긋나 있을 때는 FE 하드코딩을 지우는 게 답입니다. BE 시드를 늘려서 FE 가정에 맞추는 자리는 환경별 DB 분리(
.env.testvs.env.local)가 한 번 더 어긋나는 자리이기 때문에, 시드는 BE의 단일 진실원에 두고 FE가 동적 조회로 따라가는 패턴이 합류 지점의 표준이에요.
③ 정책 설정 DTO interface→class 변환 (#24에서 자세히)
#24에서 자세히 풀었어요. class-validator·class-transformer가 런타임에 메타데이터를 읽는 패키지라 interface는 컴파일 타임에만 존재해 데코레이터를 박을 수 없는 자리가 본질적인 어긋남이었습니다.
// ❌ before
export interface UpdatePolicyDto {
excellentThreshold: number;
// ...
}
// ✅ after
export class UpdatePolicyDto {
@IsInt() @Min(50) @Max(100)
excellentThreshold: number;
// ...
}
이 자리는 환경 요소 (1) 모킹 막의 거둠이 가시화된 또 한 자리예요. mock dataProvider 시절에는 어떤 모양의 객체를 보내도 mock이 응답했으니까 검증이 무의미했어요. REST dataProvider로 바뀌는 자리에서 처음으로 검증이 의미를 갖게 됐고, 그 자리에서 interface라는 런타임에 사라지는 형이 어긋난 자국으로 박힌 거예요.
④ 운영자 상태 변경 400 — UpdateAdminStatusDto 데코레이터 (#24와 동일 라인)
같은 #24 사이클에서 잡힌 두 번째 자리예요. PATCH /admin/admins/:id/status 엔드포인트에서 400 Bad Request가 떨어지고 있었는데, 어떤 필드가 어긋났는지가 응답에 안 적혀 있었어요.
// ❌ before
export interface UpdateAdminStatusDto {
status: 'ACTIVE' | 'INACTIVE';
}
// ✅ after
export class UpdateAdminStatusDto {
@IsEnum(['ACTIVE', 'INACTIVE'], { message: 'status는 ACTIVE 또는 INACTIVE여야 합니다.' })
@IsNotEmpty()
status: 'ACTIVE' | 'INACTIVE';
}
전환 직후 응답이 진짜 사람 같은 메시지로 바뀌었습니다. 이 자리에서 2차 점검의 메시지 가독성이 정착했고, 다음 사이클의 회귀 자리에서도 400의 본문이 비어 있지 않은 상태가 기본값이 됐어요.
⑤ dbVersion 요청 — 하드코딩 → SELECT version() 동적 조회
마지막 BE 자리는 모니터링 페이지의 DB 상태 카드예요. FE에서 GET /admin/monitoring/db-status를 호출하면 PostgreSQL 버전과 연결 풀 상태를 카드로 보여주는 자리였는데, BE는 컴파일 타임에 박은 하드코딩을 돌려주고 있었어요.
// ❌ before — apps/api/src/admin/monitoring/admin-monitoring.service.ts
async getDbStatus() {
return {
version: 'PostgreSQL 14.x',
poolSize: 10,
};
}
// ✅ after
async getDbStatus() {
const [{ version }] = await this.prisma.$queryRaw<{ version: string }[]>`SELECT version()`;
const poolSize = this.config.get<number>('DB_POOL_SIZE') ?? 10;
return { version, poolSize };
}
이 자리는 합류 지점이 환경 변수와 DB 메타정보를 처음 같이 보는 자리예요. 1차 점검에서는 카드 하나만 보면 됐는데, 2차에서는 카드의 정보가 진짜 DB의 정보와 일치하는지까지 봐야 했어요. SELECT version()은 PostgreSQL의 표준 메타 함수라 모든 환경에서 같은 진실을 돌려줍니다. 하드코딩으로는 14.x → 14.10으로 마이너 버전을 따라가는 자리에서 한 번 더 어긋날 가능성이 컸어요.
🎨 FE 여섯 자리 — useList/useCustom·UX·권한·null 가드의 어긋남
FE 6건은 합류 지점에서 가장 흔한 어긋남이 어디에 박히는지 보여주는 표본이에요. 데이터 형·UX 결정·권한 분기·null 가드 네 자리에 흩어져 있어요.
⑥ 모니터링 DB 상태 — useList → useCustom
Refine의 useList는 목록 응답({ data: [], total: number })을 가정하는 훅이에요. 모니터링 DB 상태 카드는 단일 객체 응답({ version, poolSize })이라 useList가 200 OK 응답에 대해서도 형이 맞지 않는다고 표시했어요.
// ❌ before — apps/admin-portal/src/pages/monitoring/index.tsx
const { data, isLoading } = useList({ resource: 'monitoring/db-status' });
// → data?.data is Array, data?.total is number → undefined로 접근
// ✅ after
const { data, isLoading } = useCustom({
url: '/admin/monitoring/db-status',
method: 'get',
});
// → data?.data is { version, poolSize }
useList/useOne/useCustom 셋 중에서 단일 객체 API는 useCustom이 합류 지점의 표준이에요. useOne은 id가 있는 단일 리소스에 가깝고, 운영 메타정보처럼 id가 없는 단일 객체는 useCustom이 가장 자연스러워요. 이 자리는 환경 요소 (1) 모킹 막의 거둠이 FE Refine 훅 선택에서 어긋난 자국이에요. mock 시절에는 어떤 훅을 써도 형이 맞춰 줬으니까 보이지 않던 자리예요.
📌 핵심: FE의
useList는 목록 응답에만 쓰는 훅이라는 합의가 LESSONS.md에 박혀 있어요. 모니터링·시스템 정보·대시보드 통계처럼 id가 없는 단일 객체는 모두useCustom을 쓰는 게 합류 지점에서 형 어긋남을 안 만드는 가장 빠른 길이에요.
⑦ 진단평가 문제 추가 버튼 — MVP 외 결정의 코드화
이 자리는 PM 모자가 끼어든 자리예요. 배치고사(진단평가) 상세 페이지에 문제 추가 버튼이 있었는데, 시나리오 SC-A16~A17에 해당 동사가 명시되지 않은 상태로 FE 폼이 먼저 박혀 있었어요. BE에서는 해당 엔드포인트가 아직 구현되지 않은 자리라 합류 지점에서 404가 떨어졌습니다.
// ✅ after — apps/admin-portal/src/pages/diagnostics/show.tsx
<Tooltip title="현재 MVP에서는 시드 데이터로 제공됩니다. 추가 기능은 다음 단계에서 지원 예정입니다.">
<span>
<Button disabled startIcon={<Add />}>
문제 추가 (준비 중)
</Button>
</span>
</Tooltip>
PM 결정은 Option B(disabled + “준비 중” 표시)였어요. Option A — 엔드포인트를 추가해 시나리오를 늘리는 자리는 MVP 범위 외라는 결정이 한 번 더 박혔습니다. 코드를 늘리는 게 아니라 코드를 막는 결정이 합류 지점의 답이 되는 자리가 11건 중 두 자리(이 자리 + ⑨ SUPER_ADMIN)였어요.
⑧ 이메일 유효성 — alert() → 인라인 <FormHelperText error>
운영자 등록 폼에서 이메일 형식이 맞지 않으면 alert('이메일 형식이 올바르지 않습니다')를 띄우던 자리가 있었어요. 모달이 닫히면서 입력값이 사라지고, 어떤 필드가 어긋났는지를 시각적으로 표시하지 않는 UX였습니다.
// ❌ before
const handleSubmit = (data) => {
if (!isValidEmail(data.email)) {
alert('이메일 형식이 올바르지 않습니다');
return;
}
// ...
};
// ✅ after — react-hook-form + zod resolver로 일원화
const schema = z.object({
email: z.string().email('이메일 형식이 올바르지 않습니다'),
// ...
});
<TextField {...register('email')} error={!!errors.email} />
<FormHelperText error>{errors.email?.message}</FormHelperText>
이 자리는 환경 요소 (1) 모킹 막의 거둠이 UX 일관성에서 어긋난 자국이에요. mock 시절에는 서버 에러가 없었으니까 alert로도 무방했어요. REST 합류 이후 서버 검증 메시지가 같은 자리에 표시돼야 한다는 새 기준이 들어오면서, 클라이언트 검증과 서버 검증이 같은 인라인 자리에 합쳐졌어요.
⑨ SUPER_ADMIN 활성화 가드 — 모든 SUPER_ADMIN 차단 → 본인 계정만 차단
apps/admin-portal/src/pages/admins/index.tsx의 197번째 줄 한 자리예요. SUPER_ADMIN 운영자의 활성/비활성 토글이 모든 SUPER_ADMIN에 대해 차단돼 있었어요. 그 결과 SUPER_ADMIN A가 SUPER_ADMIN B를 활성화시키는 정상 시나리오도 막혔습니다.
// ❌ before
<Switch
checked={admin.status === 'ACTIVE'}
disabled={admin.role === 'SUPER_ADMIN'} // 모든 SUPER_ADMIN 차단
onChange={() => toggleStatus(admin)}
/>
// ✅ after
<Switch
checked={admin.status === 'ACTIVE'}
disabled={admin.id === identity?.id} // 본인 계정만 차단
onChange={() => toggleStatus(admin)}
/>
가드의 단위가 역할이 아니라 동일성이라는 게 이 자리의 본질이에요. 자기 자신을 비활성화하는 행위만 막으면 되는 자리에 역할 단위 차단을 박아 둔 게 어긋남이었습니다. #28 SystemPolicy + Operator Matrix에서 정착한 SUPER_ADMIN/ADMIN 역할 매트릭스가 나중에 박힌 가드인데, 1차 점검 시점에는 역할 단위 가드를 과하게 박아 둔 자국이 합류 지점에서 정상 시나리오를 막는 자리로 보였어요.
⑩ 배치고사 상태 변경 — PATCH /:id → PATCH /:id/status 엔드포인트 분리
이 자리는 FE/BE의 엔드포인트 가정이 달랐던 자리예요. FE는 상태 토글도 일반 수정과 같은 PATCH /:id 엔드포인트로 보냈지만, BE는 상태 변경을 별도 엔드포인트로 박아 둔 상태였어요.
| 엔드포인트 | 용도 |
|---|---|
PATCH /admin/diagnostics/:id | 일반 수정 (제목·설명·시간 제한 등) |
PATCH /admin/diagnostics/:id/status | 상태 변경 (DRAFT → PUBLISHED → ARCHIVED) |
// ✅ after — FE에서 별도 호출
const { mutate: updateStatus } = useCustomMutation();
const toggle = (id: number, next: 'PUBLISHED' | 'ARCHIVED') => {
updateStatus({
url: `/admin/diagnostics/${id}/status`,
method: 'patch',
values: { status: next },
});
};
엔드포인트를 분리한 BE의 의도는 상태 전이 로직과 일반 수정 로직의 권한·검증·이벤트가 다르다는 점이었어요. 상태 전이는 발행/철회 이벤트 emit이 따라붙고, 일반 수정은 그냥 필드 갱신이에요. FE가 합류 지점에서 그 분리를 처음 알게 된 자리였고, 다음 사이클부터 Refine 리소스에 별도 mutation hook을 두는 패턴으로 정착했어요.
⑪ 콘텐츠 목록 크래시 — metricWeights/playableLevelIds null·빈배열 가드
마지막 FE 자리는 콘텐츠 목록 페이지가 첫 로드에서 크래시하던 버그예요. 콘솔에 Cannot convert undefined or null to object와 Math.min/max() of empty array → -Infinity가 동시에 떨어졌습니다.
// ❌ before — apps/admin-portal/src/pages/contents/list.tsx
const renderRow = (content: Content) => (
<TableRow>
<TableCell>
{Object.entries(content.metricWeights).map(([k, v]) => (
<Chip key={k} label={`${k}: ${v}`} />
))}
</TableCell>
<TableCell>
L{Math.min(...content.playableLevelIds)}~L{Math.max(...content.playableLevelIds)}
</TableCell>
</TableRow>
);
// ✅ after — null/빈배열 가드 두 자리
const renderRow = (content: Content) => {
const weights = content.metricWeights ?? {};
const levels = content.playableLevelIds ?? [];
return (
<TableRow>
<TableCell>
{Object.entries(weights).map(([k, v]) => (
<Chip key={k} label={`${k}: ${v}`} />
))}
{Object.keys(weights).length === 0 && <Chip label="미설정" variant="outlined" />}
</TableCell>
<TableCell>
{levels.length === 0
? '레벨 미지정'
: `L${Math.min(...levels)}~L${Math.max(...levels)}`}
</TableCell>
</TableRow>
);
};
이 자리는 환경 요소 (3) DB 시드의 가시화가 FE 렌더링 가드에서 어긋난 자국이에요. 시드로 들어간 콘텐츠는 두 필드가 모두 채워져 있었지만, 합류 지점에서 등록한 신규 콘텐츠는 선택적 필드를 비워 두는 시나리오가 있었어요. 입력값이 비어 있는 상태에 대한 가드가 빠진 자리에 Object.entries·Math.min이 직접 닿아서 페이지 전체가 흰 화면으로 떨어진 거예요.
📌 핵심: 합류 지점에서 11번 어긋난 자리 중 FE 6건이 모두 한 패턴이에요. 입력 형이 한쪽에서만 통과한 자리. mock 응답·하드코딩·optional 필드를 FE 단독 통과 기준으로 잡고 2차 점검에서 그 가정이 처음 깨지는 자리가 6번 박혔어요. 다음 사이클의 점검 매트릭스에서 FE 입력 형의 빈 케이스를 한 칸에 모은 게 이 자국에서 정착한 결정이에요.
🧭 PM 모자가 두 번 끼어든 자리 — 결정을 코드로 막는 자리
11건 중 두 자리는 PM 결정이 코드의 답이었어요. ⑦ 진단평가 문제 추가 — MVP 외가 그 첫 자리고, 원장 계정 자동 생성 — Option A 유지가 그 두 번째 자리예요.
원장 계정 자동 생성은 #26 1차 점검이 끝난 직후 합류 지점에서 처음 가시화된 자리였어요. BE는 학원 생성 시 원장 계정이 자동 생성되는 로직을 이미 박아 두고 있었어요. loginId는 {학원코드}_admin(예: ABC_admin), password는 전화번호 뒤 4자리. FE는 원장 정보를 별도 입력받는 폼을 박아 두고 있어서, 합류 지점에서 둘이 충돌했어요.
// ✅ after — apps/admin-portal/src/pages/academies/create.tsx
<Section title="기본 정보">
<TextField {...register('code')} label="학원 코드 (3자리 대문자)" />
<TextField {...register('phone')} label="전화번호" />
{/* ... */}
</Section>
<Alert severity="info">
<AlertTitle>원장 계정 자동 생성</AlertTitle>
<Typography>
학원 등록 후 원장 계정이 자동으로 생성됩니다.<br />
<code>loginId</code>: {`{학원코드}_admin`} / <code>비밀번호</code>: 전화번호 뒤 4자리
</Typography>
</Alert>
PM 결정은 Option A — 자동 생성을 그대로 두고 UX로 안내였어요. Option B — FE 폼을 그대로 두고 BE 로직을 제거하는 자리는 학원 등록 시 비밀번호를 운영자가 임의로 정해 평문으로 보관하는 자리가 한 번 더 어긋났을 거예요. 자동 생성에는 전화번호 뒤 4자리라는 디폴트 규칙과 최초 로그인 시 비밀번호 변경 강제가 같이 박혀 있어서, 그 잠금이 더 안전하다는 판단이 답이었습니다.
⚠️ 주의: 합류 지점에서 FE/BE의 가정이 부딪히는 자리는 코드를 더 박는 게 답이 아닐 수 있어요. 결정의 단일 진실원이 어디에 있어야 하는지부터 정하고, FE 또는 BE 한쪽의 가정을 지우는 게 답이 되는 자리가 11건 중 둘이었습니다. 코드를 늘리는 자리는 회귀가 다시 박힐 자리라는 점에서 코드를 막는 결정이 합류 지점의 가장 빠른 답이에요.
🛡️ 회귀 막는 두 줄 — LESSONS.md 동시 갱신
11건을 잡은 그날 안에 .claude/skills/role-frontend/LESSONS.md(FE)와 .claude/skills/role-backend/LESSONS.md(BE)에 일곱 줄을 같이 박았어요. 다음 사이클에서 같은 자리가 안 깨지게 박는 자리는 코드가 아니라 교훈 인덱스예요.
<!-- .claude/skills/role-frontend/LESSONS.md (3줄 추가) -->
## #14 Vite 6 프록시는 메서드를 정규화하지 않음
- 증상: PATCH가 CORS preflight에서 차단되는데 GET/POST/PUT은 통과
- 원인: Refine `useUpdate`가 `method: 'patch'`(소문자)로 dataProvider에 전달
- 해결: `httpMethod = method.toUpperCase()` (rest-data-provider)
- 회귀 테스트: PATCH /admins/:id/status
## #15 단일 객체 응답 API는 useCustom (목록은 useList)
- 증상: useList가 단일 객체 응답에서 형 어긋남
- 패턴: id 없는 단일 객체 = useCustom / id 있는 단일 = useOne / 목록 = useList
- 적용: 모니터링·대시보드 통계·시스템 정보
## #16 클라이언트 검증은 인라인 (alert 금지)
- 도구: react-hook-form + zod
- 표시: <FormHelperText error>{errors.field?.message}</FormHelperText>
- 서버 에러도 같은 자리에 합치기 (인라인 단일 표시)
<!-- .claude/skills/role-backend/LESSONS.md (4줄 추가) -->
## #15 DTO는 반드시 class + 데코레이터
- 이유: class-validator/class-transformer가 런타임에 메타데이터를 읽음
- interface는 컴파일 타임 소거 → 검증·변환 모두 무력화
- 적용: 모든 @Body() 파라미터
## #16 CORS는 allowedHeaders 명시
- 누락 시: cors 패키지가 요청 헤더 반사 → 환경에 따라 미묘한 어긋남
- 박을 것: Authorization, Content-Type, X-Requested-With (Refine dataProvider 표준)
## #17 FK 생성 전 참조 대상 검증
- 패턴: levelId 같은 FK는 *입력 검증 단계*에서 존재 확인
- 시드 부재 환경에서도 명확한 400 메시지 (FK 위반 500 회피)
## #18 환경 메타정보는 동적 조회
- 예: dbVersion = `SELECT version()`
- 하드코딩은 마이너 버전 변경 시 회귀 자리
일곱 줄의 공통 패턴은 어긋난 자리만이 아니라 왜 어긋났는지와 어떤 자리에 적용되는지를 한 줄에 같이 박았다는 점이에요. 회귀 자리만 적은 LESSONS는 다음 사이클에서 같은 자리만 본다는 함정이 있어요. 왜 어긋났는지까지 박혀 있어야 비슷한 자리에도 같은 규칙이 일반화돼요.

도식은 세로축 11건과 *가로축 환경 요소 셋(모킹 막·보안 모델·DB 시드)*을 한 매트릭스에 모아 둔 자리예요. 어떤 어긋남이 어떤 환경 요소에서 가시화됐는지를 한 자리에 펼쳐 두면, 다음 사이클의 점검 매트릭스를 어디에 박을지가 행 단위가 아니라 열 단위로 보입니다.
📋 정리 — 11건 매트릭스
| # | 자리 | 어긋난 환경 요소 | 잡은 자리 | 비고 |
|---|---|---|---|---|
| 1 | CORS PATCH | 보안 모델 합류 | allowedHeaders 명시 + Vite 메서드 대문자 | #25 |
| 2 | 콘텐츠 FK | DB 시드 부재 | Level 시드 31개 + FE useList 동적 조회 | — |
| 3 | 정책 DTO | 모킹 막의 거둠 | interface → class + 데코레이터 | #24 |
| 4 | 운영자 상태 400 | 모킹 막의 거둠 | UpdateAdminStatusDto 데코레이터 | #24 |
| 5 | dbVersion | 환경 메타정보 합류 | SELECT version() 동적 조회 | — |
| 6 | 모니터링 useList | 모킹 막의 거둠 (FE 훅) | useCustom으로 교체 | — |
| 7 | 진단평가 문제 추가 | 결정 충돌 | disabled + "준비 중" (PM 결정) | — |
| 8 | 이메일 alert | UX 일관성 합류 | 인라인 <FormHelperText error> | — |
| 9 | SUPER_ADMIN 활성화 | 권한 분기 합류 | admin.id === identity.id | — |
| 10 | 배치고사 상태 | 엔드포인트 가정 | PATCH /:id/status 별도 호출 | — |
| 11 | 콘텐츠 목록 크래시 | optional 필드 합류 | null·빈배열 가드 + 폴백 라벨 | — |
| 합류 지점이 가르친 메타-규칙 | 다음 사이클의 적용 자리 |
|---|---|
| ❌ 1차 통과 = 합류 통과 | ✅ 2차 점검을 별도 사이클로 분리 |
| ❌ FE 단독 형 = 합류 형 | ✅ 단일 객체는 useCustom 패턴 |
| ❌ 시드 부재 우회 | ✅ 합류 직전 시드 게이트 점검 |
| ❌ 결정을 코드로만 박기 | ✅ MVP 외 결정은 코드를 막는 자리에 잠금 |
| ❌ 회귀 자리만 LESSONS | ✅ 왜 + 어디에 일반화까지 같이 박기 |
11건의 합류 지점 매트릭스가 가르친 한 줄. 2차 점검은 단위의 합집합이 아니라 새 환경의 정의예요. 모킹 막·보안 모델·DB 시드 세 요소가 처음 같이 도는 자리가 합류 지점이고, 그 자리에서 우회의 그림자가 11번 가시화됐습니다. 다음 사이클의 점검 매트릭스를 행(시나리오) 단위로만 두면 같은 자리가 또 깨지고, 열(환경 요소) 단위까지 같이 두면 어디에 가드가 빠졌는지가 한 자리에 보여요. 11건은 코드의 결함이라기보다는 2차 점검이라는 환경의 첫 사진에 가깝고, 그 사진을 다음 사이클의 좌표로 옮긴 게 이 합류 지점이 남긴 가장 큰 자산이었어요.
📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (46편)
- 1. 왜 NestJS + Prisma를 선택했나 — B2B SaaS 백엔드 기술 선택기
- 2. 도메인 모델링 첫날 — B2B SaaS의 핵심 엔티티 정의하기
- 3. 27개 테이블의 탄생 — Prisma 스키마 설계기
- 4. 권한 매트릭스 — Admin/운영자/사용자 3역할 설계
- 5. BigInt PK에서 Int PK로 — 첫 번째 스키마 리팩토링
- 6. Seed 데이터의 함정 — FK 삭제 순서 삽질기
- 7. DDD를 도입하기로 했다 — Repository/Domain/Application 3계층
- 8. 인터페이스 구현체로 바꾸는 날 — NestJS DI와 TypeScript의 간극
- 9. 단위 테스트 인프라 구축 — Jest 설정부터 Mock까지
- 10. E2E 테스트와 Cloud SQL의 고난 — 4/8 passing에서 8/8까지
- 11. REST API 첫 구현 — 6개 Controller, 21개 엔드포인트 완성
- 12. v1.0 완성, 그리고 갈아엎기로 결심한 날
- 13. 번들 구조를 통째로 바꿔야 했던 이유
- 14. Phase 1 문서 정비 — Use Case를 번들 기반으로 다시 쓰다
- 15. Phase 2 스키마 마이그레이션 — 데이터 안 날리고 구조 바꾸기
- 16. Phase 3-1·3-2 — Repository와 Domain 서비스로 36개 빌드 에러 잡기
- 17. Phase 3-3·3-4·3-5 — Application부터 Module까지, v2.0 마이그레이션 닫는 날
- 18. 코드를 박은 다음 날 — 4,658줄 DDD 문서를 24분 사이에 다시 쓴 하루
- 19. v2.1 Domain Layer — 도메인 서비스 1,682줄을 한 커밋에 박은 날의 설계 철학
- 20. v3.0 Application Layer 재작성 — 도메인 서비스 위에 얇은 막을 한 Phase에 박은 날
- 21. 갈아엎고 80일 — v2.0 마이그레이션 8편 메타 회고
- 22. 1인 다역으로 5일 만에 90% — Admin Portal MVP를 끌어올린 토글 한 줄
- 23. Mock에선 되던 게 REST에선 안 됐다 — 응답 포맷 한 칸 차이가 만든 하루
- 24. CORS는 됐다 — PATCH만 빼고. allowedHeaders 한 줄과 Vite 프록시의 소문자 메서드
- 25. 멀티테넌트 누수 — tenantId 3계층 강제
- 26. Prisma 정책 싱글톤 — zod superRefine 임계값 가드
- 27. 멀티테넌트 쓰기 가드 — body.tenantId 차단과 집계 일관성
- 28. 두 번째 점검은 합류 지점이었다 — Admin Portal 2차에서 한 사이클에 잡힌 FE-BE 연동 버그 11건
- 29. Prisma 그래프 스키마 — 선형 레벨을 DAG로 옮긴 4가지 결정
- 30. 교육과정 구조 리팩토링 — 3필드 분리와 폴백 결정기
- 31. 배치고사 MVP — 자동 레벨 배치를 걷어내고 5지표 측정만 남기다
- 32. JWT Guard 적용 — request.user undefined부터 jwt malformed까지
- 33. 디버깅용 운영 API 7개 — Unity 만료 테스트 30분 대기를 0초로
- 34. NestJS Swagger 일괄 적용 — 35개 컨트롤러 + DTO 22개
- 35. Unity ↔ 웹 PostMessage 브릿지 설계기
- 36. Vuplex 브릿지 초기화 타이밍 — 첫 메시지가 증발한 이유
- 37. 콘텐츠 브릿지 10종 통합 완료 — 같은 규격으로 묶기
- 38. 지표 누계 시스템 — TOP5 순위를 INSERT 전용 스냅샷으로 굳히기
- 39. 킥오프 배치 첫 구현 — 매시 전체 EXPIRED 사고와 Winston 도입
- 40. 혼자 여러 역할로 QA 1차 — 브랜치 미동기화와 잔존 토큰의 함정
- 41. 타이머가 NaN:NaN으로 떴다 — Bundle API 응답 누락 필드와 비어 있는 콘텐츠 후보
- 42. 1인 개발 QA 5라운드 — 타이머·시드·스키마로 옮긴 버그들
- 43. Unity Lobby + 배치고사 씬 통합 — 두 클라이언트가 같은 회원을 보는 첫 빌드
- 44. 배치고사 MVP 후속 — 명세를 코드로 옮기고 레거시 571줄을 일괄 삭제하다
- 45. Problem 종속 끊기 — 1,891개 마이그레이션과 단위 테스트 38건
- 46. NestJS 권한 가드 — 목록은 막고 상세는 뚫린 날