(Nest.js) MSA 서비스 간 통신을 위한 Microservices

 

서비스를 잘게 쪼개고 나면 반드시 따라오는 질문이 있습니다.
이 서비스들끼리는 무엇으로 이야기하게 할 것인가 하는 문제 입니다.

처음에는 대부분 HTTP로 시작합니다. 이미 컨트롤러도 있고, Swagger도 있고, axios만 붙이면 바로 되니까요.
서비스가 셋 넷일 때는 이 방식이 잘 돌아갑니다. 문제는 서비스가 열 개를 넘어가고, 하나의 사용자 요청이 서비스 네다섯 개를 거쳐 가기 시작할 때 드러납니다.

Nest.js는 이 지점을 위해 @nestjs/microservices 패키지를 제공합니다.
Nest에서 마이크로서비스란 거창한 무언가가 아니라 HTTP가 아닌 다른 전송 계층(transport)을 쓰는 애플리케이션 을 뜻합니다.
컨트롤러, DI, 파이프, 가드, 인터셉터, 예외 필터는 그대로 쓰면서 전송 계층만 갈아 끼우는 구조 입니다.

서비스 간 통신을 HTTP로만 처리할 때의 문제

먼저 왜 HTTP만으로는 부족한지부터 정리하고 갑니다. HTTP가 나쁜 프로토콜이라는 얘기가 아닙니다. 외부에 공개하는 API와 내부 서비스 간 통신은 요구사항이 다릅니다.

1. 텍스트 패킷이라 데이터가 커집니다
HTTP/1.1은 요청 라인, 헤더, 바디가 전부 평문 텍스트 입니다. 헤더는 매 요청마다 통째로 다시 실려 갑니다.
바디에 실리는 JSON도 마찬가지로 텍스트이고, 필드명이 레코드마다 반복됩니다.

POST /internal/points/deduct HTTP/1.1
Host: point-service.internal
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
X-Request-Id: 3f2b1c9e-8a44-4d21-9a10-72c5f0d1b8ee
Content-Length: 42

{"userId":1234,"amount":5000}


실제로 보내고 싶은 정보는 정수 두 개, 논리적으로는 12바이트면 충분합니다.
그런데 위 요청은 헤더까지 합쳐 300바이트를 훌쩍 넘습니다. 의미 있는 데이터보다 포장지가 20배 이상 큰 상태 입니다.
초당 수천 건이 오가는 내부 호출에서는 이 차이가 그대로 네트워크 대역폭과 직렬화 CPU 비용으로 쌓입니다.

여기에 파싱 비용도 붙습니다. JSON 파싱은 문자열을 한 글자씩 훑으면서 토큰을 잘라내는 작업이고, 이 과정에서 임시 문자열 객체가 대량으로 생겨 GC 압력이 올라갑니다.
바이너리 포맷은 길이 정보를 읽고 버퍼를 그대로 매핑하면 끝나기 때문에 성격 자체가 다릅니다.

2. 레이턴시가 누적됩니다
호출 한 번에 붙는 비용을 나눠 보면 이렇습니다.

  • TCP 3-way 핸드셰이크 (커넥션 재사용을 안 하는 경우)
  • TLS 핸드셰이크 (내부망을 암호화하는 경우)
  • 요청 직렬화, 전송, 응답 역직렬화
  • 상대 서비스의 실제 처리 시간


문제는 이게 직렬로 쌓인다는 점 입니다.
API Gateway -> 주문 -> 재고 -> 정산 -> 알림 처럼 체인이 만들어지면 각 구간의 지연이 그대로 더해집니다.

[Gateway] --50ms--> [주문] --40ms--> [재고] --45ms--> [정산] --60ms--> [알림]

사용자 체감 응답 시간 = 50 + 40 + 45 + 60 = 195ms
알림 발송이 끝날 때까지 사용자는 로딩 화면을 봅니다.


알림 발송은 사용자가 기다릴 이유가 전혀 없는 작업입니다. 그런데 동기 HTTP 체인으로 묶여 있으면 기다릴 수밖에 없습니다.

