HooneyLog
© 2026 Seunghoon Shin. All rights reserved.
모든 게시글
nest.js
2026. 3. 28.•
1

NestJS 시작하기: 컨트롤러, 서비스, 그리고 모듈을 활용한 탄탄한 설계

Seunghoon Shin
작성자 Seunghoon Shin풀스택 개발자

1. 문제의 배경 (The Problem/Context)

Express나 기본 Node.js 환경에서 서버를 개발해 본 경험이 있다면, 폴더 구조나 아키텍처를 어떻게 잡아야 할지 막막했던 적이 있을 것입니다.

"라우터는 어디에 둬야 하지?", "비즈니스 로직은 어떻게 분리해야 테스트가 편할까?" 같은 고민을 하다 보면 결국 각 개발자마다 다른 패턴의 코드가 양산됩니다. 특히 프로젝트 규모가 커지면 라우팅 로직과 비즈니스 로직이 강하게 결합(Tight coupling)되어 유지보수가 기하급수적으로 어려워집니다.

2. 해결 방안 탐색 (Exploring Solutions)

이러한 아키텍처의 부재를 해결하기 위해 처음에는 계층형 아키텍처(Layered Architecture)를 강제하는 자체 보일러플레이트를 만들까 고민했습니다. 하지만 팀원들 간의 러닝 커브와 유지보수 비용을 고려했을 때, 이미 업계에서 검증된 프레임워크를 도입하는 것이 합리적이었습니다.

우리가 선택한 해결책은 NestJS입니다. NestJS는 Angular의 아키텍처 사상을 백엔드에 가져와, Controller, Provider(Service), Module이라는 명확하고 규격화된 계층을 제공하며, IoC(Inversion of Control) 컨테이너를 통한 강력한 의존성 주입(DI)을 기본적으로 지원합니다.

3. 핵심 개념 및 아키텍처 (Deep Dive & Architecture)

NestJS 앱은 들어온 요청을 처리하기 위해 일정한 라이프사이클을 거칩니다. 이 중 가장 핵심이 되는 세 가지 기둥은 다음과 같습니다.

  1. Controller (컨트롤러): 클라이언트로부터 들어오는 HTTP 요청(GET, POST 등)을 받아들이고, 적절한 응답을 반환하는 문지기 역할을 합니다. 단, 비즈니스 로직을 직접 처리하지는 않습니다.
  2. Provider / Service (서비스): 데이터베이스와 통신하거나 실제 복잡한 연산을 수행하는 비즈니스 로직의 집합체입니다. @Injectable() 데코레이터를 통해 IoC 컨테이너에 의해 관리되며, 다른 곳에 주입(Injection)될 수 있습니다.
  3. Module (모듈): 관련된 컨트롤러와 프로바이더들을 논리적인 도메인 단위(예: Movie, User 등)로 묶어주는 캡슐화의 단위입니다.

4. 구현 및 트러블슈팅 (Implementation & Code)

이러한 개념을 바탕으로 간단한 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로 묶어주어 엔드포인트를 깔끔하게 모듈화했습니다.

5. 결과 및 Trade-off (Results & Trade-offs)

NestJS의 구조를 도입함으로써 다음과 같은 결과를 얻었습니다.

  • 관심사의 분리: 컨트롤러는 HTTP 계층만 신경 쓰고, 서비스는 순수 로직만 신경 쓰게 되어 코드 가독성이 매우 향상되었습니다.
  • 테스트 용이성: 의존성 주입 덕분에 MovieService를 Mock 객체로 쉽게 교체하여 컨트롤러를 독립적으로 유닛 테스트할 수 있게 되었습니다.

Trade-off:

NestJS는 TypeScript 데코레이터와 리플렉션(Reflection)을 무겁게 사용하기 때문에 초기 학습 곡선(Learning curve)이 다소 높고, 단순한 "Hello World" API를 만들기에는 Express보다 보일러플레이트 코드가 많다는 단점이 있습니다.

6. 마치며 (Conclusion)

NestJS의 생명주기와 의존성 주입(DI) 개념은 처음엔 복잡해 보이지만, 한 번 익혀두면 대규모 애플리케이션을 안정적으로 확장하는 데 있어 엄청난 무기가 됩니다. 다음 포스트에서는 이 API를 더 견고하게 만들기 위해 클라이언트의 요청 데이터를 검증하는 방법에 대해 다뤄보겠습니다.

← 이전 글Nest.js에서 Shell Script로 모듈, 컨트롤러, 서비스 자동 생성하기
다음 글 →NestJS 요청 검증의 모든 것: DTO와 Class Validator, Transformer 활용기