NestJS로 백엔드 만들기: 모듈·컨트롤러·서비스, DTO 검증, TypeORM, 가드·인터셉터

이 글의 핵심

NestJS는 TypeScript 기반의 확장 가능한 서버사이드 프레임워크로 Angular 아키텍처에서 영감을 받았습니다. 의존성 주입(DI), 모듈 시스템, 데코레이터로 코드 구조화 및 테스트가 용이합니다. REST API, GraphQL, WebSocket, 마이크로서비스를 모두 지원하여 다양한 아키텍처 구현이 가능합니다.

들어가며

NestJS를 소개하는 글은 대부분 비슷한 형식을 따릅니다. 목차가 길게 나열되고, Express·Koa와의 비교 표가 등장하고, “모범 사례” 목록이 이어집니다. 틀린 정보는 아니지만, 실제로 팀에서 왜 이 프레임워크를 선택하게 되는지에 대한 맥락은 빠져 있는 경우가 많습니다. 이 글에서는 그 맥락을 먼저 짚은 뒤, NestJS의 핵심 개념과 실전 코드를 순서대로 설명하겠습니다.

실무에서 NestJS는 “Express가 싫어서” 선택하는 프레임워크가 아닙니다. 팀 규모가 커지고 코드베이스가 오래될수록, 누가 어디에 무엇을 작성하는지를 정하는 일이 먼저 문제가 됩니다. 예를 들어 결제·정산 로직이 얽혀 있는 8년 차 Express 모노리스를 생각해 보겠습니다. 라우터 파일마다 조건문이 복잡하게 얽혀 있고, 미들웨어 등록 순서가 팀마다 달라 장애 대응이 어려워지는 상황입니다. 이런 경우 마이크로서비스로 전면 재작성을 시도하기보다는, 서비스 경계(Bounded Context)를 먼저 정의하고 BFF(Backend for Frontend) 계층을 NestJS로 구축한 뒤, 결제·회원·정산 도메인부터 단계적으로 분리하는 전략이 현실적입니다. 데이터베이스를 곧바로 나눌 수 없더라도, HTTP·메시징 경계만이라도 먼저 명확히 하는 접근입니다.

이 과정에서 NestJS가 실질적으로 도움이 되는 부분은, CLI가 모듈·컨트롤러·서비스의 뼈대를 통일해 주어 “팀의 표준”을 코드 구조로 강제할 수 있다는 점입니다. 다만 의존성 주입(DI)은 양날의 검이라는 점도 짚어야 합니다. 테스트 작성과 모킹은 훨씬 수월해지지만, forwardRef로 순환 의존성을 억지로 해결하다 보면 암묵적인 의존 관계가 오히려 더 은밀하게 숨겨지기도 합니다. 이럴 때 필요한 것은 DI를 더 정교하게 쓰는 기술이 아니라 모듈 경계를 다시 설계하는 작업입니다. Angular를 경험해 본 개발자라면 공감하겠지만, DI는 만능 해결책이 아니라 “규율이 필요한 도구”에 가깝습니다.

NestJS, Express, Koa의 차이

세 프레임워크를 표로 비교하는 대신 각각의 성격을 짚어보겠습니다. Express는 최소한의 기능만 제공하는 대신 자유도가 매우 높습니다. Koa는 미들웨어 체인을 async/await 기반으로 우아하게 설계한 것이 특징입니다. NestJS는 이 위에 TypeScript, 데코레이터, 의존성 주입, CLI를 얹어 “여러 팀이 동시에 작업해도 구조가 흔들리지 않도록” 설계된 프레임워크입니다.

TypeScript, GraphQL, 다양한 마이크로서비스 트랜스포터, Swagger 통합이 하나의 생태계 안에 모여 있다는 점에서, “최소한의 도구로 빠르게”보다 “처음부터 엔지니어링 원칙을 세우겠다”는 방향의 프로젝트에 잘 맞습니다. 반대로 라우트 몇 개짜리 프로토타입이나 소규모 API 서버라면 Express가 훨씬 가볍고 빠르게 시작할 수 있습니다. 도구 선택은 결국 팀의 규모와 프로젝트의 수명 주기에 달려 있습니다.

프로젝트 시작하기