3. 장애가 그대로 전파됩니다
동기 호출은 호출 대상의 가용성에 호출자가 종속됩니다.
알림 서비스가 죽으면 정산이 멈추고, 정산이 멈추면 재고가 멈추고, 결국 주문이 실패합니다. 하나가 느려지면 앞단의 이벤트 루프에 대기 중인 요청이 쌓이면서 연쇄적으로 무너집니다.
재시도를 붙이면 이번에는 죽어가는 서비스에 요청이 몰리는 재시도 폭풍이 생깁니다.

4. 팬아웃을 호출자가 책임져야 합니다
“주문이 생성되었다”는 사실을 재고, 정산, 추천, 알림이 모두 알아야 한다고 해봅시다.
HTTP라면 주문 서비스가 네 곳의 주소를 알고, 네 번 호출하고, 네 번의 실패를 각각 처리해야 합니다. 나중에 구독자가 하나 늘면 주문 서비스 코드를 고쳐야 합니다.
사실을 알리는 쪽이 듣는 쪽 목록을 관리하는 구조 자체가 서비스가 늘어날수록 반드시 문제가 됩니다.

정리하면, 사용자에게 바로 응답해야 하는 동기 조회는 HTTP나 gRPC가 맞고, 끝난 뒤에 처리해도 되는 일과 여러 서비스가 함께 알아야 하는 사실 쪽은 브로커를 거치는 편이 맞습니다.

Nest.js Microservices의 기본 구조

Nest에서 마이크로서비스를 띄우는 방법은 두 가지 입니다.
전송 계층만 쓰는 순수 마이크로서비스는 createMicroservice()로 만듭니다.

// main.ts (수신하는 쪽)
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.createMicroservice<MicroserviceOptions>(AppModule, {
    transport: Transport.TCP,
    options: { host: '0.0.0.0', port: 3001 },
  });

  await app.listen();
}
bootstrap();


실무에서는 HTTP 엔드포인트와 브로커 구독을 한 프로세스에서 같이 쓰는 경우가 더 많습니다.
이때는 일반 HTTP 앱을 만든 뒤 connectMicroservice()로 전송 계층을 덧붙이는 하이브리드 애플리케이션 형태로 구성합니다.

// main.ts (HTTP + 브로커 동시 사용)
const app = await NestFactory.create(AppModule);

app.connectMicroservice<MicroserviceOptions>({
  transport: Transport.RMQ,
  options: {
    urls: ['amqp://localhost:5672'],
    queue: 'order_queue',
    queueOptions: { durable: true },
    noAck: false,
  },
});

await app.startAllMicroservices();
await app.listen(3000);


메시지를 보내는 쪽은 ClientsModule로 클라이언트를 등록하고 ClientProxy를 주입받습니다.
name이 곧 주입 토큰이 됩니다.

// app.module.ts (보내는 쪽)
@Module({
  imports: [
    ClientsModule.register([
      {
        name: 'ORDER_SERVICE',
        transport: Transport.RMQ,
        options: {
          urls: ['amqp://localhost:5672'],
          queue: 'order_queue',
          queueOptions: { durable: true },
        },
      },
    ]),
  ],
})
export class AppModule {}
// order.gateway.ts
@Injectable()
export class OrderGateway {
  constructor(@Inject('ORDER_SERVICE') private readonly client: ClientProxy) {}

  // 요청-응답: 결과를 받아야 하는 경우
  getOrder(id: string) {
    return this.client.send({ cmd: 'order.get' }, { id }).pipe(timeout(3000));
  }

  // 이벤트: 알리기만 하고 끝나는 경우
  publishCreated(order: Order) {
    this.client.emit('order.created', order);
  }
}


여기서 send()emit()의 차이가 뒤에서 설명할 @MessagePattern, @EventPattern과 그대로 짝을 이룹니다.

Transport 종류

Nest는 여러 전송 계층을 같은 인터페이스로 감싸 둡니다. 그래서 애플리케이션 코드를 거의 건드리지 않고 전송 계층을 바꿀 수 있습니다.

Transport 성격 적합한 상황
TCP Nest 기본값, 브로커 없이 직접 연결 서비스 수가 적고 구성을 단순하게 가져갈 때
REDIS Pub/Sub 기반 이미 Redis를 쓰고 있고 가벼운 이벤트 전파가 필요할 때
NATS 경량 메시징, 요청-응답 기본 지원 낮은 지연이 중요한 내부 통신
MQTT 경량 Pub/Sub IoT 디바이스, 불안정한 네트워크
RMQ 메시지 브로커, 큐 기반 작업 분배, 재시도, 순차 처리 보장
KAFKA 분산 로그, 스트리밍 대용량 이벤트, 재처리, 다중 구독
gRPC 바이너리 RPC, Protobuf 스키마 스키마가 명확한 고성능 동기 호출


