Prisma 정책 싱글톤 — zod superRefine 임계값 가드
📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (46편)
SystemPolicy 8필드를 id @default(1) 싱글톤으로 묶고, 상호 의존 임계값은 zod superRefine으로 BE/FE 두 곳에서 검증했다. 동사 액션은 별도 라우트로 분리하고, 정책 변경은 방향성만 검증하는 e2e로 회귀를 차단한 1.5일 트러블슈팅.
💡 Tip. 바쁜 현대인들을 위한 본문 요약
- SystemPolicy 8필드는 전 시스템에 한 행만 존재 —
@id @default(1)+ 시드upsert+ PATCH 전용 컨트롤러로 행 증감을 라우트 단계에서 차단한다- 상호 의존 임계값(우수 > 보강)은
zod.superRefine으로 두 필드 관계를 검증하고, 같은 스키마를 BE/FE 두 곳에 적용한다- 동사 액션은 명사 CRUD와 분리 —
PATCH /admins/:id/status,POST /admins/:id/reset-password로 권한·감사 로그를 한 라우트에 고정한다- 권한 분기는
can(role, action)단일 함수 + Refine<CanAccess>— 사이드바·테이블·액션 세 곳에서 동일 키를 공유한다- 자기 자신 비활성화는 FE confirm + BE 가드 양쪽에서 차단 — 단독 SUPER_ADMIN이 본인을 끄는 영구 락아웃을 막는다
- 정책 변경 회귀 e2e는 방향성만 검증(
expect(after).toBeGreaterThanOrEqual(before)) — 정확한 숫자는 시드 의존이라 강제하지 않는다
🌱 배경 — 정책 임계값 8개와 운영자 권한 2 역할을 한 단계에 묶었다
관리자 페이지의 시스템 설정 카테고리를 도입할 차례였다. 모델은 두 개. SystemPolicy 8필드와 AdminUser 2 역할.
SystemPolicy: 우수/보강 임계값(excellentThreshold,poorThreshold), 등급 승급/강등 연속일, 비활성→활성 보호일(restoreConsecutiveDays), 활동 묶음 제한 시간, 일일 킥오프 시각 등 8필드. 대시보드 카드 카운트, 알림 큐, 일일 배치 작업 시각까지 전 시스템이 이 값을 읽는다.AdminUser:'SUPER_ADMIN' | 'ADMIN'두 역할. 정책 편집, 운영자 CRUD, 비활성화 토글, 비밀번호 초기화는 SUPER_ADMIN 전용. 콘텐츠/배치고사/모니터링은 두 역할 공통.
처음엔 일반 CRUD 패턴으로 빠르게 만들었다. POST/GET/PATCH/DELETE /admin/policies 4종 라우트, POST/GET/PATCH/DELETE /admin/admins 4종 라우트, zod 스키마는 FE 폼에만 적당히, 권한은 컨트롤러 @Roles 데코레이터로. 코드만 놓고 보면 30분짜리.
통합 테스트를 돌리고 나서야 세 가지 문제가 동시에 드러났다.
🔥 증상 — 폼 통과 / 자기 자신 비활성화 / 정책 변경 후 화면 미반영
증상 1: 우수 임계가 보강 임계보다 작아도 폼이 통과한다
운영자 시점에서 정책 편집 폼에 잘못된 값을 입력해 봤다.
# 우수 임계 70, 보강 임계 80 — 비즈니스 룰상 불가능한 조합
$ curl -s -X PATCH http://localhost:3000/api/v1/admin/settings/policies \
-H "Authorization: Bearer $SUPER_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"excellentThreshold": 70, "poorThreshold": 80}'
{ "statusCode": 400, "message": "excellentThreshold must be greater than poorThreshold" }
BE는 400으로 거절했다. 그런데 FE 폼은 제출 시점에 통과했다. 사용자는 저장 버튼을 누르고 나서 토스트 빨강을 보고서야 잘못된 입력을 알게 됐다. zod 스키마에 excellentThreshold: z.number().min(50).max(100) / poorThreshold: z.number().min(0).max(99) 만 있어서, 두 필드의 관계는 검증되지 않았다.
⚠️ 주의:
min/max단일 필드 validation은 두 필드 관계 룰을 표현하지 못한다. BE에만 두면 사용자는 400 응답을 받고서야 잘못된 입력을 알게 되고, FE만 두면 API 직접 호출에서 룰이 깨진다. 두 곳 모두 강제하는 게 표준 패턴이다.
증상 2: 본인을 비활성화하니 토큰 갱신이 안 된다
단독 SUPER_ADMIN 계정으로 로그인한 상태에서 운영자 목록에서 본인 행의 비활성화 토글을 눌렀다.
$ curl -s -X PATCH http://localhost:3000/api/v1/admin/admins/1 \
-H "Authorization: Bearer $TOKEN" \
-d '{"status": "INACTIVE"}'
# 204 No Content
# 새 로그인 시도
$ curl -s -X POST http://localhost:3000/api/v1/admin/auth/login \
-d '{"email":"[email protected]","password":"..."}'
{ "statusCode": 403, "message": "Account inactive" }
- 본인 계정이 비활성 상태라 새 토큰 발급도 거절됐고, 액세스 토큰이 만료되는 순간 영구 락아웃이었다. DB를 직접 만져서
status='ACTIVE'로 복구했다.
증상 3: 정책 PATCH 후 대시보드 숫자가 그대로다
excellentThreshold를 90 → 80으로 완화한 직후 대시보드의 우수 활동 카운트를 확인했다.
PATCH /admin/settings/policies { excellentThreshold: 80 } → 200
GET /admin/dashboard/stats → { excellent: 142 }
# 변경 전: excellent: 142
# 변경 후: excellent: 142 ← 동일
캐시 무효화 누락인지, 쿼리가 정책을 안 읽는지, 그것도 아니면 회귀 시나리오가 처음부터 누락된 건지 한눈에 보이지 않았다. 정책 변경이 화면에 반영되는지를 검증하는 자동 테스트가 0건이었다.
📌 핵심: 단일 행 정책 모델은 변경 후 즉시 반영이 본체 기능이다. 시드 데이터 의존도가 크니까 정확한 숫자는 강제하기 어렵지만, 방향성(완화 → 카운트 증가)은 한 줄 어서션으로 검증 가능하다. 이 검증을 빼면 정책 변경 회귀가 통째로 침묵한다.
🔍 탐색 — 가설 3건 검증
가설 1: zod 룰을 더 정교하게 쓰면 되나
zod의 refine() 메서드로 단일 필드 검증 안에서 다른 필드를 참조할 수 없는지 찾아봤다. refine은 한 필드 컨텍스트로만 동작한다. 두 필드의 관계를 검증하려면 오브젝트 레벨 검증이 필요하다.
zod 공식 문서를 다시 정독했다. superRefine이 정확히 오브젝트 전체를 받아 임의의 이슈를 추가할 수 있는 API였다. path 인자로 어느 필드에 에러를 붙일지도 명시할 수 있다.
가설 2: PATCH 한 라우트에 status까지 합치고 가드만 정교화하면 되나
PATCH /admins/:id에 { status, name, email, ... } 모두 받고, 가드에서 동사 액션 종류별 권한을 분기하면 라우트 수가 줄어든다. 코드만 보면 간결.
문제는 권한·감사 로그·점검 게이트 위치가 한 라우트에 쏠린다는 점이다. 비밀번호 초기화는 별도 액션인데 같은 PATCH 라우트에 합치면 어떤 액션이 호출됐는지가 body 안에 숨어 버린다. 감사 로그에서 PATCH /admins/42가 이름 수정인지 비활성화인지 비밀번호 초기화인지 식별하려면 body 파싱이 필요하다. NestJS 공식 문서의 RESTful 가이드도 동사 액션은 별도 segment를 권장한다.
🔍 단서: RESTful 명사 CRUD와 동사 액션을 합치면 권한 데코레이터 한 줄로 막을 수 있던 검사가 body 파싱 후 분기로 내려간다. 감사 로그에서 액션 종류를 식별하려면 body 역직렬화가 필수가 되고, 점검 e2e도 한 라우트에 시나리오를 모두 우겨넣어야 한다. URL segment 분리가 더 싸다.
가설 3: 정책 라우트도 일반 CRUD 4종으로 두고 시드에서 1행 보장하면 되나
POST /policies와 DELETE /policies/:id 라우트를 만들고, 시드에서 1행을 만들어 두면 운영 중에는 행이 늘어나지 않는다는 전제가 가능해 보였다. 그런데 라우트가 살아 있는 한 권한 데코레이터 누락 한 번이면 행이 늘어난다. 코드 리뷰에서 권한 데코레이터를 확인하는 것보다, 애초에 해당 라우트를 만들지 않는 게 훨씬 싸다.
세 가설 모두 같은 방향을 가리켰다. 모델 성격(단일 행)이 라우트 구조(PATCH 전용)와 검증 위치(BE/FE 두 곳)와 액션 분리(명사/동사) 결정을 동시에 끌고 가야 한다는 결론.
🔬 진짜 범인 — 단일 행 모델을 일반 CRUD 템플릿에 끼웠다
세 증상의 공통 원인이 풀렸다.
[모델] SystemPolicy (전 시스템 1행) AdminUser (역할 분기)
↓ ↓ ↓
[라우트] POST/GET/PATCH/DELETE POST/GET/PATCH/DELETE
(4종 — 행 증감 가능) (4종 — 동사 액션 혼재)
↓ ↓ ↓
[검증] zod (FE만, 단일 필드) @Roles(BE만, 컨트롤러 단위)
↓ ↓ ↓
[결과] 잘못된 폼 통과 / 자기 자신 비활성화 / 정책 변경 회귀 누락
CRUD 템플릿 그대로 찍어내면서 모델별 차이를 라우트·검증·권한 단계에 반영하지 않은 게 본질이다.
- 단일 행 정책은 POST/DELETE 자체가 없어야 한다. 라우트가 살아 있는 한 권한 가드는 기억해야 할 검사다. 라우트가 없으면 기억할 검사도 없다.
- 상호 의존 룰은 BE와 FE 두 곳에 같은 스키마가 필요하다. BE만 두면 사용자 경험이 망가지고, FE만 두면 직접 호출에서 룰이 깨진다.
- 동사 액션(
status토글,reset-password)은 명사 CRUD와 다른 라우트에 있어야 한다. 권한 데코레이터 한 줄로 막히고, 감사 로그에서 액션 종류가 URL에 그대로 보인다.
🛠️ 해결 — 싱글톤 모델 + 공유 zod + 동사 분리 + 권한 단일 함수