가장 먼저 할 일은 NestJS CLI를 설치하는 것입니다. CLI를 전역으로 설치해 두면 어디서든 nest 명령을 사용할 수 있고, 프로젝트 생성 시 tsconfig, Jest, ESLint 설정까지 함께 만들어 주기 때문에 초기 설정에 드는 시간을 크게 줄일 수 있습니다.

npm install -g @nestjs/cli
nest --version

프로젝트 생성은 대화형 명령 하나로 끝납니다.

nest new my-project

패키지 매니저를 선택하고 나면 src/main.ts, src/app.module.ts 등 기본 파일이 자동으로 생성됩니다. npm run start:dev로 실행하면 코드 변경 시 자동으로 다시 컴파일되는 개발 서버가 뜨고, 기본적으로 3000번 포트에서 “Hello World!” 메시지를 확인할 수 있습니다.

생성 직후의 디렉터리 구조는 다음과 같이 단순합니다.

src/
├── app.controller.ts
├── app.module.ts
├── app.service.ts
└── main.ts

모듈, 컨트롤러, 서비스

NestJS의 기본 구성 요소인 모듈, 컨트롤러, 서비스는 하나의 흐름으로 이해하는 것이 좋습니다. 모듈은 관련된 기능을 하나로 묶는 경계 역할을 하고, 컨트롤러는 HTTP 요청을 받는 입구, 서비스는 실제 비즈니스 로직을 담당하는 부분입니다. 아래는 users 도메인을 예시로 한 전형적인 구성입니다.

// users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';

@Module({
  imports: [],
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}
// users/users.controller.ts
import {
  Controller, Get, Post, Body, Param, Put, Delete, Query, HttpCode, HttpStatus,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto, UpdateUserDto } from './dto';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post()
  @HttpCode(HttpStatus.CREATED)
  async create(@Body() createUserDto: CreateUserDto) {
    return this.usersService.create(createUserDto);
  }

  @Get()
  async findAll(@Query('page') page: number = 1) {
    return this.usersService.findAll(page);
  }

  @Get(':id')
  async findOne(@Param('id') id: string) {
    return this.usersService.findOne(+id);
  }

  @Put(':id')
  async update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
    return this.usersService.update(+id, updateUserDto);
  }

  @Delete(':id')
  @HttpCode(HttpStatus.NO_CONTENT)
  async remove(@Param('id') id: string) {
    return this.usersService.remove(+id);
  }
}
// users/users.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { CreateUserDto, UpdateUserDto } from './dto';

@Injectable()
export class UsersService {
  private users = [];

  create(createUserDto: CreateUserDto) {
    const user = { id: Date.now(), ...createUserDto, createdAt: new Date() };
    this.users.push(user);
    return user;
  }

  findAll(page: number = 1) {
    const limit = 10;
    const start = (page - 1) * limit;
    return { data: this.users.slice(start, start + limit), page, total: this.users.length };
  }

  findOne(id: number) {
    const user = this.users.find(u => u.id === id);
    if (!user) throw new NotFoundException(`User #${id} not found`);
    return user;
  }

  update(id: number, updateUserDto: UpdateUserDto) {
    const user = this.findOne(id);
    Object.assign(user, updateUserDto);
    return user;
  }

  remove(id: number) {
    const index = this.users.findIndex(u => u.id === id);
    if (index === -1) throw new NotFoundException(`User #${id} not found`);
    this.users.splice(index, 1);
  }
}

이 세 파일이 하나의 모듈로 묶이는 구조를 이해하면, 이후 다른 도메인(예: posts, orders)을 추가할 때도 동일한 패턴을 반복하기만 하면 됩니다. 이 반복 가능성이 바로 여러 개발자가 동시에 작업할 때 코드 스타일이 갈라지지 않도록 해주는 핵심입니다.

