본문 바로가기

마이크로서비스 개념과 NestJS 적용

모놀리식과 마이크로서비스의 경계를 비교하고 TCP 기반 사용자·주문 서비스를 분리해 요청 통신을 검증합니다.

6장에서는 오래 걸리는 작업을 BullMQ와 Redis로 분리하고 실행 상태와 복구 기준을 관리했습니다.

7장에서는 이 비동기 경계를 서비스 사이로 확장하여 마이크로서비스(Microservices) 아키텍처의 기본 개념과 NestJS 적용 방법을 다룹니다.

단일 거대 애플리케이션(Monolithic Application)은 개발 초기에는 빠르고 간단하지만, 규모가 커지고 복잡해질수록 유지보수, 확장, 배포에 어려움을 겪게 됩니다.

마이크로서비스는 이러한 문제를 해결하기 위해 애플리케이션을 작고 독립적인 서비스들로 분해하는 아키텍처 접근 방식입니다.

각 서비스는 특정 비즈니스 기능에 집중합니다. 다만 독립 개발·배포·확장은 서비스별 소유권, 실행 환경, 배포 파이프라인, 데이터 경계를 실제로 분리했을 때 얻을 수 있는 운영 특성이지, 코드를 나누는 것만으로 자동 보장되지는 않습니다.


마이크로서비스 아키텍처란?

마이크로서비스 아키텍처는 단일 애플리케이션을 작고 독립적인 서비스의 모음으로 개발하는 접근 방식입니다.

각 서비스는 자체 프로세스에서 실행되며, 잘 정의된 경량 통신 메커니즘(예: HTTP REST API, gRPC, 메시지 큐)을 사용하여 통신합니다.

마이크로서비스의 주요 특징
  • 독립적인 배포: 서비스별 릴리스 파이프라인과 호환 가능한 계약을 갖추면 다른 서비스와 분리해 배포할 수 있습니다.
  • 독립적인 개발: 비즈니스 소유권과 변경 책임이 분명하면 별도 팀이 독립적으로 개발할 수 있습니다.
  • 독립적인 확장: 실행 환경과 용량 설정을 분리하면 부하가 높은 서비스만 확장할 수 있습니다.
  • 기술 스택 다양성: 각 서비스는 특정 요구사항에 가장 적합한 기술 스택(언어, 프레임워크, 데이터베이스)을 선택할 수 있습니다 (Polyglot Persistence/Programming).
  • 높은 응집도, 낮은 결합도: 각 서비스는 특정 비즈니스 도메인에 대한 높은 응집도를 가지며, 다른 서비스와의 결합도는 최소화됩니다.
  • 장애 격리: 타임아웃, 격리된 자원, 재시도·서킷 브레이커 같은 실패 정책을 함께 설계하면 한 서비스의 장애 전파를 제한할 수 있습니다.
마이크로서비스의 장점
  • 확장성: 특정 서비스만 유연하게 확장 가능
  • 유지보수성: 서비스 단위가 작아 이해하고 수정하기 용이
  • 배포 용이성: 독립적인 배포로 인한 빠른 릴리스 주기
  • 기술 유연성: 서비스별 최적의 기술 스택 선택 가능
  • 팀 자율성: 소규모 팀이 독립적으로 작업 가능
마이크로서비스의 단점
  • 복잡성 증가: 분산 시스템 관리의 복잡성 (네트워크 지연, 장애 처리, 데이터 일관성)
  • 운영 오버헤드: 더 많은 서비스, 배포 파이프라인, 모니터링 시스템 필요
  • 데이터 일관성: 분산 트랜잭션 관리의 어려움
  • 서비스 간 통신: 통신 메커니즘 선택 및 관리
  • 디버깅 어려움: 여러 서비스에 걸친 문제 추적의 복잡성

마이크로서비스는 무조건 쪼개는 기술이 아니라, 독립 배포와 운영 비용 사이의 균형을 판단하는 설계입니다.

아래 다이어그램은 서비스를 분리할 때 확인해야 할 기준을 한 화면에 정리합니다.

Nest 서비스 분리 · 이득과 비용

독립 운영의 구체적 이득과 분산 비용을 함께 판단합니다

소유권·런타임·데이터·배포 경계를 실제로 분리할 때 이득이 성립합니다. 작은 시스템에서는 모놀리스의 모듈 경계를 강화하는 선택도 충분합니다.

소유권