이 중에서 MSA 백앤드에서 실제로 선택을 고민하게 되는 것은 대부분 RabbitMQ와 Kafka 입니다.
둘 다 브로커지만 구조와 철학이 완전히 다르기 때문에, 용도를 잘못 고르면 계속 불편한 상태로 쓰게 됩니다.

RabbitMQ의 구조

RabbitMQ는 AMQP 기반 메시지 브로커 입니다. 핵심 개념은 Exchange, Binding, Queue 세 가지 입니다.

[Producer]
    |  publish(exchange: "order", routing key: "order.created")
    v
+-----------------------------+
|         Exchange            |   direct | topic | fanout | headers
+-----------------------------+
      |                  |
      | binding          | binding
      | "order.created"  | "order.#"
      v                  v
  [Queue: 재고]      [Queue: 알림]        메시지가 큐에 '쌓입니다'
      |                  |
      v                  v
  [Consumer]         [Consumer]          가져가서 ack하면 큐에서 '사라집니다'


프로듀서는 큐를 직접 지정하지 않습니다. Exchange에 라우팅 키와 함께 던지면, Exchange가 바인딩 규칙에 따라 어느 큐에 넣을지 결정합니다.
이 구조를 흔히 똑똑한 브로커, 단순한 컨슈머(smart broker, dumb consumer) 라고 부릅니다. 라우팅 판단이 브로커 쪽에 있다는 뜻 입니다.

RabbitMQ의 가장 중요한 성질은 소비하면 사라진다는 점 입니다.
컨슈머가 메시지를 받아 처리하고 ack를 보내면 브로커는 그 메시지를 삭제합니다. 큐에 컨슈머 인스턴스를 여러 개 붙이면 메시지가 하나씩 나눠 들어가는 경쟁 소비자(competing consumer) 구조가 되고, 인스턴스를 늘리는 것만으로 처리량이 늘어납니다.
여기에 다음 장치들이 따라옵니다.

  • 수동 ack: noAck: false로 두면 처리 성공 후 직접 ack를 보냅니다. 컨슈머가 중간에 죽으면 ack가 없으므로 브로커가 메시지를 다시 큐에 넣습니다. 즉 작업이 유실되지 않습니다.
  • prefetch: 컨슈머 하나가 동시에 붙잡을 메시지 수를 제한합니다. 느린 컨슈머에게 일이 몰리는 것을 막는 배압 장치 입니다.
  • DLQ(Dead Letter Queue): 정해진 횟수만큼 실패한 메시지를 격리 큐로 보냅니다. 실패한 한 건 때문에 큐 전체가 막히는 상황을 막습니다.
  • 우선순위, TTL, 지연 큐: 급한 작업을 먼저 처리하거나 일정 시간 뒤에 처리하는 것이 가능합니다.


RabbitMQ는 메시지 주도(Message-driven) 구조에 어울립니다.
메시지 주도란 “이 일을 누군가 한 번 처리해 달라” 는 명령을 전달하는 방식 입니다. 보내는 쪽은 받는 쪽이 누구인지 대체로 알고 있고, 관심사는 “이 작업이 확실히 한 번 처리되었는가” 입니다.

사용 예로 적합한 프로젝트는 이런 것들 입니다.

  • 주문 후처리 워커: 주문 완료 후 이메일, SMS, 푸시 발송. 사용자를 기다리게 할 이유가 없고, 실패하면 재시도해야 하며, 워커를 늘려 처리량을 조절합니다.
  • 미디어 변환 파이프라인: 업로드된 영상의 트랜스코딩, 썸네일 추출. 건당 처리 시간이 길어서 prefetch와 워커 수 조절이 핵심 입니다.
  • 정산 배치, 리포트 생성: PDF 생성이나 대량 엑셀 추출처럼 무겁고 실패 시 재시도가 필요한 작업.
  • 외부 연동 요청 큐: PG사, 물류사처럼 상대 API가 불안정한 연동. 실패분을 DLQ로 격리하고 나중에 재처리합니다.


