디버깅용 운영 API 7개 — Unity 만료 테스트 30분 대기를 0초로

Unity 클라이언트 QA에서 할당 만료를 보려고 30분을 기다리거나 DB를 직접 건드리던 흐름을 운영용 디버그 API 7개로 흡수했다. PLATFORM_ADMIN 단독 권한·환경 토글·멱등 reset 같은 설계 결정과 후속 개선(loginId 쿼리, createNewAssignment 옵션)까지의 트레이드오프를 정리한다.


💡 Tip. 바쁜 현대인들을 위한 본문 요약

  • Unity 클라이언트 QA에서 할당 만료·세션 리셋 테스트마다 30분 대기 또는 DB 수동 수정이 반복됐다
  • 해결: 운영용 디버그 API 7개 — 상태 강제 변경, 시작 시각 이동, 강제 만료, 회원 데이터 초기화 등
  • 보안: @UseGuards(JwtAuthGuard, RolesGuard) + @Roles('PLATFORM_ADMIN') + 환경 토글로 운영 노출 차단
  • 단순 DB 변경비즈니스 로직 포함 변경PATCH status / POST force-expire로 명확히 분리
  • 1차 출시 후 개선 2건: loginId 쿼리 파라미터(?loginId=TEST009) + reset-learningcreateNewAssignment 옵션
  • 결과: 만료 테스트 30분 → 0초, QA 1라운드당 평균 4~6회 호출되는 표준 도구로 정착

🎯 배경 — 30분 대기 또는 DB 수동 수정의 반복

이전 편에서 JWT Guard 인프라를 정리하고 4역할(PLATFORM_ADMIN·ACADEMY_OWNER·TEACHER·STUDENT) 시드 계정을 붙였다. 직후 임베디드 게임 클라이언트의 QA가 본격적으로 돌기 시작했고, 동일한 시나리오가 반복된다는 게 한 라운드 만에 드러났다.

가장 자주 막혔던 시나리오는 할당 만료 처리 검증이었다. 회원에게 할당된 작업(Assignment)은 시작 시각 기준 30분이 지나면 EXPIRED로 자동 전환된다. QA는 만료 후의 UI 흐름·재시도 정책·상태 메시지를 보고 싶었지만, 만료를 보려면 다음 셋 중 하나를 해야 했다.

  1. 30분을 실제로 기다린다 — 한 케이스당 30분, 하루 4번이면 2시간이 통째로 비효율
  2. DB에 직접 들어가서 startedAt을 30분 전으로 옮긴다psql 접속·SQL 작성·관련 캐시 무효화 누락 위험
  3. 로컬 시간을 흔든다 — 다른 시간 의존 모듈까지 한꺼번에 깨지는 부작용

세션 리셋도 비슷했다. QA 한 번 끝나면 회원의 진행 상태·할당·시도 기록·점수를 모두 초기화해 다음 라운드를 도는데, 이걸 psql로 매번 처리하는 동안 어느 테이블을 어느 순서로 지우는지 기억에 의존했다. 이전 편에서 다룬 FK 삭제 순서와 같은 함정이 운영 측에서도 재현되고 있었다.

📌 핵심: 운영 도구가 없으면 사람의 손이 운영 도구가 된다. 사람의 손은 멱등이 아니고, 로그가 남지 않으며, 가드 받지 않는다. QA 라운드를 한 번 더 도는 게 두려워서 시나리오를 줄이는 순간 품질은 바로 떨어진다. 30분짜리 대기는 0초짜리 API 한 번으로 줄어드는 게 정상이다.


⚖️ 설계 결정 5건 — 무엇을 API로 흡수하고 무엇을 거절했나

운영 도구를 API로 만들 때 마주한 결정 5건을 표로 먼저 정리하고, 본문에서 각 트레이드오프를 푼다.