얻을 수 있는 이득: 도메인 규칙과 변경 책임이 선명해진다
성립 조건: 한 팀이 계약과 데이터를 끝까지 소유한다
반드시 감당할 비용: 팀 사이 계약 조율과 중복 운영

독립 배포

얻을 수 있는 이득: 서비스별 속도로 릴리스하고 롤백한다
성립 조건: 별도 파이프라인과 하위 호환 계약을 갖춘다
반드시 감당할 비용: 버전 호환, 배포 순서, 더 많은 파이프라인

부하 격리

얻을 수 있는 이득: 트래픽이 몰린 기능만 별도로 확장한다
성립 조건: 런타임·용량·자원 풀을 실제로 분리한다
반드시 감당할 비용: 네트워크 지연·부분 실패와 용량 조정

장애 격리

얻을 수 있는 이득: 한 서비스의 실패 전파 범위를 줄인다
성립 조건: 타임아웃·격리·재시도·저하 모드를 설계한다
반드시 감당할 비용: 분산 로그·메트릭·트레이스로 원인을 추적

데이터 경계

얻을 수 있는 이득: 상태의 주인과 변경 규칙이 분명해진다
성립 조건: 서비스별 쓰기 권한과 계약을 제한한다
반드시 감당할 비용: 단일 트랜잭션 대신 일관성·보상 정책 필요
Monolith

한 프로세스·한 릴리스 주기

Orders, Users, Payments가 같은 배포와 장애 경계를 공유합니다. 초기에는 모듈 경계만 선명하게 해도 충분할 수 있습니다.

Microservices

여러 운영 경계·네트워크 계약

각 서비스가 별도 프로세스와 데이터 책임을 갖고 pattern·payload 같은 원격 계약으로 연결될 때 독립 운영의 이득이 생깁니다.

Users

계정·인증·프로필 소유권이 분명하면 후보입니다. Orders와 늘 같은 트랜잭션만 쓴다면 보류합니다.

Orders

주문·결제 흐름의 변경 주기가 독립적이면 후보입니다. 초기 규모라면 한 HTTP 모듈로 충분할 수 있습니다.

Notifications

실패가 핵심 주문을 막지 않아야 하면 후보입니다. 전송량이 작고 격리가 불필요하면 보류합니다.

판정: 독립 운영의 구체적 이유가 있고 네트워크·관측성·일관성 비용을 감당할 때 분리합니다. 그렇지 않으면 모놀리스 안의 모듈 경계를 먼저 강화합니다.


NestJS가 마이크로서비스에 적합한 이유

NestJS는 마이크로서비스 아키텍처를 구축할 때 전송 계층, 메시지 패턴, DI 구조를 함께 제공하는 프레임워크입니다.

  • 모듈 기반 아키텍처: NestJS의 모듈 시스템은 애플리케이션을 기능별로 분리하고 구성하기 용이하게 하여, 자연스럽게 마이크로서비스의 경계를 정의하는 데 도움을 줍니다.

  • 엔터프라이즈급 패턴 지원: NestJS는 컨트롤러, 프로바이더, 모듈 등 잘 알려진 엔터프라이즈 디자인 패턴을 적용하여, 복잡한 시스템의 구조를 명확하게 유지할 수 있습니다.

  • 다양한 통신 방식 지원: NestJS는 마이크로서비스 간 통신을 위한 다양한 전송 계층(Transport Layer)을 내장하고 있습니다.

    • TCP: 기본 제공, 가벼운 통신
    • Redis: 메시지 브로커로 사용, Pub/Sub 지원
    • Kafka: 고처리량 분산 메시징 시스템
    • RabbitMQ: 메시지 큐 기능
    • NATS: 고성능 메시징 시스템
    • gRPC: 고성능, 언어 중립적인 RPC 프레임워크 (HTTP/2 기반)
    • MQTT: IoT 환경에 최적화된 경량 메시징 프로토콜
  • DI(Dependency Injection): 의존성 주입 시스템은 서비스 간의 결합도를 낮추고 테스트 용이성을 높입니다.

  • TypeScript 기반: 타입 안전성을 제공하여 대규모 프로젝트에서 코드 품질과 유지보수성을 향상시킵니다.

  • 통일된 개발 경험: 각 마이크로서비스가 NestJS로 구현될 경우, 개발자들은 일관된 아키텍처 패턴과 코딩 스타일을 유지할 수 있어 학습 곡선을 줄이고 생산성을 높일 수 있습니다.


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 예제에 런타임 검증이 자동으로 생기지는 않습니다.