Nest에서의 사용 코드는 다음과 같습니다.

// notification 서비스 (수신)
@Controller()
export class NotificationController {
  @EventPattern('order.created')
  async handleOrderCreated(@Payload() data: OrderCreatedEvent, @Ctx() context: RmqContext) {
    const channel = context.getChannelRef();
    const originalMsg = context.getMessage();

    try {
      await this.mailer.sendOrderConfirm(data);
      channel.ack(originalMsg);          // 처리 성공을 브로커에 알림
    } catch (e) {
      channel.nack(originalMsg, false, false);  // 재큐잉 없이 DLQ로 보냄
    }
  }
}


noAck: false로 설정했다면 ack를 반드시 직접 보내야 합니다.
빠뜨리면 메시지가 unacked 상태로 남아 prefetch 한도를 갉아먹고, 결국 컨슈머가 아무것도 받지 못하는 상태가 됩니다. 실무에서 자주 만나는 사고 입니다.

Kafka의 구조

Kafka는 메시지 브로커라기보다 분산 커밋 로그(distributed commit log) 에 가깝습니다.
핵심 개념은 Topic, Partition, Offset, Consumer Group 입니다.

[Producer] --key: userId--> Topic "order.created"

  +--------------------------------------------------+
  | Partition 0 |  0  1  2  3  4  5  6                |  append-only 로그
  | Partition 1 |  0  1  2  3                         |  (읽어도 사라지지 않음)
  | Partition 2 |  0  1  2  3  4  5                   |
  +--------------------------------------------------+
          ^                        ^
          |                        |
  group "inventory"          group "settlement"
   offset = 4                 offset = 2
   (재고 서비스가 여기까지 읽음)  (정산 서비스는 여기까지 읽음)


Kafka에서 메시지는 추가만 가능한 로그 파일(append-only log) 인 파티션에 순서대로 쌓입니다.
각 메시지는 파티션 내에서 offset이라는 순번을 갖고, 컨슈머는 “내가 어디까지 읽었는지”를 offset으로 기록합니다.
읽어도 메시지가 사라지지 않습니다. 보관 정책(retention)에 따라 정해진 기간이나 크기만큼 그대로 남아 있습니다. 이 한 가지 차이가 RabbitMQ와의 성격 차이를 거의 다 만들어 냅니다.

  • 재처리(replay)가 가능합니다: 컨슈머 로직에 버그가 있었다면 offset을 되감아 지난 3일치 이벤트를 다시 흘려보낼 수 있습니다. 큐 방식에서는 불가능한 일 입니다.
  • 다중 구독이 자연스럽습니다: 컨슈머 그룹마다 offset을 따로 관리합니다. 재고, 정산, 추천, 알림이 같은 토픽을 각자의 속도로 읽어도 서로 간섭하지 않습니다. 구독자가 늘어도 프로듀서는 아무것도 바뀌지 않습니다.
  • 파티션 단위로 순서가 보장됩니다: 같은 key를 가진 메시지는 항상 같은 파티션으로 갑니다. key: userId로 보내면 한 사용자의 이벤트 순서는 보장되고, 사용자끼리는 병렬 처리됩니다.
  • 컨슈머 그룹이 곧 확장 단위입니다: 그룹 내 컨슈머 수만큼 파티션이 나눠 배정됩니다. 다만 컨슈머 수가 파티션 수를 넘으면 남는 컨슈머는 놀게 됩니다. 파티션 수가 병렬성의 상한 입니다.


Kafka는 라우팅 판단을 브로커가 하지 않습니다. 브로커는 로그를 저장하고 넘겨줄 뿐이고, 어디까지 읽었는지는 컨슈머가 관리합니다. RabbitMQ와 반대로 단순한 브로커, 똑똑한 컨슈머 구조 입니다.

Kafka는 이벤트 주도(Event-driven) 구조에 어울립니다.
이벤트 주도란 “이런 일이 일어났다” 는 사실을 흘려보내는 방식 입니다. 발행자는 누가 듣는지 알지 못하고 알 필요도 없습니다. 관심사는 “이 사실이 순서대로, 유실 없이, 필요하면 다시 읽을 수 있게 기록되는가” 입니다.