NestJS를 처음 쓸 때 가장 자주 만나는 에러는 거의 항상 같은 모양입니다. Nest can't resolve dependencies of the OrdersService (?). Please make sure that the argument UsersService at index [0] is available in the OrdersModule context. 이 메시지는 OrdersService가 생성자에서 UsersService를 요구하는데, OrdersModule의 시야 안에 그 프로바이더가 없다는 뜻입니다. 해결책은 UsersModule이 exports에 UsersService를 넣고, OrdersModule이 imports에 UsersModule을 추가하는 것입니다. 흔한 실수는 OrdersModule의 providers에 UsersService를 직접 다시 등록하는 것인데, 그러면 에러는 사라지지만 모듈마다 서로 다른 UsersService 인스턴스가 생겨 인메모리 캐시나 상태가 모듈별로 갈라집니다. 위 UsersModule이 exports: [UsersService]를 선언한 이유가 여기에 있습니다.

컨트롤러의 @Query('page') page: number도 주의할 지점입니다. TypeScript 타입 표기는 런타임에 아무 변환도 하지 않으므로, 아래에서 설명할 ValidationPipe의 transform: true가 없으면 page에는 실제로 문자열 "2"가 들어옵니다. (page - 1) * limit은 암묵적 숫자 변환 덕분에 우연히 동작하지만, page + 1은 "21"이 되는 식의 버그가 생깁니다. 전역 변환을 켜지 않았다면 @Query('page', ParseIntPipe)처럼 파이프를 명시하는 편이 안전합니다.

DTO와 유효성 검사

요청 데이터의 형태와 검증 규칙은 DTO(Data Transfer Object) 클래스로 정의합니다. class-validator와 함께 사용하면 컨트롤러에 도달하기 전에 잘못된 요청을 걸러낼 수 있고, Swagger를 함께 사용하는 팀이라면 @ApiProperty 데코레이터로 문서화까지 동시에 처리할 수 있습니다.

// users/dto/create-user.dto.ts
import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

export class CreateUserDto {
  @ApiProperty({ description: '사용자 이름', example: '홍길동' })
  @IsString()
  @MinLength(2)
  name: string;

  @ApiProperty({ description: '이메일', example: '[email protected]' })
  @IsEmail()
  email: string;

  @ApiProperty({ description: '비밀번호', minLength: 8 })
  @IsString()
  @MinLength(8)
  password: string;

  @ApiPropertyOptional({ description: '프로필 이미지 URL' })
  @IsOptional()
  @IsString()
  avatar?: string;
}

// users/dto/update-user.dto.ts
import { PartialType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';

export class UpdateUserDto extends PartialType(CreateUserDto) {}

UpdateUserDto가 PartialType을 이용해 CreateUserDto의 모든 필드를 선택적으로 만드는 부분에 주목할 필요가 있습니다. 생성 시 필수였던 필드를 수정 시에는 선택적으로 바꿔야 하는 경우가 대부분인데, 이를 매번 새로 작성하지 않고 기존 DTO를 재사용할 수 있다는 점에서 코드 중복을 크게 줄여줍니다.

다만 Swagger를 함께 쓴다면 PartialType을 @nestjs/mapped-types가 아니라 @nestjs/swagger에서 import해야 합니다. 두 패키지 모두 같은 이름의 함수를 제공하지만, mapped-types 버전은 @ApiProperty 메타데이터를 복사하지 않아서 문서의 UpdateUserDto 스키마가 빈 객체로 나옵니다. 검증은 정상 동작하기 때문에 문서를 열어 보기 전까지 알아차리기 어려운 실수입니다.

CLI로 CRUD 뼈대 생성하기

nest generate resource 명령을 사용하면 REST, GraphQL, WebSocket, 마이크로서비스 중 원하는 방식을 선택해 CRUD 뼈대 전체를 한 번에 생성할 수 있습니다. 이 기능이 특히 유용한 이유는, 여러 개발자가 각자 다른 방식으로 리소스를 구성하면서 발생하는 스타일 충돌을 CLI가 사전에 방지해 주기 때문입니다.

nest generate resource posts
# REST / GraphQL / WebSocket / Microservice 중 선택

nest g module cats
nest g controller cats
nest g service cats

TypeORM으로 데이터베이스 연동하기

데이터베이스 연동에는 TypeORM을 사용하는 팀이 많습니다. 개발 초기에는 synchronize: true 옵션으로 엔터티 변경 사항을 자동으로 스키마에 반영할 수 있지만, 이 옵션은 반드시 로컬 개발 환경에서만 사용해야 합니다. 운영 환경에서 이 옵션을 켜둔 채 배포했다가 예기치 않은 스키마 변경으로 데이터가 손상되는 사고는 실제로 드물지 않게 발생합니다.

npm install @nestjs/typeorm typeorm mysql2
# PostgreSQL이면 pg, SQLite면 sqlite3
// app.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersModule } from './users/users.module';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'mysql',
      host: 'localhost',
      port: 3306,
      username: 'root',
      password: 'password',
      database: 'test',
      entities: [__dirname + '/**/*.entity{.ts,.js}'],
      synchronize: true, // 로컬 개발 전용. 운영에서는 false + 마이그레이션
    }),
    UsersModule,
  ],
})
export class AppModule {}
// users/entities/user.entity.ts
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm';
import { Post } from '../../posts/entities/post.entity';

