Framework/NestJS

NestJS 완전정복: 모듈, 컨트롤러, 서비스의 모든 것

Joonfluence 2025. 5. 16.

1. NestJS의 아키텍처 개요

NestJS는 Node.js 환경에서 엔터프라이즈급 서버 애플리케이션을 개발할 수 있도록 설계된 프레임워크입니다. Angular에서 영감을 받은 구조와 데코레이터 기반 프로그래밍, 그리고 강력한 의존성 주입(Dependency Injection, DI) 시스템을 갖추고 있습니다. NestJS의 핵심은 모듈(Module), 컨트롤러(Controller), 서비스(Service) 세 가지 컴포넌트로 구성됩니다. 이 세 가지는 각각의 역할이 명확하게 분리되어 있으며, 대규모 프로젝트에서도 유지보수성과 확장성을 극대화할 수 있도록 설계되어 있습니다

2. 모듈(Module): 기능 단위와 의존성 관리의 중심

2.1. 모듈이란?

모듈은 NestJS 애플리케이션의 구조를 구성하는 기본 단위입니다. 하나의 모듈은 관련된 컨트롤러, 서비스, 프로바이더 등을 하나로 묶어 관리합니다. NestJS 애플리케이션은 최소한 하나의 루트 모듈(AppModule)을 가져야 하며, 실제로는 여러 기능별 모듈로 분리하여 개발하는 것이 일반적입니다. 또 Nest에서는 모듈이 기본적으로 싱글턴이므로 여러 모듈 간에 모든 공급자의 동일한 인스턴스를 손쉽게 공유할 수 있습니다.

2.2. 모듈의 역할

  • 기능별 분리: 사용자, 게시글, 인증 등 각 기능별로 모듈을 만들어 코드의 응집도와 재사용성을 높입니다.
  • 의존성 관리: 모듈 간 의존성을 명확하게 관리할 수 있습니다. 필요한 모듈만 imports에 명시적으로 추가하여 의존성을 주입받습니다.
  • 스케일링: 대규모 애플리케이션도 모듈 단위로 확장 및 유지보수가 쉽습니다.

2.3. 모듈의 구조

모듈은 @Module() 데코레이터로 정의하며, 다음과 같은 속성을 가집니다.

  • imports: 다른 모듈을 현재 모듈에 가져올 때 사용합니다.
  • controllers: 이 모듈에서 관리하는 컨트롤러 목록입니다.
  • providers: 서비스, 리포지토리 등 DI로 주입할 객체(프로바이더) 목록입니다.
  • exports: 현재 모듈에서 외부로 공개할 프로바이더 목록입니다(다른 모듈에서 사용 가능).
@Module({
  imports: [UsersModule],
  controllers: [PostsController],
  providers: [PostsService],
  exports: [PostsService],
})
export class PostsModule {}

2.4 모듈의 의존성 주입

또 모듈에서도 아래와 같은 방식으로 의존성을 주입 받을 수 있습니다.

@Module({
  controllers: [CatsController],
  providers: [CatsService],
})
export class CatsModule {
  constructor(private catsService: CatsService) {}
}

2.5. 글로벌 모듈

특정 모듈을 앱 전체에서 한 번만 등록하고 싶을 때 @Global() 데코레이터를 사용해 글로벌 모듈로 만들 수 있습니다. 예를 들어, 데이터베이스 연결 모듈 등은 글로벌로 등록하는 것이 일반적입니다.

import { Module, Global } from '@nestjs/common';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';

@Global()
@Module({
  controllers: [CatsController],
  providers: [CatsService],
  exports: [CatsService],
})
export class CatsModule {}

2.6 동적 모듈

Nest의 동적 모듈을 사용하면 런타임에 구성 가능한 모듈을 생성할 수 있습니다. 이는 특히 특정 옵션이나 구성에 따라 제공자를 생성할 수 있는 유연하고 사용자 정의 가능한 모듈을 제공해야 할 때 유용합니다.


import { Module, DynamicModule } from '@nestjs/common';
import { createDatabaseProviders } from './database.providers';
import { Connection } from './connection.provider';