#결정채택거절트레이드오프
1모듈 분리DebugModule 단독 + /debug prefix기존 컨트롤러에 디버그 메서드 추가도메인 컨트롤러에 운영용 코드 섞이지 않음. 모듈 1개 추가 비용 발생
2권한PLATFORM_ADMIN 단독 + JwtAuthGuard + RolesGuard@Public() 또는 별도 토큰표준 인증 파이프라인 재사용, 별도 비밀키 관리 안 함
3환경 토글DEBUG_API_ENABLED=true일 때만 모듈 로드라우트만 막고 코드는 항상 로드운영 빌드에 디버그 코드 자체가 안 들어감. dev/staging 토글 누락 시 404
4단순 DB vs 비즈니스 로직PATCH status는 enum 직접 변경, POST force-expire는 도메인 서비스 호출모두 도메인 서비스 경유디버그용 과감한 상태 점프비즈니스 규칙 검증을 라우트 메서드로 분리
5reset-learning 멱등성매 호출이 완전 초기화 + upsert 패턴차이만 갱신(diff sync)같은 회원으로 N번 호출해도 동일 상태 보장. 다만 매번 모든 자식 데이터 삭제 비용 발생

결정 1: 모듈 분리

운영용 코드가 도메인 컨트롤러에 섞이기 시작하면 언젠가 빠뜨리고 운영에 흘러간다. DebugModule을 별도로 둔다. 컨트롤러 prefix는 /api/v1/debug로 통일해, 라우트만 봐도 디버그용임이 명확하다.

// apps/api/src/debug/debug.module.ts
@Module({
  imports: [PrismaModule, AuthModule, AssignmentDomainModule],
  controllers: [DebugController],
  providers: [DebugService],
})
export class DebugModule {}

// apps/api/src/app.module.ts
@Module({
  imports: [
    // ... 도메인 모듈들
    ...(process.env.DEBUG_API_ENABLED === 'true' ? [DebugModule] : []),
  ],
})
export class AppModule {}

AppModule에서 환경변수로 모듈 자체를 조건부 로드한다. 운영 빌드에서는 DEBUG_API_ENABLED를 풀지 않으므로 디버그 코드가 코드 경로에 존재하지 않는다. 라우트 차단은 한 단계 약한 방어이고, 모듈 미로드는 그보다 한 단계 강한 방어다.

결정 2: PLATFORM_ADMIN 단독 권한

권한을 별도 토큰이나 비밀 헤더로 가져가려는 충동이 있었지만, 이미 JWT Guard 인프라가 있는 상태였다. 표준 인증을 재사용하는 게 정답이었다.

// apps/api/src/debug/debug.controller.ts
@Controller('debug')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('PLATFORM_ADMIN')
@ApiBearerAuth()
export class DebugController {
  // ... 7 routes
}

컨트롤러 클래스 레벨에 @Roles('PLATFORM_ADMIN')를 박으면 컨트롤러 안의 모든 라우트가 동일 권한 요구다. 라우트별로 잊고 빠뜨릴 가능성 자체가 없어진다. NestJS 공식 문서의 컨트롤러-수준 메타데이터 적용 패턴과 같은 흐름이다.

“When the metadata is applied at the controller level, it applies to every route handler defined inside that controller.” — NestJS Custom Decorators 공식 가이드

NestJS — Authorization (Role-based, RolesGuard)
RolesGuard와 @Roles() 데코레이터로 클래스/메서드 수준 권한을 적용하는 표준 패턴. 컨트롤러 레벨에 박으면 라우트별 누락 없이 동일 권한이 강제된다.
docs.nestjs.com

결정 3: 환경 토글로 운영 차단

DEBUG_API_ENABLED.env에서 명시적으로 켜야 모듈이 로드된다. 운영 환경의 .env에는 변수 자체를 두지 않고, dev/staging에만 true로 설정한다.

# .env.dev / .env.staging
DEBUG_API_ENABLED=true

# .env.production
# DEBUG_API_ENABLED 변수 자체를 두지 않는다 (=== 'true' 비교가 false로 떨어짐)