@Entity('users')
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ length: 100 })
  name: string;

  @Column({ unique: true })
  email: string;

  @Column({ select: false })
  password: string;

  @Column({ nullable: true })
  avatar?: string;

  @Column({ default: true })
  isActive: boolean;

  @OneToMany(() => Post, post => post.author)
  posts: Post[];

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;
}

password 컬럼에 select: false를 지정한 부분을 눈여겨봐야 합니다. 이렇게 설정하면 별도로 명시하지 않는 한 조회 쿼리 결과에 비밀번호 필드가 포함되지 않아, 실수로 API 응답에 해시된 비밀번호가 노출되는 사고를 원천적으로 방지할 수 있습니다.

Repository를 서비스에 주입하는 패턴은 다음과 같습니다. NotFoundException처럼 의미가 명확한 예외 클래스를 사용하면, 이전에 상태 코드를 숫자로 직접 다루던 방식보다 코드의 가독성과 일관성이 크게 향상됩니다.

// users/users.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';

@Module({
  imports: [TypeOrmModule.forFeature([User])],
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

아래 서비스는 구조를 보여 주기 위해 DTO를 그대로 저장합니다. 실제로는 create와 update에서 password를 bcrypt·argon2 같은 해시 함수로 변환한 뒤 저장해야 합니다. 특히 PartialType으로 만든 UpdateUserDto에는 password도 선택 필드로 들어 있어서, repository.update(id, dto)를 그대로 호출하면 비밀번호 변경 요청이 평문으로 저장됩니다. 비밀번호 변경은 별도 엔드포인트와 DTO로 분리하고, 일반 수정 DTO에서는 OmitType(CreateUserDto, ['password'])로 빼 두는 편이 안전합니다.

// users/users.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
import { CreateUserDto, UpdateUserDto } from './dto';

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User)
    private usersRepository: Repository<User>,
  ) {}

  async create(createUserDto: CreateUserDto): Promise<User> {
    const user = this.usersRepository.create(createUserDto);
    return this.usersRepository.save(user);
  }

  async findAll(): Promise<User[]> {
    return this.usersRepository.find({ relations: ['posts'], order: { createdAt: 'DESC' } });
  }

  async findOne(id: number): Promise<User> {
    const user = await this.usersRepository.findOne({ where: { id }, relations: ['posts'] });
    if (!user) throw new NotFoundException(`User #${id} not found`);
    return user;
  }

  async update(id: number, updateUserDto: UpdateUserDto): Promise<User> {
    await this.usersRepository.update(id, updateUserDto);
    return this.findOne(id);
  }

  async remove(id: number): Promise<void> {
    const result = await this.usersRepository.delete(id);
    if (result.affected === 0) throw new NotFoundException(`User #${id} not found`);
  }
}

전역 파이프로 요청 검증 강제하기

DTO에 검증 규칙을 정의했다면, 이를 실제로 적용하기 위해 전역 파이프를 등록해야 합니다. 특히 whitelist 옵션을 활성화하면 DTO에 정의되지 않은 필드는 자동으로 제거되고, forbidNonWhitelisted 옵션을 함께 켜면 그런 필드가 포함된 요청 자체를 거부할 수 있습니다. 이 두 옵션은 클라이언트가 의도하지 않은 필드(예: 관리자 권한 플래그)를 요청 본문에 몰래 포함시키는 공격을 방어하는 데 실질적으로 도움이 됩니다.

