Post

NestJS 개념 정리 - Controller, Provider, Module, Middleware

NestJS 개념 정리 - Controller, Provider, Module, Middleware

1. 개요


NestJS를 처음 배울 때 가장 먼저 만나는 개념이 Controller, Provider, Module, Middleware다. 이 4가지는 독립적으로 존재하지 않고 서로 역할을 분담하면서 하나의 요청-응답 사이클을 완성한다. 이번 글에서는 공식문서 기반으로 각 개념을 정리한다.

📌 전체 역할 구조

개념역할핵심 데코레이터
ControllerHTTP 요청 수신 → 라우팅 → 응답 반환@Controller(), @Get(), @Post()
Provider비즈니스 로직 처리 (Service, Repository 등)@Injectable()
Module관련 컴포넌트 묶음 + DI 컨텍스트 정의@Module()
Middleware라우트 핸들러 이전에 실행되는 함수NestMiddleware

💡 요청이 들어오면 Middleware → Controller → Provider 순서로 처리된다. Module은 이 전체를 조직하는 컨테이너다.

📌 모듈 구조 한눈에 보기

NestJS 모듈 구조


2. Controller


컨트롤러는 들어오는 요청을 처리하고 클라이언트에 응답을 반환하는 역할을 한다. 라우팅 메커니즘이 어떤 컨트롤러가 요청을 처리할지 결정한다.

📌 기본 구조

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// cats.controller.ts
import {
  Controller, Get, Post, Put, Delete,
  Body, Param, Query
} from '@nestjs/common';
import { CreateCatDto } from './dto/create-cat.dto';

@Controller('cats') // ← '/cats' 경로 prefix
export class CatsController {

  @Post()
  create(@Body() createCatDto: CreateCatDto) {
    return 'This action adds a new cat';
  }

  @Get()
  findAll(@Query('breed') breed: string) {
    return `All cats, breed: ${breed}`;
  }

  @Get(':id')                      // ← GET /cats/:id
  findOne(@Param('id') id: string) {
    return `Cat #${id}`;
  }

  @Put(':id')
  update(@Param('id') id: string, @Body() updateDto: any) {
    return `Update cat #${id}`;
  }

  @Delete(':id')
  remove(@Param('id') id: string) {
    return `Remove cat #${id}`;
  }
}

📌 요청 데이터 추출 데코레이터

데코레이터매핑설명
@Req()req전체 요청 객체
@Body(key?)req.body[key]요청 바디
@Param(key?)req.params[key]URL 경로 파라미터
@Query(key?)req.query[key]쿼리 스트링
@Headers(name?)req.headers[name]요청 헤더
@Ip()req.ip클라이언트 IP
@Res()res응답 객체 (Library-specific mode 진입)

📌 DTO는 interface가 아닌 class로

TypeScript interface는 컴파일 후 사라진다. Pipe 같은 기능이 런타임에 metatype에 접근할 때 class만 유효하다.

1
2
3
4
5
6
// create-cat.dto.ts
export class CreateCatDto {
  name: string;
  age: number;
  breed: string;
}

📌 응답 처리 방식 비교

방식설명주의
Standard (권장)객체/배열 반환 시 자동 JSON 직렬화. 기본 200, POST는 201Interceptor, @HttpCode() 등 NestJS 기능과 완전 호환
Library-specific@Res() 주입 후 res.json(), res.send() 직접 호출플랫폼 종속, Interceptor 비활성화

💡 @Res({ passthrough: true })를 쓰면 쿠키/헤더 세팅은 직접 하면서 응답 처리는 NestJS에 위임할 수 있다.

1
2
3
4
5
@Get()
findAll(@Res({ passthrough: true }) res: Response) {
  res.status(HttpStatus.OK); // 헤더만 직접 설정
  return [];                 // 응답 처리는 NestJS가
}

3. Provider


프로바이더는 NestJS의 핵심 개념이다. 서비스, 레포지토리, 팩토리, 헬퍼 등 많은 기본 클래스들이 프로바이더로 취급될 수 있다. 핵심 아이디어는 의존성으로 주입(inject)될 수 있다는 것이며, 이 “배선” 작업은 Nest 런타임 시스템이 처리한다.

📌 서비스 정의

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// cats.service.ts
import { Injectable } from '@nestjs/common';
import { Cat } from './interfaces/cat.interface';

/**
 * @Injectable() 데코레이터가 핵심.
 * Nest IoC 컨테이너가 이 클래스를 관리할 수 있도록 메타데이터를 부착한다.
 * 다른 클래스에서 constructor를 통해 주입받을 수 있게 된다.
 */
