모듈, 컨트롤러, 서비스의 기본 개념
모듈이 기능 경계를 조직하고 컨트롤러와 서비스가 요청 처리와 비즈니스 로직을 나누는 방식을 코드로 익힙니다.
앞서 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 애플리케이션 시작 경계와 요청 처리 흐름
graph 생성·전역 정책·listen 뒤 runtime 요청이 guard/pipe/controller/service를 거치며 Module은 요청 단계가 아니다.
Nest 구성 · 부트스트랩과 요청
앱 그래프를 구성하는 시작 경계와 Controller가 실행되는 요청 경계를 나눕니다
main.ts에서 그래프·전역 정책·listener를 준비합니다. 요청 경로의 Guard는 등록한 경우, DTO 필드 검증은 런타임 규칙이 있는 class DTO를 사용한 경우에 적용됩니다.
부트스트랩 · 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가 인자를 처리한 뒤 컨트롤러가 실행되며, 이 절의 인라인 구조 타입에는 class DTO의 필드 검증 규칙이 없습니다.
Controller — 경로와 HTTP 메서드를 매칭하고 요청 값을 읽은 뒤 Service에 작업을 위임하고 응답을 반환합니다.
Service provider — Service도 모듈의
providers에 등록되는 provider이며, 여러 진입점에서 재사용할 비즈니스 규칙을 수행합니다.선택적 인프라 provider — Service가 데이터베이스나 외부 API 작업을 필요로 할 때만 DI로 연결된 Repository 또는 API client를 호출합니다. 모든 요청이 이 단계를 거치는 것은 아닙니다.
응답 반환 — 결과가 Service와 컨트롤러로 돌아와 어댑터를 통해 클라이언트 응답이 됩니다.
모듈은 컨트롤러와 provider를 연결하는 구성 경계입니다. Service 자체가 provider이고, Repository·API client 같은 인프라 provider는 필요한 요청에서만 사용합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- main.ts에서 AppModule로 애플리케이션 그래프를 만들고 전역 파이프와 접두사를 설정한 다음 HTTP 리스너를 시작한다. 런타임 요청은 어댑터와 전역 접두사, Guard와 Pipe를 거쳐 컨트롤러로 전달된다. 컨트롤러는 provider인 Service에 위임하고, Service는 데이터베이스나 외부 API가 필요할 때만 선택적 인프라 provider를 호출한 뒤 결과를 응답으로 반환한다. 모듈은 요청이 거치는 런타임 단계가 아니라 의존성 그래프를 구성하는 경계다.
- main.ts에서 앱 그래프와 전역 정책을 구성하고 리스너를 시작한다. 요청은 HTTP 경계와 컨트롤러를 거쳐 서비스로 위임된다. 서비스는 필요한 경우에만 인프라를 호출하고 결과를 응답으로 반환한다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | Nest 애플리케이션 시작 경계와 요청 처리 흐름 |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 그래프와 전역 정책 AppModule 그래프 뒤 pipe·prefix를 설정하고 listen합니다. | Module: 조립 경계 · listener: 시작 | 요청 처리 전 공통 정책이 준비됩니다. |
| runtime handler 요청이 adapter·prefix·guard·pipe를 지나 controller에 갑니다. | 입력: 검사된 요청 · Module: 요청 단계 아님 | 실제 handler 진입 경계를 통과합니다. |
| service와 선택 인프라 service가 필요할 때만 DB·외부 provider를 부른 뒤 응답합니다. | infra: 필요 시 선택 · 결과: 응답 | 업무와 조립 메타데이터를 구분합니다. |
Nest 애플리케이션 시작 경계와 요청 처리 흐름
graph 생성·전역 정책·listen 뒤 runtime 요청이 guard/pipe/controller/service를 거치며 Module은 요청 단계가 아니다.
Nest 구성 · 부트스트랩과 요청
앱 그래프를 구성하는 시작 경계와 Controller가 실행되는 요청 경계를 나눕니다
main.ts에서 그래프·전역 정책·listener를 준비합니다. 요청 경로의 Guard는 등록한 경우, DTO 필드 검증은 런타임 규칙이 있는 class DTO를 사용한 경우에 적용됩니다.
부트스트랩 · 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가 인자를 처리한 뒤 컨트롤러가 실행되며, 이 절의 인라인 구조 타입에는 class DTO의 필드 검증 규칙이 없습니다.
Controller — 경로와 HTTP 메서드를 매칭하고 요청 값을 읽은 뒤 Service에 작업을 위임하고 응답을 반환합니다.
Service provider — Service도 모듈의
providers에 등록되는 provider이며, 여러 진입점에서 재사용할 비즈니스 규칙을 수행합니다.선택적 인프라 provider — Service가 데이터베이스나 외부 API 작업을 필요로 할 때만 DI로 연결된 Repository 또는 API client를 호출합니다. 모든 요청이 이 단계를 거치는 것은 아닙니다.
응답 반환 — 결과가 Service와 컨트롤러로 돌아와 어댑터를 통해 클라이언트 응답이 됩니다.
모듈은 컨트롤러와 provider를 연결하는 구성 경계입니다. Service 자체가 provider이고, Repository·API client 같은 인프라 provider는 필요한 요청에서만 사용합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- main.ts에서 AppModule로 애플리케이션 그래프를 만들고 전역 파이프와 접두사를 설정한 다음 HTTP 리스너를 시작한다. 런타임 요청은 어댑터와 전역 접두사, Guard와 Pipe를 거쳐 컨트롤러로 전달된다. 컨트롤러는 provider인 Service에 위임하고, Service는 데이터베이스나 외부 API가 필요할 때만 선택적 인프라 provider를 호출한 뒤 결과를 응답으로 반환한다. 모듈은 요청이 거치는 런타임 단계가 아니라 의존성 그래프를 구성하는 경계다.
- main.ts에서 앱 그래프와 전역 정책을 구성하고 리스너를 시작한다. 요청은 HTTP 경계와 컨트롤러를 거쳐 서비스로 위임된다. 서비스는 필요한 경우에만 인프라를 호출하고 결과를 응답으로 반환한다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | Nest 애플리케이션 시작 경계와 요청 처리 흐름 |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 그래프와 전역 정책 AppModule 그래프 뒤 pipe·prefix를 설정하고 listen합니다. | Module: 조립 경계 · listener: 시작 | 요청 처리 전 공통 정책이 준비됩니다. |
| runtime handler 요청이 adapter·prefix·guard·pipe를 지나 controller에 갑니다. | 입력: 검사된 요청 · Module: 요청 단계 아님 | 실제 handler 진입 경계를 통과합니다. |
| service와 선택 인프라 service가 필요할 때만 DB·외부 provider를 부른 뒤 응답합니다. | infra: 필요 시 선택 · 결과: 응답 | 업무와 조립 메타데이터를 구분합니다. |
모듈: 애플리케이션의 조직자
모듈은 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 응답을 반환하는 역할을 합니다.
컨트롤러는 HTTP 요청을 애플리케이션의 기능으로 연결하는 경계입니다. 어댑터, 미들웨어, 가드와 파이프가 먼저 실행될 수 있으므로 요청 처리 전체의 첫 단계로 보지는 않습니다.
컨트롤러의 주된 목적은 요청을 받고, 필요한 경우 서비스(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()와 같은 메서드를 호출하여 실제 작업을 수행하도록 위임할 수 있습니다.
예시: 추가한 이름을 다시 조회하기
앞의 AppModule에 컨트롤러와 서비스를 등록하고, 이 절의 main.ts처럼 api 접두사를 설정한 앱을 실행한 상태에서 순서대로 요청합니다. 아래 예시는 PORT를 지정하지 않은 3000번 포트를 사용합니다.
curl http://localhost:3000/api/users
curl -X POST http://localhost:3000/api/users \
-H 'Content-Type: application/json' \
-d '{"name":"Dana"}'
curl http://localhost:3000/api/users첫 목록은 ["Alice","Bob","Charlie"]입니다. 추가 요청은 User Dana added! 문자열을 반환하고, 마지막 목록에는 Dana가 끝에 붙습니다. @Body()가 JSON의 name을 읽고 컨트롤러가 서비스에 넘긴 뒤, 서비스의 push()가 공유 배열을 바꾼 결과입니다. GET /api/users/9는 배열의 아홉 번째 사용자를 찾지 않고 User with ID: 9라는 예시 문자열만 반환합니다. 아직 실제 사용자 식별자 조회를 구현한 코드는 아닙니다.
이 배열은 프로세스 메모리에만 있으므로 앱을 재시작하면 초기 세 이름으로 돌아갑니다. 같은 앱이 실행 중일 때 연속 조회한 결과와 재시작한 뒤의 결과를 나눠 확인하세요. 또한 인라인 body 타입만으로 빈 이름이나 누락된 필드를 거부한다고 기대하지 않습니다. 필드 검사와 영속 저장은 이후 DTO·데이터베이스 절에서 추가합니다.
한 가지 변경을 직접 설계하기
HTTP 목록 경로만 /api/members로 바꾸고 목록 데이터와 추가 규칙은 유지하려고 합니다. 어느 데코레이터를 바꾸고 어떤 서비스 메서드는 그대로 둘지 정해 보세요. 앱을 재시작한 뒤에도 Dana를 유지하라는 요구는 같은 변경인지도 설명해 보세요.
변경 위치와 이유
@Controller('users')를 @Controller('members')로 바꾸면 같은 @Get() 핸들러의 경로는 /api/members가 됩니다. getUsers()와 addUser()의 배열 처리 규칙은 바꿀 필요가 없습니다. 재시작 뒤 데이터 유지는 메모리 배열을 영속 저장으로 바꾸는 별도 요구입니다. 서비스가 저장 프로바이더를 사용하고 모듈이 이를 연결하도록 확장해야 하므로 URL 변경만으로 해결되지 않습니다.
실제 코드를 작성할 때는 어느 클래스에 어떤 코드를 두어야 하는지가 가장 자주 헷갈립니다.
앞의 흐름에서 모듈은 런타임 요청이 거쳐 가는 단계가 아니라 컨트롤러와 프로바이더를 연결하는 구성 경계였습니다. 다음 다이어그램은 전역 애플리케이션 설정까지 포함해, 변경 신호에 따라 코드를 어디에 둘지 결정하는 기준을 정리합니다.
Nest 코드 위치 · 변경 이유
시작 설정·기능 조합·HTTP 계약·규칙·외부 구현의 변경 위치를 구분합니다
포트와 전역 정책은 생성 경계에, 요청 계약은 Controller에, 재사용할 규칙은 Service에 둡니다. Provider 토큰의 등록과 테스트 대역 교체는 조립 경계에서 확인합니다.
포트·전역 접두사·전역 검증·HTTP 플랫폼
main.ts와 애플리케이션 생성 경계INestApplication의 전역 정책, 어댑터 선택, 리스너 시작에 관한 설정입니다.기능 조합·컨트롤러 소속·주입·외부 공개
@Module() 메타데이터: imports · controllers · providers · exports경로·HTTP 메서드·요청 값·상태·응답 형식
여러 진입점에서 재사용할 비즈니스 규칙
DB·외부 API 구현 또는 테스트 대역 교체
overrideProvider()에 useValue · useClass · useFactory를 연결해 대역을 확인합니다.기본 원칙은 얇은 컨트롤러, 재사용 가능한 서비스, 교체 가능한 프로바이더입니다. 모듈은 이들을 등록하고 필요한 범위만 내보냅니다.
NestJS의 모듈, 컨트롤러, 서비스는 긴밀하게 연결되어 조화롭게 동작하며, 견고하고 유지보수하기 쉬운 애플리케이션 구조를 만듭니다.
이들을 적절히 활용하는 것은 NestJS 개발의 핵심 역량이라 할 수 있습니다.
이제 NestJS의 기본적인 구성 요소들을 정리했으므로, 다음 장에서는 이 구조를 실제로 연결해 주는 핵심 메커니즘인 의존성 주입(Dependency Injection)과 IoC 컨테이너를 더 깊이 다루겠습니다.
서비스 내부 배열과 HTTP 응답을 구분하기
현재 getUsers()는 this.users 자체를 반환합니다. 같은 서버 프로세스에서 서비스를 직접 호출한 코드는 그 배열을 함께 참조할 수 있습니다. 반면 이 예제의 표준 HTTP 응답으로 배열을 JSON 직렬화해 받은 클라이언트는 서버 배열의 메모리 참조를 받지 않습니다.
아래 코드는 HTTP 요청 대신 서비스를 직접 호출하여 참조 차이를 확인하는 작은 실험입니다. AppService는 앞의 코드 그대로 사용합니다.
import { AppService } from './app.service';
const service = new AppService();
const shared = service.getUsers();
shared.push('Dana');
const copied = [...service.getUsers()];
copied.push('Eve');
console.log(service.getUsers().length); // 4
console.log(shared === service.getUsers()); // true
console.log(copied.length); // 5shared에 Dana를 넣을 때는 실제 서비스 배열도 바뀝니다. 전개 구문으로 새 배열을 만든 copied에 Eve를 넣을 때는 서비스 배열이 바뀌지 않습니다. 이 목록의 원소는 문자열이라 배열 복사로 이 차이를 확인할 수 있습니다. 나중에 원소가 객체가 되면 전개 구문은 객체 내부까지 복사하지 않는다는 점도 함께 확인해야 합니다. 외부 코드가 상태를 바꾸는 통로를 관리하려면 조회 결과를 복사하고 변경은 서비스 메서드로 모으는 방식을 고려할 수 있습니다.
조건을 바꿔 확인하기
연습문제: 직접 호출과 복사본 변경
초기 사용자 세 명인 새 AppService에서 const a = service.getUsers(), const b = [...a]를 만들었습니다. 먼저 b.push('Dana'), 이어 a.push('Eve')를 호출했습니다. a, b, service의 목록 길이와 마지막 이름은 각각 무엇일까요?
풀이: a와 service의 목록은 같은 배열이므로 둘 다 길이 4, 마지막 이름 Eve입니다. b는 복사할 당시의 세 이름을 가진 별도 배열이므로 길이 4, 마지막 이름 Dana입니다. b를 변경할 때 원본은 그대로였고, a를 변경할 때 b에는 그 변경이 전달되지 않았습니다.
연습문제: 같은 인스턴스와 새 인스턴스
테스트 코드에서 const first = new AppService(), const second = new AppService()를 각각 만들었습니다. first에만 addUser('Dana')를 호출했습니다. 두 목록 길이는 무엇이며, 이것으로 실제 앱의 요청마다 서비스가 새로 만들어진다고 결론 낼 수 있을까요?
풀이: first는 4, second는 3입니다. 각 인스턴스의 필드 초기화가 별도 배열을 만들기 때문입니다. 테스트에서 직접 두 인스턴스를 만든 사실은 Nest의 provider scope를 바꾸지 않습니다. 이 장의 기본 scope 앱은 같은 컨테이너가 관리하는 서비스 인스턴스를 재사용하며, 프로세스를 다시 시작하면 메모리 목록이 초기화됩니다.
연습문제: 타입 선언과 입력 검사
앞의 POST handler는 @Body() createUserDto: { name: string }을 사용합니다. 현재 코드에 필드 검증 규칙을 가진 DTO class나 별도 입력 검사를 추가하지 않았습니다. JSON의 name이 숫자라는 이유만으로 전역 ValidationPipe가 반드시 400을 반환한다고 예상해도 될까요?
풀이: 그렇게 예상할 수 없습니다. 인라인 구조 타입은 실행할 때 필드 검증 규칙으로 남지 않습니다. 현재 handler가 전달받은 createUserDto.name을 검사 없이 서비스에 넘기면 string 타입 선언만으로 런타임 값을 막을 수 없습니다. 문자열 입력을 보장하려면 실제 DTO class의 검증 규칙 또는 명시적인 입력 검사를 구현하고 잘못된 값의 응답을 확인해야 합니다.