npm install class-validator class-transformer
// main.ts
import { ValidationPipe } from '@nestjs/common';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe({
    whitelist: true,
    forbidNonWhitelisted: true,
    transform: true,
  }));
  await app.listen(3000);
}
bootstrap();

가드, 인터셉터, 예외 필터

가드(Guard)는 요청이 컨트롤러에 도달하기 전에 접근 권한을 판단하는 역할을 합니다. 아래는 역할(Role) 기반 접근 제어를 구현한 예시입니다.

// auth/guards/roles.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const requiredRoles = this.reflector.get<string[]>('roles', context.getHandler());
    if (!requiredRoles) return true;
    const { user } = context.switchToHttp().getRequest();
    return requiredRoles.some(role => user.roles?.includes(role));
  }
}

이 가드가 읽는 'roles' 메타데이터는 핸들러에 @SetMetadata('roles', ['admin'])를 붙이거나, 이를 감싼 @Roles('admin') 커스텀 데코레이터로 달아 줍니다. 컨트롤러 클래스 단위로도 역할을 지정하려면 reflector.get 대신 reflector.getAllAndOverride('roles', [context.getHandler(), context.getClass()])를 써야 클래스에 붙인 값까지 읽힙니다.

실무에서 이 가드가 TypeError: Cannot read properties of undefined (reading 'roles')로 터지는 일이 자주 있습니다. request.user는 JWT 인증 가드가 토큰을 검증한 뒤에야 채워지는데, @UseGuards(RolesGuard, JwtAuthGuard)처럼 순서를 거꾸로 적거나 역할 가드만 전역으로 등록하면 인증 전에 역할을 검사하게 되기 때문입니다. 가드는 적힌 순서대로 실행되므로 인증 가드를 앞에 두고, 역할 가드에서도 user가 없을 때는 false를 반환하도록 방어해 두는 것이 좋습니다.

인터셉터(Interceptor)는 요청 전후로 로깅을 남기거나 응답 형태를 통일하는 데 주로 사용됩니다. 예외 필터(Exception Filter)는 애플리케이션 전역에서 발생하는 오류를 하나의 JSON 형식으로 통일해 줍니다. 클라이언트 개발자가 에러 응답의 구조를 예측할 수 있게 해주는 것은 사소해 보이지만, 실제 협업 환경에서는 매우 중요한 부분입니다.

// common/filters/http-exception.filter.ts
import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';
import { Request, Response } from 'express';

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const status = exception.getStatus();
    const exceptionResponse = exception.getResponse();
    const error = typeof exceptionResponse === 'string'
      ? { message: exceptionResponse }
      : (exceptionResponse as object);
    response.status(status).json({ success: false, ...error, timestamp: new Date().toISOString(), path: request.url });
  }
}

문서화와 환경 설정

@nestjs/swagger 패키지를 사용하면 별도의 문서 작성 도구 없이도 /api 경로에 대화형 API 문서를 자동으로 생성할 수 있습니다. 코드에 붙인 데코레이터가 곧 문서가 되는 방식이므로, 코드와 문서가 따로 관리되면서 발생하는 불일치 문제를 줄일 수 있습니다.

환경 변수 관리는 ConfigModule.forRoot로 처리하는 것이 일반적입니다. 운영 환경과 스테이징 환경을 분리해야 한다면 envFilePath 옵션에 환경별 파일 경로를 지정하는 것만으로 충분합니다.

npm install @nestjs/config
@Module({
  imports: [ConfigModule.forRoot({ isGlobal: true, envFilePath: `.env.${process.env.NODE_ENV}` })],
})
export class AppModule {}

테스트 작성하기

NestJS는 Jest를 기본 테스트 프레임워크로 채택하고 있으며, TestingModule을 통해 Repository 같은 의존성을 목(Mock) 객체로 손쉽게 대체할 수 있습니다. 의존성 주입 구조 덕분에 단위 테스트 작성이 상대적으로 수월해지는 것은 사실입니다. 다만 한 서비스에 목 객체가 열 개, 스무 개씩 필요해진다면 이는 테스트 코드의 문제가 아니라 해당 서비스가 너무 많은 책임을 지고 있다는 설계상의 경고 신호로 받아들이는 것이 바람직합니다. 그런 상황에서는 목 객체를 더 정교하게 만드는 대신 모듈 자체를 더 작은 단위로 분리하는 것이 근본적인 해결책입니다.