여기서 단순 불리언 캐스팅을 안 쓴 이유가 있다. Boolean(process.env.DEBUG_API_ENABLED)'false' 문자열도 true로 평가한다. 명시적으로 === 'true'를 비교해야 철자가 다르면 꺼진 상태가 기본값이다. 환경변수 디폴트는 안전한 쪽이 옳다.

⚠️ 주의: “운영에서는 라우트만 막으면 되겠지” 패턴은 Guard가 실수로 빠지는 순간에 노출된다. 모듈 자체를 운영 빌드에 안 싣는 게 한 단계 강한 방어다. 두 단계가 동시에 무너져야 노출되는 구조가 안전하다.

결정 4: 단순 DB vs 비즈니스 로직 분리

같은 상태 변경이라도 두 종류였다.

  • 단순 DB 변경: “지금 이 할당의 status를 EXPIRED로 만들어 줘” — 비즈니스 규칙 무시
  • 비즈니스 로직 포함 변경: “지금 이 할당을 정상적인 만료 흐름으로 종료시켜 줘” — 점수 정산·다음 할당 생성 같은 후처리까지

이걸 한 라우트로 합치면 디버그 의도가 흐려진다. 상태를 점프하고 싶을 때 후처리가 끼어들면 곤란하고, 정상 흐름을 흉내내고 싶을 때 enum만 바꾸면 점수가 안 맞는다. 라우트 메서드를 분리했다.

// 단순 DB — 비즈니스 규칙 무시하고 상태만 변경
@Patch('assignment/:id/status')
async setAssignmentStatus(@Param('id') id: string, @Body() dto: SetStatusDto) {
  return this.debugService.setRawAssignmentStatus(id, dto.status);
}

// 비즈니스 로직 — 정상 만료 후처리(점수 정산·다음 할당 생성 트리거)까지 실행
@Post('assignment/:id/force-expire')
async forceExpire(@Param('id') id: string) {
  return this.assignmentDomainService.expireAssignment(id, { reason: 'DEBUG' });
}

force-expire는 도메인 서비스를 그대로 호출한다. 디버그 라우트가 비즈니스 규칙을 다시 작성하지 않는다는 게 핵심이다. 디버그용 후처리 분기가 도메인에 침투하면, 어느 날 운영에서도 그 분기가 도는 사고가 난다.

결정 5: reset-learning은 완전 초기화 + upsert

세션 리셋은 부분 sync가 아니라 완전 초기화다.

async resetLearning(memberId: string, opts: { createNewAssignment: boolean }) {
  await this.prisma.$transaction(async (tx) => {
    // 자식 → 부모 역순 삭제 (FK 함정 회피)
    await tx.contentAttempt.deleteMany({ where: { assignment: { memberId } } });
    await tx.bundle.deleteMany({ where: { assignment: { memberId } } });
    await tx.assignment.deleteMany({ where: { memberId } });

    // 회원 진행 상태 upsert (멱등성 보장)
    await tx.memberProgress.upsert({
      where: { memberId },
      update: { currentLevelId: null, diagnosticStatus: 'NOT_STARTED' },
      create: { memberId, currentLevelId: null, diagnosticStatus: 'NOT_STARTED' },
    });
  });

  if (opts.createNewAssignment) {
    return this.assignmentDomainService.createInitialAssignment(memberId);
  }
  return null;
}

회원 한 명을 처음 가입한 직후 상태로 되돌린다. upsert로 진행 상태를 다시 쓰면 두 번 호출해도 같은 결과다. 비용은 한 번 호출당 4개 테이블의 deleteMany라 작지 않지만, 디버그 호출 빈도가 낮고 멱등성이 더 중요하다.

📌 핵심: 디버그 API에 부분 갱신을 허락하면 호출 순서에 따라 결과가 달라진다. 항상 상태를 정해두고 그 상태로 보낸다는 멱등 패턴이 디버그 도구의 정신이다. 호출자(QA)가 “이 API 한 번 더 부르면 어떻게 되지?”를 고민할 필요가 없어야 한다.


🛠️ 구현 — 7개 엔드포인트 한눈에

