Prisma 정책 싱글톤 — zod superRefine 임계값 가드

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" }
  1. 본인 계정이 비활성 상태라 새 토큰 발급도 거절됐고, 액세스 토큰이 만료되는 순간 영구 락아웃이었다. 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 룰을 더 정교하게 쓰면 되나

zodrefine() 메서드로 단일 필드 검증 안에서 다른 필드를 참조할 수 없는지 찾아봤다. 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 /policiesDELETE /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 + 동사 분리 + 권한 단일 함수

Prisma 싱글톤 정책 테이블 8필드와 운영자 역할 분기가 사이드바 14페이지에 미치는 영향 구조도

단계 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에서 statuspassword 필드를 완전히 제외한 게 핵심. PATCH /admins/:id로는 이름·이메일만 수정되고, status 변경은 PATCH /admins/:id/status로만 가능하다. class-validatorwhitelist: 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>로 받는다.

docs.nestjs.com

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/maxzod.superRefine으로 오브젝트 검증
zod 스키마 배치BE/FE 각자 작성packages/shared-types에 한 곳 정의 → 양쪽 import
명사 CRUD + 동사 액션PATCH /:id{ status, password, ... } 합침:id/status, :id/reset-password로 segment 분리
DTO 필드 정의UpdateDtostatus? 옵셔널 포함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. 1. 왜 NestJS + Prisma를 선택했나 — B2B SaaS 백엔드 기술 선택기
  2. 2. 도메인 모델링 첫날 — B2B SaaS의 핵심 엔티티 정의하기
  3. 3. 27개 테이블의 탄생 — Prisma 스키마 설계기
  4. 4. 권한 매트릭스 — Admin/운영자/사용자 3역할 설계
  5. 5. BigInt PK에서 Int PK로 — 첫 번째 스키마 리팩토링
  6. 6. Seed 데이터의 함정 — FK 삭제 순서 삽질기
  7. 7. DDD를 도입하기로 했다 — Repository/Domain/Application 3계층
  8. 8. 인터페이스 구현체로 바꾸는 날 — NestJS DI와 TypeScript의 간극
  9. 9. 단위 테스트 인프라 구축 — Jest 설정부터 Mock까지
  10. 10. E2E 테스트와 Cloud SQL의 고난 — 4/8 passing에서 8/8까지
  11. 11. REST API 첫 구현 — 6개 Controller, 21개 엔드포인트 완성
  12. 12. v1.0 완성, 그리고 갈아엎기로 결심한 날
  13. 13. 번들 구조를 통째로 바꿔야 했던 이유
  14. 14. Phase 1 문서 정비 — Use Case를 번들 기반으로 다시 쓰다
  15. 15. Phase 2 스키마 마이그레이션 — 데이터 안 날리고 구조 바꾸기
  16. 16. Phase 3-1·3-2 — Repository와 Domain 서비스로 36개 빌드 에러 잡기
  17. 17. Phase 3-3·3-4·3-5 — Application부터 Module까지, v2.0 마이그레이션 닫는 날
  18. 18. 코드를 박은 다음 날 — 4,658줄 DDD 문서를 24분 사이에 다시 쓴 하루
  19. 19. v2.1 Domain Layer — 도메인 서비스 1,682줄을 한 커밋에 박은 날의 설계 철학
  20. 20. v3.0 Application Layer 재작성 — 도메인 서비스 위에 얇은 막을 한 Phase에 박은 날
  21. 21. 갈아엎고 80일 — v2.0 마이그레이션 8편 메타 회고
  22. 22. 1인 다역으로 5일 만에 90% — Admin Portal MVP를 끌어올린 토글 한 줄
  23. 23. Mock에선 되던 게 REST에선 안 됐다 — 응답 포맷 한 칸 차이가 만든 하루
  24. 24. CORS는 됐다 — PATCH만 빼고. allowedHeaders 한 줄과 Vite 프록시의 소문자 메서드
  25. 25. 멀티테넌트 누수 — tenantId 3계층 강제
  26. 26. Prisma 정책 싱글톤 — zod superRefine 임계값 가드
  27. 27. 멀티테넌트 쓰기 가드 — body.tenantId 차단과 집계 일관성
  28. 28. 두 번째 점검은 합류 지점이었다 — Admin Portal 2차에서 한 사이클에 잡힌 FE-BE 연동 버그 11건
  29. 29. Prisma 그래프 스키마 — 선형 레벨을 DAG로 옮긴 4가지 결정
  30. 30. 교육과정 구조 리팩토링 — 3필드 분리와 폴백 결정기
  31. 31. 배치고사 MVP — 자동 레벨 배치를 걷어내고 5지표 측정만 남기다
  32. 32. JWT Guard 적용 — request.user undefined부터 jwt malformed까지
  33. 33. 디버깅용 운영 API 7개 — Unity 만료 테스트 30분 대기를 0초로
  34. 34. NestJS Swagger 일괄 적용 — 35개 컨트롤러 + DTO 22개
  35. 35. Unity ↔ 웹 PostMessage 브릿지 설계기
  36. 36. Vuplex 브릿지 초기화 타이밍 — 첫 메시지가 증발한 이유
  37. 37. 콘텐츠 브릿지 10종 통합 완료 — 같은 규격으로 묶기
  38. 38. 지표 누계 시스템 — TOP5 순위를 INSERT 전용 스냅샷으로 굳히기
  39. 39. 킥오프 배치 첫 구현 — 매시 전체 EXPIRED 사고와 Winston 도입
  40. 40. 혼자 여러 역할로 QA 1차 — 브랜치 미동기화와 잔존 토큰의 함정
  41. 41. 타이머가 NaN:NaN으로 떴다 — Bundle API 응답 누락 필드와 비어 있는 콘텐츠 후보
  42. 42. 1인 개발 QA 5라운드 — 타이머·시드·스키마로 옮긴 버그들
  43. 43. Unity Lobby + 배치고사 씬 통합 — 두 클라이언트가 같은 회원을 보는 첫 빌드
  44. 44. 배치고사 MVP 후속 — 명세를 코드로 옮기고 레거시 571줄을 일괄 삭제하다
  45. 45. Problem 종속 끊기 — 1,891개 마이그레이션과 단위 테스트 38건
  46. 46. NestJS 권한 가드 — 목록은 막고 상세는 뚫린 날