모듈, 컨트롤러, 서비스의 기본 개념
모듈이 기능 경계를 조직하고 컨트롤러와 서비스가 요청 처리와 비즈니스 로직을 나누는 방식을 코드로 익힙니다.
앞서 1장 1절에서 NestJS의 주요 아키텍처 구성 요소로 모듈(Modules), 컨트롤러(Controllers), 프로바이더(Providers)를 간략하게 소개해 드렸습니다.
그리고 1장 3절에서 첫 NestJS 애플리케이션의 기본 구조를 살펴보며 이들이 어떻게 상호작용하는지 대략적인 흐름을 파악했습니다.
이번 절에서는 이 세 가지 핵심 요소, 특히 프로바이더 중 가장 흔히 사용되는 서비스(Service)에 대해 더욱 깊이 있게 들여다보는 시간을 갖겠습니다.
이들의 역할과 관계를 명확히 이해하는 것은 NestJS 애플리케이션을 효율적이고 체계적으로 구축하는 데 필수적입니다.
다만 모듈, 컨트롤러, 서비스를 살펴보기 전에 애플리케이션 시작 경계와 요청 처리 경계를 구분해야 합니다. main.ts는 루트 모듈을 바탕으로 애플리케이션을 만들고, 전역 HTTP 설정을 적용한 뒤 리스너를 시작하는 곳입니다.
ValidationPipe를 사용하는 다음 예제를 실행하기 전에 검증과 변환 패키지를 설치합니다.
npm install class-validator class-transformerimport { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe());
app.setGlobalPrefix('api');
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();NestFactory.create(AppModule)은 루트 모듈에서 시작하는 모듈·의존성 그래프를 구성하고 INestApplication을 반환합니다. 기본 HTTP 플랫폼은 Express이며, Fastify 같은 다른 플랫폼을 사용한다면 애플리케이션을 만들 때 해당 HTTP 어댑터를 선택합니다. useGlobalPipes()와 setGlobalPrefix()는 생성된 애플리케이션에 전역 정책을 설정하고, listen()은 그 설정이 끝난 애플리케이션의 HTTP 리스너를 시작합니다. 이들은 기능 모듈이나 서비스의 책임이 아닙니다.
위 패키지를 설치하면 활성 new ValidationPipe()를 포함한 예제가 부팅할 수 있습니다. 이 코드는 전역 파이프를 어디에 설치하는지 보여주지만, 아래 컨트롤러의 createUserDto: { name: string } 같은 인라인 구조 타입은 빌드 후 사라져 필드별 런타임 검증 규칙을 제공하지 않습니다. 실제 필드 검증은 구체적인 class DTO에 class-validator 데코레이터로 런타임 규칙을 선언했을 때 적용됩니다.
Nest · bootstrap + request
앱을 시작하는 일과 요청을 처리하는 일은 다른 경계다
NestFactory.create(AppModule)로 그래프를 만든 뒤 전역 정책과 리스너를 설정합니다. 실행 중인 요청은 HTTP 경계를 거쳐 컨트롤러가 Service provider에 위임하고, Service는 필요할 때만 인프라 provider를 사용합니다.
부트스트랩 · main.ts
앱 그래프 생성 —
NestFactory.create(AppModule)이 루트 모듈의imports·controllers·providers·exports를 따라 애플리케이션과 DI 그래프를 구성합니다. 기본 HTTP 플랫폼은 Express이며, 다른 어댑터는 이 생성 경계에서 선택합니다.전역 정책 설정 —
app.useGlobalPipes(...)와app.setGlobalPrefix(...)가 생성된 애플리케이션의 전역 HTTP 정책을 설정합니다.리스너 시작 —
app.listen(port)가 설정이 끝난 애플리케이션의 HTTP 리스너를 시작합니다.
요청 처리 · runtime
HTTP 경계 — 선택된 어댑터가 요청을 받고 전역 접두사로 경로를 찾습니다. 등록된 Guard가 인증·인가를 판단하고, 전역 Pipe가 핸들러 인자를 변환·검증한 뒤 컨트롤러가 실행됩니다.
Controller — 경로와 HTTP 메서드를 매칭하고 요청 값을 읽은 뒤 Service에 작업을 위임하고 응답을 반환합니다.
Service provider — Service도 모듈의
providers에 등록되는 provider이며, 여러 진입점에서 재사용할 비즈니스 규칙을 수행합니다.선택적 인프라 provider — Service가 데이터베이스나 외부 API 작업을 필요로 할 때만 DI로 연결된 Repository 또는 API client를 호출합니다. 모든 요청이 이 단계를 거치는 것은 아닙니다.
응답 반환 — 결과가 Service와 컨트롤러로 돌아와 어댑터를 통해 클라이언트 응답이 됩니다.
모듈은 컨트롤러와 provider를 연결하는 구성 경계입니다. Service 자체가 provider이고, Repository·API client 같은 인프라 provider는 필요한 요청에서만 사용합니다.
모듈: 애플리케이션의 조직자
모듈은 NestJS 애플리케이션의 기본 조직 단위입니다.
건물을 지을 때 거실, 침실, 주방을 나눠 설계하듯이, NestJS도 모듈을 통해 특정 기능 또는 도메인 영역을 분리하고 캡슐화합니다.
이 구조는 애플리케이션 규모가 커질수록 코드 복잡성을 관리하고, 재사용성을 높이며, 유지보수를 쉽게 만드는 데 큰 역할을 합니다.
모듈은 @Module() 데코레이터를 사용하여 정의합니다.
이 데코레이터는 모듈의 메타데이터를 담는 객체를 인자로 받으며, 주로 다음과 같은 속성들을 포함합니다.
imports: 이 모듈에서 DI로 사용할 프로바이더를 내보내는 다른 모듈을 선언합니다. 예를 들어,UserModule에서AuthModule이exports한 기능을 사용해야 한다면imports배열에AuthModule을 추가합니다.controllers: 이 모듈에 속하는 컨트롤러 클래스들을 배열로 선언합니다. 이 모듈이 처리할 HTTP 요청 라우팅을 정의합니다.providers: 이 모듈에 속하는 프로바이더(주로 서비스, 레포지토리 등) 클래스들을 배열로 선언합니다. 비즈니스 로직이나 데이터 접근 로직을 담당하는 핵심 요소들입니다.exports: 이 모듈의providers중 다른 모듈에서 사용될 프로바이더를 배열로 선언합니다.exports된 프로바이더는 해당 모듈을imports하는 다른 모듈에서 의존성 주입을 통해 사용할 수 있게 됩니다.
예시:
AppModule은 애플리케이션의 루트 모듈이며, 다른 모든 모듈의 진입점 역할을 합니다.
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
// import { UsersModule } from './users/users.module'; // 가정: 사용자 관련 기능 모듈
@Module({
imports: [/* UsersModule */], // 만약 사용자 모듈을 사용한다면 여기에 추가
controllers: [AppController],
providers: [AppService],
// exports: [], // 필요에 따라 이 모듈의 프로바이더를 외부에 노출
})
export class AppModule {}컨트롤러: 요청과 응답의 관문
컨트롤러는 클라이언트로부터 들어오는 HTTP 요청을 처리하고 적절한 HTTP 응답을 반환하는 역할을 합니다.
NestJS 애플리케이션에서 클라이언트와 가장 먼저 상호작용하는 지점이라고 볼 수 있습니다.
컨트롤러의 주된 목적은 요청을 받고, 필요한 경우 서비스(Service) 같은 프로바이더에 비즈니스 로직 처리를 위임한 뒤, 그 결과를 클라이언트에 전달하는 것입니다.
컨트롤러는 @Controller() 데코레이터를 사용하여 정의합니다.
이 데코레이터는 선택적으로 경로 접두사(path prefix)를 인자로 받을 수 있습니다.
아래 코드 주석은 전역 접두사가 없는 경우의 경로를 표시합니다. 앞의 main.ts처럼 api를 전역 접두사로 설정했다면 실제 경로는 /api/users, /api/users/:id처럼 전역 접두사, 컨트롤러 접두사, 핸들러 경로를 차례로 결합합니다.
import { Controller, Get, Post, Body, Param } from '@nestjs/common';
import { AppService } from './app.service';
@Controller('users') // '/users' 경로로 들어오는 요청을 처리합니다.
export class AppController {
constructor(private readonly appService: AppService) {} // 서비스 주입
@Get() // GET /users 요청을 처리
findAll(): string[] {
return this.appService.getUsers(); // 사용자 목록 반환 로직을 서비스에 위임
}
@Get(':id') // GET /users/:id 요청을 처리 (예: /users/1)
findOne(@Param('id') id: string): string {
return this.appService.getUser(id);
}
@Post() // POST /users 요청을 처리
create(@Body() createUserDto: { name: string }): string { // 요청 본문(body)을 받음
return this.appService.addUser(createUserDto.name);
}
}위 예시처럼 @Get(), @Post(), @Put(), @Delete() 같은 HTTP 메서드 데코레이터를 사용해 특정 경로 요청을 처리할 핸들러 메서드를 정의합니다.
위 예시처럼 컨트롤러는 복잡한 비즈니스 로직을 직접 담기보다 요청 값을 읽고 해당 로직을 서비스에 위임하는 역할을 맡습니다.
서비스 및 기타 프로바이더
NestJS에서 프로바이더(Providers)는 매우 넓은 개념입니다.
서비스(Service), 레포지토리(Repository), 팩토리(Factory), 헬퍼(Helper) 등 애플리케이션 핵심 클래스 대부분이 프로바이더에 해당합니다.
클래스 기반 프로바이더는 보통 @Injectable() 데코레이터로 표시하고 모듈의 providers에 등록합니다.
이렇게 등록된 객체는 NestJS의 의존성 주입(Dependency Injection) 시스템 덕분에 다른 컴포넌트(컨트롤러, 다른 프로바이더)에 쉽게 주입되어 사용됩니다.
그중에서도 서비스(Service)는 프로바이더의 가장 대표적인 형태로, 애플리케이션의 비즈니스 로직을 담는 역할을 합니다.
컨트롤러가 요청을 받고 응답을 반환하는 데 집중한다면, 서비스는 실제 데이터 처리, 계산, 외부 API 호출 등 핵심적인 작업을 수행합니다.
왜 서비스로 비즈니스 로직을 분리해야 할까요?- 관심사 분리(Separation of Concerns): 컨트롤러는 요청 처리, 서비스는 비즈니스 로직 처리에 집중함으로써 각 컴포넌트의 책임이 명확해집니다.
- 재사용성(Reusability): 동일한 비즈니스 로직이 여러 컨트롤러나 다른 서비스에서 필요할 때, 서비스를 재사용하여 중복 코드를 줄일 수 있습니다.
- 테스트 용이성(Testability): 서비스는 컨트롤러와 독립적으로 단위 테스트를 수행하기 용이합니다. 컨트롤러 테스트 시 서비스의 동작을 목(mock) 처리하여 컨트롤러 자체의 로직에만 집중할 수 있습니다.
import { Injectable } from '@nestjs/common';
@Injectable() // Nest가 DI에 필요한 클래스 메타데이터를 읽을 수 있게 표시합니다.
export class AppService {
private users: string[] = ['Alice', 'Bob', 'Charlie']; // 간단한 사용자 데이터
getHello(): string {
return 'Hello World!';
}
getUsers(): string[] {
// 실제로는 데이터베이스에서 사용자 데이터를 가져오는 로직이 들어갈 수 있습니다.
return this.users;
}
getUser(id: string): string {
return `User with ID: ${id}`;
}
addUser(name: string): string {
this.users.push(name);
return `User ${name} added!`;
}
}이 AppService는 getHello(), getUsers(), getUser(), addUser()와 같은 메서드를 통해 애플리케이션의 특정 비즈니스 로직을 수행합니다.
AppController는 이 AppService를 주입받아 사용자 관련 요청을 처리할 때 getUsers()나 addUser()와 같은 메서드를 호출하여 실제 작업을 수행하도록 위임할 수 있습니다.
실제 코드를 작성할 때는 어느 클래스에 어떤 코드를 두어야 하는지가 가장 자주 헷갈립니다.
앞의 흐름에서 모듈은 런타임 요청이 거쳐 가는 단계가 아니라 컨트롤러와 프로바이더를 연결하는 구성 경계였습니다. 다음 다이어그램은 전역 애플리케이션 설정까지 포함해, 변경 신호에 따라 코드를 어디에 둘지 결정하는 기준을 정리합니다.
Nest · placement check
변경 신호를 보면 코드를 둘 경계가 보인다
라우트 하나를 구현할 때도 시작 설정, 모듈 구성, HTTP 계약, 비즈니스 규칙, 외부 구현을 같은 클래스에 섞지 않습니다.
| 변경 신호 | 둘 위치 | 경계의 이유 | 확인 |
|---|---|---|---|
| 포트·전역 접두사·전역 검증·HTTP 플랫폼 | main.ts와 애플리케이션 생성 경계 |
INestApplication의 전역 정책, 어댑터 선택, 리스너 시작에 관한 설정입니다. |
부팅 후 포트·최종 URL·어댑터와 전역 파이프의 요청 경계 등록을 확인합니다. 필드 검증은 런타임 메타데이터가 있는 class DTO를 도입한 경우에 별도로 테스트합니다. |
| 기능 조합·컨트롤러 소속·주입·외부 공개 | @Module() 메타데이터: imports · controllers · providers · exports |
모듈 메타데이터가 DI 그래프와 기능 캡슐화 경계를 정의합니다. | 모듈을 컴파일하고, 가져온 모듈에서 내보낸 프로바이더가 해결되는지 확인합니다. |
| 경로·HTTP 메서드·요청 값·상태·응답 형식 | Controller | 컨트롤러가 들어오는 요청을 해석하고 서비스 결과를 HTTP 응답으로 반환합니다. | 핸들러 또는 HTTP 테스트로 라우트 매칭과 응답 계약을 확인합니다. |
| 여러 진입점에서 재사용할 비즈니스 규칙 | Service | 전송 계층과 독립된 규칙은 서비스에 두어 재사용하고 단위 테스트할 수 있습니다. | 서비스를 직접 호출해 정상·경계·오류 규칙을 단위 테스트합니다. |
| DB·외부 API 구현 또는 테스트 대역 교체 | Provider 등록과 모듈 또는 테스트 모듈 | DI 토큰이 실제 구현을 결정하므로 레포지토리와 클라이언트를 교체할 수 있습니다. | overrideProvider()에 useValue · useClass · useFactory를 연결해 대역을 확인합니다. |
- 포트·전역 접두사·전역 검증·HTTP 플랫폼
- 위치:
main.ts와 애플리케이션 생성 경계.INestApplication의 전역 정책, 어댑터 선택, 리스너 시작에 관한 설정입니다. 부팅 후 포트·최종 URL·어댑터와 전역 파이프의 요청 경계 등록을 확인합니다. 필드 검증은 런타임 메타데이터가 있는 class DTO를 도입한 경우에 별도로 테스트합니다. - 기능 조합·컨트롤러 소속·주입·외부 공개
- 위치:
@Module()메타데이터의imports·controllers·providers·exports. 모듈 메타데이터가 DI 그래프와 기능 캡슐화 경계를 정의합니다. 모듈을 컴파일하고, 가져온 모듈에서 내보낸 프로바이더가 해결되는지 확인합니다. - 경로·HTTP 메서드·요청 값·상태·응답 형식
- 위치: Controller. 컨트롤러가 들어오는 요청을 해석하고 서비스 결과를 HTTP 응답으로 반환합니다. 핸들러 또는 HTTP 테스트로 라우트 매칭과 응답 계약을 확인합니다.
- 여러 진입점에서 재사용할 비즈니스 규칙
- 위치: Service. 전송 계층과 독립된 규칙은 서비스에 두어 재사용하고 단위 테스트할 수 있습니다. 서비스를 직접 호출해 정상·경계·오류 규칙을 단위 테스트합니다.
- DB·외부 API 구현 또는 테스트 대역 교체
- 위치: Provider 등록과 모듈 또는 테스트 모듈. DI 토큰이 실제 구현을 결정하므로 레포지토리와 클라이언트를 교체할 수 있습니다.
overrideProvider()에useValue·useClass·useFactory를 연결해 대역을 확인합니다.
기본 원칙은 얇은 컨트롤러, 재사용 가능한 서비스, 교체 가능한 프로바이더입니다. 모듈은 이들을 등록하고 필요한 범위만 내보냅니다.
NestJS의 모듈, 컨트롤러, 서비스는 긴밀하게 연결되어 조화롭게 동작하며, 견고하고 유지보수하기 쉬운 애플리케이션 구조를 만듭니다.
이들을 적절히 활용하는 것은 NestJS 개발의 핵심 역량이라 할 수 있습니다.
이제 NestJS의 기본적인 빌딩 블록들을 정리했으므로, 다음 장에서는 이 구조를 실제로 연결해 주는 핵심 메커니즘인 의존성 주입(Dependency Injection)과 IoC 컨테이너를 더 깊이 다루겠습니다.