NestJS Swagger 일괄 적용 — 35개 컨트롤러 + DTO 22개
📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (46편)
Swagger UI는 멀쩡한데 모듈마다 빠진 데코레이터·interface DTO 때문에 API 문서가 실제 동작과 어긋났다. 35개 컨트롤러에 @ApiTags·@ApiBearerAuth를 일괄 적용하고, 한 모듈의 9개 메서드·22개 DTO를 interface에서 class로 한 번에 정리했다. 모듈별 점진 적용을 거절하고 일괄 적용을 택한 트레이드오프와 적용 패턴을 정리한다.
💡 Tip. 바쁜 현대인들을 위한 본문 요약
- 결정: Swagger를 API 출처로 격상. 수동 API 문서(
docs/api/*.md)는 폐기하고, NestJS 컨트롤러·DTO 메타데이터가 곧 문서다- 1차 — 폭 적용: 35개 컨트롤러 전체에
@ApiTags·@ApiBearerAuth일괄 부착 (1커밋)- 2차 — 상세 적용: 한 모듈의 9개 메서드에
@ApiOperation·@ApiResponse·@ApiParam추가 + Response DTO 9개·중첩 DTO 13개를interface에서class로 변환 +@ApiProperty부착- 트레이드오프: 모듈별 점진 적용을 거절. 한 번의 큰 PR이 후속 컨트롤러 작성 시점의 표준을 강제하는 효과가 더 컸다
- 결과: 컨트롤러 신규 작성 시 Swagger 누락이 코드 리뷰 0순위 항목으로 정착, FE의
dataProvider시그니처 추론이 문서 → 코드 한 방향으로 정렬
🎯 배경 — 문서가 코드와 어긋난 상태
이전 편에서 한 모듈의 정책 설정 DTO가 interface로 정의돼 Swagger UI가 텅 비고 ValidationPipe가 침묵하는 증상을 잡았다. 그날 밤의 결론은 DTO는 반드시 class + 데코레이터였다. 다음 날 아침 http://localhost:3000/api/docs를 다시 열었을 때, 같은 패턴이 다른 모듈에서도 그대로 나오는 게 보였다.
admin/settings ✅ Schemas: 7개 모델 그려짐
admin/admins ✅ Schemas: 4개 모델 그려짐
admin/contents ⚠️ Schemas: empty
academy/members ⚠️ Schemas: empty
student/... ⚠️ Schemas: 모델은 보이지만 enum이 string으로 추론
한 모듈만 고친 상태로 두면, 나머지 모듈은 수정될 때까지 계속 거짓말을 한다. FE는 Swagger UI에서 본 빈 스키마를 보고 손으로 타입을 다시 작성하고, 그 추론이 BE의 실제 응답과 어긋나면 다시 한 시간을 디버깅한다. 문서가 진실과 어긋난 상태는 문서가 없는 상태보다 나쁘다.
같은 시기 백엔드와 프론트엔드를 혼자 오가며 작업하던 상황에서, 두 코드베이스 사이의 유일한 매개가 API였다. 그 매개의 서면 합의가 흔들리면 양쪽에서 동시에 같은 추측을 반복한다. 운영 도구·이메일·문서·구두 합의 — 흔들릴 수 있는 출처를 모두 줄이고, 코드 한 곳에 진실을 모으는 게 1인 풀스택의 생존 전략이었다.
📌 핵심: API 문서를 별도 파일로 두면 코드 변경이 문서를 자동으로 갱신하지 못한다. NestJS의 Swagger 플러그인은 컨트롤러 데코레이터와 DTO 클래스 메타데이터를 런타임에 읽어 OpenAPI 스펙을 만든다. 코드가 곧 문서가 되는 구조는, 코드 변경이 곧 문서 변경이라는 보장을 준다. 단, 데코레이터를 빠뜨리면 그 보장도 같이 빠진다.
⚖️ 설계 결정 5건 — 무엇을 한 번에 정리했나
Swagger를 API 출처로 격상하면서 마주한 결정 5건을 표로 먼저 정리하고, 본문에서 각 트레이드오프를 푼다.
| # | 결정 | 채택 | 거절 | 트레이드오프 |
|---|---|---|---|---|
| 1 | 적용 범위 | 전 컨트롤러 35개 일괄 적용 | 모듈별 점진 적용 | 1개의 큰 변경 vs N개의 작은 변경. 큰 변경 쪽이 표준의 가시성이 높음 |
| 2 | 문서 출처 | NestJS Swagger 데코레이터 단독 | 수동 docs/api/*.md 병행 | 동기화 비용 0. 다만 외부에 고정된 URL로 공유할 수 없음 |
| 3 | DTO 형식 | 모든 Response DTO를 class + @ApiProperty | interface 유지 + 별도 스키마 선언 | 코드 변경이 곧 문서 변경. interface 자유도 포기 |
| 4 | 권한 메타 | @ApiBearerAuth()를 컨트롤러 클래스 레벨에 부착 | 메서드별 부착 | 누락 가능성 제거. 컨트롤러 안에 공개 라우트가 섞일 경우 메서드별 @ApiBearerAuth() 또는 별도 컨트롤러로 분리 |
| 5 | 태그 이름 규칙 | @ApiTags('admin/settings') 처럼 모듈 경로와 정합 | 자연어 이름 ('설정') | UI 그루핑이 라우트 prefix와 1:1 매칭. 다국어 표시 포기 |
결정 1: 모듈별 점진 적용을 거절
자연스러운 충동은 “기능 작업할 때 같이 정비하자”였다. 하지만 점진 적용은 적용 안 된 모듈이 새로 추가될 여지를 남긴다. 큰 PR 한 번이 다음 컨트롤러 작성 시점에 “Swagger 데코레이터 누락 PR은 머지 금지”라는 규범을 만든다. 비용은 집중된 하루, 이득은 이후 4개월의 누락 0건.
// 이번 일괄 적용 전 — 모듈마다 들쭉날쭉
@Controller('admin/settings')
@ApiTags('admin/settings') // ✅
@ApiBearerAuth() // ✅
export class AdminSettingsController { ... }
@Controller('admin/contents')
// @ApiTags 누락 ❌
// @ApiBearerAuth 누락 ❌
export class AdminContentsController { ... }
35개 컨트롤러를 한 커밋(d93447b)으로 정비해 dev로 머지(e75a62b)했다. 이후 컨트롤러를 새로 만들 때 데코레이터 5줄이 누락된 PR은 코드 리뷰에서 바로 잡힌다. 일관성은 예외 없음에서 나온다.
결정 2: 수동 API 문서를 폐기
직전까지 docs/api/admin-settings.md 같은 수동 문서를 병행 운영했다. Swagger와 수동 문서 어느 쪽이 진실인가를 매번 결정해야 했고, 결정 후에는 다른 쪽을 손으로 수정해야 했다. 두 출처를 유지하는 비용이 한 출처가 가끔 어긋나는 비용보다 컸다.
수동 문서를 폐기했다. 외부 공유가 필요한 경우는 Swagger의 JSON 스펙을 export해서 정적 페이지로 호스팅하는 방식으로 우회한다. NestJS 공식 문서가 권장하는 워크플로다.
“Swagger generates an OpenAPI specification, which can be exposed as a JSON document or rendered as interactive HTML.” — NestJS OpenAPI 공식 가이드
결정 3: DTO는 class 전환
이전 편에서 잡은 interface는 런타임에 사라진다는 함정이 한 모듈을 넘어선다. @nestjs/swagger의 CLI 플러그인이 일부 interface를 추론해주긴 하지만, enum·중첩 DTO·optional 필드에서 한계가 빠르게 드러난다. 룰을 단순화했다 — Request·Response DTO는 모두 class.
// ❌ Before — interface는 런타임에 흔적이 없음
export interface ContentCandidatesResponseDto {
contentOrder: number;
metricRank: 'TOP1' | 'TOP3' | 'TOP4' | 'TOP5';
levelId: number;
candidates: ContentCandidateDto[];
}
// ✅ After — class + @ApiProperty
export class ContentCandidatesResponseDto {
@ApiProperty({ description: '콘텐츠 순서 (1-5)', example: 1 })
contentOrder!: number;
@ApiProperty({
description: '지표 순위',
enum: ['TOP1', 'TOP3', 'TOP4', 'TOP5'],
example: 'TOP1',
})
metricRank!: 'TOP1' | 'TOP3' | 'TOP4' | 'TOP5';
@ApiProperty({ description: '레벨 ID', example: 5 })
levelId!: number;
@ApiProperty({ type: [ContentCandidateDto], description: '후보 목록' })
candidates!: ContentCandidateDto[];
}
!: (definite assignment) 표기로 strictPropertyInitialization을 피한다. 값을 항상 채워 보낼 것이라는 약속을 타입 시스템에 명시한 표기다. 이전 편에서 정한 컨벤션을 그대로 이어 쓴다.
결정 4: @ApiBearerAuth는 컨트롤러 레벨
JWT가 필요한 컨트롤러는 클래스에 @ApiBearerAuth() 한 줄로 끝낸다. 메서드별로 부착하면 새 메서드가 추가될 때 누락될 가능성이 생긴다.
@Controller('student/assignment')
@ApiTags('student/assignment')
@ApiBearerAuth() // ← 컨트롤러 단위로 한 번
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('STUDENT')
export class StudentAssignmentController {
@Get('current')
@ApiOperation({ summary: '현재 과제 조회' })
@ApiResponse({ status: 200, type: CurrentAssignmentResponseDto })
@ApiResponse({ status: 401, description: '인증 필요' })
getCurrent(@Req() req) { ... }
// ... 그 외 8개 메서드 — 모두 자동으로 @ApiBearerAuth 적용
}
공개 라우트가 한 컨트롤러에 섞인다면 그건 별도 컨트롤러로 분리한다. 보안 메타데이터가 클래스 안에서 예외가 생기는 메서드를 만들지 않는 게 핵심이다. NestJS의 컨트롤러-수준 데코레이터가 메서드까지 자동 상속되는 동작을 그대로 활용한 셈이다.
결정 5: 태그는 라우트 prefix와 1:1
@ApiTags('admin/settings')처럼 컨트롤러 prefix와 같은 이름을 쓴다. Swagger UI의 그룹이 라우트 트리와 일치해, 화면에서 본 그룹을 코드에서 바로 검색할 수 있다.
@Controller('admin/settings')
@ApiTags('admin/settings') // ← 그룹 이름 = prefix
export class AdminSettingsController { ... }
자연어 태그('설정')는 UI에서는 친절해 보이지만, 코드 검색이 한 단계 끊긴다. 1인 풀스택 환경에서는 그루핑 일관성이 다국어 친화성보다 가치 있었다.
🛠️ 1차 구현 — 35개 컨트롤러 폭 적용
35개 컨트롤러에 적용한 변경의 본질은 5줄 패치다.
// 변경 전
import { Controller } from '@nestjs/common';
@Controller('admin/contents')
export class AdminContentsController {
// ...
}
// 변경 후
import { Controller } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
@Controller('admin/contents')
@ApiTags('admin/contents')
@ApiBearerAuth()
export class AdminContentsController {
// ...
}
작업은 컨트롤러 발견 → 패치 적용 → 빌드의 단순한 반복이다. 한 컨트롤러당 평균 30초.
발견 스크립트
먼저 데코레이터가 누락된 컨트롤러를 찾는다.
# @ApiTags가 없는 컨트롤러 파일 목록
rg -l 'export class \w+Controller' apps/api/src \
| xargs rg -L '@ApiTags' 2>/dev/null
# @ApiBearerAuth가 없는 컨트롤러 (인증 모듈 외)
rg -l 'export class \w+Controller' apps/api/src \
| xargs rg -L '@ApiBearerAuth' 2>/dev/null
ripgrep의 -L은 패턴이 없는 파일을 출력한다. 35개 중 28개가 누락 상태였다. 7개는 직전 모듈 작업에서 부분적으로 박혀 있었다.
적용 분류표
| 카테고리 | 컨트롤러 수 | 적용 데코레이터 |
|---|---|---|
| 회원(Student) API | 8 | @ApiTags('student/...') + @ApiBearerAuth() |
| 관리자(Admin) API | 10 | @ApiTags('admin/...') + @ApiBearerAuth() |
| 고객사(Academy) API | 11 | @ApiTags('academy/...') + @ApiBearerAuth() |
| 공개(Auth/Health) API | 6 | @ApiTags('...')만, @ApiBearerAuth() 제외 |
| 합계 | 35 | — |
공개 라우트는 로그인·헬스체크·웹훅 수신 6개다. Bearer 메타데이터를 박지 않는 것이 명시적이도록 컨트롤러를 분리해 두었다.
커밋 단위
| 커밋 | 내용 |
|---|---|
d93447b | feat(api): Swagger API 문서화 — 전체 컨트롤러 @ApiTags 적용 |
e75a62b | Merge feature/backend-work: Swagger API 문서화 |
한 커밋에 변경 파일 35개. PR 리뷰에서 각 컨트롤러의 prefix와 태그 정합성만 봤고, 로직 변경은 0이라 리뷰 시간이 짧았다. 기계적인 변경을 한 커밋으로 묶는 것은 PR 리뷰 부하를 떨어뜨리는 부수 효과이기도 하다.
⚠️ 주의: “큰 PR은 리뷰가 힘들다”는 일반론이 기계적 일괄 적용에는 반대로 적용된다. 같은 패치가 N번 반복되는 PR은, 첫 번째와 마지막을 보면 중간을 전수 확인할 필요가 줄어든다. 다만 로직이 끼어 있다면 즉시 별 PR로 분리한다.
🛠️ 2차 구현 — 한 모듈을 표준 사례로
1차가 수평 일관성(태그·인증 메타)이라면, 2차는 수직 상세화(메서드별 메타 + DTO 메타)다. 한 모듈을 골라 완성된 모습을 먼저 만들고, 그게 다른 모듈의 적용 표준이 되도록 했다. 대상은 student/assignment 컨트롤러. 메서드 9개·Response DTO 9개·중첩 DTO 13개로 적당히 복잡하고, FE의 호출 빈도가 가장 높아 문서 완성도의 효과가 컸다.
메서드 9개 — @ApiOperation + @ApiResponse + @ApiParam
// apps/api/src/application/controllers/student-assignment.controller.ts
@Controller('student/assignment')
@ApiTags('student/assignment')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('STUDENT')
export class StudentAssignmentController {
@Post('start')
@ApiOperation({
summary: '과제 시작/재개',
description: '오늘의 과제를 시작하거나 진행 중인 과제를 재개한다. ' +
'묶음이 없으면 첫 묶음과 5개의 콘텐츠 슬롯을 자동 생성한다.',
})
@ApiResponse({ status: 200, type: AssignmentStartResponseDto })
@ApiResponse({ status: 401, description: '인증 필요' })
@ApiResponse({ status: 404, description: '오늘의 과제 없음' })
async startAssignment(@Req() req) {
return this.service.start(req.user.id);
}
@Get('current')
@ApiOperation({ summary: '현재 진행 중인 과제 조회' })
@ApiResponse({ status: 200, type: CurrentAssignmentResponseDto })
@ApiResponse({ status: 401, description: '인증 필요' })
async getCurrent(@Req() req) {
return this.service.getCurrent(req.user.id);
}
// ... 7개 메서드 동일 패턴
}
summary와 description을 분리해, Swagger UI 목록에서는 한 줄로 보이고 확장하면 동작 규칙이 풀어 보이도록 했다. 목록 가독성과 상세 완전성을 동시에 확보하는 패턴이다.
Response DTO 9개 + 중첩 DTO 13개 — interface→class
가장 시간이 든 작업. 변환 자체는 단순했지만, typo·필드 누락·중첩 타입 누락을 잡느라 빌드를 반복했다.
// 변환 패턴 — Step 1: interface를 class로 (모든 필드 그대로)
export class BundleSummaryDto {
id!: string;
status!: 'ACTIVE' | 'COMPLETED' | 'EXPIRED';
totalContent!: number;
completedContent!: number;
}
// 변환 패턴 — Step 2: @ApiProperty 부착
export class BundleSummaryDto {
@ApiProperty({ description: '묶음 ID', example: '85' })
id!: string;
@ApiProperty({
description: '묶음 상태',
enum: ['ACTIVE', 'COMPLETED', 'EXPIRED'],
example: 'ACTIVE',
})
status!: 'ACTIVE' | 'COMPLETED' | 'EXPIRED';
@ApiProperty({ description: '총 콘텐츠 수', example: 5 })
totalContent!: number;
@ApiProperty({ description: '완료 콘텐츠 수', example: 2 })
completedContent!: number;
}
// 변환 패턴 — Step 3: 중첩 DTO는 type 배열로 명시
export class AssignmentStartResponseDto {
@ApiProperty({ description: '과제 ID', example: '61' })
assignmentId!: string;
@ApiProperty({ type: BundleSummaryDto, description: '현재 묶음' })
currentBundle!: BundleSummaryDto;
@ApiProperty({ type: [ContentSlotDto], description: '콘텐츠 슬롯 목록' })
contentSlots!: ContentSlotDto[];
}
배열을 명시하지 않으면(type: BundleSummaryDto[] 대신 type: [BundleSummaryDto]) Swagger가 단일 객체로 추론한다. 작은 함정이지만 모든 응답 DTO에서 한 번씩은 마주친다.
nullable·optional·Date 처리
// nullable: null이 올 수 있음
@ApiProperty({ description: '썸네일 URL', nullable: true, example: null })
thumbnailUrl!: string | null;
// optional: 필드 자체가 없을 수 있음
@ApiPropertyOptional({ description: '디버그 정보' })
debugInfo?: ContentCandidateDebugInfoDto;
// Date: 직렬화 형태를 명시
@ApiPropertyOptional({
description: '완료 시각 (ISO 8601 UTC)',
type: String,
format: 'date-time',
example: '2026-01-23T16:30:00.000Z',
})
completedAt?: Date;
null이 값으로서 의미가 있는 경우(예: 썸네일이 없다는 정보)는 nullable: true로 명시한다. 필드 자체가 없는 경우(예: debug=true일 때만 동봉)는 @ApiPropertyOptional. 두 표기를 섞으면 FE 타입이 어긋난다.
2차 변경 요약
| 항목 | 수 |
|---|---|
| 컨트롤러 메서드 데코레이터 추가 | 9 |
Response DTO interface → class 변환 | 9 |
중첩 DTO interface → class 변환 | 13 (+ 신규 1 = 14) |
추가된 @ApiProperty / @ApiPropertyOptional | 약 80 |
| 빌드 통과 | ✅ (7 tasks) |
| 커밋 | 0f282fc |

📊 결과 — 무엇이 줄고 무엇이 늘었나
| 지표 | 적용 전 | 적용 후 |
|---|---|---|
| Swagger UI에 Schemas 모델이 그려진 컨트롤러 | 7 / 35 | 35 / 35 |
@ApiBearerAuth 누락 컨트롤러 | 22 / 29 (인증 대상) | 0 / 29 |
| 수동 API 문서 파일 | docs/api/*.md 18개 | 0 (전부 삭제) |
| 한 컨트롤러 신규 작성 시 PR 평균 추가 패치 | 약 5건 (문서·태그·인증 누락 반복 지적) | 0건 (코드 리뷰 0순위 체크) |
| FE가 손으로 작성하던 응답 타입 | 매 모듈마다 재작성 | 일부 모듈은 spec → ts 자동 생성으로 전환 |
가장 큰 효과는 프로세스 변화에 있었다. FE 측에서 Swagger 스펙 JSON을 build 산출물로 받아 타입을 생성하는 흐름이 가능해졌다. 1인 풀스택 환경에서 FE 타입을 서버 변경에 자동으로 따라오게 만든 것만으로 한 회의 분량의 동기화 시간이 사라졌다.
빌드·테스트
$ pnpm build
✓ 7 tasks successful
$ pnpm test:unit
✓ 252 tests passed
DTO 형식 변경이 런타임 객체 구조를 바꾸지 않았기 때문에, 기존 서비스 로직과 테스트는 그대로 통과했다. 타입 시스템에만 영향이 있는 변경은 회귀가 적다.
🔄 회고 — 다시 한다면 무엇을 바꿀까
1. CLI 플러그인은 처음부터 켜 두기
@nestjs/swagger의 CLI 플러그인(@nestjs/swagger/plugin)은 간단한 DTO는 데코레이터 없이도 자동 추론해 준다. 처음부터 켜 두었다면 2차 작업의 @ApiProperty 부착 비용이 절반 이하로 줄었을 것이다. 대신 명시성은 떨어진다. 둘의 트레이드오프를 미리 정해두는 게 다음 프로젝트의 시작 작업이다.
// nest-cli.json
{
"compilerOptions": {
"plugins": ["@nestjs/swagger/plugin"]
}
}
플러그인은 컴파일 시점에 DTO 필드를 분석해 @ApiProperty를 자동 주입한다. 수동 데코레이터가 있으면 그쪽을 우선하므로, 자동 + 수동 혼합 운영이 가능하다.
2. PartialType / OmitType / IntersectionType 더 적극 사용
Create DTO와 Update DTO를 손으로 따로 정의하는 패턴이 2차 작업에서 발견된 안티패턴이었다. NestJS Swagger의 매핑 타입을 쓰면 한 줄로 끝난다.
import { PartialType, OmitType } from '@nestjs/swagger';
export class CreateAdminDto {
@ApiProperty() email!: string;
@ApiProperty() password!: string;
@ApiProperty() name!: string;
}
// password는 update 시 별도 라우트라서 빼고, 나머지를 optional로
export class UpdateAdminDto extends PartialType(
OmitType(CreateAdminDto, ['password'] as const),
) {}
PartialType이 모든 필드를 optional로 + Swagger 메타 보존 + validator 보존을 한 번에 해 준다. 손으로 재정의한 6개의 Update DTO를 1줄짜리로 줄일 여지가 남았다.
3. 2차 작업을 한 모듈 표준 사례로 둔 건 잘한 결정
2차 작업 직후 다른 모듈에 같은 패턴을 옮길 때 결정이 이미 다 내려진 상태였다. 새 모듈은 컨벤션만 따르면 됐고, 검토 시간이 짧았다. 완성된 사례 하나가 다음 N개의 모듈에 같은 상세도를 빠르게 전파한다.
4. 변경 로그를 컨트롤러 헤더에 명시
/**
* StudentAssignmentController
*
* 2026-01-23 — Swagger 데코레이터 일괄 부착, DTO interface→class 변환
* 2026-01-16 — JWT Guard 적용
*/
@Controller('student/assignment')
어떤 메서드가 언제 표준에 맞게 정비되었는지를 컨트롤러 헤더에 명시하면, 다른 컨트롤러를 손볼 때 적용 여부를 즉시 식별할 수 있다. 변경 로그를 컨트롤러 헤더에 두는 컨벤션을 같이 출발시켰어야 했다.
🛡️ 예방 — 후속 컨트롤러 작성 체크리스트
이 작업 이후 새 컨트롤러를 만들 때마다 PR 체크리스트에 명시한 항목이다.
-
@Controller('<prefix>')+@ApiTags('<prefix>')1:1 매칭 - 인증 대상이면
@ApiBearerAuth()클래스 레벨 부착 - 모든 메서드에
@ApiOperation({ summary, description }) - 응답 코드별
@ApiResponse({ status, type, description }) - 경로 파라미터는
@ApiParam, 쿼리는@ApiQuery - Request·Response DTO는 class +
@ApiProperty(interface 금지) - enum 필드는
enum: [...]명시 - nullable 필드는
nullable: true, optional 필드는@ApiPropertyOptional - 중첩/배열은
type: [ChildDto]형태로 명시 -
pnpm build통과 +http://localhost:3000/api/docs에서 시각 확인
마지막 항목이 핵심이다. 빌드는 통과해도 UI에서 빈 칸이 보이는 경우가 있다. 사람 눈이 마지막 검증자다.
📋 정리 — 핵심 결정 요약
| 항목 | 안티패턴 | 권장 패턴 |
|---|---|---|
| 적용 범위 | 모듈별 점진 적용 | 전 컨트롤러 일괄 적용 1커밋 |
| 문서 출처 | 수동 docs/api/*.md + Swagger 병행 | Swagger 단독 |
| DTO 형식 | interface 유지 | class + @ApiProperty |
| 인증 메타 | 메서드별 @ApiBearerAuth | 컨트롤러 클래스 레벨 |
| 태그 이름 | 자연어 ('설정') | prefix 정합 ('admin/settings') |
| 매핑 타입 | Create/Update DTO 손으로 재정의 | PartialType / OmitType |
| 배열 응답 | type: ChildDto[] | type: [ChildDto] |
| Date 필드 | 기본 추론 | type: String, format: 'date-time' 명시 |
숫자로 보는 일괄 적용
- 적용 컨트롤러: 35개 (1차 폭 적용) / 1개 상세 적용 (2차)
- 변환 DTO: 22개 (Response 9 + 중첩 13) + 신규 1개
- 추가 데코레이터:
@ApiTags/@ApiBearerAuth약 35쌍 +@ApiProperty약 80개 - 삭제된 수동 문서:
docs/api/*.md18개 - 빌드 영향: 7 tasks successful, 252 unit tests 통과
- 이후 4개월간 Swagger 누락 PR: 0건
다음 편에서는 임베디드 게임 클라이언트와 웹 콘텐츠 사이의 PostMessage 브릿지 규격을 정리한다. 서버가 아닌 두 클라이언트 사이의 합의를 어디서 어떻게 강제했는지의 이야기다.
📚 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 권한 가드 — 목록은 막고 상세는 뚫린 날