사용 예로 적합한 프로젝트는 이런 것들 입니다.

  • 사용자 행동 로그 수집과 실시간 집계: 클릭, 조회, 검색 이벤트를 초당 수만 건 쌓고 여러 소비자가 각자 집계합니다. 대량 쓰기와 다중 구독이 동시에 필요한 전형적인 경우 입니다.
  • 주문 이벤트 브로드캐스트: order.created 하나를 재고, 정산, 추천, 알림, 데이터 웨어하우스가 각자 구독합니다. 새 소비자를 붙일 때 주문 서비스는 손대지 않습니다.
  • CDC 기반 데이터 동기화: Debezium 등으로 DB 변경분을 토픽에 흘리고, 검색 인덱스나 캐시, 분석 DB를 이 스트림으로 맞춥니다.
  • 이벤트 소싱과 감사 로그: 상태 변경 이력 자체가 원본 데이터가 되는 구조. 로그가 보존되고 재생 가능하다는 성질이 그대로 요구사항과 맞습니다.
  • IoT, 로봇 텔레메트리: 장비별 센서 스트림을 장비 ID를 key로 넣어 순서를 보장하면서 병렬로 처리합니다.


Nest에서의 설정과 사용 코드는 다음과 같습니다.

// main.ts (수신하는 쪽)
const app = await NestFactory.createMicroservice<MicroserviceOptions>(AppModule, {
  transport: Transport.KAFKA,
  options: {
    client: {
      clientId: 'inventory',            // 실제로는 inventory-server 로 등록됨
      brokers: ['kafka-1:9092', 'kafka-2:9092'],
    },
    consumer: {
      groupId: 'inventory-consumer',    // 컨슈머 그룹이 곧 구독 단위
    },
    run: { autoCommit: false },         // offset을 직접 커밋
  },
});
await app.listen();
// inventory.controller.ts
@Controller()
export class InventoryController {
  @EventPattern('order.created')
  async handleOrderCreated(@Payload() data: OrderCreatedEvent, @Ctx() context: KafkaContext) {
    await this.inventoryService.reserve(data);

    // 처리에 성공한 뒤에 offset을 직접 커밋
    const { offset } = context.getMessage();
    const partition = context.getPartition();
    const topic = context.getTopic();
    const consumer = context.getConsumer();

    await consumer.commitOffsets([{ topic, partition, offset }]);
  }
}


보내는 쪽에서는 key를 지정하는 것이 중요합니다. key가 곧 파티션 배정 기준이고, 파티션이 곧 순서 보장 단위이기 때문 입니다.

// order.service.ts
this.kafkaClient.emit('order.created', {
  key: order.userId,     // 같은 사용자의 이벤트는 같은 파티션 = 순서 보장
  value: order,
  headers: { 'x-correlation-id': correlationId },
});

RabbitMQ와 Kafka, 무엇을 고를 것인가

항목 RabbitMQ Kafka
모델 큐 (소비하면 삭제) 로그 (읽어도 보존)
라우팅 판단 브로커(Exchange) 컨슈머(offset 관리)
재처리 사실상 불가 offset 되감기로 가능
다중 구독 큐를 추가로 바인딩 컨슈머 그룹 추가
순서 보장 큐 단위 파티션 단위
병렬 확장 컨슈머 수만큼 파티션 수까지
처리량 중간 매우 높음
개별 메시지 제어 강함 (우선순위, TTL, DLQ) 약함
어울리는 성격 메시지 주도, 작업 분배 이벤트 주도, 스트림


한 줄로 정리하면 “누군가 이 일을 해줘”는 RabbitMQ, “이런 일이 있었어”는 Kafka 입니다.
둘을 함께 쓰는 것도 흔합니다. 도메인 이벤트는 Kafka로 흘리고, 그 이벤트를 받아 만들어지는 실제 작업 큐는 RabbitMQ로 처리하는 식 입니다.

MessagePattern - 요청과 응답

@MessagePattern()요청-응답(request-response) 스타일의 핸들러를 만듭니다.
클라이언트의 send()와 짝을 이루고, 반환값이 그대로 호출자에게 돌아갑니다.

