05 [NestJS] Request-Response 생명주기
NestJS의 개별 요소들을 하나씩 보다보니, 문득 전체적인 그림이 궁금해졌다. GET /users/1 같은 HTTP 요청이 하나 들어왔을 때, 이 요청이 어떤 과정을 거쳐 데이터베이스까지 도달하고, 또 어떻게 응답으로 돌아가는지 그 흐름을 제대로 정리해보고 싶었다.
Express로 백엔드를 개발할 때는 라우터와 콜백 함수 구조에 익숙했다. 요청이 들어오면 미들웨어를 거쳐 해당 라우터의 콜백 함수가 실행되는, 비교적 단순하고 자유로운 흐름이었다. 하지만 NestJS를 공부하면서 마주한 파이프, 가드, 서비스 같은 개념들은 처음엔 좀 낯설었다.
NestJS 요청 처리의 큰 그림
가장 기본적인 흐름은 이렇다. 클라이언트의 요청이 들어오면, 여러 단계를 거쳐 최종적으로 데이터베이스에 접근하고, 그 결과를 다시 클라이언트에게 반환한다.
Request ➡️ Pipe ➡️ Controller ➡️ Service ➡️ Repository ➡️ Database
물론 실제로는 미들웨어(Middleware)나 가드(Guard), 인터셉터(Interceptor) 등이 이 흐름 사이사이에 끼어들어 로깅, 인증, 데이터 변환 등 다양한 역할을 수행한다. 하지만 이번은 핵심 골격인 위 흐름에 집중해보겠다.
1. 첫 번째 관문: 파이프 (Pipe)
클라이언트의 요청이 NestJS 애플리케이션에 도착하면, 라우팅되어 해당 컨트롤러 메서드로 가기 직전에 파이프를 만난다. 파이프의 주된 임무는 두 가지다.
- 유효성 검사 (Validation): 요청 데이터가 우리가 원하는 형식과 규칙에 맞는지 검사한다.
- 데이터 변환 (Transformation): 들어온 데이터를 우리가 필요한 형태로 가공한다.
예를 들어, POST /users로 새로운 유저를 생성하는 요청이 들어온다고 생각해보자. 요청 바디(body)에는 name과 email이 반드시 포함되어야 하고, email은 실제 이메일 형식이어야 한다.
예전 같았으면 컨트롤러 메서드 안에서 if (!body.email || !body.email.includes('@')) 같은 코드를 직접 짰을 것이다. 하지만 NestJS에서는 ValidationPipe를 사용해 이 과정을 아주 깔끔하게 처리할 수 있다.
// users.controller.ts
@Post()
// ValidationPipe가 DTO를 보고 유효성 검사를 자동으로 해준다.
create(@Body(ValidationPipe) createUserDto: CreateUserDto) {
return this.usersService.create(createUserDto);
}
// create-user.dto.ts
import { IsString, IsEmail } from 'class-validator';
export class CreateUserDto {
@IsString()
readonly name: string;
@IsEmail()
readonly email: string;
}
만약 유효성 검사에 실패하면, 파이프는 아예 컨트롤러가 실행되기도 전에 400 Bad Request 에러를 자동으로 발생시킨다. 덕분에 컨트롤러는 지저분한 유효성 검사 코드 없이, 순수하게 비즈니스 로직 호출에만 집중할 수 있게 된다.
2. 교통 정리: 컨트롤러 (Controller)
파이프라는 관문을 무사히 통과한 요청은 드디어 컨트롤러에 도착한다. 컨트롤러의 역할은 교통정리다. @Get, @Post 같은 데코레이터를 통해 어떤 경로의 요청을 어떤 메서드가 처리할지 결정하고, 요청에서 필요한 데이터(@Body, @Param, @Query)를 추출하여 서비스에 넘겨주는 역할을 한다.
컨트롤러는 절대 뚱뚱해져서는 안 된다. 컨트롤러의 핵심 원칙은 **'Thin Controller'**다. 복잡한 계산, 데이터베이스 접근, 외부 API 호출 같은 비즈니스 로직은 컨트롤러에 있어서는 안 된다. 컨트롤러는 그저 요청을 받아 내용을 확인하고, 실제 일꾼인 서비스에게 "이거 처리해줘"라고 위임한 뒤, 결과를 받아 클라이언트에게 응답하는 역할에만 충실해야 한다.
3. 진짜 일꾼: 서비스 (Service)
서비스는 애플리케이션의 핵심 비즈니스 로직을 담당하는 심장과도 같은 곳이다. 컨트롤러로부터 작업을 위임받으면, 실제 "일"을 처리한다.
예를 들어 '유저 생성'이라는 작업은 단순히 데이터를 DB에 저장하는 것만으로 끝나지 않을 수 있다.
- 비밀번호를 해싱(hashing)하고,
- 이미 가입된 이메일인지 확인하고,
- 가입 축하 이메일을 발송하는 등
이러한 일련의 비즈니스 절차들이 모두 서비스 레이어에서 이루어진다. 서비스는 '무엇을 할지'에 대한 구체적인 로직을 담고 있지만, 데이터베이스와 '어떻게 소통할지'에 대한 구체적인 방법은 알지 못한다. 그건 다음 단계인 리포지토리의 역할이다.
4. 데이터 전문가: 리포지토리 (Repository)
서비스가 비즈니스 로직을 처리하다 데이터베이스에 접근해야 할 때, 리포지토리를 호출한다. 리포지토리 패턴은 데이터베이스와의 소통을 한 곳으로 집중시키고 추상화하는 디자인 패턴이다. TypeORM을 사용하면 @InjectRepository 데코레이터를 통해 이 리포지토리를 아주 쉽게 주입받을 수 있다.
서비스는 "이 유저 객체를 저장해줘" (userRepository.save(user)) 라고 말할 뿐, 이 명령이 MariaDB에서 INSERT INTO ... 쿼리로 변환되는지, PostgreSQL에서 다르게 변환되는지는 전혀 신경 쓰지 않는다. 그건 리포지토리의 역할이다.
이러한 역할의 분리 덕분에 서비스는 데이터베이스의 종류나 세부 구현에 얽매이지 않고 순수한 비즈니스 로직에만 집중할 수 있다. 이는 코드의 재사용성을 높이고, 테스트를 훨씬 쉽게 만들어준다.
정리하며
하나의 요청이 처리되는 과정을 따라가 보니, NestJS가 왜 '견고하고 확장 가능한 서버 애플리케이션'을 만드는 데 적합한 프레임워크인지 다시 한번 깨닫게 되었다.
파이프(유효성/변환) ➡️ 컨트롤러(라우팅) ➡️ 서비스(비즈니스 로직) ➡️ 리포지토리(데이터 접근)
각 계층이 자신의 역할에만 충실하게 책임을 다하게 코드를 구현해야 하는것은 기본이다.