요청은 Users로, 응답 판정은 Orders로 돌아온다 두 프로세스 사이의 요청과 응답을 위에서 아래로 펼친 흐름이다. ClientProxy의 cold Observable을 구독해야 pattern과 숫자 payload가 TCP로 Users handler에 간다. User 또는 null 응답은 다시 Orders에서 판정한다. 조회 성공은200, null은404, 호출 timeout은408, 다른 transport 오류는503으로 번역한다. 입력 변환만으로 검증되지는 않는다. Orders · HTTP 프로세스 Orders · 응답 판정 Users · TCP 프로세스 요청 응답 User 없음 / 오류 HTTP client GET /orders/1/user OrdersController 입력 해석 ClientProxy.send() 구독으로 시작 TCP transport pattern + payload Users handler get_user_by_id User 또는 null Orders 결과 판정 정상 값 · 빈 값 · 오류 정상 응답 200 · User 실패 응답 404 / 408 / 503
TCP 요청과 응답, HTTP 결과의 분기 두 프로세스 사이의 요청과 응답을 위에서 아래로 펼친 흐름이다. ClientProxy의 cold Observable을 구독해야 pattern과 숫자 payload가 TCP로 Users handler에 간다. User 또는 null 응답은 다시 Orders에서 판정한다. 조회 성공은200, null은404, 호출 timeout은408, 다른 transport 오류는503으로 번역한다. 입력 변환만으로 검증되지는 않는다. User 없음/오류 HTTP client GET /orders/1/user OrdersController parseInt · 입력 해석 ClientProxy.send() 구독 때 전송 TCP transport pattern + payload Users handler get_user_by_id User 또는 null Orders 결과 판정 정상 값 · 빈 값 · 오류 성공 200 User 본문 실패 404 / 408 또는 503
Request–response

GET 요청에서 응답까지

  1. HTTP client

    GET /orders/1/user로 외부 HTTP 계약을 호출합니다.

  2. OrdersController

    orderId를 userId로 해석하고 결과·오류를 HTTP 응답으로 바꿀 준비를 합니다.

  3. ClientProxy.send()

    ClientProxy.send()가 pattern과 payload로 cold Observable을 만들고, 구독 또는 firstValueFrom() 때 요청을 보냅니다.

  4. Nest TCP transport

    pattern과 payload를 직렬화해 Users 프로세스로 보내고 요청과 응답의 상관관계를 관리합니다.

  5. Users @MessagePattern()

    @MessagePattern('get_user_by_id')가 같은 pattern을 받아 payload로 조회한 뒤 User | null을 반환합니다.

  6. 응답과 HTTP 변환

    응답 Observable이 값을 내보내면 Orders가 성공, null, timeout, transport 오류를 각각 HTTP 결과로 매핑합니다.

pattern

Orders가 보내는 값: 'get_user_by_id'
Users가 받거나 반환하는 값: @MessagePattern('get_user_by_id')
불일치하면: 대상 handler가 실행되지 않는다

payload

Orders가 보내는 값: userId: number
Users가 받거나 반환하는 값: @Payload() id: number
불일치하면: 잘못된 조회가 나거나, 별도 검증을 구성한 경우 거부된다

response

Orders가 보내는 값: Observable<User | null>
Users가 받거나 반환하는 값: User | null
불일치하면: HTTP 상태·본문 변환이 어긋난다
send()

질문하고 응답을 기다린다

request–response용 cold Observable입니다. 구독해야 전송되며 timeout()과 transport·원격 오류 처리를 호출자가 명시해야 합니다.

emit()

이벤트를 발행하고 응답 본문은 받지 않는다

@EventPattern()용 hot Observable로, 구독하지 않아도 전달을 즉시 시도합니다. 그렇다고 전달 오류 관측 정책까지 사라지는 것은 아닙니다.

① HTTP 표면

상태 코드, 응답 본문, 전체 지연을 확인합니다.

② Orders + transport

pattern, 마스킹한 payload, timeout·오류와 TCP status를 기록합니다.

③ Users handler

handler 도착, 조회 시간, User 또는 null 결과를 확인합니다.

NestJS 책임

전송의 공통 틀

transport 연결, packet 직렬화, pattern 라우팅, 요청·응답 상관관계와 ClientProxy 추상화를 제공합니다.

Application 책임