// 수신 측
@Controller()
export class OrderController {
  @MessagePattern({ cmd: 'order.get' })
  async getOrder(@Payload() data: { id: string }): Promise<OrderDto> {
    return this.orderService.findOne(data.id);
  }
}
// 호출 측
const order = await firstValueFrom(
  this.client.send<OrderDto>({ cmd: 'order.get' }, { id }).pipe(timeout(3000)),
);


패턴은 문자열이든 객체든 상관없습니다. 패턴 자체가 직렬화되어 페이로드와 함께 전송되고, 이걸로 어느 핸들러가 처리할지 매칭됩니다.
동작 방식에서 알아 둘 것이 몇 가지 있습니다.

  • 논리 채널을 두 개 씁니다. 요청을 보내는 채널과 응답을 기다리는 채널이 따로 필요합니다. NATS처럼 이걸 기본 제공하는 전송 계층도 있지만, 그렇지 않은 곳에서는 Nest가 응답용 채널을 따로 만듭니다. 즉 요청-응답에는 오버헤드가 있습니다.
  • send()가 반환하는 것은 cold Observable 입니다. 구독하기 전에는 아무것도 전송되지 않습니다. subscribe()하거나 firstValueFrom()으로 감싸야 실제로 나갑니다. 이걸 모르면 “메시지가 안 나가요” 상태로 한참 헤맵니다.
  • 타임아웃은 선택이 아닙니다. 상대가 죽어 있으면 응답은 영원히 오지 않습니다. pipe(timeout(ms))를 기본으로 붙이는 습관이 필요합니다.
  • 핸들러는 Observable도 반환할 수 있습니다. 이 경우 스트림이 완료될 때까지 값이 여러 번 전달됩니다.
  • Kafka에서는 추가 준비가 필요합니다. 요청 토픽과 별도로 {토픽}.reply 응답 토픽을 쓰기 때문에, 클라이언트가 미리 응답 토픽을 구독해 두어야 합니다.


Kafka에서 요청-응답을 쓸 때는 다음과 같이 초기화 시점에 응답 토픽을 등록합니다.

@Injectable()
export class HeroService implements OnModuleInit {
  constructor(@Inject('HERO_SERVICE') private readonly client: ClientKafkaProxy) {}

  async onModuleInit() {
    this.client.subscribeToResponseOf('hero.get');   // hero.get.reply 를 구독
    await this.client.connect();
  }
}


다만 Kafka로 요청-응답을 하는 것은 대체로 좋은 선택이 아닙니다.
Kafka는 로그 기반 스트리밍에 최적화된 구조이고, 응답 토픽과 파티션을 따로 관리해야 하며, 실행 중인 Nest 애플리케이션 수만큼 응답 토픽 파티션이 확보되어 있어야 합니다.
동기 조회가 필요하면 gRPC나 HTTP를, Kafka는 이벤트 발행에 쓰는 편이 구조적으로 깔끔합니다.

EventPattern - 단방향 이벤트

@EventPattern()응답을 돌려주지 않는 핸들러를 만듭니다. 클라이언트의 emit()과 짝을 이룹니다.

// 수신 측
@Controller()
export class SettlementController {
  @EventPattern('order.created')
  async handleOrderCreated(@Payload() data: OrderCreatedEvent) {
    await this.settlementService.record(data);
    // 반환값은 아무도 기다리지 않습니다
  }
}
// 발행 측
this.client.emit('order.created', event);


차이를 정리하면 이렇습니다.

  • 응답 채널이 없습니다. 요청-응답에서 필요했던 두 번째 채널을 만들지 않으므로 오버헤드가 없습니다.
  • emit()이 반환하는 것은 hot Observable 입니다. send()와 반대로, 구독하지 않아도 즉시 전송을 시도합니다.
  • 같은 패턴에 핸들러를 여러 개 등록할 수 있고, 모두 병렬로 실행됩니다.
  • 발행자는 구독자를 모릅니다. 앞에서 이야기한 팬아웃 문제가 여기서 해결됩니다. 구독자가 늘어도 발행 코드는 그대로 입니다.


선택 기준은 단순합니다.
호출한 쪽이 결과를 알아야 하면 @MessagePattern, 사실을 알리기만 하면 @EventPattern 입니다.
“주문 조회”는 전자이고 “주문이 생성되었음”은 후자 입니다. 그리고 실무에서는 후자가 훨씬 많은데도 습관적으로 전자를 쓰다가 불필요한 결합을 만드는 경우가 많습니다.