@Module({
  providers: [Connection],
  exports: [Connection],
})
export class DatabaseModule {
  static forRoot(entities = [], options?): DynamicModule {
    const providers = createDatabaseProviders(options, entities);
    return {
      module: DatabaseModule,
      providers: providers,
      exports: providers,
    };
  }
}

3. 컨트롤러(Controller): 라우팅과 요청/응답 처리

3.1. 컨트롤러란?

컨트롤러는 들어오는 HTTP 요청을 처리하고, 클라이언트에 응답을 반환하는 역할을 합니다. NestJS에서 컨트롤러는 주로 라우팅을 담당하며, 실제 비즈니스 로직은 서비스에 위임합니다. 컨트롤러는 @Controller() 데코레이터로 정의하며, 각 메서드는 HTTP 메서드 데코레이터(@Get, @Post 등)로 라우팅을 지정합니다.

3.2. 컨트롤러의 구조와 라우팅

컨트롤러 클래스는 @Controller() 데코레이터로 선언하며, 데코레이터의 인자로 기본 경로를 지정할 수 있습니다. 각 메서드는 @Get(), @Post(), @Put(), @Delete(), @Patch(), @Options(), @Head() 등 HTTP 메서드 데코레이터로 라우팅을 지정합니다.

import { Controller, Get, Post, Body, Param, Query, Req, Res, HttpCode, Header } from '@nestjs/common';
import { CatsService } from './cats.service';
import { CreateCatDto } from './dto/create-cat.dto';
import { Cat } from './interfaces/cat.interface';

@Controller('cats')
export class CatsController {
  constructor(private catsService: CatsService) {}

  @Post()
  async create(@Body() createCatDto: CreateCatDto) {
    this.catsService.create(createCatDto);
  }

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

  @Get(':id')
  findOne(@Param('id') id: string): Cat {
    return this.catsService.findOne(id);
  }
}

3.3. 라우팅 데코레이터

  • @Get(), @Post(), @Put(), @Delete(), @Patch(), @Options(), @Head(): 각각 HTTP 메서드에 해당하는 요청을 처리합니다.
  • @Controller('cats'): 이 컨트롤러의 기본 경로를 지정합니다. 예를 들어, @Get(':id')는 /cats/:id 경로와 매핑됩니다.

3.4. 파라미터 데코레이터

NestJS는 다양한 데코레이터를 통해 요청의 여러 부분을 메서드 인자로 받을 수 있습니다.

  • @Param(): URL 경로 파라미터 추출
  • @Query(): 쿼리스트링 추출
  • @Body(): 요청 본문 데이터 추출
  • @Headers(): 요청 헤더 추출
  • @Req(): Express의 Request 객체 주입
  • @Res(): Express의 Response 객체 주입 (권장 방식은 아님)
  • @Ip(): 요청자의 IP 주소 추출
  • @Session(): 세션 객체 추출
  • @HostParam(): 호스트 파라미터 추출

예시:

@Get(':id')
findOne(@Param('id') id: string, @Query('details') details: boolean) {
  // ...
}

3.5. 응답 반환 및 상태 코드 제어

NestJS는 메서드의 반환값을 자동으로 JSON으로 변환해 응답합니다. 필요에 따라 @HttpCode(), @Header() 데코레이터로 상태 코드나 헤더를 직접 지정할 수 있습니다.

@Post()
@HttpCode(204)
@Header('Cache-Control', 'none')
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}

3.6. 커스텀 응답 (Express Response 객체 직접 사용)

특정 상황에서는 Express의 Response 객체를 직접 다뤄야 할 수도 있습니다. 이때는 @Res()를 사용합니다. 단, 이 방식은 Nest의 추상화된 응답 처리 흐름을 우회하므로, 특별한 경우에만 사용을 권장합니다.

@Get('custom')
findCustom(@Res() res) {
  res.status(200).json({ message: 'Custom response' });
}

⚠️ @Res()를 사용하면 Nest의 자동 응답 처리가 동작하지 않으므로, 반드시 직접 응답을 반환해야 합니다.

3.7. 의존성 주입과 컨트롤러