업무 계약과 실패 정책

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으로 번역한다. 입력 변환만으로 검증되지는 않는다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1HTTP 요청이 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 예제에 런타임 검증이 자동으로 생기지는 않습니다.

요청은 Users로, 응답 판정은 Orders로 돌아온다 두 프로세스 사이의 요청과 응답을 위에서 아래로 펼친 흐름이다. ClientProxy의 cold Observable을 구독해야 pattern과 숫자 payload가 TCP로 Users handler에 간다. User 또는 null 응답은 다시 Orders에서 판정한다. 조회 성공은200, null은404, 호출 timeout은408, 다른 transport 오류는503으로 번역한다. 입력 변환만으로 검증되지는 않는다. Orders · HTTP 프로세스 Orders · 응답 판정 Users · TCP 프로세스 요청 응답 User 없음 / 오류 HTTP client GET /orders/1/user OrdersController 입력 해석 ClientProxy.send() 구독으로 시작 TCP transport pattern + payload Users handler get_user_by_id User 또는 null Orders 결과 판정 정상 값 · 빈 값 · 오류 정상 응답 200 · User 실패 응답 404 / 408 / 503
TCP 요청과 응답, HTTP 결과의 분기 두 프로세스 사이의 요청과 응답을 위에서 아래로 펼친 흐름이다. ClientProxy의 cold Observable을 구독해야 pattern과 숫자 payload가 TCP로 Users handler에 간다. User 또는 null 응답은 다시 Orders에서 판정한다. 조회 성공은200, null은404, 호출 timeout은408, 다른 transport 오류는503으로 번역한다. 입력 변환만으로 검증되지는 않는다. User 없음/오류 HTTP client GET /orders/1/user OrdersController parseInt · 입력 해석 ClientProxy.send() 구독 때 전송 TCP transport pattern + payload Users handler get_user_by_id User 또는 null Orders 결과 판정 정상 값 · 빈 값 · 오류 성공 200 User 본문 실패 404 / 408 또는 503
Request–response

GET 요청에서 응답까지

  1. HTTP client

    GET /orders/1/user로 외부 HTTP 계약을 호출합니다.

  2. OrdersController

    orderId를 userId로 해석하고 결과·오류를 HTTP 응답으로 바꿀 준비를 합니다.

  3. ClientProxy.send()

    ClientProxy.send()가 pattern과 payload로 cold Observable을 만들고, 구독 또는 firstValueFrom() 때 요청을 보냅니다.

  4. Nest TCP transport

    pattern과 payload를 직렬화해 Users 프로세스로 보내고 요청과 응답의 상관관계를 관리합니다.

  5. Users @MessagePattern()

    @MessagePattern('get_user_by_id')가 같은 pattern을 받아 payload로 조회한 뒤 User | null을 반환합니다.

  6. 응답과 HTTP 변환

    응답 Observable이 값을 내보내면 Orders가 성공, null, timeout, transport 오류를 각각 HTTP 결과로 매핑합니다.

pattern

Orders가 보내는 값: 'get_user_by_id'
Users가 받거나 반환하는 값: @MessagePattern('get_user_by_id')
불일치하면: 대상 handler가 실행되지 않는다

payload

Orders가 보내는 값: userId: number
Users가 받거나 반환하는 값: @Payload() id: number
불일치하면: 잘못된 조회가 나거나, 별도 검증을 구성한 경우 거부된다

response

Orders가 보내는 값: Observable<User | null>
Users가 받거나 반환하는 값: User | null
불일치하면: HTTP 상태·본문 변환이 어긋난다
send()

질문하고 응답을 기다린다

request–response용 cold Observable입니다. 구독해야 전송되며 timeout()과 transport·원격 오류 처리를 호출자가 명시해야 합니다.

emit()

이벤트를 발행하고 응답 본문은 받지 않는다

@EventPattern()용 hot Observable로, 구독하지 않아도 전달을 즉시 시도합니다. 그렇다고 전달 오류 관측 정책까지 사라지는 것은 아닙니다.

① HTTP 표면

상태 코드, 응답 본문, 전체 지연을 확인합니다.

② Orders + transport

pattern, 마스킹한 payload, timeout·오류와 TCP status를 기록합니다.

③ Users handler

handler 도착, 조회 시간, User 또는 null 결과를 확인합니다.

NestJS 책임

전송의 공통 틀