실무에서 놓치기 쉬운 것들

멱등성은 선택이 아닙니다
브로커 기반 통신은 대부분 at-least-once 보장 입니다. ack가 유실되거나 컨슈머가 처리 직후 죽으면 같은 메시지가 다시 들어옵니다.
따라서 핸들러는 같은 메시지를 두 번 받아도 결과가 같아야 합니다. 이벤트에 고유 ID를 넣고 처리 이력을 저장하거나, DB 유니크 제약으로 중복을 막는 방식이 일반적 입니다.

이벤트 핸들러의 예외는 재시도로 해석됩니다
Kafka 전송 계층에서 이벤트 핸들러의 미처리 예외는 기본적으로 재시도 대상 입니다. offset이 커밋되지 않으므로 같은 메시지가 계속 다시 들어옵니다.
검증 실패처럼 몇 번을 다시 시도해도 성공할 수 없는 오류를 그대로 던지면 무한 재시도 루프에 빠집니다. 재시도해도 소용없는 오류는 잡아서 별도 토픽으로 보내거나 로깅 후 커밋해야 합니다.
반대로 일시적인 오류라서 재시도가 필요한 경우에는 KafkaRetriableException을 던집니다.

예외는 RpcException으로 다룹니다
HTTP의 HttpException에 대응하는 것이 RpcException 입니다. 처리되지 않은 예외는 자동으로 감싸져서 호출자에게 전달됩니다.
@Catch() 예외 필터도 그대로 동작하므로, 전송 계층별 재시도 정책을 필터로 분리해 두면 컨트롤러가 깔끔해집니다.

추적 ID를 직접 실어 보내야 합니다
HTTP 헤더처럼 자동으로 따라가는 것이 없습니다. 요청 하나가 여러 서비스를 거치는 흐름을 추적하려면 correlation id를 페이로드나 메시지 헤더에 직접 넣고, 각 서비스 로그에 같이 남겨야 합니다.
RabbitMQ는 RmqRecordBuilder로 헤더를 설정할 수 있고, Kafka는 메시지의 headers를 그대로 쓸 수 있습니다.

연결 상태를 감시할 수 있습니다
클라이언트와 서버 모두 status 스트림을 제공합니다. Kafka라면 connected, disconnected, rebalancing, crashed, stopped 상태가 흘러나옵니다.
리밸런싱 중에 처리량이 떨어지는 현상을 추적하거나, 헬스체크와 연동할 때 유용합니다.

this.client.status.subscribe((status: KafkaStatus) => {
  this.logger.log(`kafka status: ${status}`);
});

정리

  • 내부 서비스 간 통신을 전부 동기 HTTP로 처리하면 텍스트 패킷 오버헤드, 레이턴시 누적, 장애 전파, 팬아웃 관리 부담이 한꺼번에 따라온다
  • Nest의 마이크로서비스는 컨트롤러와 DI를 그대로 두고 전송 계층만 바꾸는 구조이며, createMicroservice() 또는 connectMicroservice()로 붙인다
  • RabbitMQ는 소비하면 사라지는 큐다. 작업 분배, 재시도, DLQ가 강점이고 메시지 주도 구조에 맞는다
  • Kafka는 읽어도 남는 로그다. 재처리, 다중 구독, 파티션 단위 순서 보장이 강점이고 이벤트 주도 구조에 맞는다
  • @MessagePattern은 요청-응답, @EventPattern은 단방향 이벤트다. send()는 cold, emit()은 hot Observable이라는 차이도 기억해 둔다
  • 브로커 통신은 at-least-once가 기본이므로 핸들러는 멱등하게 작성한다


전송 계층을 바꾸는 것 자체는 Nest가 추상화해 주기 때문에 어렵지 않습니다.
정작 어려운 것은 어떤 호출을 동기로 남기고 어떤 것을 이벤트로 밀어낼지 결정하는 일 입니다. 이 결정이 서비스 간 결합도를 그대로 결정하기 때문에, 전송 계층을 고르기 전에 통신의 성격부터 나눠 보는 편이 좋습니다.