컨트롤러는 생성자에서 서비스 등 프로바이더를 주입받아 사용할 수 있습니다. NestJS의 DI 컨테이너가 자동으로 인스턴스를 주입해줍니다.

constructor(private catsService: CatsService) {}

3.8. 라우트 경로 결합

컨트롤러의 @Controller() 데코레이터에 지정한 경로와, 각 메서드의 데코레이터에 지정한 경로가 결합되어 최종 라우트가 결정됩니다.

@Controller('cats')
export class CatsController {
  @Get('profile')
  getProfile() { /* ... */ }
}
// => GET /cats/profile

3.9. 중첩 라우팅(서브라우터)

컨트롤러를 계층적으로 중첩하여 라우팅 구조를 만들 수 있습니다. 예를 들어, /cats/:catId/photos와 같은 경로를 만들 때 유용합니다.

@Controller('cats/:catId/photos')
export class CatPhotosController {
  @Get(':photoId')
  getPhoto(@Param('catId') catId: string, @Param('photoId') photoId: string) {
    // ...
  }
}

3.10. 비동기 컨트롤러 메서드

NestJS는 Promise를 반환하는 비동기 메서드를 완벽하게 지원합니다. async/await를 자유롭게 사용할 수 있습니다.

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

3.11. 컨트롤러에서의 예외 처리

NestJS는 예외가 발생하면 자동으로 HTTP 예외로 변환해줍니다. 필요시 throw new HttpException() 또는 내장 예외 클래스를 사용할 수 있습니다.

import { NotFoundException } from '@nestjs/common';

@Get(':id')
findOne(@Param('id') id: string) {
  const cat = this.catsService.findOne(id);
  if (!cat) {
    throw new NotFoundException('Cat not found');
  }
  return cat;
}

3.12. API 문서화와 Swagger 연동

NestJS는 @nestjs/swagger 패키지를 통해 컨트롤러와 DTO를 기반으로 자동 API 문서화를 지원합니다. 각 메서드와 파라미터에 데코레이터를 추가해 문서화할 수 있습니다.

import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';

@ApiTags('cats')
@Controller('cats')
export class CatsController {
  @ApiOperation({ summary: 'Get all cats' })
  @ApiResponse({ status: 200, description: 'Return all cats.' })
  @Get()
  findAll() { /* ... */ }
}

3.13. 기타 고급 기능

  • 라우트 가드, 인터셉터, 파이프: 컨트롤러 레벨에서 인증, 로깅, 유효성 검사 등 다양한 기능을 데코레이터로 적용할 수 있습니다.
  • 커스텀 데코레이터: @User() 등 커스텀 파라미터 데코레이터를 만들어 사용할 수 있습니다.
  • 컨트롤러 레벨 미들웨어: 특정 컨트롤러에만 미들웨어를 적용할 수 있습니다.

이처럼 NestJS의 컨트롤러는 라우팅, 요청 데이터 추출, 응답 반환, 예외 처리, 의존성 주입 등 HTTP 요청 처리의 중심 역할을 하며, 공식문서의 다양한 기능을 적극적으로 활용하면 생산적이고 유지보수성 높은 코드를 작성할 수 있습니다.

자세한 내용과 예제는 공식 문서에서 확인할 수 있습니다.
https://docs.nestjs.com/controllers

4. 서비스(Service): 비즈니스 로직과 의존성 주입

4.1. 서비스란?

서비스는 실제 비즈니스 로직을 담당하는 계층입니다. 데이터베이스 접근, 외부 API 호출, 복잡한 연산 등 컨트롤러에서 분리해야 할 모든 로직을 서비스에 구현합니다.

4.2. 서비스의 역할

  • 비즈니스 로직 구현: 컨트롤러는 요청/응답만 담당하고, 실제 로직은 서비스에서 처리합니다.
  • 재사용성: 여러 컨트롤러에서 동일한 서비스를 주입받아 사용할 수 있습니다.
  • 테스트 용이성: 서비스는 독립적으로 테스트가 가능하며, Mocking도 쉽습니다.

4.3. 서비스의 구조