단계 1: Prisma 싱글톤 — @id @default(1) + 시드 upsert + PATCH 전용
// prisma/schema.prisma — ✅ 싱글톤 표현
model SystemPolicy {
id Int @id @default(1) // ← 1행만
excellentThreshold Int @default(90)
poorThreshold Int @default(70)
levelUpConsecutiveDays Int @default(3)
levelDownConsecutiveDays Int @default(3)
restoreConsecutiveDays Int @default(3)
defaultBundleTimeLimitMs Int @default(1800000) // 30분
kickoffTime String @default("06:00")
updatedAt DateTime @updatedAt
updatedBy Int?
@@map("system_policies")
}
// prisma/seed.ts — 1행 보장
async function seedSystemPolicy() {
await prisma.systemPolicy.upsert({
where: { id: 1 },
create: { id: 1 }, // 8개 필드 모두 schema @default 사용
update: {},
});
}
// apps/api/src/admin/settings/policies/policies.controller.ts — ✅ PATCH 전용
@Controller('admin/settings/policies')
@UseGuards(JwtAuthGuard, RolesGuard)
@ApiTags('Admin · Settings')
export class PoliciesController {
constructor(private readonly service: PoliciesService) {}
@Get()
@Roles('SUPER_ADMIN', 'ADMIN') // 조회는 두 역할 공통
async findOne(): Promise<SystemPolicyDto> {
return this.service.findSingleton(); // findFirst({ where: { id: 1 } })
}
@Patch() // ← URL에 :id 없음
@Roles('SUPER_ADMIN') // 편집은 SUPER_ADMIN 전용
@ApiOkResponse({ type: SystemPolicyDto })
async update(
@CurrentUser() user: AdminUser,
@Body() dto: UpdateSystemPolicyDto,
): Promise<SystemPolicyDto> {
return this.service.updateSingleton(dto, user.id);
}
// POST / DELETE / :id 라우트는 만들지 않는다
}
세 가지가 바뀌었다.
| 항목 | 변경 | 이유 |
|---|---|---|
id @default(1) | 자동증가 PK 폐기 | 행 증감을 스키마 수준에서 제약 |
시드 upsert | 마이그레이션 직후 1행 보장 | ”없으면 만들어주기” 패턴의 락 경합 회피 |
@Patch() 한 위치 | POST/DELETE 미존재 | 라우트가 없으면 권한 누락 위험도 없음 |
@id @default(1) 한 줄이 스키마가 의도를 표현하는 핵심이다. Prisma 공식 @default 문서 참고.
단계 2: zod 스키마 공유 + superRefine
packages/shared-types/src/system-policy.ts에 스키마를 두고 BE/FE 양쪽이 import 한다.
// packages/shared-types/src/system-policy.ts — ✅ 한 곳에서 정의
import { z } from 'zod';
export const SystemPolicySchema = z.object({
excellentThreshold: z.coerce.number().int().min(50).max(100),
poorThreshold: z.coerce.number().int().min(0).max(99),
levelUpConsecutiveDays: z.coerce.number().int().min(1).max(30),
levelDownConsecutiveDays: z.coerce.number().int().min(1).max(30),
restoreConsecutiveDays: z.coerce.number().int().min(1).max(30),
defaultBundleTimeLimitMs: z.coerce.number().int().min(60_000).max(7_200_000),
kickoffTime: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/),
}).superRefine((data, ctx) => {
if (data.excellentThreshold <= data.poorThreshold) {
ctx.addIssue({
code: 'custom',
path: ['excellentThreshold'],
message: '우수 임계값은 보강 임계값보다 커야 한다',
});
}
});
export type SystemPolicyInput = z.infer<typeof SystemPolicySchema>;
// apps/api/src/admin/settings/policies/policies.controller.ts — BE 적용
import { SystemPolicySchema } from '@app/shared-types';
import { ZodValidationPipe } from '@/common/pipes/zod-validation.pipe';
@Patch()
@Roles('SUPER_ADMIN')
async update(
@CurrentUser() user: AdminUser,
@Body(new ZodValidationPipe(SystemPolicySchema)) dto: SystemPolicyInput,
) {
return this.service.updateSingleton(dto, user.id);
}
// apps/admin-portal/src/pages/settings/policies/edit.tsx — FE 적용
import { SystemPolicySchema, type SystemPolicyInput } from '@app/shared-types';
import { useForm } from '@refinedev/react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
export const PoliciesEdit = () => {
const {
register,
handleSubmit,
formState: { errors },
refineCore: { onFinish },
} = useForm<SystemPolicyInput>({
resolver: zodResolver(SystemPolicySchema),
refineCoreProps: { resource: 'settings/policies', action: 'edit', id: '' },
});
return (
<form onSubmit={handleSubmit(onFinish)}>
<NumberField label="우수 임계값(%)" {...register('excellentThreshold')}
error={errors.excellentThreshold} />
<NumberField label="보강 임계값(%)" {...register('poorThreshold')}
error={errors.poorThreshold} />
{/* ... 6개 필드 생략 */}
<SubmitButton>저장</SubmitButton>
</form>
);
};
같은 SystemPolicySchema를 BE의 ZodValidationPipe와 FE의 zodResolver가 import 한다. 룰 변경이 일어나도 한 파일만 수정하면 두 위치가 동시에 반영된다.
💡 인사이트: 비즈니스 룰을 BE/FE 두 곳에 넣는 건 DRY 위반이 아니라 룰의 명시적 복제다. BE만 막으면 사용자는 400 응답을 받고서야 알게 되고, FE만 막으면 API 직접 호출에서 룰이 깨진다. 두 곳에서 같은 스키마를 import 하면 DRY 위반도 아니면서 양쪽이 안전하다.
단계 3: 명사 CRUD와 동사 액션을 별도 라우트로 분리
// apps/api/src/admin/admins/admins.controller.ts — ✅ 동사 segment 분리
@Controller('admin/admins')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('SUPER_ADMIN') // 컨트롤러 전체 SUPER_ADMIN 전용
export class AdminsController {
constructor(private readonly service: AdminsService) {}
// === 명사 CRUD 4종 ===
@Get() async list(@Query() q: ListAdminsQueryDto) { /* ... */ }
@Get(':id') async detail(@Param('id', ParseIntPipe) id: number) { /* ... */ }
@Post() async create(@Body() dto: CreateAdminDto) { /* ... */ }
@Patch(':id') async update(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateAdminDto,
) { /* 이름/이메일만 — status·password는 안 받음 */ }
// === 동사 액션 별도 라우트 ===
@Patch(':id/status')
@HttpCode(204)
async toggleStatus(
@CurrentUser() user: AdminUser,
@Param('id', ParseIntPipe) id: number,
@Body() dto: ToggleAdminStatusDto,
) {
if (id === user.id) {
throw new BadRequestException('자기 자신은 비활성화할 수 없다');
}
return this.service.toggleStatus(id, dto.status);
}
@Post(':id/reset-password')
@HttpCode(204)
async resetPassword(
@Param('id', ParseIntPipe) id: number,
) {
return this.service.resetPassword(id); // 임시 비밀번호 메일 발송
}
}
UpdateAdminDto에서 status와 password 필드를 완전히 제외한 게 핵심. PATCH /admins/:id로는 이름·이메일만 수정되고, status 변경은 PATCH /admins/:id/status로만 가능하다. class-validator의 whitelist: true로 DTO에 없는 필드는 자동 제거되도록 강제한다.
// apps/api/src/admin/admins/dto/update-admin.dto.ts
export class UpdateAdminDto {
@IsOptional() @IsString() @MaxLength(50)
name?: string;
@IsOptional() @IsEmail()
email?: string;
// status, password는 정의 자체 안 함
}
📌 핵심: 명사 CRUD DTO에서 동사 액션이 다루는 필드를 빼면, 클라이언트가
PATCH /admins/:id에{ status: 'INACTIVE' }를 끼워 넣어도whitelist가 제거한다. 권한 우회로가 컨트롤러 단계에서 닫힌다.
단계 4: can(role, action) 단일 함수 + Refine <CanAccess>
// apps/admin-portal/src/lib/can.ts — ✅ 한 함수에 권한 결정 고정
type AdminRole = 'SUPER_ADMIN' | 'ADMIN';
const SUPER_ADMIN_ONLY: ReadonlyArray<string> = [
'settings.policies.update',
'settings.admins.create',
'settings.admins.update',
'settings.admins.deactivate',
'settings.admins.reset-password',
];
export function can(role: AdminRole, action: string): boolean {
if (role === 'SUPER_ADMIN') return true;
return !SUPER_ADMIN_ONLY.includes(action);
}
// apps/admin-portal/src/pages/settings/admins/list.tsx — 액션 한 곳
import { useTable, useUpdate, CanAccess, useGetIdentity } from '@refinedev/core';
const AdminsList = () => {
const { tableQuery: { data } } = useTable<AdminUser>({ resource: 'settings/admins' });
const { mutate } = useUpdate();
const { user } = useGetIdentity<AdminUser>();
const toggleStatus = (target: AdminUser) => {
if (target.id === user?.id) {
toast.error('자기 자신은 비활성화할 수 없다');
return;
}
const next: AdminStatus = target.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE';
if (!confirm(`${target.name}을(를) ${next === 'INACTIVE' ? '비활성화' : '재활성화'}할까?`)) {
return;
}
mutate({
resource: `settings/admins/${target.id}`,
values: { status: next },
meta: { method: 'patch', endpoint: 'status' }, // → PATCH /admins/:id/status
});
};
return (
<Table>
{data?.data.map((a) => (
<Row key={a.id}>
<Cell>{a.email}</Cell>
<Cell>{a.role}</Cell>
<Cell>
<CanAccess action="settings.admins.deactivate" fallback={<Tag>{a.status}</Tag>}>
<Switch checked={a.status === 'ACTIVE'} onChange={() => toggleStatus(a)} />
</CanAccess>
</Cell>
</Row>
))}
</Table>
);
};
세 가드가 한 컴포넌트에 모인다. (1) 자기 자신 차단, (2) confirm 한 번, (3) <CanAccess> 권한 분기. ADMIN으로 로그인하면 <CanAccess>가 읽기 전용 태그만 노출하고, SUPER_ADMIN일 때만 토글이 활성화된다. 사이드바 메뉴·운영자 테이블·정책 페이지 저장 버튼 모두 동일한 can() 함수의 결과를 <CanAccess>로 받는다.
NestJS 공식 Authorization 가이드는 역할·권한 결정을 한 함수에 모으고, 가드는 그 함수의 결과만 읽으라고 권한다. Refine <CanAccess> 컴포넌트도 같은 패턴을 컴포넌트 레벨로 옮긴 형태다.
✅ 검증 — policy-change regression + 비활성화 차단 + 자기 자신 가드
세 단계를 적용하고 BE·FE를 재시작했다. 증상 셋을 다시 재현했다.
검증 1: 잘못된 임계값 조합은 폼 제출 단계에서 차단
# FE 폼에 70/80 입력 → 저장 클릭
# 토스트: "우수 임계값은 보강 임계값보다 커야 한다"
# Network 탭: API 호출 없음 (FE 단계에서 차단)
# 직접 API 호출도 막힘
$ curl -s -X PATCH http://localhost:3000/api/v1/admin/settings/policies \
-H "Authorization: Bearer $SUPER_ADMIN_TOKEN" \
-d '{"excellentThreshold": 70, "poorThreshold": 80}'
{ "statusCode": 400,
"errors": [{ "path": ["excellentThreshold"],
"message": "우수 임계값은 보강 임계값보다 커야 한다" }] }
같은 메시지가 BE/FE 양쪽에서 나온다. SystemPolicySchema를 import 한 결과.
검증 2: 자기 자신 비활성화는 클라이언트·서버 양쪽에서 차단
# 본인 ID 토글 → FE 토스트 차단
# 직접 API 호출도 막힘
$ curl -s -X PATCH http://localhost:3000/api/v1/admin/admins/1/status \
-H "Authorization: Bearer $SUPER_ADMIN_TOKEN" \
-d '{"status": "INACTIVE"}'
{ "statusCode": 400, "message": "자기 자신은 비활성화할 수 없다" }
검증 3: policy-change regression e2e
// apps/admin-portal/e2e/policy-regression.spec.ts
test('우수 임계값을 완화하면 대시보드 카드 카운트가 늘어난다', async ({ page, request }) => {
await page.goto('/dashboard');
const before = Number(await page.getByTestId('excellent-count').innerText());
const token = await loginAsSuperAdmin(request);
await request.patch('/api/v1/admin/settings/policies', {
headers: { authorization: `Bearer ${token}` },
data: { excellentThreshold: 80 }, // 90 → 80 완화
});
await page.reload();
const after = Number(await page.getByTestId('excellent-count').innerText());
expect(after).toBeGreaterThanOrEqual(before);
// 원복
await request.patch('/api/v1/admin/settings/policies', {
headers: { authorization: `Bearer ${token}` },
data: { excellentThreshold: 90 },
});
});
test('비활성화된 운영자는 로그인이 차단된다', async ({ request }) => {
const superToken = await loginAsSuperAdmin(request);
const target = await createAdmin(request, superToken, { email: '[email protected]' });
await request.patch(`/api/v1/admin/admins/${target.id}/status`, {
headers: { authorization: `Bearer ${superToken}` },
data: { status: 'INACTIVE' },
});
const login = await request.post('/api/v1/admin/auth/login', {
data: { email: '[email protected]', password: target.tempPassword },
});
expect(login.status()).toBe(403);
});
$ pnpm --filter admin-portal test:e2e policy-regression admin-deactivation
PASS e2e/policy-regression.spec.ts (3.218 s)
✓ 우수 임계값을 완화하면 대시보드 카드 카운트가 늘어난다 (1.842 s)
PASS e2e/admin-deactivation.spec.ts (1.476 s)
✓ 비활성화된 운영자는 로그인이 차단된다 (1.476 s)
expect(after).toBeGreaterThanOrEqual(before) 한 줄이 방향성만 검증한다. 정확한 숫자는 시드 데이터에 따라 달라지니까 강제하지 않는다. 임계 완화 → 카운트 증가라는 변경의 방향이 회귀했는지만 본다.
🛡️ 예방 — PR 체크리스트 + CI 게이트
같은 세 가지 증상을 다시 일으키지 않으려고, 코드 작성 시점과 머지 시점에 각각 검사를 넣었다.
PR 체크리스트 (정책·운영자 모듈)
- 단일 행 모델이라면
id @default(1)+ 시드upsert+ PATCH 전용 컨트롤러인가? - zod 스키마가
packages/shared-types에 있고 BE/FE 양쪽이 import 하는가? - 상호 의존 룰이 있다면
superRefine으로 검증하는가? - 명사 CRUD DTO에 동사 액션이 다루는 필드가 빠져 있는가? (
whitelist: true강제) - 동사 액션이
:id/<verb>형태의 별도 segment로 분리됐는가? - 권한이
can(role, action)단일 함수에 등록됐고, 컴포넌트가<CanAccess>로 받는가? - 자기 자신 차단이 FE confirm + BE 가드 양쪽에 있는가?
CI 게이트 — 정책 회귀 자동 실행
# .github/workflows/policy-regression.yml
name: policy-regression
on:
pull_request:
paths:
- 'apps/api/src/admin/settings/**'
- 'apps/api/src/admin/admins/**'
- 'apps/admin-portal/src/pages/settings/**'
- 'packages/shared-types/src/system-policy.ts'
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
- uses: actions/setup-node@v4
with: { node-version: 20, cache: pnpm }
- run: pnpm install --frozen-lockfile
- name: policy-change regression e2e
run: pnpm --filter admin-portal test:e2e policy-regression admin-deactivation
정책 모듈을 건드릴 때마다 두 e2e가 자동으로 돈다. 정책 PATCH 후 화면 반영 누락도, 자기 자신 비활성화도 PR 단계에서 빨갛게 잡힌다.
📋 정리 — 핵심 요약
| 위치 | ❌ 안티패턴 | ✅ 권장 패턴 |
|---|---|---|
| 단일 행 모델 스키마 | @id @default(autoincrement()) | @id @default(1) + 시드 upsert |
| 단일 행 모델 라우트 | POST/GET/PATCH/DELETE 4종 | GET + PATCH 두 종 (POST/DELETE 미존재) |
| 상호 의존 임계값 검증 | 단일 필드 min/max만 | zod.superRefine으로 오브젝트 검증 |
| zod 스키마 배치 | BE/FE 각자 작성 | packages/shared-types에 한 곳 정의 → 양쪽 import |
| 명사 CRUD + 동사 액션 | PATCH /:id에 { status, password, ... } 합침 | :id/status, :id/reset-password로 segment 분리 |
| DTO 필드 정의 | UpdateDto에 status? 옵셔널 포함 | DTO에서 완전 제외 + whitelist: true |
| 권한 결정 위치 | 컴포넌트마다 if (role === 'SUPER_ADMIN') | can(role, action) 단일 함수 + <CanAccess> |
| 자기 자신 차단 | 클라이언트만 막거나 무방어 | FE confirm + BE 가드 양쪽 |
| 정책 변경 회귀 | 수동 화면 확인 | 방향성 e2e (>=/<=) + CI 게이트 |
숫자로 보는 삽질
- 최초 세 증상 발견까지: 약 30분 (통합 테스트 직후)
- 가설 3건 검증까지: 약 2시간
- 싱글톤 + 공유 zod + 동사 분리 + can() 구현: 약 6시간
- e2e 2종 작성 + CI 게이트: 약 2시간
- 이후 정책 변경 회귀: 0건 (3개월간)
단일 행 모델은 일반 CRUD 템플릿에 그대로 끼우면 안 된다. 모델 성격이 라우트 구조와 검증 위치와 액션 분리를 동시에 끌고 가야 한다. Prisma @id @default(1), zod superRefine, NestJS 동사 segment, Refine <CanAccess> — 네 도구가 각자의 역할로 맞물려야 정책 변경 회귀가 PR 단계에서 닫힌다.
다음 편에서는 ZodValidationPipe를 BE 전역에 도입한 이야기를 한다. class-validator와 zod 사이에서 한쪽으로 정리한 결정과 그 트레이드오프.
📚 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 권한 가드 — 목록은 막고 상세는 뚫린 날