junyeokk
Blog
NestJS·2025. 11. 28

NestJS Controller

Controller는 HTTP 요청을 받아 적절한 Service에 위임하고, 결과를 응답으로 반환하는 역할을 한다. NestJS에서 Controller는 라우팅과 요청/응답 처리만 담당하고, 비즈니스 로직은 Service에 맡긴다.

@Controller 데코레이터

@Controller() 데코레이터에 경로 접두사(prefix)를 지정하면, 해당 클래스의 모든 라우트에 자동으로 붙는다.

typescript
@Controller('admin/stores')
export class AdminStoresController {
  constructor(private readonly adminStoresService: AdminStoresService) {}
}

이 Controller의 모든 엔드포인트는 /admin/stores로 시작한다. 생성자에서 AdminStoresService를 주입받아 사용하는데, 이는 DI 컨테이너가 자동으로 처리한다.

HTTP 메서드 데코레이터

각 메서드에 HTTP 메서드 데코레이터를 붙여서 엔드포인트를 정의한다.

typescript
@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) 클래스를 타입으로 지정하면 요청 데이터가 해당 타입으로 변환된다.

typescript
@Post()
async createStore(@Body() dto: CreateStoreDto) {
  return this.adminStoresService.createStore(dto);
}

클라이언트가 { "name": "강남점", "address": "서울시 강남구" } JSON을 보내면, dtoCreateStoreDto 타입의 객체가 들어온다.

@Param

URL 경로 파라미터를 추출한다.

typescript
@Get(':id')
async findStoreById(@Param('id') storeId: string) {
  return this.adminStoresService.findStoreById(storeId);
}

GET /admin/stores/abc-123 요청이 오면 storeId"abc-123"이 들어온다. @Param() 인자를 생략하면 모든 경로 파라미터가 객체로 들어온다.

@Query

URL 쿼리 스트링을 추출한다.

typescript
@Get()
async findAll(@Query('page') page: string, @Query('limit') limit: string) {
  // GET /stores?page=1&limit=10
}

@Res

Express의 Response 객체에 직접 접근한다. 파일 다운로드나 스트리밍처럼 NestJS의 기본 응답 방식으로 처리할 수 없는 경우에 사용한다.

typescript
@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 함수를 사용한다.

typescript
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 에러를 던진다.

사용하는 쪽에서는 간결하게 쓸 수 있다.

typescript
@Post()
async createSession(
  @Body() dto: CreateSessionDto,
  @DeviceId() deviceId: string,   // 커스텀 데코레이터
) {
  return this.sessionsService.createSession(dto, deviceId);
}

@DeviceId()만 붙이면 헤더 추출과 검증이 자동으로 이루어진다. 여러 Controller에서 반복되는 헤더 파싱 로직을 데코레이터 하나로 캡슐화한 것이다.

Interceptor

Interceptor는 요청 전후에 추가 로직을 실행한다. 파일 업로드 처리가 대표적인 사례다.

typescript
@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 메서드에 도달하기까지 여러 단계를 거친다. 각 단계는 독립적으로 동작하며, 특정 관심사를 처리한다.

text
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에서 확인할 수 있는 문서가 만들어진다.

typescript
@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에 전달하고, 결과를 반환하는 것이 전부다.

typescript
// 좋은 예: 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 데코레이터는 런타임에 영향 없이 문서를 자동 생성한다

관련 문서