Express나 기본 Node.js 환경에서 서버를 개발해 본 경험이 있다면, 폴더 구조나 아키텍처를 어떻게 잡아야 할지 막막했던 적이 있을 것입니다.
"라우터는 어디에 둬야 하지?", "비즈니스 로직은 어떻게 분리해야 테스트가 편할까?" 같은 고민을 하다 보면 결국 각 개발자마다 다른 패턴의 코드가 양산됩니다. 특히 프로젝트 규모가 커지면 라우팅 로직과 비즈니스 로직이 강하게 결합(Tight coupling)되어 유지보수가 기하급수적으로 어려워집니다.
이러한 아키텍처의 부재를 해결하기 위해 처음에는 계층형 아키텍처(Layered Architecture)를 강제하는 자체 보일러플레이트를 만들까 고민했습니다. 하지만 팀원들 간의 러닝 커브와 유지보수 비용을 고려했을 때, 이미 업계에서 검증된 프레임워크를 도입하는 것이 합리적이었습니다.
우리가 선택한 해결책은 NestJS입니다. NestJS는 Angular의 아키텍처 사상을 백엔드에 가져와, Controller, Provider(Service), Module이라는 명확하고 규격화된 계층을 제공하며, IoC(Inversion of Control) 컨테이너를 통한 강력한 의존성 주입(DI)을 기본적으로 지원합니다.
NestJS 앱은 들어온 요청을 처리하기 위해 일정한 라이프사이클을 거칩니다. 이 중 가장 핵심이 되는 세 가지 기둥은 다음과 같습니다.
@Injectable() 데코레이터를 통해 IoC 컨테이너에 의해 관리되며, 다른 곳에 주입(Injection)될 수 있습니다.이러한 개념을 바탕으로 간단한 Movie API를 설계하고 구현해 보았습니다.
// bad-example.ts // 라우터에서 로직을 직접 처리하는 안 좋은 예 (Express 스타일) @Controller('movie') export class MovieController { private movies = []; @Get() getAllMovies() { // 컨트롤러가 직접 데이터를 관리하고 비즈니스 로직을 수행함 return this.movies; } }
위와 같이 코드를 짜면 단위 테스트가 어렵고 코드가 비대해집니다. 이를 의존성 주입을 활용해 분리해 보겠습니다.
// good-example.ts // 1. Service 분리 (비즈니스 로직 전담) import { Injectable, NotFoundException } from '@nestjs/common'; @Injectable() export class MovieService { private movies = []; getAll() { return this.movies; } getOne(id: string) { const movie = this.movies.find(m => m.id === id); if (!movie) throw new NotFoundException(`Movie with ID ${id} not found.`); return movie; } } // 2. Controller 구현 (요청 처리 전담) import { Controller, Get, Param, Query } from '@nestjs/common'; @Controller('movies') export class MovieController { // 의존성 주입 (DI): NestJS IoC 컨테이너가 MovieService의 인스턴스를 알아서 주입해 줌 constructor(private readonly movieService: MovieService) {} @Get() getAll() { return this.movieService.getAll(); } @Get(':id') // Path Variable 적용 getOne(@Param('id') movieId: string) { return this.movieService.getOne(movieId); } }
마지막으로 CLI(nest g mo movies)를 사용하여 이들을 하나의 MoviesModule로 묶어주어 엔드포인트를 깔끔하게 모듈화했습니다.
NestJS의 구조를 도입함으로써 다음과 같은 결과를 얻었습니다.
MovieService를 Mock 객체로 쉽게 교체하여 컨트롤러를 독립적으로 유닛 테스트할 수 있게 되었습니다.Trade-off:
NestJS는 TypeScript 데코레이터와 리플렉션(Reflection)을 무겁게 사용하기 때문에 초기 학습 곡선(Learning curve)이 다소 높고, 단순한 "Hello World" API를 만들기에는 Express보다 보일러플레이트 코드가 많다는 단점이 있습니다.
NestJS의 생명주기와 의존성 주입(DI) 개념은 처음엔 복잡해 보이지만, 한 번 익혀두면 대규모 애플리케이션을 안정적으로 확장하는 데 있어 엄청난 무기가 됩니다. 다음 포스트에서는 이 API를 더 견고하게 만들기 위해 클라이언트의 요청 데이터를 검증하는 방법에 대해 다뤄보겠습니다.