@Injectable()
export class CatsService {
  private readonly cats: Cat[] = [];

  create(cat: Cat) {
    this.cats.push(cat);
  }

  findAll(): Cat[] {
    return this.cats;
  }
}

📌 의존성 주입 (DI)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// cats.controller.ts
@Controller('cats')
export class CatsController {
  /**
   * TypeScript 타입 정보를 기반으로 Nest가 자동으로 CatsService를 주입한다.
   * private 키워드로 선언과 초기화를 한 줄에 처리.
   */
  constructor(private catsService: CatsService) {}

  @Get()
  async findAll(): Promise<Cat[]> {
    return this.catsService.findAll();
  }
}

📌 주입 방식 비교

방식코드권장 여부
Constructor-basedconstructor(private svc: Service) {}✅ 권장 - 의존성이 명확히 드러남
Property-based@Inject('TOKEN') private svc: Service상속 계층이 깊을 때만 사용

📌 Provider Scope (생명주기)

프로바이더는 기본적으로 앱 라이프사이클과 동일한 수명을 가진다. 앱 부트스트랩 시 인스턴스화되고 종료 시 소멸된다.

Scope설명사용 사례
DEFAULT싱글톤 (앱 전체에서 동일 인스턴스)대부분의 서비스
REQUESTHTTP 요청마다 새 인스턴스 생성요청별 캐싱, 멀티테넌시
TRANSIENT주입받는 곳마다 새 인스턴스 생성상태 없는 유틸리티

📌 선택적 프로바이더

1
2
3
4
5
6
7
8
9
10
import { Injectable, Optional, Inject } from '@nestjs/common';

@Injectable()
export class HttpService<T> {
  /**
   * HTTP_OPTIONS가 없어도 에러가 발생하지 않는다.
   * 설정 객체처럼 "있으면 쓰고 없으면 기본값" 패턴에 유용하다.
   */
  constructor(@Optional() @Inject('HTTP_OPTIONS') private httpClient: T) {}
}

4. Module


모듈은 @Module() 데코레이터로 어노테이션된 클래스다. 모든 Nest 앱은 최소 하나의 루트 모듈(AppModule)을 가지며, 이것이 Nest가 애플리케이션 그래프를 구성하는 시작점이 된다.

📌 @Module() 데코레이터 속성

속성설명
providers이 모듈에서 인스턴스화될 프로바이더 목록
controllers이 모듈에서 인스턴스화될 컨트롤러 목록
imports이 모듈에 필요한 프로바이더를 export하는 모듈 목록
exports다른 모듈에서 사용할 수 있도록 공개하는 프로바이더 목록

📌 Feature Module (기능 모듈)

1
2
3
4
5
6
7
8
9
10
11
src/
├── cats/
│   ├── dto/
│   │   └── create-cat.dto.ts
│   ├── interfaces/
│   │   └── cat.interface.ts
│   ├── cats.controller.ts
│   ├── cats.service.ts
│   └── cats.module.ts   ← Feature Module
├── app.module.ts        ← Root Module
└── main.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// cats/cats.module.ts
import { Module } from '@nestjs/common';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';

@Module({
  controllers: [CatsController],
  providers: [CatsService],
  // exports에 추가해야 다른 모듈에서 CatsService를 주입받을 수 있다
})
export class CatsModule {}

// app.module.ts
import { Module } from '@nestjs/common';
import { CatsModule } from './cats/cats.module';

@Module({
  imports: [CatsModule],
})
export class AppModule {}

📌 Shared Module (공유 모듈)

Nest에서 모듈은 기본적으로 싱글톤이다. 동일한 Provider 인스턴스를 여러 모듈에서 공유할 수 있다.

1
2
3
4
5
6
@Module({
  controllers: [CatsController],
  providers: [CatsService],
  exports: [CatsService], // ← 이제 CatsModule을 import하는 모든 모듈이 CatsService 사용 가능
})
export class CatsModule {}

💡 exports 없이 각 모듈이 직접 등록하면 별도 인스턴스가 생겨 메모리 낭비 + 상태 불일치가 발생할 수 있다. exports를 통한 공유가 정석이다.

📌 Global Module

1
2
3
4
5
6
7
8
9
10
11
12
13
import { Module, Global } from '@nestjs/common';

/**
 * DB 연결, 공통 헬퍼처럼 어디서나 필요한 것들에만 사용한다.
 * 남발하면 모듈 간 결합도가 높아져 유지보수가 어려워진다.
 */