직접 정리한 NestJS 디버그 API 7개 라우트 비교도 — 30분 대기 QA 흐름과 PLATFORM_ADMIN 단독 권한 라우트 비교표
직접 정리한 NestJS 디버그 API 7개 라우트 비교도 — 30분 대기 QA 흐름과 PLATFORM_ADMIN 단독 권한 라우트 비교표

#메서드경로책임비고
1GET/debug/student/:id/assignments회원의 할당 목록 + 상태 + 묶음 요약QA 시작 시 현재 상태 스냅샷
2PATCH/debug/assignment/:id/status할당 status enum 직접 변경비즈니스 규칙 무시
3PATCH/debug/assignment/:id/timestartedAt을 N분 전으로 이동만료 임박/만료 시점 만들기
4POST/debug/assignment/:id/force-expire정상 만료 흐름 트리거점수 정산·다음 할당 생성 포함
5DELETE/debug/student/:id/assignments회원 할당 전체 삭제reset의 가벼운 버전
6GET/debug/bundle/:id/status묶음(Bundle) 상세 + 시도 기록 요약묶음 단위 진단
7POST/debug/student/:id/reset-learning회원 학습 데이터 완전 초기화createNewAssignment 옵션

12개 파일·1,467줄로 dev 머지(c1b737f). 컨트롤러 1·서비스 1·DTO 7·테스트 3 정도의 구성이다.

라우트 1: 현재 상태 스냅샷

@Get('student/:id/assignments')
@ApiOperation({ summary: '회원 할당 목록 + 상태 + 묶음 요약' })
async getStudentAssignments(@Param('id') memberId: string) {
  const assignments = await this.prisma.assignment.findMany({
    where: { memberId },
    include: {
      bundles: {
        select: { id: true, status: true, totalContent: true, completedContent: true },
      },
    },
    orderBy: { createdAt: 'desc' },
  });

  return assignments.map((a) => ({
    id: a.id,
    status: a.status,
    startedAt: a.startedAt,
    expiresAt: a.startedAt ? new Date(a.startedAt.getTime() + 30 * 60 * 1000) : null,
    remainingMs: a.startedAt
      ? Math.max(0, a.startedAt.getTime() + 30 * 60 * 1000 - Date.now())
      : null,
    bundles: a.bundles.map((b) => ({
      id: b.id,
      status: b.status,
      progress: `${b.completedContent}/${b.totalContent}`,
    })),
  }));
}

remainingMs를 응답에 포함시키는 게 QA에게 가장 자주 쓰였다. *“이 할당은 얼마나 남았나”*가 QA의 첫 질문이고, 그걸 psql로 보던 흐름이 한 줄 cURL로 들어왔다.

라우트 3: 시작 시각 이동

@Patch('assignment/:id/time')
@ApiOperation({ summary: 'startedAt을 N분 전으로 이동 (만료 임박/만료 만들기)' })
async setAssignmentTime(@Param('id') id: string, @Body() dto: SetTimeDto) {
  const movedAt = new Date(Date.now() - dto.offsetMinutes * 60 * 1000);
  return this.prisma.assignment.update({
    where: { id },
    data: { startedAt: movedAt },
  });
}

offsetMinutes가 핵심 파라미터다. 25를 보내면 남은 시간 5분인 임박 상태, 35를 보내면 이미 만료인 상태가 만들어진다. 시간 의존 자동 전환은 백그라운드 스케줄러가 처리하므로 QA는 그저 임박/만료 둘 중 하나의 상황을 즉시 만들 수 있다.

라우트 4: 정상 만료 흐름 트리거

@Post('assignment/:id/force-expire')
@ApiOperation({ summary: '정상 만료 후처리(점수 정산·다음 할당 생성)까지 실행' })
async forceExpire(@Param('id') id: string) {
  return this.assignmentDomainService.expireAssignment(id, { reason: 'DEBUG' });
}

