
Express.js에서 OpenAPI를 사용해 Swagger 문서를 설정할 때는, 설정 파일과 스키마를 하나하나 직접 정의해가며 꽤 많은 작업을 해야 했다. 반면 Nest.js에서는 @nestjs/swagger 라이브러리를 사용하면, 비교적 적은 설정만으로도 Swagger UI를 빠르게 구성할 수 있다.
Nest.js의 가장 큰 강점은 컨트롤러와 데코레이터에 이미 존재하는 메타데이터를 Swagger 문서로 그대로 끌어올 수 있다는 점이다. 덕분에 API 문서를 따로 관리하기보다, 코드 자체를 문서의 단일 진실 공급원(source of truth)으로 삼을 수 있다.
Swagger 설정
Swagger UI를 띄우는 설정은 main.ts에서 이루어진다. DocumentBuilder를 사용해 기본적인 문서 정보를 설정하고, SwaggerModule을 통해 문서를 생성한 뒤 UI를 연결한다.
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
// main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const config = new DocumentBuilder()
.setTitle('API 문서 제목')
.setDescription('API 설명')
.setVersion('API 버전')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('swagger', app, document);
await app.listen(3000);
}
bootstrap();이 정도 설정만 해두어도 @nestjs/swagger는 컨트롤러에 정의된 엔드포인트들을 자동으로 수집해 Swagger UI를 구성해준다. 이후 개발자가 신경 써야 할 부분은 요청과 응답을 어떻게 표현할지뿐이다.
엔드포인트 설정
@nestjs/swagger는 데코레이터 기반 설정을 적극적으로 활용한다. 각 엔드포인트에는 @ApiOperation 데코레이터를 사용해 요약, 설명, 태그 등을 지정할 수 있다.
@Get()
@ApiOperation({
summary: 'Get all users',
description: 'This endpoint returns a list of all users in the system',
tags: ['users'],
})
getAllUsers() {}또한 엔드포인트가 더 이상 사용되지 않는 경우 deprecated 옵션을 통해 이를 명시할 수도 있다.
컨트롤러 단위로 태그를 묶고 싶다면 @ApiTags를 사용한다. 이때 @ApiOperation의 tags와 함께 사용하면, @ApiTags의 값이 우선 적용된다.
@ApiTags('Report')
@Controller('report')
export class ReportController {}인증 / 인가 설정
Swagger에서는 OpenAPI의 securitySchemes에 대응하는 여러 데코레이터를 제공한다. 기본적으로는 @ApiSecurity를 사용하며, 상황에 따라 @ApiBearerAuth, @ApiOAuth2, @ApiCookieAuth 같은 축약 데코레이터도 사용할 수 있다.
다만 브라우저 보안 정책 때문에 Swagger UI에서 쿠키가 요청 헤더에 포함되지 않는 경우가 있다. 쿠키 기반 인증을 사용하는 경우라면, Swagger UI에서 인증 테스트가 정상적으로 동작하지 않을 수 있다는 점을 염두에 두어야 한다. 이 부분은 Swagger 공식 문서에서도 언급하고 있다.
요청 설정
클라이언트 요청은 쿼리 스트링, 패스 파라미터, 요청 바디를 통해 서버로 전달된다. 이를 Swagger 문서에 명시하기 위해 각각 @ApiQuery, @ApiParam, @ApiBody 데코레이터를 사용한다.
@Get(':id')
@ApiParam({
name: 'id',
description: 'The ID of the user',
type: String,
})
getUserById(@Param('id') id: string) {
return `User with ID ${id}`;
}@Get()
@ApiQuery({ name: 'age', required: false, type: Number })
@ApiQuery({ name: 'gender', required: false, type: String })
getUsers(
@Query('age') age: number,
@Query('gender') gender: string,
) {}@ApiBody({
schema: {
type: 'array',
items: {
type: 'array',
items: { type: 'number' },
},
},
})
async create(@Body() coords: number[][]) {}DTO를 사용하지 않는 경우에도 @nestjs/swagger는 @Body, @Param, @Query를 인식해 기본적인 스키마를 자동 생성한다. 다만 세부적인 옵션은 기본값으로 채워진다.
DTO를 사용하는 경우에는 @ApiProperty, @ApiPropertyOptional을 통해 훨씬 정교한 문서를 만들 수 있다.
export class SignUpDto {
@ApiProperty({ example: 'example@test.com' })
email: string;
@ApiProperty({ example: 'password1234!' })
password: string;
@ApiPropertyOptional({ example: 'https://example.com' })
profileImage?: string;
@ApiPropertyOptional({ example: '안녕하세요' })
bio?: string;
}enum 값이 제한된 경우에도 enum 옵션을 통해 명시할 수 있다.
mapped-types와 Swagger
@nestjs/mapped-types를 사용해 DTO를 파생시키면, @ApiProperty 메타데이터가 사라지는 문제가 있다. 이는 Swagger 문서 생성 시 필요한 메타데이터가 복사되지 않기 때문이다.
이를 해결하기 위해 @nestjs/swagger는 자체적인 mapped-types 유틸리티를 제공한다.
import { PickType } from '@nestjs/swagger';
export class SignInDto extends PickType(SignUpDto, [
'email',
'password',
] as const) {}이 방식을 사용하면 원본 DTO에 정의된 @ApiProperty 정보가 파생 DTO에도 그대로 유지된다.