transport 연결, packet 직렬화, pattern 라우팅, 요청·응답 상관관계와 ClientProxy 추상화를 제공합니다.

Application 책임

업무 계약과 실패 정책

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으로 번역한다. 입력 변환만으로 검증되지는 않는다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1HTTP 요청이 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가 됩니다.

NestJS에서 마이크로서비스를 구성하는 가장 기본적인 방법은 클라이언트(Consumer) 서비스와 서버(Provider) 서비스로 나누는 것입니다.

여기서는 가장 간단한 TCP 기반의 통신 예시를 통해 구조를 이해합니다.

시나리오: Users 서비스와 Orders 서비스가 있습니다.

Orders 서비스가 주문을 처리하는 과정에서 Users 서비스에 사용자 정보를 요청해야 하는 경우.

TCP 사용자 서비스 구축

사용자 정보를 제공하는 독립적인 마이크로서비스를 생성합니다.

단계 1: 새 NestJS 프로젝트 생성 (또는 기존 프로젝트 내에 모듈로 분리)

마이크로서비스는 독립적인 프로젝트로 관리하는 것이 일반적입니다.

이 장의 실습은 users-service와 orders-service를 같은 상위 폴더 아래의 서로 다른 프로젝트로 둡니다. 사용자 서버는 TCP 3001, 주문 서버는 HTTP 3000을 사용합니다. 두 프로젝트의 설정 파일과 터미널을 구분하고, 새 프로젝트를 만드는 명령은 상위 폴더에서 실행합니다.

다음은 원래의 버전 생략 예시입니다. 앞 장처럼 Nest 11로 재현한다면 이 블록을 먼저 실행하지 말고 뒤의 11판 명령을 선택합니다.

# 새로운 사용자 서비스 프로젝트 생성
nest new users-service --skip-install
cd users-service
npm install @nestjs/microservices
npm install

Nest 11로 사용자 프로젝트 만들기

위 블록 대신 아래를 한 번 실행합니다. npm install @nestjs/microservices@11은 생성된 프로젝트의 다른 의존성도 함께 설치합니다. 이미 Nest 11 프로젝트가 있으면 새 프로젝트 생성과 cd를 생략하고 그 프로젝트 안에서 필요한 패키지만 설치합니다.

npx @nestjs/cli@11 new users-service --skip-install
cd users-service
npm install @nestjs/microservices@11

설치 후 npm ls @nestjs/core @nestjs/common @nestjs/microservices로 같은 주버전인지 확인하고 lockfile을 보관합니다. Nest 11 통합 패키지의 peer dependency도 common과 core의 11판을 요구합니다. 이는 프레임워크 버전 조건이며, 원격 통신 계약까지 검증해 주는 명령은 아닙니다.

단계 2: main.ts 파일 수정 (마이크로서비스 서버 설정)
users-service/src/main.ts
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');
}
bootstrap();
  • NestFactory.createMicroservice(): 일반적인 HTTP 서버 대신 마이크로서비스 애플리케이션을 생성합니다.
  • transport: Transport.TCP: TCP 프로토콜을 사용하여 메시지를 주고받도록 설정합니다. NestJS는 다양한 Transport 옵션을 제공합니다.
  • options: 특정 전송 방식에 대한 추가 옵션을 설정합니다 (호스트, 포트 등).
단계 3: 사용자 마이크로서비스 컨트롤러 (핸들러) 구현

마이크로서비스에서는 @MessagePattern() 데코레이터를 사용하여 메시지를 처리하는 핸들러를 정의합니다.

users-service/src/users/users.controller.ts (새로 생성 또는 수정)
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'): 이 핸들러가 처리할 메시지의 패턴(이름)을 정의합니다. 클라이언트는 이 패턴을 사용하여 특정 핸들러를 호출합니다.
  • @Payload(): 메시지에서 전송된 데이터를 추출하여 메서드 인자로 주입합니다.
단계 4: 사용자 서비스 구현
users-service/src/users/users.service.ts (새로 생성 또는 수정)
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;
  }
}
단계 5: UsersModule 구성
users-service/src/users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';

@Module({
  controllers: [UsersController],
  providers: [UsersService],
})
export class UsersModule {}
단계 6: AppModule에 UsersModule 임포트
users-service/src/app.module.ts
import { Module } from '@nestjs/common';
import { UsersModule } from './users/users.module';

@Module({
  imports: [UsersModule],
  controllers: [],
  providers: [],
})
export class AppModule {}