서비스는 @Injectable() 데코레이터로 정의하며, 모듈의 providers에 등록해야 DI가 가능합니다.

@Injectable()
export class CatsService {
  private cats = [];

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

  findAll() {
    return this.cats;
  }
}

아래와 같은 Cat 인터페이스가 존재할 때

export interface Cat {
  name: string;
  age: number;
  breed: string;
}

컨트롤러에서 서비스 의존성 주입 예시

@Controller('cats')
export class CatsController {
  constructor(private catsService: CatsService) {}

  @Post()
  async create(@Body() createCatDto: CreateCatDto) {
    this.catsService.create(createCatDto);
  }

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

5. Providers(프로바이더)와 DI 컨테이너

5.1. 프로바이더란?

provider.png
133.7 kB

NestJS에서 프로바이더는 의존성 주입을 통해 객체를 생성하고 관리하는 핵심 개념입니다. 서비스, 리포지토리, 팩토리, 헬퍼 등 다양한 역할을 수행할 수 있습니다.

5.2. DI(Dependency Injection) 컨테이너의 동작 방식

  • 싱글턴 관리: 기본적으로 모든 프로바이더는 싱글턴으로 동작합니다. 즉, 애플리케이션 전체에서 하나의 인스턴스만 존재합니다.
  • 생성자 주입: 컨트롤러나 다른 서비스의 생성자에 프로바이더를 선언하면, NestJS가 자동으로 인스턴스를 주입해줍니다.
  • 커스텀 프로바이더: useClass, useValue, useFactory, useExisting 등 다양한 방식으로 프로바이더를 등록할 수 있습니다.

5.3. Spring과의 비교

  • @Service, @Controller, @Component: Spring에서는 각각의 역할을 어노테이션으로 구분합니다. NestJS도 유사하게 @Injectable(), @Controller()를 사용합니다.
  • DI 컨테이너: Spring의 ApplicationContext와 유사하게, NestJS도 자체 DI 컨테이너를 통해 객체의 생성과 생명주기를 관리합니다.
  • 모듈화: Spring의 @Configuration, @ComponentScan과 유사하게, NestJS는 @Module()을 통해 모듈 단위로 컴포넌트를 관리합니다.

5.4. Custom Providers(커스텀 프로바이더)

NestJS는 내장 IoC(Inversion of Control) 컨테이너를 통해 다양한 방식으로 프로바이더를 정의할 수 있습니다. 단순히 클래스를 주입하는 것뿐만 아니라, 값(value), 팩토리(factory), 비동기/동기 방식 등 여러 형태로 프로바이더를 등록할 수 있습니다. 커스텀 프로바이더를 활용하면 복잡한 의존성이나 외부 라이브러리, 설정값 등을 유연하게 주입할 수 있습니다.

예시 - 값 프로바이더

import { CatsService } from './cats.service';

const mockCatsService = {
  /* mock implementation
  ...
  */
};

@Module({
  imports: [CatsModule],
  providers: [
    {
      provide: CatsService,
      useValue: mockCatsService,
    },
  ],
})
export class AppModule {}

예시 - 팩토리 프로바이더

const connectionProvider = {
  provide: 'CONNECTION',
  useFactory: (optionsProvider: MyOptionsProvider, optionalProvider?: string) => {
    const options = optionsProvider.get();
    return new DatabaseConnection(options);
  },
  inject: [MyOptionsProvider, { token: 'SomeOptionalProvider', optional: true }],
  //       \______________/             \__________________/
  //        This provider                The provider with this token
  //        is mandatory.                can resolve to `undefined`.
};

@Module({
  providers: [
    connectionProvider,
    MyOptionsProvider, // class-based provider
    // { provide: 'SomeOptionalProvider', useValue: 'anything' },
  ],
})
export class AppModule {}

자세한 커스텀 프로바이더 작성법은 Dependency Injection 공식 문서에서 확인할 수 있습니다.

5.5. Optional Providers(옵셔널 프로바이더)

때로는 의존성이 항상 주입될 필요가 없는 경우가 있습니다. 예를 들어, 설정 객체가 주입되지 않으면 기본값을 사용하고 싶을 때가 있습니다. 이럴 때는 @Optional() 데코레이터를 사용하여 해당 의존성을 선택적으로 주입받을 수 있습니다.

import { Injectable, Optional, Inject } from '@nestjs/common';

@Injectable()
export class HttpService<T> {
  constructor(@Optional() @Inject('HTTP_OPTIONS') private httpClient: T) {}
}

위 예시에서 'HTTP_OPTIONS'라는 커스텀 토큰으로 주입되는 의존성이 없더라도, NestJS는 에러를 발생시키지 않고 undefined로 처리합니다.

5.6. Property-based injection(프로퍼티 기반 주입)

일반적으로 NestJS는 생성자 기반 주입을 권장하지만, 상속 구조 등에서 생성자 주입이 번거로운 경우 프로퍼티 기반 주입도 가능합니다. 이때는 @Inject() 데코레이터를 프로퍼티에 직접 사용합니다.

import { Injectable, Inject } from '@nestjs/common';

@Injectable()
export class HttpService<T> {
  @Inject('HTTP_OPTIONS')
  private readonly httpClient: T;
}

⚠️ 주의: 클래스가 다른 클래스를 상속하지 않는다면, 생성자 기반 주입이 더 명확하고 가독성이 좋으므로 권장됩니다.

5.7. Provider registration(프로바이더 등록)

프로바이더(예: CatsService)를 정의하고 이를 컨트롤러(CatsController)에서 사용하려면, 반드시 해당 서비스를 모듈의 providers 배열에 등록해야 합니다. 그래야 NestJS가 의존성 주입을 처리할 수 있습니다.

import { Module } from '@nestjs/common';
import { CatsController } from './cats/cats.controller';
import { CatsService } from './cats/cats.service';

@Module({
  controllers: [CatsController],
  providers: [CatsService],
})
export class AppModule {}

이렇게 하면 NestJS가 CatsController의 생성자에 CatsService를 자동으로 주입해줍니다.

5.8. Manual instantiation(수동 인스턴스화)

대부분의 경우 NestJS가 의존성 주입을 자동으로 처리하지만, 때로는 DI 컨테이너 밖에서 직접 인스턴스를 얻거나 동적으로 프로바이더를 생성해야 할 때가 있습니다.