이미지 업로드
멀티파트 요청 역시 Swagger 문서에 명시할 수 있다.
class FilesUploadDto {
@ApiProperty({
name: 'images',
type: 'array',
format: 'binary',
})
files: Express.Multer.File[];
}@Post('/multi-upload')
@UseInterceptors(FilesInterceptor('images'))
@ApiConsumes('multipart/form-data')
@ApiBody({
type: FilesUploadDto,
})
async putImages(@UploadedFiles() files: Express.Multer.File[]) {}응답 설정
응답은 @ApiResponse 계열 데코레이터를 사용해 정의할 수 있다. 상태 코드별로 이미 분리된 데코레이터들이 제공되며, 이는 Nest.js의 예외 클래스 구조와도 잘 맞는다.
@ApiOkResponse({
description: '로그인 성공',
type: ResponseMessageDto,
})
@Post('signin')
async signIn() {}간단한 응답이라면 DTO 대신 schema를 직접 정의할 수도 있다.
@ApiOkResponse({
description: '로그인 성공',
schema: {
type: 'object',
properties: {
message: { type: 'string' },
},
},
example: { message: '로그인 성공' },
})
@Post('signin')
async signIn(@Body() signInDto: SignInDto, @Res() res: Response) {}Discriminated Union 처리
프론트엔드에서 openapi-typescript 같은 도구를 사용해 타입을 생성하는 경우, 백엔드에서 Discriminated Union 형태로 응답을 설계해주면 타입 안정성이 크게 향상된다.
Nest.js와 Swagger에서는 @ApiExtraModels와 oneOf 옵션을 활용해 이를 명확히 표현할 수 있다. 이렇게 작성하면 Swagger UI에서도 응답 케이스가 분리되어 보이며, 생성된 타입 역시 유니언 타입으로 정확히 추론된다.
@ApiExtraModels(
AnsweredTechQuestionDTO,
NotAnsweredTechQuestionDTO,
)
@ApiOkResponse({
schema: {
oneOf: [
{ $ref: getSchemaPath(AnsweredTechQuestionDTO) },
{ $ref: getSchemaPath(NotAnsweredTechQuestionDTO) },
],
},
})
async getTechQuestionAnswers() {}export class NotAnsweredTechQuestionDTO {
@IsBoolean()
@ApiProperty({
description: '질문에 답변이 있는지 여부',
example: false,
})
isAnswered: false;
}
export class AnsweredTechQuestionDTO {
@IsBoolean()
@ApiProperty({
description: '질문에 답변이 있는지 여부',
example: true,
})
isAnswered: true;
@ApiProperty({
description: '답변 목록',
type: TechQuestionAnswerDTO,
isArray: true,
})
answer: TechQuestionAnswerDTO[];
}Discriminated Union과 undefined 문제
Discriminated Union을 설계할 때 가장 까다로운 부분은 undefined를 어떻게 표현할 것인가이다. Swagger 스키마는 undefined를 타입으로 직접 표현할 수 없기 때문에, type: undefined를 명시하면 순환 참조 오류가 발생한다.
이를 우회하는 방법 중 하나는 enum: [undefined] 형태로 명시하는 것이다. 이 방식은 OpenAPI 스키마에서 해당 필드를 null로 해석하도록 유도하며, 결과적으로 타입 생성 단계에서도 안전하게 처리된다.
class UnEvaluation {
@ApiProperty({
required: false,
enum: [undefined],
})
evaluationType?: undefined;
}/**
* @description Interview evaluation type
* @enum {number}
*/
evaluationType?: null;
/**
* @description Interview evaluation
* @enum {number}
*/
evaluation?: null;다소 트릭처럼 보일 수 있지만, Swagger 스키마 제약과 타입 생성 도구의 한계를 함께 고려했을 때 현실적인 타협안이라고 생각한다.
더 읽어보기
2025.05.21
14. Redis를 사용한 세션 관리 및 캐싱
새로운 프로젝트를 준비하면서 인증 방식부터 다시 고민하게 되었다. 이전 프로젝트에서는 JWT를 사용해 유저 인증과 상태 관리를 처리했지만, 이번에는 Redis를 활용한 세션 방식으로 전환하기로 결정했다. 이 방식은 유저 상태를 서버에서 직접 관리할 수 있어 보안 측면에서도 유리하고, 필…
2025.01.30
비즈니스 로직은 어디에 있어야 할까
Nest.js에서 컨트롤러와 각각의 프로바이더는 분명한 책임을 갖는다. 하지만 막상 서버를 개발하다 보면, 이 책임들을 깊이 고민하지 않은 채 코드를 작성하게 되고, 그 결과 클래스들이 서로의 영역을 침범하는 상황이 반복된다. 나 역시 컨트롤러에 비즈니스 로직이 섞이거나, 서비스가 지나…
2024.12.07
13. Jest 테스트 구성
이 포스트를 시작하기에 앞서 한 가지 분명히 밝혀두고 싶은 점이 있다. 나는 컨트롤러(혹은 GraphQL 기준으로는 리졸버) 와 레포지토리 클래스에 대한 테스트를 거의 작성하지 않는다.이유는 단순하다. 애플리케이션에서 가장 많은 비즈니스 규칙이 응집되어 있는 곳은 서비스 계층이며, 테스…
2024.11.14
12. GraphQL로 요청 처리
GraphQL은 페이스북에서 개발한 데이터 쿼리 언어이자 런타임으로, 기존 REST API가 가진 구조적 한계를 보완하기 위해 등장했다. 전통적인 REST API에서는 각 엔드포인트마다 반환되는 데이터 구조가 고정되어 있고, 동일한 리소스에 대해 서로 다른 작업을 수행하기 위해 HTTP…
2026.04.11
Trie 자료구조
문자열 데이터를 다룰 때 단순히 “이 단어가 있나?”만으로는 부족한 순간이 있다. 자동 완성처럼 특정 접두사로 시작하는 후보를 모아야 할 때도 있고, 어떤 키에 값을 두고 빠르게 찾고 싶을 때도 있다. 이럴 때 가장 자연스럽게 떠올릴 수 있는 자료구조가 바로 Trie이다.Trie는 무엇…
2026.03.19
Streams API 부록 2. 왜 이미지는 위에서 아래로 나타날까
웹 페이지에서 이미지를 로드할 때 흥미로운 장면을 종종 볼 수 있다. 이미지가 한 번에 완전히 나타나는 것이 아니라, 위에서 아래로 조금씩 채워지면서 나타나는 경우가 있기 때문이다. 특히 네트워크가 느리거나 이미지가 큰 경우 이런 현상이 더 분명하게 보인다. 마치 이미지가 위쪽부터 스캔…
2026.03.19
Streams API 부록 1. HTTP 다운로드 진행률은 어떻게 계산될까
파일을 다운로드할 때 가끔 몇 퍼센트 진행되었는지 혹은 진행 막대(progress bar)가 조금씩 채워지는 모습을 볼 수 있다. 그런데 모든 다운로드가 이런 식으로 진행률을 보여 주는 것은 아니다. 어떤 다운로드는 퍼센트가 표시되지만, 어떤 경우에는 진행 막대 없이 로딩 스피너만 계속…
2026.03.13
Streams API 4. 왜 모든 언어에는 Stream API가 존재할까
Streams API를 공부하다 보면 묘한 기시감을 느끼게 된다. JavaScript에서 ReadableStream, WritableStream, TransformStream을 살펴보고 있는데, 어딘가 낯설지 않다. Java를 써 본 사람이라면 InputStream, OutputStre…
댓글
댓글을 불러오는 중...