이 라우트는 디버그 라우트가 자체 로직을 갖지 않는다는 결정의 가장 분명한 예다. expireAssignment() 안에서는 점수 정산·콘텐츠 묶음 상태 정리·다음 할당 자동 생성까지 모두 실행된다. 디버그 라우트는 도메인 서비스의 호출자일 뿐이다.

reason: 'DEBUG'는 활동 로그에 기록된다. 운영에서 발견된 비정상 만료가 디버그 호출 때문인지 시간 만료인지 구분된다. 운영 로그를 보는 사람이 왜 만료됐나를 빨리 좁힐 수 있어야 디버그 도구가 도움이 된다.

라우트 7: reset-learning

createNewAssignment 옵션은 1차 출시 직후에 붙은 후속 개선이다. 1차 출시 시점에는 완전 초기화만 가능했는데, QA가 초기화 직후 바로 다음 케이스로 들어가야 하는데 할당 생성 단계를 또 거쳐야 한다고 보고했다. 옵션 한 줄로 원샷 리셋 + 즉시 다음 케이스 시작 흐름이 완성됐다(자세한 흐름은 아래 후속 개선 섹션).


🔄 1차 출시 후 개선 — loginId 쿼리, createNewAssignment, 그리고 한 번의 버그

운영 도구는 써 봐야 부족함이 드러난다. 1차 dev 머지 같은 날 오후에 두 가지 개선과 한 번의 버그 수정이 따라붙었다.

개선 1: loginId 쿼리 파라미터

1차 명세는 /debug/student/:id/assignments처럼 회원 PK를 경로 변수로 받았다. QA가 보는 건 PK가 아니라 테스트 계정 ID(TEST009)였다.

# ❌ 1차 — PK를 알아야 호출 가능
$ curl ".../debug/student/cmd8b9zzz000080j5xq5p2k01/assignments"

# ✅ 개선 — loginId(테스트 계정)로 직접 호출
$ curl ".../debug/student/assignments?loginId=TEST009"
$ curl -X POST ".../debug/student/reset-learning?loginId=TEST009"

PK는 매 라운드마다 바뀌지만 loginId는 시드에서 고정이다. 호출자가 기억할 수 있는 식별자를 받는 게 운영 도구 정신과 맞다(accd16d).

라우트 시그니처는 PK 경로loginId 쿼리를 함께 받는 형태로 확장했다. 둘 중 하나라도 들어오면 회원을 찾는다.

@Get('student/:memberId?/assignments')
async getStudentAssignments(
  @Param('memberId') memberId?: string,
  @Query('loginId') loginId?: string,
) {
  const member = memberId
    ? await this.prisma.member.findUnique({ where: { id: memberId } })
    : loginId
      ? await this.prisma.member.findUnique({ where: { loginId } })
      : null;
  if (!member) throw new NotFoundException('Member not found');
  return this.debugService.getAssignmentsByMemberId(member.id);
}

개선 2: createNewAssignment 옵션

reset 직후 바로 다음 케이스로 들어가야 하는 QA 흐름을 위한 옵션이다.

// POST /debug/student/reset-learning?loginId=TEST009
// body: { createNewAssignment: true }
{
  "reset": true,
  "newAssignment": {
    "id": "asg_new_01",
    "status": "ACTIVE",
    "bundleId": null
  }
}

createNewAssignment: true이면 reset 후 assignmentDomainService.createInitialAssignment(memberId)를 호출해 새 할당을 발급한다. 배치고사 완료 상태인 회원만 이 옵션을 허용한다 — 배치고사 전 회원에게 할당을 발급하면 콘텐츠 선정 알고리즘이 빈 입력을 받기 때문이다(962a631).

그리고 한 번의 버그 — startAssignment 충돌

이 옵션이 1차로 들어왔을 때, 디버그 서비스는 할당을 만들면서 묶음(Bundle)까지 같이 생성하고 있었다. QA가 첫 콘텐츠를 시작하려고 POST /student/assignment/start를 누르자 콘텐츠가 없다는 404가 떨어졌다.