// users/users.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { UsersService } from './users.service';
import { User } from './entities/user.entity';

describe('UsersService', () => {
  let service: UsersService;
  let mockRepository: { find: jest.Mock; findOne: jest.Mock; create: jest.Mock; save: jest.Mock; update: jest.Mock; delete: jest.Mock };

  beforeEach(async () => {
    mockRepository = {
      find: jest.fn(),
      findOne: jest.fn(),
      create: jest.fn(),
      save: jest.fn(),
      update: jest.fn(),
      delete: jest.fn(),
    };
    const module: TestingModule = await Test.createTestingModule({
      providers: [UsersService, { provide: getRepositoryToken(User), useValue: mockRepository }],
    }).compile();
    service = module.get<UsersService>(UsersService);
  });

  it('findAll', async () => {
    const users = [{ id: 1, name: 'Test User' }];
    mockRepository.find.mockResolvedValue(users);
    await expect(service.findAll()).resolves.toEqual(users);
  });
});

JWT 인증 붙이기

인증 기능은 Passport와 조합해서 구현하는 것이 실무에서 가장 널리 쓰이는 방식입니다. 로그인 로직은 별도의 AuthService에 두고, 인증이 필요한 엔드포인트에는 가드를 선언적으로 붙이는 방식이 일반적입니다.

@Injectable()
export class AuthService {
  constructor(private usersService: UsersService, private jwtService: JwtService) {}
  // register / login ... bcrypt + this.jwtService.sign(payload)
}
@Controller('profile')
export class ProfileController {
  @UseGuards(JwtAuthGuard)
  @Get()
  getProfile(@Request() req) {
    return req.user;
  }
}

마이크로서비스로 확장하기

NestJS는 connectMicroservice API를 통해 TCP, Redis, NATS, RabbitMQ, Kafka 등 다양한 트랜스포터를 하나의 애플리케이션에 결합할 수 있습니다. 모노리스를 마이크로서비스로 전환하는 초기 단계에서는 “프로세스 분리를 먼저, 데이터베이스 분리는 나중에”라는 전략이 흔히 사용됩니다. NestJS가 여러 트랜스포터를 일관된 방식으로 묶어주는 것은 분명한 장점이지만, 메시징 채널이 늘어날수록 운영 복잡도는 그에 비례해 커진다는 점을 반드시 염두에 두어야 합니다. 프레임워크가 아키텍처 설계의 어려움 자체를 없애주지는 않습니다.

const app = await NestFactory.create(AppModule);
app.connectMicroservice({ transport: Transport.TCP, options: { port: 3001 } });
await app.startAllMicroservices();

마무리

순환 의존성 경고가 보이기 시작하면 forwardRef로 임시방편을 늘리기보다 모듈 경계를 다시 설계하는 쪽을 먼저 검토해야 합니다. DTO 검증은 프로젝트 초기부터 켜두는 것이 장기적으로 훨씬 이득이며, 전역 예외 필터를 도입하면 프론트엔드 개발자와 온콜 담당자 모두의 부담을 크게 줄일 수 있습니다.

다시 한번 강조하자면, 의존성 주입은 양날의 검입니다. 적절히 사용하면 팀 전체의 개발 속도를 높여주지만, 남용하면 코드에서 눈으로 추적하기 어려운 의존성 그래프만 남기게 됩니다. 이 글은 엔터프라이즈 환경에서의 이전 사례로 시작했으니 그 맥락으로 마무리하겠습니다. 모노리스를 한 번에 재작성하는 것보다, BFF 계층과 명확한 서비스 경계, 그리고 표준화된 모듈 구조를 통해 팀 전체가 같은 방식으로 코드를 작성하게 만드는 접근이 실무에서는 훨씬 성공 확률이 높았습니다. NestJS는 그 “같은 방식”을 코드 구조로 강제할 수 있는 여러 선택지 중 하나입니다. 프로젝트의 규모와 팀의 성향에 따라 Express나 Fastify가 더 적합할 수도 있으므로, 이 글의 내용을 참고해 상황에 맞는 도구를 선택하기를 권합니다.

같이 보면 좋은 글