마이크로서비스 개념과 NestJS 적용
모놀리식과 마이크로서비스의 경계를 비교하고 TCP 기반 사용자·주문 서비스를 분리해 요청 통신을 검증합니다.
6장에서는 오래 걸리는 작업을 BullMQ와 Redis로 떼어 내고, 실행 상태와 복구 기준을 관리했습니다. 7장에서는 이렇게 프로세스 밖으로 일을 나누는 생각을 서비스 사이로 넓혀, 마이크로서비스(microservices) 아키텍처의 기본 개념과 NestJS에서 이를 적용하는 방법을 다룹니다.
하나의 큰 애플리케이션(monolithic application)은 개발 초기에는 빠르고 단순하지만, 규모가 커지고 복잡해질수록 유지보수, 확장, 배포가 어려워집니다. 마이크로서비스는 애플리케이션을 작고 독립적인 서비스들로 나누어 이 문제에 대응하는 아키텍처 방식입니다.
각 서비스는 특정 비즈니스 기능에 집중합니다. 다만 독립적인 개발·배포·확장은 서비스별 소유권, 실행 환경, 배포 파이프라인, 데이터를 실제로 나누었을 때 얻는 운영상의 특성이며, 코드를 나누는 것만으로 저절로 생기지는 않습니다.
마이크로서비스 아키텍처란
마이크로서비스 아키텍처는 하나의 애플리케이션을 작고 독립적인 서비스의 모음으로 개발하는 방식입니다. 각 서비스는 자기 프로세스에서 실행되며, HTTP REST API, gRPC, 메시지 큐처럼 잘 정의된 가벼운 통신 방식으로 서로 통신합니다.
마이크로서비스의 주요 특징은 다음과 같습니다.
- 독립 배포: 서비스별 릴리스 파이프라인과 하위 호환되는 통신 형식을 갖추면 다른 서비스와 따로 배포할 수 있습니다.
- 독립 개발: 비즈니스 소유권과 변경을 맡을 팀이 분명하면 별도 팀이 독립적으로 개발할 수 있습니다.
- 독립 확장: 실행 환경과 용량 설정을 나누면 부하가 높은 서비스만 확장할 수 있습니다.
- 기술 스택 다양성: 각 서비스가 요구사항에 가장 맞는 언어, 프레임워크, 데이터베이스를 고를 수 있습니다(polyglot programming, polyglot persistence).
- 높은 응집도와 낮은 결합도: 각 서비스는 특정 비즈니스 도메인 안에서 응집도가 높고, 다른 서비스와의 결합은 최소화합니다.
- 장애 격리: 타임아웃, 격리된 자원, 재시도와 서킷 브레이커 같은 실패 정책을 함께 설계하면 한 서비스의 장애가 퍼지는 범위를 줄일 수 있습니다.
장점으로는 특정 서비스만 유연하게 확장하는 확장성, 서비스 단위가 작아 이해하고 고치기 쉬운 유지보수성, 독립 배포로 빨라지는 릴리스 주기, 서비스별로 기술을 고르는 유연성, 작은 팀이 독립적으로 일하는 자율성을 들 수 있습니다.
반면 단점도 분명합니다. 네트워크 지연, 장애 처리, 데이터 일관성 같은 분산 시스템의 복잡성이 생기고, 서비스·배포 파이프라인·모니터링 시스템이 늘어나 운영 부담이 커집니다. 여러 서비스에 걸친 트랜잭션을 다루기 어렵고, 통신 방식을 고르고 관리해야 하며, 여러 서비스를 오가는 문제를 추적하기도 까다롭습니다.
마이크로서비스는 무조건 잘게 쪼개는 기술이 아니라, 독립 배포로 얻는 이득과 운영 비용 사이의 균형을 판단하는 설계입니다. 아래 그림은 서비스를 나눌 때 확인해야 할 기준을 한 화면에 정리한 것입니다.
Nest 서비스 분리 · 이득과 비용
독립 운영의 구체적 이득과 분산 비용을 함께 판단합니다
소유권·런타임·데이터·배포 경계를 실제로 분리할 때 이득이 성립합니다. 작은 시스템에서는 모놀리스의 모듈 경계를 강화하는 선택도 충분합니다.
소유권
독립 배포
부하 격리
장애 격리
데이터 경계
한 프로세스·한 릴리스 주기
Orders, Users, Payments가 같은 배포와 장애 경계를 공유합니다. 초기에는 모듈 경계만 선명하게 해도 충분할 수 있습니다.
여러 운영 경계·네트워크 계약
각 서비스가 별도 프로세스와 데이터 책임을 갖고 pattern·payload 같은 원격 계약으로 연결될 때 독립 운영의 이득이 생깁니다.
Users
계정·인증·프로필 소유권이 분명하면 후보입니다. Orders와 늘 같은 트랜잭션만 쓴다면 보류합니다.
Orders
주문·결제 흐름의 변경 주기가 독립적이면 후보입니다. 초기 규모라면 한 HTTP 모듈로 충분할 수 있습니다.
Notifications
실패가 핵심 주문을 막지 않아야 하면 후보입니다. 전송량이 작고 격리가 불필요하면 보류합니다.
판정: 독립 운영의 구체적 이유가 있고 네트워크·관측성·일관성 비용을 감당할 때 분리합니다. 그렇지 않으면 모놀리스 안의 모듈 경계를 먼저 강화합니다.
위 그림의 표는 소유권, 독립 배포, 부하 격리, 장애 격리, 데이터 분리라는 다섯 가지 이득마다 그 이득이 성립하려면 무엇이 갖춰져야 하는지와, 대신 감당해야 할 비용을 나란히 적은 것입니다. 아래쪽에서는 Users, Orders, Notifications 서비스를 예로 들어, 분리할 구체적인 이유가 있을 때는 후보가 되고 그렇지 않으면 보류한다고 판단합니다. 분리할 이유가 약하다면 모놀리스 안에서 모듈 사이의 구분을 먼저 분명히 하는 편이 낫다는 결론이 그림 맨 아래에 있습니다.
NestJS가 마이크로서비스에 맞는 이유
NestJS는 마이크로서비스를 만들 때 필요한 전송 계층, 메시지 패턴, 의존성 주입 구조를 함께 제공합니다.
- 모듈 기반 구조: 모듈 시스템이 애플리케이션을 기능별로 나누고 구성하기 쉽게 해 주어, 서비스를 어디서 나눌지 정하는 데 도움이 됩니다.
- 익숙한 설계 패턴: controller, provider, module처럼 잘 알려진 패턴을 써서 복잡한 시스템의 구조를 분명하게 유지할 수 있습니다.
- 다양한 전송 계층: 서비스 사이 통신을 위한 전송 계층(transport layer)이 내장되어 있습니다.
- TCP: 기본으로 제공되는 가벼운 통신
- Redis: 메시지 브로커로 쓰며 Pub/Sub 지원
- Kafka: 처리량이 높은 분산 메시징 시스템
- RabbitMQ: 메시지 큐
- NATS: 고성능 메시징 시스템. NestJS 12부터는
nats대신@nats-io/transport-node패키지를 씁니다. - gRPC: HTTP/2 기반의 고성능·언어 중립 RPC 프레임워크
- MQTT: IoT 환경에 맞춘 가벼운 메시징 프로토콜
- 의존성 주입(DI): 서비스 사이의 결합도를 낮추고 테스트하기 쉽게 만듭니다.
- TypeScript 기반: 타입 안전성으로 큰 프로젝트에서도 코드 품질과 유지보수성을 높입니다.
- 일관된 개발 경험: 여러 서비스를 NestJS로 만들면 같은 구조와 코딩 스타일을 유지할 수 있어 학습 부담이 줄고 생산성이 높아집니다.
NestJS 마이크로서비스의 기본 구조
마이크로서비스를 처음 나눌 때는 요청이 들어오는 위치, 각 처리를 맡는 계층, 실패가 응답으로 나가는 지점을 구분해 두어야 합니다.
HTTP 요청이 TCP 메시지 계약을 거쳐 응답으로 돌아오는 과정
cold Observable 구독·TCP pattern/payload·Users handler·응답 판정·HTTP 200/404/408/503으로 왕복한다.
Nest TCP · 요청과 메시지 계약
HTTP 입력을 TCP 계약으로 바꾸고 결과를 HTTP로 돌려줍니다
Orders는 HTTP 진입점입니다. send()를 구독해 원격 호출을 시작하고 null·timeout·transport 오류를 구분합니다. 현재 수기 타입과 parseInt 예제에 런타임 검증이 자동으로 생기지는 않습니다.
GET 요청에서 응답까지
-
HTTP client
GET /orders/1/user로 외부 HTTP 계약을 호출합니다. -
OrdersController
orderId를userId로 해석하고 결과·오류를 HTTP 응답으로 바꿀 준비를 합니다. -
ClientProxy.send()
ClientProxy.send()가 pattern과 payload로 cold Observable을 만들고, 구독 또는firstValueFrom()때 요청을 보냅니다. -
Nest TCP transport
pattern과 payload를 직렬화해 Users 프로세스로 보내고 요청과 응답의 상관관계를 관리합니다.
-
Users @MessagePattern()
@MessagePattern('get_user_by_id')가 같은 pattern을 받아 payload로 조회한 뒤 User | null을 반환합니다. -
응답과 HTTP 변환
응답 Observable이 값을 내보내면 Orders가 성공,
null, timeout, transport 오류를 각각 HTTP 결과로 매핑합니다.
pattern
'get_user_by_id'@MessagePattern('get_user_by_id')payload
userId: number@Payload() id: numberresponse
Observable<User | null>User | null질문하고 응답을 기다린다
request–response용 cold Observable입니다. 구독해야 전송되며 timeout()과 transport·원격 오류 처리를 호출자가 명시해야 합니다.
이벤트를 발행하고 응답 본문은 받지 않는다
@EventPattern()용 hot Observable로, 구독하지 않아도 전달을 즉시 시도합니다. 그렇다고 전달 오류 관측 정책까지 사라지는 것은 아닙니다.
① HTTP 표면
상태 코드, 응답 본문, 전체 지연을 확인합니다.
② Orders + transport
pattern, 마스킹한 payload, timeout·오류와 TCP status를 기록합니다.
③ Users handler
handler 도착, 조회 시간, User 또는 null 결과를 확인합니다.
전송의 공통 틀
transport 연결, packet 직렬화, pattern 라우팅, 요청·응답 상관관계와 ClientProxy 추상화를 제공합니다.
업무 계약과 실패 정책
pattern·DTO, 입력 검증, timeout·재시도·멱등성, HTTP 오류 변환, 추적 컨텍스트와 데이터 일관성을 정합니다.
장애를 좁히는 순서: HTTP 응답 → Orders의 send와 구독 → TCP 상태 → Users handler → 도메인 조회. 세 구간에 같은 correlation ID를 남기면 분산 추적이 쉬워집니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 두 프로세스 사이의 요청과 응답을 위에서 아래로 펼친 흐름이다. ClientProxy의 cold Observable을 구독해야 pattern과 숫자 payload가 TCP로 Users handler에 간다. User 또는 null 응답은 다시 Orders에서 판정한다. 조회 성공은 200, null은 404, 호출 timeout은 408, 다른 transport 오류는 503으로 번역한다. 입력 변환만으로 검증되지는 않는다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | HTTP 요청이 TCP 메시지 계약을 거쳐 응답으로 돌아오는 과정 |
| 6 | 두 프로세스 사이의 요청과 응답을 위에서 아래로 펼친 흐름이다. ClientProxy의 cold Observable을 구독해야 pattern과 숫자 payload가 TCP로 Users handler에 간다. User 또는 null 응답은 다시 Orders에서 판정한다. 조회 성공은 200, null은 404, 호출 timeout은 408, 다른 transport 오류는 503으로 번역한다. 입력 변환만으로 검증되지는 않는다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| cold Observable 구독 Orders가 ClientProxy 결과를 구독합니다. | 구독: 필수 · 입력: pattern·숫자 payload | 구독이 실제 TCP 전송을 시작합니다. |
| Users 왕복 TCP가 Users handler를 호출하고 User 또는 null을 돌립니다. | 프로세스: Orders·Users · 응답: User·null | 다른 프로세스의 조회 결과가 돌아옵니다. |
| HTTP 번역 Orders가 성공·null·timeout·transport 오류를 번역합니다. | 성공: 200 · null: 404 · timeout·오류: 408·503 | 내부 메시지 결과가 공개 HTTP가 됩니다. |
HTTP 요청이 TCP 메시지 계약을 거쳐 응답으로 돌아오는 과정
cold Observable 구독·TCP pattern/payload·Users handler·응답 판정·HTTP 200/404/408/503으로 왕복한다.
Nest TCP · 요청과 메시지 계약
HTTP 입력을 TCP 계약으로 바꾸고 결과를 HTTP로 돌려줍니다
Orders는 HTTP 진입점입니다. send()를 구독해 원격 호출을 시작하고 null·timeout·transport 오류를 구분합니다. 현재 수기 타입과 parseInt 예제에 런타임 검증이 자동으로 생기지는 않습니다.
GET 요청에서 응답까지
-
HTTP client
GET /orders/1/user로 외부 HTTP 계약을 호출합니다. -
OrdersController
orderId를userId로 해석하고 결과·오류를 HTTP 응답으로 바꿀 준비를 합니다. -
ClientProxy.send()
ClientProxy.send()가 pattern과 payload로 cold Observable을 만들고, 구독 또는firstValueFrom()때 요청을 보냅니다. -
Nest TCP transport
pattern과 payload를 직렬화해 Users 프로세스로 보내고 요청과 응답의 상관관계를 관리합니다.
-
Users @MessagePattern()
@MessagePattern('get_user_by_id')가 같은 pattern을 받아 payload로 조회한 뒤 User | null을 반환합니다. -
응답과 HTTP 변환
응답 Observable이 값을 내보내면 Orders가 성공,
null, timeout, transport 오류를 각각 HTTP 결과로 매핑합니다.
pattern
'get_user_by_id'@MessagePattern('get_user_by_id')payload
userId: number@Payload() id: numberresponse
Observable<User | null>User | null질문하고 응답을 기다린다
request–response용 cold Observable입니다. 구독해야 전송되며 timeout()과 transport·원격 오류 처리를 호출자가 명시해야 합니다.
이벤트를 발행하고 응답 본문은 받지 않는다
@EventPattern()용 hot Observable로, 구독하지 않아도 전달을 즉시 시도합니다. 그렇다고 전달 오류 관측 정책까지 사라지는 것은 아닙니다.
① HTTP 표면
상태 코드, 응답 본문, 전체 지연을 확인합니다.
② Orders + transport
pattern, 마스킹한 payload, timeout·오류와 TCP status를 기록합니다.
③ Users handler
handler 도착, 조회 시간, User 또는 null 결과를 확인합니다.
전송의 공통 틀
transport 연결, packet 직렬화, pattern 라우팅, 요청·응답 상관관계와 ClientProxy 추상화를 제공합니다.
업무 계약과 실패 정책
pattern·DTO, 입력 검증, timeout·재시도·멱등성, HTTP 오류 변환, 추적 컨텍스트와 데이터 일관성을 정합니다.
장애를 좁히는 순서: HTTP 응답 → Orders의 send와 구독 → TCP 상태 → Users handler → 도메인 조회. 세 구간에 같은 correlation ID를 남기면 분산 추적이 쉬워집니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 두 프로세스 사이의 요청과 응답을 위에서 아래로 펼친 흐름이다. ClientProxy의 cold Observable을 구독해야 pattern과 숫자 payload가 TCP로 Users handler에 간다. User 또는 null 응답은 다시 Orders에서 판정한다. 조회 성공은 200, null은 404, 호출 timeout은 408, 다른 transport 오류는 503으로 번역한다. 입력 변환만으로 검증되지는 않는다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | HTTP 요청이 TCP 메시지 계약을 거쳐 응답으로 돌아오는 과정 |
| 6 | 두 프로세스 사이의 요청과 응답을 위에서 아래로 펼친 흐름이다. ClientProxy의 cold Observable을 구독해야 pattern과 숫자 payload가 TCP로 Users handler에 간다. User 또는 null 응답은 다시 Orders에서 판정한다. 조회 성공은 200, null은 404, 호출 timeout은 408, 다른 transport 오류는 503으로 번역한다. 입력 변환만으로 검증되지는 않는다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| cold Observable 구독 Orders가 ClientProxy 결과를 구독합니다. | 구독: 필수 · 입력: pattern·숫자 payload | 구독이 실제 TCP 전송을 시작합니다. |
| Users 왕복 TCP가 Users handler를 호출하고 User 또는 null을 돌립니다. | 프로세스: Orders·Users · 응답: User·null | 다른 프로세스의 조회 결과가 돌아옵니다. |
| HTTP 번역 Orders가 성공·null·timeout·transport 오류를 번역합니다. | 성공: 200 · null: 404 · timeout·오류: 408·503 | 내부 메시지 결과가 공개 HTTP가 됩니다. |
위 그림은 HTTP 요청이 Orders 프로세스에서 TCP 메시지로 바뀌어 Users 프로세스까지 갔다가, 그 결과가 다시 HTTP 응답으로 바뀌어 돌아오는 과정입니다. ClientProxy.send()가 만든 cold Observable은 구독해야 전송이 시작되고, pattern과 payload가 TCP로 Users의 handler에 도착합니다. Users가 사용자나 null을 돌려주면 Orders가 그 결과를 판정해 조회 성공은 200, null은 404, 응답 시간 초과는 408, 그 밖의 전송 오류는 503으로 바꿉니다. 그림에는 응답 시간 초과가 408로 적혀 있지만, 408 Request Timeout은 서버가 클라이언트의 요청을 끝까지 받지 못했을 때 쓰는 코드입니다. 그래서 아래 코드는 상류 서비스의 응답이 늦었다는 뜻인 504 Gateway Timeout을 씁니다. 아래쪽 표는 pattern, payload, 응답 형식이 두 프로세스 사이에서 어긋날 때 무엇이 깨지는지 정리합니다.
NestJS에서 마이크로서비스를 구성하는 가장 기본적인 방법은 요청을 보내는 클라이언트(consumer) 서비스와 요청을 처리하는 서버(provider) 서비스로 나누는 것입니다. 여기서는 가장 간단한 TCP 기반 통신으로 구조를 익힙니다.
예제의 시나리오는 다음과 같습니다. Users 서비스와 Orders 서비스가 있고, Orders 서비스는 주문을 처리하면서 Users 서비스에 사용자 정보를 요청해야 합니다.
TCP 사용자 서비스 만들기
사용자 정보를 제공하는 독립 마이크로서비스를 만듭니다. 마이크로서비스는 보통 독립된 프로젝트로 관리하므로, 이 장의 실습은 users-service와 orders-service를 같은 상위 폴더 아래의 서로 다른 프로젝트로 둡니다. 사용자 서버는 TCP 3001번, 주문 서버는 HTTP 3000번 포트를 씁니다. 두 프로젝트의 설정 파일과 터미널을 구분하고, 새 프로젝트를 만드는 명령은 상위 폴더에서 실행합니다.
npx @nestjs/cli@12 new users-service --no-observe
cd users-service
npm install @nestjs/microservices@12CLI의 질문에서는 1장과 같이 모듈 형식은 CommonJS, 패키지 관리자는 npm을 고릅니다. 설치한 뒤 npm ls @nestjs/core @nestjs/common @nestjs/microservices로 세 패키지의 주 버전이 12로 같은지 확인하고 lockfile을 보관합니다. NestJS 12.3.2의 microservices 패키지도 peer dependency로 common과 core의 12 버전을 요구합니다. 다만 이 확인은 프레임워크 버전에 대한 것이며, 두 서비스가 주고받는 메시지 형식까지 검사해 주지는 않습니다.
먼저 main.ts를 HTTP 서버 대신 마이크로서비스 서버로 바꿉니다.
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { MicroserviceOptions, Transport } from '@nestjs/microservices'; // MicroserviceOptions, Transport 임포트
async function bootstrap() {
const app = await NestFactory.createMicroservice<MicroserviceOptions>(AppModule, {
transport: Transport.TCP, // TCP 전송 방식 사용
options: {
host: 'localhost', // 서비스가 바인딩될 호스트
port: 3001, // 서비스가 리스닝할 포트
},
});
await app.listen();
console.log('Users Microservice is listening on port 3001');
}
void bootstrap();NestFactory.createMicroservice(): HTTP 서버 대신 마이크로서비스 애플리케이션을 만듭니다.transport: Transport.TCP: TCP로 메시지를 주고받도록 설정합니다. NestJS는 여러Transport값을 제공합니다.options: 전송 방식별 추가 옵션으로, 여기서는 호스트와 포트를 지정합니다.
마이크로서비스에서는 @MessagePattern() 데코레이터로 메시지를 처리할 handler를 정의합니다.
import { Controller } from '@nestjs/common';
import { MessagePattern, Payload } from '@nestjs/microservices'; // MessagePattern, Payload 임포트
import { UsersService } from './users.service';
interface User {
id: number;
name: string;
email: string;
}
@Controller() // 마이크로서비스 컨트롤러는 일반적으로 경로가 없습니다.
export class UsersController {
constructor(private readonly usersService: UsersService) {}
// 'get_user_by_id' 메시지 패턴에 대한 핸들러
@MessagePattern('get_user_by_id')
getUserById(@Payload() id: number): User | null {
console.log(`Users Service: Received request for user ID: ${id}`);
// 실제로는 DB에서 조회
return this.usersService.findOne(id); // 사용자 없으면 null 반환
}
// 'create_user' 메시지 패턴에 대한 핸들러
@MessagePattern('create_user')
createUser(@Payload() userDto: { name: string; email: string }): User {
console.log(`Users Service: Received request to create user: ${JSON.stringify(userDto)}`);
const newUser = this.usersService.create(userDto);
return newUser;
}
}@MessagePattern('pattern_name'): 이 handler가 처리할 메시지의 패턴(이름)을 정합니다. 클라이언트는 이 패턴으로 특정 handler를 호출합니다.@Payload(): 메시지에 담겨 온 데이터를 꺼내 메서드 인자로 넣어 줍니다.
사용자 서비스를 구현합니다.
import { Injectable } from '@nestjs/common';
interface User {
id: number;
name: string;
email: string;
}
@Injectable()
export class UsersService {
private users: User[] = [
{ id: 1, name: 'Alice', email: '[email protected]' },
{ id: 2, name: 'Bob', email: '[email protected]' },
];
private nextId = 3;
findOne(id: number): User | null {
return this.users.find(user => user.id === id) ?? null;
}
create(user: { name: string; email: string }): User {
const newUser = { id: this.nextId++, ...user };
this.users.push(newUser);
return newUser;
}
}서비스는 Alice와 Bob을 미리 넣은 메모리 배열을 쓰므로, 프로세스를 다시 시작하면 새로 만든 사용자는 사라지고 다음 ID도 다시 3부터 시작합니다. findOne()은 ?? null로 사용자가 없을 때 undefined 대신 null을 돌려주어, 주문 서비스가 '없음'을 한 가지 값으로 확인하게 합니다. 이제 UsersModule을 구성합니다. 메시지 handler를 가진 controller도 HTTP controller처럼 controllers에 등록하므로, 모듈의 모양은 일반 Nest 애플리케이션과 같습니다.
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
controllers: [UsersController],
providers: [UsersService],
})
export class UsersModule {}UsersService는 이 모듈 안에서만 쓰므로 exports에 넣지 않았습니다. AppModule에서 UsersModule을 가져옵니다. 루트 모듈이 가져오지 않은 모듈의 controller는 등록되지 않으므로, 이 연결이 빠지면 get_user_by_id 패턴을 처리할 handler도 없습니다.
import { Module } from '@nestjs/common';
import { UsersModule } from './users/users.module';
@Module({
imports: [UsersModule],
controllers: [],
providers: [],
})
export class AppModule {}주문 서비스(TCP 클라이언트) 만들기
이번에는 사용자 서비스의 기능을 호출하는 주문 서비스를 만듭니다. 사용자 서비스 프로젝트를 만든 터미널이 아직 users-service 안에 있다면, 먼저 상위 폴더로 돌아가거나 새 터미널을 엽니다.
npx @nestjs/cli@12 new orders-service --no-observe
cd orders-service
npm install @nestjs/microservices@12주문 서비스는 클라이언트의 HTTP 요청을 받고, 내부적으로 사용자 서비스와 통신합니다. 그래서 main.ts는 일반 HTTP 서버를 그대로 둡니다. 이 예제의 Orders 서비스는 HTTP 진입점이지만, 라우팅·인증·응답 집계 같은 게이트웨이 역할을 따로 설계하지 않는 한 저절로 API 게이트웨이가 되지는 않습니다.
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000); // HTTP 요청을 받을 포트
console.log('Orders Service is listening on port 3000 (HTTP)');
}
void bootstrap();다른 마이크로서비스에 메시지를 보낼 때는 ClientProxy를 씁니다. NestJS는 이를 위한 @Client() 데코레이터도 제공하지만, 아래 예시는 ClientsModule.register()와 생성자의 @Inject()를 씁니다. name으로 등록한 token과 주입하는 token이 같아야 같은 프록시가 연결됩니다.
import { Module } from '@nestjs/common';
import { ClientsModule, Transport } from '@nestjs/microservices'; // ClientsModule, Transport 임포트
import { OrdersController } from './orders.controller';
import { OrdersService } from './orders.service';
@Module({
imports: [
ClientsModule.register([
{
name: 'USERS_SERVICE', // 클라이언트 프록시의 토큰 (주입 시 사용)
transport: Transport.TCP, // Users Service와 동일한 전송 방식
options: {
host: 'localhost',
port: 3001, // Users Service가 리스닝하는 포트
},
},
]),
],
controllers: [OrdersController],
providers: [OrdersService],
})
export class OrdersModule {}ClientsModule.register(): 하나 이상의 마이크로서비스 클라이언트를 등록합니다.name: 이 클라이언트 프록시를 의존성 주입으로 참조할 때 쓸 token입니다.transport와options: 연결할 마이크로서비스 서버의 전송 방식과 옵션을 지정합니다.
주문 서비스의 controller는 클라이언트의 HTTP 요청을 받고, USERS_SERVICE 프록시로 사용자 서비스에 메시지를 보냅니다.
import {
Body,
Controller,
Get,
Inject,
NotFoundException,
Param,
Post,
GatewayTimeoutException,
ServiceUnavailableException,
} from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices'; // ClientProxy 임포트
import { OrdersService } from './orders.service';
import { firstValueFrom, timeout, TimeoutError } from 'rxjs';
interface User {
id: number;
name: string;
email: string;
}
@Controller('orders') // HTTP 엔드포인트
export class OrdersController {
constructor(
private readonly ordersService: OrdersService,
@Inject('USERS_SERVICE') private readonly usersClient: ClientProxy, // 'USERS_SERVICE' 클라이언트 주입
) {}
@Get(':orderId/user')
async getOrderUser(@Param('orderId') orderId: string): Promise<User> {
// 임시로 orderId를 사용자 ID로 사용한다고 가정
const userId = parseInt(orderId, 10);
const user = await this.requestUser(userId);
if (!user) {
throw new NotFoundException(`User for order ${orderId} not found.`);
}
return user;
}
@Post('/create-order-with-user')
async createOrderWithUser(@Body() orderDto: { userId: number; item: string }): Promise<any> {
const { userId, item } = orderDto;
const user = await this.requestUser(userId);
if (!user) {
throw new NotFoundException(`User with ID ${userId} not found.`);
}
const order = this.ordersService.createOrder(userId, item);
return { order, user };
}
private async requestUser(userId: number): Promise<User | null> {
try {
// send()는 cold Observable이므로 firstValueFrom()의 구독 때 전송됩니다.
return await firstValueFrom(
this.usersClient
.send<User | null, number>('get_user_by_id', userId)
.pipe(timeout(3000)),
);
} catch (error) {
if (error instanceof TimeoutError) {
throw new GatewayTimeoutException('Users Service response timed out.');
}
throw new ServiceUnavailableException('Users Service request failed.');
}
}
}@Inject('USERS_SERVICE'):OrdersModule에서 등록한USERS_SERVICE클라이언트 프록시를 주입받습니다.this.usersClient.send('pattern', data): 요청-응답용 coldObservable을 반환합니다. 구독하거나firstValueFrom()으로 값을 꺼내야 실제 전송이 시작되며, 원격 호출이 무한히 기다리지 않도록timeout()과 오류 변환을 애플리케이션에서 정해야 합니다.this.usersClient.emit('pattern', data):@EventPattern()으로 받을 이벤트를 발행합니다. 반환값은 hotObservable이라 구독하지 않아도 바로 전달을 시도하지만, 요청-응답 결과는 없습니다. 전달 실패를 확인하고 처리해야 한다면 반환된 스트림의 오류도 운영 정책에 맞게 다룹니다.
주문 서비스를 구현합니다.
import { Injectable } from '@nestjs/common';
interface Order {
id: number;
userId: number;
item: string;
createdAt: Date;
}
@Injectable()
export class OrdersService {
private orders: Order[] = [];
private nextId = 1;
createOrder(userId: number, item: string): Order {
const newOrder = {
id: this.nextId++,
userId,
item,
createdAt: new Date(),
};
this.orders.push(newOrder);
return newOrder;
}
}createOrder()는 controller가 사용자 서비스에서 사용자를 확인한 뒤에만 호출됩니다. 주문 서비스는 사용자 정보를 따로 저장하지 않고 userId만 기록하며, 주문도 메모리 배열에 있으므로 프로세스를 다시 시작하면 사라집니다. AppModule에서 OrdersModule을 가져옵니다. OrdersModule이 ClientsModule로 등록한 USERS_SERVICE 프록시도 이 연결을 통해 애플리케이션에 포함됩니다.
import { Module } from '@nestjs/common';
import { OrdersModule } from './orders/orders.module';
@Module({
imports: [OrdersModule],
controllers: [],
providers: [],
})
export class AppModule {}더 깊이: TCP 전송 계층이 메시지를 나르는 방식
TCP의 핵심 특성에서 보듯 TCP는 바이트의 흐름만 전달하므로, 메시지 하나가 어디서 끝나는지는 그 위의 계층이 정해야 합니다. Nest의 TCP 전송 계층이 기본으로 쓰는 JsonSocket은 메시지를 JSON.stringify()로 문자열로 만든 뒤, 그 문자열의 길이와 #을 앞에 붙여 보냅니다. 받는 쪽은 그 길이만큼 문자가 모일 때까지 버퍼에 쌓았다가 하나의 메시지로 해석하며, 마이크로서비스 문서에 따르면 이 버퍼의 기본 상한 maxBufferSize는 (512 × 1024 × 1024) / 4 문자입니다. 메시지가 JSON을 거치므로 Date는 ISO 문자열이 되고, Map과 Set은 빈 객체가 되며, BigInt가 들어 있으면 직렬화하다 TypeError가 납니다. 그래서 서비스 사이에서는 날짜를 문자열로 주고받는다고 보고, 받는 쪽에서 다시 Date로 바꿉니다.
요청과 응답의 짝은 패킷의 id로 맞춥니다. 소스 코드를 보면 클라이언트는 send()마다 패킷에 무작위 UUID를 붙이고, 그 id와 콜백을 표에 넣어 둡니다. 서버의 응답에도 같은 id가 실려 오므로, 연결 하나로 여러 요청을 보내도 응답을 제 요청에 돌려줄 수 있습니다. 연결이 끊기면 아직 응답을 받지 못한 요청은 모두 Connection closed 오류로 끝납니다. 또 ClientProxy는 첫 호출 직전에야 연결하는 지연(lazy) 방식이므로, 사용자 서비스에 연결할 수 없다는 사실을 시작 단계에서 알고 싶다면 onApplicationBootstrap()에서 connect()를 직접 호출합니다.
서비스 실행과 호출 확인
두 서버는 서로 다른 터미널에서 시작합니다. 각 cd는 공통 상위 폴더에서 해당 프로젝트로 들어가는 명령이므로, 이미 프로젝트 안에 있다면 반복하지 않습니다. 앞 장의 다른 HTTP 서버가 3000번 포트를 쓰고 있다면 먼저 종료합니다.
먼저 사용자 서비스를 시작합니다. users-service에서 npm run start:dev를 실행하면 콘솔에 Users Microservice is listening on port 3001이 출력됩니다. 다음으로 주문 서비스를 시작합니다. orders-service에서 npm run start:dev를 실행하면 Orders Service is listening on port 3000 (HTTP)가 출력됩니다.
이제 Postman이나 curl로 API를 호출해 봅니다.
GET http://localhost:3000/orders/1/user:{"id":1,"name":"Alice","email":"[email protected]"}가 돌아오고, 사용자 서비스 콘솔에 요청을 받았다는 로그가 찍힙니다.GET http://localhost:3000/orders/999/user: 사용자가 없으므로 HTTP404 Not Found와 NestJS 오류 본문이 돌아옵니다.POST http://localhost:3000/orders/create-order-with-user:Content-Type: application/json헤더와{"userId": 2, "item": "Laptop"}본문을 보내면{"order":{"id":1,"userId":2,"item":"Laptop","createdAt":"..."},"user":{"id":2,"name":"Bob","email":"[email protected]"}}모양의 응답이 돌아옵니다.createdAt은 요청한 시각의 ISO 문자열입니다.
TCP 예제를 확인한다는 것은 두 프로세스가 모두 떠 있고, send() 요청이 사용자 서비스의 로그까지 도착하는지 보는 일입니다. 호출이 이상할 때는 HTTP 응답만 보지 말고 주문 서비스 쪽의 pattern과 payload, TCP 연결 상태, 사용자 서비스 handler의 도착 여부를 함께 봅니다. 응답이 늦으면 포트·서비스 기동·타임아웃을, handler가 실행되지 않으면 send()와 @MessagePattern()의 pattern 불일치를, null이 오면 payload와 조회 결과를 먼저 확인합니다.
TCP 외에도 Redis, Kafka, gRPC 같은 전송 계층을 골라 요구사항에 맞는 통신 방식을 쓸 수 있습니다. 하지만 마이크로서비스는 복잡도를 높이므로 서비스를 나누는 기준, 통신 방식, 배포 단위, 관측 기준을 분명하게 관리해야 합니다. NestJS는 전송 연결, pattern 라우팅, 직렬화, 요청과 응답의 짝 맞추기를 제공하지만, pattern과 DTO 형식, 입력 검증, 타임아웃·재시도·멱등성, HTTP 오류 변환, 추적 정보 전달, 데이터 일관성 정책은 애플리케이션이 정해야 합니다.
정리
- 마이크로서비스의 독립 배포·확장·장애 격리는 소유권, 실행 환경, 데이터, 실패 정책을 실제로 나누었을 때 생기는 특성이며, 분산 시스템의 운영 비용을 함께 감당해야 합니다.
- NestJS는
createMicroservice()와Transport로 마이크로서비스 서버를 만들고,@MessagePattern()과@Payload()로 메시지 handler를 정의합니다. - 클라이언트는
ClientsModule.register()로 등록한ClientProxy를 주입받아send()로 요청-응답을,emit()으로 이벤트 발행을 합니다. send()는 구독해야 전송되는 coldObservable이므로firstValueFrom()과timeout()을 함께 쓰고,null·시간 초과·전송 오류를 각각 HTTP 응답으로 바꿉니다.- 프로젝트는
npx @nestjs/cli@12로 만들고@nestjs/microservices@12를 설치해 주 버전을 맞춥니다.
연습 문제
1. 조회 실패 뒤에 주문이 생길까
새로 시작한 두 프로세스에서 POST /orders/create-order-with-user에 {"userId":999,"item":"Mouse"}를 보냈습니다. 사용자 서비스는 null을 정상 응답했습니다. HTTP 상태, OrdersService.createOrder()의 실행 여부, 다음 정상 주문의 ID를 예측해 보세요. 다른 요청이나 프로세스 재시작은 없고 처음 nextId는 1입니다.
해설 보기
원격 호출 자체는 성공했지만 사용자가 없으므로 controller의 if (!user)에서 404를 던집니다. 주문 생성은 그 뒤에 있으므로 실행되지 않고 nextId도 1 그대로입니다. 따라서 이어서 유효한 사용자로 처음 만든 주문의 ID는 1입니다.
이 결과는 두 저장소를 하나의 트랜잭션으로 묶었기 때문이 아니라, 조회하고 판정한 뒤에 주문을 만드는 코드의 순서 때문입니다. TCP 응답을 기다리다 시간이 초과된 경우에도 주문 생성까지 가지 않지만, 호출한 쪽의 timeout만으로 원격 handler가 전혀 실행되지 않았다고 결론 내릴 수는 없습니다.
2. pattern 이름이 다르면
주문 서비스의 requestUser()에서 실수로 'get_user_by_id' 대신 'get_user'를 보내도록 바꾸었습니다. 사용자 서비스는 그대로입니다. GET /orders/1/user를 호출하면 사용자 서비스 콘솔과 HTTP 응답은 어떻게 될까요?
해설 보기
사용자 서비스에는 'get_user' pattern의 handler가 없으므로 handler가 실행되지 않고, 콘솔에도 요청 로그가 찍히지 않습니다. NestJS 12의 TCP 서버는 일치하는 handler가 없으면 There is no matching message handler defined in the remote service.라는 오류를 응답으로 돌려보냅니다.
주문 서비스에서는 firstValueFrom()이 이 오류로 실패하고, TimeoutError가 아니므로 catch에서 ServiceUnavailableException이 던져져 503 Service Unavailable이 됩니다. 사용자 서비스가 꺼져 있을 때와 같은 응답이므로, 원인을 구분하려면 주문 서비스 쪽에서 원래 오류 메시지를 로그로 남겨야 합니다.
3. 숫자가 아닌 주문 ID
GET /orders/1abc/user를 호출하면 어떤 응답이 올까요? 이 경로를 400 Bad Request로 거부하려면 무엇을 바꿔야 할까요?
해설 보기
parseInt('1abc', 10)은 문자열 앞부분의 숫자만 읽어 1을 반환하므로, 이 예제에서는 Alice의 정보가 그대로 돌아옵니다. parseInt()는 값을 변환할 뿐 문자열 전체가 정수인지 검사하지 않습니다.
잘못된 경로를 거부하려면 5장에서처럼 @Param('orderId', ParseIntPipe) orderId: number로 HTTP 입구에서 정수를 검증합니다. 그러면 1abc는 controller에 도착하기 전에 400으로 거부됩니다. 같은 이유로 POST 본문의 인라인 타입도 실제 JSON을 검사하지 않으므로, 실서비스에서는 DTO와 ValidationPipe를 붙여야 합니다.
다음 글 gRPC를 이용한 서비스 간 통신에서는 같은 사용자 조회를 Protocol Buffers로 정의한 gRPC 서비스로 옮기고, 스키마를 기준으로 서비스 사이의 형식을 맞추는 방법을 다룹니다.