  • Module reference 사용: NestJS의 모듈 참조를 통해 기존 인스턴스를 가져오거나 동적으로 프로바이더를 생성할 수 있습니다.
  • bootstrap() 함수 내에서 사용: Standalone application이나 부트스트랩 과정에서 설정 서비스 등을 직접 사용하고 싶을 때 활용할 수 있습니다.

자세한 내용은 Standalone applications 공식 문서에서 확인할 수 있습니다.

6. 실전 예시: 사용자(User) 도메인

// user.module.ts
@Module({
  controllers: [UserController],
  providers: [UserService],
  exports: [UserService],
})
export class UserModule {}

// user.controller.ts
@Controller('users')
export class UserController {
  constructor(private readonly userService: UserService) {}

  @Get()
  findAll() {
    return this.userService.findAll();
  }
}

// user.service.ts
@Injectable()
export class UserService {
  private users = [];

  findAll() {
    return this.users;
  }
}

이 구조를 통해 각 계층의 역할이 명확히 분리되고, 유지보수와 확장성이 뛰어난 코드를 작성할 수 있습니다.

7. 학습 포인트 및 결론

  • NestJS의 모듈, 컨트롤러, 서비스 구조는 대규모 애플리케이션 개발에 최적화되어 있습니다.
  • 각 계층의 역할을 명확히 분리하면 코드의 응집도와 재사용성이 높아집니다.
  • DI 컨테이너를 통해 객체의 생성과 의존성 관리를 자동화할 수 있어, 테스트와 유지보수가 쉬워집니다.
  • Spring 프레임워크와 유사한 구조를 가지고 있어, Spring 경험자라면 빠르게 적응할 수 있습니다.
  • 공식 문서의 예제 코드를 직접 따라해보며, 각 계층의 역할과 DI 컨테이너의 동작 방식을 체득하는 것이 중요합니다.

참고 문서

반응형

댓글