주문 서비스(TCP 클라이언트) 구축

사용자 서비스의 기능을 호출하는 클라이언트(또는 다른 마이크로서비스)를 구축합니다.

단계 1: 새 NestJS 프로젝트 생성 (또는 기존 프로젝트 내에 모듈로 분리)
# 새로운 주문 서비스 프로젝트 생성
nest new orders-service --skip-install
cd orders-service
npm install @nestjs/microservices
npm install

새 orders-service도 Nest 11로 맞추려면 위 블록 대신 다음을 상위 폴더에서 실행합니다. 사용자 서비스 프로젝트를 만든 터미널이 아직 users-service 안에 있다면 먼저 상위 폴더로 돌아가거나 새 터미널을 엽니다.

npx @nestjs/cli@11 new orders-service --skip-install
cd orders-service
npm install @nestjs/microservices@11
단계 2: main.ts 파일 수정 (일반적인 HTTP 서버 유지)

주문 서비스는 클라이언트로부터 HTTP 요청을 받고, 내부적으로 사용자 서비스와 통신할 수 있습니다. 이 예제의 Orders 서비스는 HTTP 진입점이지만, 라우팅·인증·집계 같은 게이트웨이 책임을 별도로 설계하지 않는 한 자동으로 API Gateway가 되는 것은 아닙니다.

orders-service/src/main.ts
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)');
}
bootstrap();
단계 3: 클라이언트 프록시 생성 및 주입

ClientProxy를 사용하여 다른 마이크로서비스에 메시지를 보냅니다.

NestJS는 이를 위한 @Client() 데코레이터도 제공하지만, 아래 예시는 ClientsModule.register()와 생성자 @Inject()를 사용합니다. name으로 등록한 토큰과 주입한 토큰이 같아야 같은 프록시가 연결됩니다.

orders-service/src/orders/orders.module.ts (새로 생성)
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: 이 클라이언트 프록시를 의존성 주입을 통해 참조할 때 사용할 토큰입니다.
  • transport & options: 연결할 마이크로서비스 서버의 전송 방식과 옵션을 지정합니다.
단계 4: 주문 서비스 컨트롤러 (HTTP 진입점)

클라이언트로부터 HTTP 요청을 받고, USERS_SERVICE 프록시를 통해 사용자 서비스로 메시지를 보냅니다.

orders-service/src/orders/orders.controller.ts (새로 생성)
import {
  Body,
  Controller,
  Get,
  Inject,
  NotFoundException,
  Param,
  Post,
  RequestTimeoutException,
  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 RequestTimeoutException('Users Service response timed out.');
      }
      throw new ServiceUnavailableException('Users Service request failed.');
    }
  }
}
  • @Inject('USERS_SERVICE'): OrdersModule에서 정의한 USERS_SERVICE 클라이언트 프록시를 주입받습니다.
  • this.usersClient.send('pattern', data): 요청-응답용 cold Observable을 반환합니다. 구독하거나 firstValueFrom()으로 소비해야 실제 전송이 시작되며, 분산 호출이 무한히 기다리지 않도록 timeout()과 오류 매핑을 애플리케이션에서 정합니다.
  • this.usersClient.emit('pattern', data): @EventPattern()으로 받을 이벤트를 발행합니다. 반환값은 hot Observable이라 명시적으로 구독하지 않아도 전달을 즉시 시도하지만, 요청-응답 결과는 없습니다. 전달 실패를 관측·처리해야 한다면 반환된 스트림의 오류도 운영 정책에 맞게 다룹니다.
단계 5: 주문 서비스 구현
orders-service/src/orders/orders.service.ts (새로 생성)
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;
  }
}
단계 6: AppModule에 OrdersModule 임포트
orders-service/src/app.module.ts
import { Module } from '@nestjs/common';
import { OrdersModule } from './orders/orders.module';

@Module({
  imports: [OrdersModule],
  controllers: [],
  providers: [],
})
export class AppModule {}

서비스 기동 및 호출 검증

아래 두 서버는 서로 다른 터미널에서 시작합니다. 각 cd는 공통 상위 폴더에서 해당 프로젝트로 들어가는 명령입니다. 이미 해당 프로젝트 안이라면 cd를 반복하지 않습니다. 앞 장의 다른 HTTP 서버가 3000을 사용하고 있으면 먼저 종료합니다.

Users Service 시작
  • cd users-service
  • npm run start:dev
  • 콘솔에 Users Microservice is listening on port 3001 메시지 확인
