NestJS Controller
Controller는 HTTP 요청을 받아 적절한 Service에 위임하고, 결과를 응답으로 반환하는 역할을 한다. NestJS에서 Controller는 라우팅과 요청/응답 처리만 담당하고, 비즈니스 로직은 Service에 맡긴다.
@Controller 데코레이터
@Controller() 데코레이터에 경로 접두사(prefix)를 지정하면, 해당 클래스의 모든 라우트에 자동으로 붙는다.
@Controller('admin/stores')
export class AdminStoresController {
constructor(private readonly adminStoresService: AdminStoresService) {}
}
이 Controller의 모든 엔드포인트는 /admin/stores로 시작한다. 생성자에서 AdminStoresService를 주입받아 사용하는데, 이는 DI 컨테이너가 자동으로 처리한다.
HTTP 메서드 데코레이터
각 메서드에 HTTP 메서드 데코레이터를 붙여서 엔드포인트를 정의한다.
@Controller('admin/stores')
export class AdminStoresController {
@Post()
async createStore(@Body() dto: CreateStoreDto) {
return this.adminStoresService.createStore(dto);
}
@Get()
async findAllStores() {
return this.adminStoresService.findAllStores();
}
@Get(':id')
async findStoreById(@Param('id') storeId: string) {
return this.adminStoresService.findStoreById(storeId);
}
@Put(':id')
async updateStore(
@Param('id') storeId: string,
@Body() dto: UpdateStoreDto,
) {
return this.adminStoresService.updateStore(storeId, dto);
}
}
위 코드는 다음 엔드포인트를 생성한다.
| 데코레이터 | 경로 | HTTP 메서드 |
|---|---|---|
@Post() | POST /admin/stores | 매장 생성 |
@Get() | GET /admin/stores | 매장 목록 조회 |
@Get(':id') | GET /admin/stores/:id | 매장 상세 조회 |
@Put(':id') | PUT /admin/stores/:id | 매장 수정 |
:id는 경로 파라미터다. 실제 요청에서 GET /admin/stores/abc-123처럼 구체적인 값이 들어온다.
Controller 메서드가 객체를 반환하면 NestJS가 자동으로 JSON으로 직렬화해서 응답한다. @Post()는 기본 상태 코드 201을, 나머지는 200을 반환한다.
파라미터 데코레이터
HTTP 요청의 여러 부분에서 값을 추출하는 데코레이터가 있다.
@Body
요청 본문을 추출한다. DTO(Data Transfer Object) 클래스를 타입으로 지정하면 요청 데이터가 해당 타입으로 변환된다.
@Post()
async createStore(@Body() dto: CreateStoreDto) {
return this.adminStoresService.createStore(dto);
}
클라이언트가 { "name": "강남점", "address": "서울시 강남구" } JSON을 보내면, dto에 CreateStoreDto 타입의 객체가 들어온다.
@Param
URL 경로 파라미터를 추출한다.
@Get(':id')
async findStoreById(@Param('id') storeId: string) {
return this.adminStoresService.findStoreById(storeId);
}
GET /admin/stores/abc-123 요청이 오면 storeId에 "abc-123"이 들어온다. @Param() 인자를 생략하면 모든 경로 파라미터가 객체로 들어온다.
@Query
URL 쿼리 스트링을 추출한다.
@Get()
async findAll(@Query('page') page: string, @Query('limit') limit: string) {
// GET /stores?page=1&limit=10
}
@Res
Express의 Response 객체에 직접 접근한다. 파일 다운로드나 스트리밍처럼 NestJS의 기본 응답 방식으로 처리할 수 없는 경우에 사용한다.
@Get(':sessionId/qr-code')
@Header('Cache-Control', 'public, max-age=3600')
async getSessionQrCode(
@Param('sessionId') sessionId: string,
@Res({ passthrough: true }) res: Response,
): Promise<StreamableFile> {
const qrImageBuffer = await this.sessionsService.getSessionQrCode(sessionId);
res.set({ 'Content-Type': 'image/png' });
return new StreamableFile(qrImageBuffer);
}
@Res({ passthrough: true })는 Response 객체에 접근하면서도 NestJS의 응답 처리 파이프라인을 유지한다. passthrough를 빼면 NestJS가 응답을 자동 처리하지 않으므로, 직접 res.send()를 호출해야 한다.
커스텀 파라미터 데코레이터
기본 데코레이터로 충분하지 않을 때 커스텀 데코레이터를 만들 수 있다. createParamDecorator 함수를 사용한다.
export const DEVICE_ID_HEADER = 'x-device-id';
export const DeviceId = createParamDecorator(
(data: unknown, ctx: ExecutionContext): string => {
const request = ctx.switchToHttp().getRequest();
const deviceId = request.headers[DEVICE_ID_HEADER];
if (!deviceId) {
throw new BadRequestException(
`Missing required header: ${DEVICE_ID_HEADER}`,
);
}
return deviceId;
},
);
createParamDecorator의 콜백이 받는 ctx(ExecutionContext)에서 HTTP 요청 객체에 접근할 수 있다. 여기서는 x-device-id 헤더 값을 추출하고, 없으면 400 에러를 던진다.
사용하는 쪽에서는 간결하게 쓸 수 있다.
@Post()
async createSession(
@Body() dto: CreateSessionDto,
@DeviceId() deviceId: string, // 커스텀 데코레이터
) {
return this.sessionsService.createSession(dto, deviceId);
}
@DeviceId()만 붙이면 헤더 추출과 검증이 자동으로 이루어진다. 여러 Controller에서 반복되는 헤더 파싱 로직을 데코레이터 하나로 캡슐화한 것이다.
Interceptor
Interceptor는 요청 전후에 추가 로직을 실행한다. 파일 업로드 처리가 대표적인 사례다.
@Patch(':sessionId/complete')
@UseInterceptors(
FileFieldsInterceptor([
{ name: 'composedImage', maxCount: 1 },
{ name: 'composedImageWithQR', maxCount: 1 },
{ name: 'videos', maxCount: 16 },
]),
)
@ApiConsumes('multipart/form-data')
async completeSession(
@Param('sessionId') sessionId: string,
@Body() body: CompleteSessionBodyDto,
@UploadedFiles() files: {
composedImage?: Express.Multer.File[];
composedImageWithQR?: Express.Multer.File[];
videos?: Express.Multer.File[];
},
) {
if (!files.composedImage?.[0]) {
throw new BadRequestException('composedImage is required');
}
// ...
}
@UseInterceptors(FileFieldsInterceptor(...))는 multipart/form-data 요청을 파싱해서 파일을 추출한다. FileFieldsInterceptor에 필드 이름과 최대 파일 수를 지정하면, @UploadedFiles()로 파싱된 파일 객체를 받을 수 있다.
Interceptor는 요청이 핸들러에 도달하기 전에 실행되므로, 핸들러 메서드에서는 이미 파싱된 파일 데이터를 사용할 수 있다.
요청 라이프사이클
HTTP 요청이 Controller 메서드에 도달하기까지 여러 단계를 거친다. 각 단계는 독립적으로 동작하며, 특정 관심사를 처리한다.
Client Request
↓
Middleware → 요청/응답 변환, 로깅
↓
Guard → 인증/인가 (요청을 허용할지 거부할지)
↓
Interceptor (전) → 요청 전처리 (파일 파싱 등)
↓
Pipe → 데이터 변환/검증 (DTO 유효성 검사)
↓
Controller Handler → 비즈니스 로직 호출
↓
Interceptor (후) → 응답 후처리 (캐싱, 로깅 등)
↓
Exception Filter → 예외 발생 시 에러 응답 생성
↓
Client Response
Guard에서 거부되면 Controller 메서드는 실행되지 않는다. Pipe에서 유효성 검사가 실패해도 마찬가지다. 각 단계가 관심사를 분리해서, Controller는 순수하게 라우팅과 위임에만 집중할 수 있다.
Swagger 데코레이터
NestJS는 @nestjs/swagger 패키지로 API 문서를 자동 생성한다. Controller 메서드에 데코레이터를 붙이면 Swagger UI에서 확인할 수 있는 문서가 만들어진다.
@ApiTags('admin')
@Controller('admin/stores')
export class AdminStoresController {
@Post()
@ApiOperation({ summary: '매장 생성' })
@ApiResponse({ status: 201, description: '매장이 성공적으로 생성됨', type: AdminStoreResponseDto })
@ApiResponse({ status: 404, description: '매장을 찾을 수 없음' })
async createStore(@Body() dto: CreateStoreDto) {
return this.adminStoresService.createStore(dto);
}
}
| 데코레이터 | 역할 |
|---|---|
@ApiTags('admin') | Swagger UI에서 그룹 분류 |
@ApiOperation({ summary }) | 엔드포인트 설명 |
@ApiResponse({ status, description }) | 가능한 응답 상태와 설명 |
@ApiParam({ name, description }) | 경로 파라미터 문서화 |
@ApiBody({ type }) | 요청 본문 스키마 문서화 |
@ApiConsumes('multipart/form-data') | 요청 Content-Type 명시 |
이 데코레이터들은 런타임 동작에 영향을 주지 않는다. 순수하게 문서 생성을 위한 메타데이터를 추가하는 역할이다.
Controller가 하지 말아야 할 것
Controller는 "얇게" 유지해야 한다. HTTP 요청을 받아서 Service에 전달하고, 결과를 반환하는 것이 전부다.
// 좋은 예: Controller는 위임만 한다
@Post()
async createStore(@Body() dto: CreateStoreDto) {
return this.adminStoresService.createStore(dto);
}
// 나쁜 예: Controller에 비즈니스 로직이 들어감
@Post()
async createStore(@Body() dto: CreateStoreDto) {
const existing = await this.storesRepository.findByName(dto.name);
if (existing) throw new ConflictException('이미 존재하는 매장');
const store = new Store(dto.name, dto.address);
await this.storesRepository.create(store);
return store;
}
나쁜 예에서는 Controller가 Repository에 직접 접근하고, 중복 검사 로직까지 수행한다. 이 로직이 Controller에 있으면 다른 곳(예: 배치 작업, 다른 Service)에서 매장 생성이 필요할 때 재사용할 수 없다. Service에 두면 어디서든 호출 가능하다.
파일 필수 여부 검사처럼 HTTP 요청 형식에 관한 검증은 Controller에 있어도 괜찮다. "composedImage 파일이 있는가"는 HTTP 요청의 형식 문제이지 비즈니스 규칙이 아니기 때문이다.
왜 NestJS Controller인가
Express에서는 라우트 핸들러가 곧 비즈니스 로직이 되기 쉽다. app.get('/users', (req, res) => { ... })에 모든 코드가 들어가면서 파일이 비대해지고, 미들웨어 순서에 의존하는 암묵적 구조가 만들어진다. NestJS Controller는 데코레이터 기반 라우팅으로 선언적이고, DI를 통해 Service 분리가 자연스럽다. Guard/Pipe/Interceptor가 라이프사이클 단계별로 관심사를 분리해주므로, Controller 자체는 라우팅과 위임에만 집중할 수 있다. Fastify 같은 다른 프레임워크도 플러그인 시스템으로 구조화를 지원하지만, NestJS는 모듈 시스템과 DI 컨테이너가 기본 제공되어 팀 단위 컨벤션을 강제하기 쉽다.
정리
- Controller는 라우팅과 Service 위임만 담당하고, 비즈니스 로직은 Service에 둬야 재사용과 테스트가 가능하다
- Guard → Interceptor → Pipe → Handler → Interceptor → Filter 라이프사이클이 관심사를 단계별로 분리한다
- @Res({ passthrough: true })로 Response 접근하면서도 NestJS 응답 파이프라인을 유지할 수 있고, Swagger 데코레이터는 런타임에 영향 없이 문서를 자동 생성한다