@Global()
@Module({
  providers: [CatsService],
  exports: [CatsService],
})
export class CatsModule {}
// → 이 모듈을 import하지 않아도 CatsService를 바로 주입받을 수 있다

📌 Dynamic Module

런타임에 설정을 받아 동적으로 프로바이더를 생성해야 할 때 사용한다. TypeOrmModule.forRoot(), ConfigModule.forRoot() 같은 라이브러리들이 이 패턴으로 만들어져 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// database.module.ts
import { Module, DynamicModule } from '@nestjs/common';

@Module({
  providers: [Connection],
  exports: [Connection],
})
export class DatabaseModule {
  /**
   * 정적 메서드로 DynamicModule을 반환한다.
   * 반환 객체가 @Module()의 기본 메타데이터를 extend(오버라이드 아님)한다.
   */
  static forRoot(entities = [], options?): DynamicModule {
    const providers = createDatabaseProviders(options, entities);
    return {
      module: DatabaseModule,
      providers: providers,
      exports: providers,
    };
  }
}

// 사용 예
@Module({
  imports: [DatabaseModule.forRoot([User, Product])],
})
export class AppModule {}

5. Middleware


미들웨어는 라우트 핸들러보다 먼저 호출되는 함수다. request, response 객체와 next() 함수에 접근할 수 있다. Nest 미들웨어는 기본적으로 Express 미들웨어와 동일하다.

미들웨어에서 할 수 있는 것: 임의 코드 실행, request/response 객체 변경, 요청-응답 사이클 종료, 스택의 다음 미들웨어 호출.

📌 클래스 기반 미들웨어

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// common/middleware/logger.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';

/**
 * @Injectable()이 붙으므로 DI가 가능하다.
 * → 같은 모듈의 Service를 constructor로 주입받을 수 있다.
 */
@Injectable()
export class LoggerMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    console.log(`[${req.method}] ${req.url}`);
    next(); // ← 반드시 호출해야 한다. 안 하면 요청이 멈춰버린다.
  }
}

📌 함수형 미들웨어

1
2
3
4
5
6
7
// 의존성이 없는 단순한 미들웨어라면 함수로 작성하는 것이 더 간결하다.
import { Request, Response, NextFunction } from 'express';

export function logger(req: Request, res: Response, next: NextFunction) {
  console.log(`Request...`);
  next();
}

📌 미들웨어 적용 방법

미들웨어는 @Module() 데코레이터 안에 넣을 수 없다. 모듈 클래스의 configure() 메서드를 통해 설정한다. 미들웨어를 포함하는 모듈은 반드시 NestModule 인터페이스를 구현해야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// app.module.ts
import {
  Module, NestModule, MiddlewareConsumer, RequestMethod
} from '@nestjs/common';
import { LoggerMiddleware } from './common/middleware/logger.middleware';
import { CatsModule } from './cats/cats.module';
import { CatsController } from './cats/cats.controller';

@Module({
  imports: [CatsModule],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(LoggerMiddleware)        // 적용할 미들웨어
      .exclude(                       // 제외할 라우트 (선택)
        { path: 'cats', method: RequestMethod.POST },
        'cats/{*splat}',
      )
      .forRoutes(CatsController);     // 문자열, RouteInfo, Controller 모두 가능
  }
}

📌 미들웨어 적용 범위 비교

적용 방식코드설명
특정 경로.forRoutes('cats')‘/cats’ 경로에만
경로 + 메서드.forRoutes({ path: 'cats', method: RequestMethod.GET })GET /cats에만
컨트롤러 단위.forRoutes(CatsController)컨트롤러 전체 라우트에 (권장)
복수 미들웨어.apply(cors(), helmet(), logger)순서대로 실행
전역 (main.ts)app.use(logger)함수형만 가능, DI 불가

💡 전역 미들웨어(app.use())에서는 DI 컨테이너에 접근할 수 없다. 의존성이 필요한 미들웨어는 .forRoutes('*')를 써야 한다.


6. 정리


NestJS 요청 처리 흐름

  • Controller는 HTTP 요청을 받아 라우팅하고, 실제 로직은 Provider에 위임한다.
  • Provider는 @Injectable()로 등록되며, DI를 통해 자동으로 주입된다. 기본 싱글톤.
  • Module은 Controller + Provider를 하나의 단위로 묶는다. exports를 통해 Provider를 외부에 공개할 수 있다.
  • Middleware는 route handler 이전에 실행되며, @Module() 밖에서 configure()로 등록한다.
  • 이 4가지가 조합되어 요청 → Middleware → Controller → Provider → 응답 흐름이 완성된다.

참고 자료

This post is licensed under CC BY 4.0 by the author.