Orders Service 시작
  • cd orders-service
  • npm run start:dev
  • 콘솔에 Orders Service is listening on port 3000 (HTTP) 메시지 확인
API 테스트 (Postman 또는 cURL)
  • 사용자 정보 가져오기
    • GET http://localhost:3000/orders/1/user
    • 응답: {"id":1,"name":"Alice","email":"[email protected]"} (Users Service 콘솔에 요청 메시지 확인)
  • 존재하지 않는 사용자 정보 가져오기
    • GET http://localhost:3000/orders/999/user
    • 응답: HTTP 404 Not Found와 NestJS 오류 본문
  • 주문 생성과 사용자 정보 확인
    • POST http://localhost:3000/orders/create-order-with-user
    • Headers: Content-Type: application/json
    • Body: {"userId": 2, "item": "Laptop"}
    • 응답: {"order":{"id":1,"userId":2,"item":"Laptop","createdAt":"2023-06-23T...Z"},"user":{"id":2,"name":"Bob","email":"[email protected]"}}

TCP 예제의 검증은 두 프로세스가 모두 떠 있고 send() 요청이 Users Service 로그까지 도달하는지 확인하는 과정입니다.

입력 변환과 입력 검증 구분하기

원래 코드의 parseInt(orderId, 10)은 문자열 전체가 정수인지 검사하지 않습니다. 예를 들어 GET /orders/1abc/user의 앞부분은 숫자 1로 해석되므로 이 축약 예시에서는 Alice 조회로 이어질 수 있습니다. 잘못된 경로를 400으로 거부하려는 계약이면 앞 장의 ParseIntPipe처럼 HTTP 경계의 정수 검증을 추가해야 합니다. 인라인 body 타입 역시 실제 JSON을 검증하지 않으므로 실서비스에는 DTO와 Pipe가 필요합니다.

독립 연습: 조회 실패 뒤 주문이 생기는가?

새로 시작한 두 프로세스에서 POST /orders/create-order-with-user에 {"userId":999,"item":"Mouse"}를 보냈습니다. Users는 null을 정상 응답했습니다. HTTP 상태, OrdersService.createOrder() 실행 여부, 다음 정상 주문의 ID를 예측하세요. 다른 요청이나 프로세스 재시작은 없고 최초 nextId는 1입니다.

풀이 확인

원격 호출 자체는 성공했지만 사용자가 없으므로 컨트롤러의 if (!user)에서 404를 던집니다. 주문 생성은 그 뒤에 있으므로 실행되지 않고 nextId도 1입니다. 이어 유효한 사용자로 처음 만든 주문은 ID 1입니다. 이는 두 저장소를 하나의 트랜잭션으로 묶은 결과가 아니라 이 코드의 조회→판정→주문 생성 순서에 따른 결과입니다. TCP 응답을 기다리다 timeout이 난 경우도 주문 생성까지 가지 않지만, 호출자의 timeout만으로 원격 handler가 전혀 실행되지 않았다고 결론 낼 수는 없습니다.

호출을 검증할 때는 HTTP 응답만 보지 말고 Orders 로그의 pattern·payload, TCP 연결 상태, Users 로그의 handler 도착 여부를 함께 봅니다. 응답 지연은 포트·서비스 기동·타임아웃을, handler 미실행은 send()와 @MessagePattern()의 pattern 불일치를, null 응답은 payload와 조회 결과를 우선 확인합니다.


이 예시는 NestJS가 마이크로서비스 아키텍처를 얼마나 쉽게 구축할 수 있는지 보여줍니다.

TCP 외에도 Redis, Kafka, gRPC 등 다양한 전송 계층을 사용하여 특정 요구사항에 맞는 통신 방식을 선택할 수 있습니다.

마이크로서비스는 복잡도를 증가시키므로 서비스 경계, 통신 방식, 배포 단위, 관측성 기준을 명확히 관리해야 합니다.

NestJS의 모듈 구조와 전송 계층 추상화는 이 기준을 코드 구조로 옮기는 데 사용됩니다. 프레임워크는 transport 연결, pattern 라우팅, 직렬화와 응답 상관관계를 제공하지만, pattern·DTO 계약, 입력 검증, 타임아웃·재시도·멱등성, HTTP 오류 변환, 추적 컨텍스트와 데이터 일관성 정책은 애플리케이션이 정해야 합니다.