// ❌ 첫 구현 — reset이 묶음까지 만들어 두면 startAssignment가 스킵
await this.assignmentDomainService.createInitialAssignment(memberId);
await this.bundleDomainService.createInitialBundle(assignment.id); // ← 여기서 미리 만들면 안 됐다

기존 startAssignment 흐름은 묶음이 비어 있을 때 새로 콘텐츠 후보를 뽑아 묶음을 만든다는 전제로 짜여 있었다. 디버그 reset이 묶음을 미리 만들어두면 startAssignment이미 만들어진 묶음을 그대로 쓰려고 시도하다가 콘텐츠 후보가 0건이라 404를 던졌다.

// ✅ 수정 — reset은 할당까지만, 묶음은 startAssignment가 만든다
await this.assignmentDomainService.createInitialAssignment(memberId);
// 묶음 생성 X — startAssignment에서 콘텐츠 후보와 함께 생성됨

원인은 콘텐츠 선정 알고리즘이 startAssignment 안에서만 실행된다는 사실을 미처 반영하지 않은 것이었다(1b13407). 운영 도구가 기존 도메인 흐름과 의도를 어디까지 알고 있어야 하는지를 한 번 짚어준 사고다.

🔍 단서: 운영 도구가 도메인의 한 단계를 미리 실행하면, 이후 단계가 이미 실행된 줄 모르고 다시 실행하다가 충돌한다. 디버그 라우트는 시작 지점에 멈춰 있고, 비즈니스 흐름은 도메인 서비스의 진입 메서드(startAssignment)에서 시작되어야 한다. 운영 도구가 비즈니스 흐름의 중간 단계를 흉내내면 그 단계의 전제가 같이 따라붙어야 한다.


📊 결과 — 30분 → 0초, QA 1라운드 평균 4~6회 호출

운영 도구의 효과는 호출 빈도에 직접 나타난다. dev 머지 후 일주일치 활동 로그를 보면 다음과 같았다.

라우트일주일 호출 수1라운드 평균
GET /debug/student/:id/assignments843.2
POST /debug/student/:id/reset-learning411.6
PATCH /debug/assignment/:id/time281.1
POST /debug/assignment/:id/force-expire110.4
기타 3개합계 70.3

QA 1라운드당 평균 6.6회 디버그 API 호출. 호출당 체감 절감은 다음과 같았다.

  • 시작 시각 이동(time): 30분 → 0초 (만료 임박 케이스는 25분 단축)
  • 세션 리셋: psql 5단계 수동 → API 1회 호출 (멱등성으로 실수 가능성 0)
  • 현황 스냅샷: psql 세 테이블 조회 → cURL 1회 (응답 JSON 한 덩어리)

QA가 시나리오를 줄이는 충동이 사라졌다는 점이 가장 컸다. 한 라운드를 도는 비용이 낮아지면 변형 시나리오를 더 돌리게 된다. 운영 도구의 진짜 가치는 반복의 비용을 깎는다는 데 있다.


🛡️ 운영 환경 노출 방지 — 두 단계 방어

운영 빌드에 디버그 코드가 흘러가지 않게 하는 데 두 단계의 방어를 둔다.

1단계 — 모듈 미로드(AppModule 조건부)

imports: [
  // 도메인 모듈
  ...(process.env.DEBUG_API_ENABLED === 'true' ? [DebugModule] : []),
],

운영 빌드에서 DEBUG_API_ENABLED가 비어 있으면 DebugModule 자체가 로드되지 않는다. /debug/* 모든 라우트가 서버에 존재하지 않는 상태다(404).

2단계 — PLATFORM_ADMIN 단독 권한

1단계가 실수로 무너져 모듈이 로드되더라도, 모든 라우트가 PLATFORM_ADMIN 토큰을 요구한다. 운영의 일반 사용자(어드민·운영자·회원) 토큰으로는 403 Forbidden이 즉시 떨어진다.

자동 점검 — e2e 테스트로 두 단계 검증

// debug.controller.e2e-spec.ts
describe('DebugController in production-like env', () => {
  beforeAll(() => {
    delete process.env.DEBUG_API_ENABLED;
  });

  it('GET /debug/student/:id/assignments → 404 (module not loaded)', async () => {
    await request(app.getHttpServer())
      .get('/debug/student/any/assignments')
      .set('Authorization', `Bearer ${platformAdminToken}`)
      .expect(404);
  });
});

CI에서 환경변수 미설정Admin 외 토큰 두 케이스 모두 거부됨을 자동 검증한다. 사람이 환경변수 설정을 잊는 사고를 빌드 단계에서 막는 게 핵심이다.

⚠️ 주의: 운영용 디버그 API의 흔한 사고환경변수 디폴트가 true이거나 Public 데코레이터를 디버그용으로만 잠시 풀어놓고 잊은 경우다. 두 단계 방어 + e2e 자동 점검 둘 다 갖춰야 실수의 표면적을 줄일 수 있다.


📋 정리 — 핵심 요약

항목1차 출시후속 개선의도
모듈DebugModule 환경 토글 로드(변경 없음)운영 빌드에 코드 자체 미포함
권한@Roles('PLATFORM_ADMIN') 단독(변경 없음)표준 인증 재사용, 별도 비밀키 X
식별자회원 PK 경로loginId 쿼리 추가QA가 기억하는 ID로 직접 호출
reset완전 초기화 + 멱등성createNewAssignment 옵션리셋 직후 다음 케이스로 즉시 진입
상태 변경PATCH status(raw) + force-expire(도메인) 분리(변경 없음)디버그 의도와 비즈니스 흐름 분리
만료 만들기PATCH time (offsetMinutes)(변경 없음)30분 대기 → 0초

운영 도구 7개를 만들 때 챙긴 체크리스트

  • 별도 모듈로 격리했는가? 도메인 컨트롤러에 디버그 메서드를 섞지 않았는가?
  • 환경변수로 모듈 자체를 로드/언로드하는가? (라우트 차단보다 한 단계 강한 방어)
  • 단일 역할(PLATFORM_ADMIN)에만 권한을 줬는가? 컨트롤러 레벨에 적용했는가?
  • 단순 DB 변경비즈니스 로직 포함 변경을 다른 라우트로 분리했는가?
  • 디버그 라우트가 도메인 서비스의 호출자인가, 자체 로직 작성자인가?
  • 멱등성을 보장하는가? 호출자가 *한 번 더 누르면 어떻게 되지?*를 고민할 필요가 없는가?
  • 호출자가 기억하는 식별자(loginId 등)로 호출 가능한가?
  • e2e 테스트로 환경변수 미설정 → 404, Admin 외 토큰 → 403 두 케이스를 자동 검증하는가?
  • 활동 로그에 디버그 호출임을 표시하는 reason이 남는가?

숫자로 보는 1차 출시

  • 구현 규모: 12개 파일·1,467줄 (컨트롤러 1·서비스 1·DTO 7·테스트 3)
  • 엔드포인트 수: 7개
  • 머지까지: 명세 수령 → dev 머지 약 4시간
  • 1주일 운용 후 호출 빈도: QA 1라운드당 평균 6.6회
  • 추가 개선 작업: 같은 일자 오후 2건(loginId 쿼리, createNewAssignment 옵션) + 버그 수정 1건

운영 도구는 언젠가 만들 것이 아니라 반복이 두 번 보이면 즉시 만든다. 30분 대기가 두 번 반복되는 순간, 그게 팀의 새 표준 비효율이 된다는 신호다. 그 신호를 두 번 보고 세 번째에 손대지 않으면 QA가 시나리오를 줄이는 결정까지 따라온다 — 결국 품질 손실이다.

다음 편에서는 Swagger API 문서화 전면 적용기를 정리한다. DTO 변환·ApiProperty 데코레이터 일괄 적용·CI에서 OpenAPI 스펙 회귀를 막는 패턴을 다룬다.

📚 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 권한 가드 — 목록은 막고 상세는 뚫린 날