Apache Kafka와 이벤트 기반 아키텍처
Kafka 토픽과 소비자 그룹의 역할을 이해하고 사용자 생성 이벤트를 발행해 알림 서비스에서 비동기로 처리합니다.
앞 글에서는 NestJS와 gRPC로 서비스 사이의 동기 통신을 구현했습니다. 이 글에서는 비동기 통신과 이벤트 기반 아키텍처(EDA)의 핵심 요소인 Apache Kafka를 NestJS에 적용합니다.
동기 통신은 요청-응답 패턴에 잘 맞지만, 서비스 사이의 결합을 강하게 만들고 한 서비스의 장애가 다른 서비스로 번질 위험이 있습니다. 대량의 이벤트를 처리하거나 실시간 데이터 파이프라인을 만들 때도 효율이 떨어집니다.
Apache Kafka는 이벤트를 파티션 로그에 보관하고, 이벤트를 만드는 쪽과 읽는 쪽의 처리 시점을 떼어 놓는 분산 스트리밍 플랫폼입니다. 실제 처리량과 장애를 견디는 범위는 파티션 수, 복제, 승인 설정, 보관 정책과 실행 환경에 따라 달라집니다.
이벤트 기반 아키텍처와 Kafka
이벤트 기반 아키텍처(EDA, Event-Driven Architecture)는 구성 요소들이 서로를 직접 호출하는 대신, 이벤트를 발행하고 구독하는 방식으로 상호작용하는 아키텍처 스타일입니다. 각 서비스는 관심 있는 이벤트를 구독하고, 그 이벤트가 발생하면 필요한 작업을 수행합니다.
- 느슨한 결합(loose coupling): 서비스들이 서로를 직접 호출하지 않으므로 한 서비스의 변경이 다른 서비스에 미치는 영향이 줄어듭니다.
- 확장성: 이벤트를 만드는 쪽과 읽는 쪽을 각각 독립적으로 확장할 수 있습니다.
- 복원력(resilience): 이벤트가 브로커에 기록되고 보관 기간 안에 남아 있다면, 소비자가 잠시 멈췄다가 다시 읽어 처리할 수 있습니다.
- 비동기 통신: 응답을 바로 기다리지 않으므로 오래 걸리는 작업이나 백그라운드 작업에 잘 맞습니다.
- 낮은 지연 처리: 데이터 변경 이벤트를 계속 흘려보낼 수 있지만, 실제 지연은 생산자·브로커·소비자 설정과 부하에 따라 달라집니다.
Apache Kafka는 이런 구조를 구현할 때 가장 널리 쓰이는 플랫폼 가운데 하나입니다. Kafka는 분산 커밋 로그(commit log)나 분산 메시지 브로커로 볼 수 있으며, 다음과 같은 특징이 있습니다.
- 처리량 확장: 토픽을 여러 파티션으로 나누고 생산자와 소비자를 늘릴 수 있습니다. 얻을 수 있는 처리량은 작업 특성과 클러스터 구성에 달려 있습니다.
- 장애 허용: 파티션 복제와 ISR(in-sync replicas), 생산자의
acks설정을 조합해 장애를 견디는 범위를 정합니다. 복제만으로 모든 장애에서 데이터 손실이 없다고 보장되지는 않습니다. - 수평 확장: 브로커를 늘려 데이터 양이 많아져도 대응할 수 있습니다.
- 영속성: 브로커가 승인한 이벤트는 디스크 로그에 기록되며, 소비자가 다시 읽을 수 있는 범위는 토픽의 cleanup·retention 정책에 따라 달라집니다.
- 다중 소비자: 서로 다른 consumer group은 같은 이벤트 스트림을 각자 독립적으로 읽고, 같은 group 안의 인스턴스들은 파티션을 나누어 처리합니다.
Kafka의 핵심 개념은 다음과 같습니다.
- Producer(생산자): Kafka 토픽에 이벤트를 발행하는 애플리케이션입니다.
- Consumer(소비자): Kafka 토픽의 이벤트를 구독하고 처리하는 애플리케이션입니다.
- Topic(토픽): 이벤트가 발행되고 소비되는 논리적인 분류입니다.
- Partition(파티션): 토픽 로그를 나누는 단위입니다. 레코드의 순서는 파티션 안에서만 정해지므로, 같은 업무 키의 순서가 필요하다면 그 키가 항상 같은 파티션으로 가도록 설계해야 합니다.
- Broker(브로커): 파티션 로그를 저장하고 생산·소비 요청을 처리하는 Kafka 서버입니다. 여러 브로커에 걸친 복제와 배치가 장애를 견디는 범위를 결정합니다.
- KRaft controller: 메타데이터 요청과 로그를 관리하는 서버 역할입니다. 여러 controller가 Raft 기반의 메타데이터 quorum을 구성하며, Kafka 4.x는 ZooKeeper 없이 KRaft 모드만 지원합니다.
Kafka 이벤트는 단순한 메시지가 아니라 재처리, 순서, 소비자 확장까지 함께 정해야 하는 운영상의 약속입니다. 토픽을 만들기 전에 아래 그림의 기준으로 이벤트의 범위를 먼저 잡아 두면 구현과 확인이 쉬워집니다.
NestJS Kafka 이벤트 발행과 소비 관측 흐름
메모리 사용자 저장·broker 생산·HTTP 성공과 독립 소비 handler 상태를 구분하며 HTTP가 소비 완료를 기다리지 않는다.
Nest Kafka · 생산과 소비의 경계
HTTP 응답, broker 기록, 소비자 업무 결과를 따로 확인합니다
현재 예제는 emit() 완료로 생산자 오류를 관측하고 메일 발송은 모의 실행합니다. outbox·event ID·멱등 처리·retry·DLQ와 전체 trace는 추가 구현할 운영 계약입니다.
요청 진입부터 소비 완료까지
-
HTTP 경계
POST /users를 받은UsersController가 사용자를 생성합니다. 사용자 저장 성공과 이벤트 전달 성공은 별도 결과이므로 둘을 하나의 성공으로 간주하지 않습니다. -
NestJS 이벤트 발행
emit에 토픽'user_created', 업무 키, payload를 전달합니다. 이는 reply topic이 없는 이벤트 방식이므로subscribeToResponseOf가 필요하지 않습니다. -
Broker 기록과 partition 선택
생산이 성공하면 broker가 선택된 partition 로그에 record와 offset을 남깁니다. 같은 키의 순서는 그 키가 같은 partition으로 갈 때 해당 partition 안에서만 정의됩니다.
-
Consumer group 배분
notification-group의 인스턴스들은 partition을 나눠 맡습니다. 다른 group은 같은 스트림을 독립적으로 읽지만, group 자체가 재전달이나 업무 중복을 제거하지는 않습니다. -
NestJS 이벤트 처리
@EventPattern과 토픽'user_created'가 연결되고,@Payload로 value를 받아 예제의 환영 메일 모의 실행을 합니다. 성공 후 offset을 확정하는 경계에서도 장애 시 재처리가 생길 수 있습니다.
응답 시점을 계약으로 정한다
emit은 hot Observable로 즉시 발행을 시도합니다. 완료를 기다리면 생산자 오류를 요청 경계에서 관측할 수 있지만 소비자 처리 완료까지 기다리는 것은 아닙니다. 먼저 HTTP를 반환한다면 미발행 복구 책임을 별도로 둡니다.
로컬 이름과 Kafka 계약을 구분한다
'KAFKA_SERVICE'는 Nest DI token입니다. clientId는 클라이언트를 식별하고 brokers는 bootstrap 주소를 제공하며, groupId는 소비 진행 위치와 partition 배분의 단위가 됩니다.
저장은 승인·복제·보관 설정에 달려 있다
Broker는 topic의 partition 로그를 보관합니다. 생산자 acks, ISR과 복제 계수는 승인 가능한 장애 범위를 정하고, retention은 소비 여부와 별개로 재읽기 가능한 기간을 정합니다.
Consumer group은 중복 제거 장치가 아니다
같은 group 안에서는 한 partition을 한 시점에 한 member가 담당하지만, 처리 후 commit 전에 중단되면 record가 다시 전달될 수 있습니다. 메일·정산 같은 부작용은 event ID로 멱등하게 처리합니다.
이벤트가 멈춘 경계를 순서대로 좁힌다
1 · Runtime / broker
컨테이너 상태와 broker 로그, bootstrap 포트 9092, advertised listener를 확인합니다.
2 · Producer
HTTP trace와 event ID, emit 완료 또는 오류, 기록된 topic·partition·offset을 남깁니다.
3 · Topic / group
토픽의 log end offset, group의 current offset과 lag, partition assignment와 rebalance를 확인합니다.
4 · Handler
@EventPattern 진입, payload 검증, 업무 성공, retry와 DLQ 전환을 같은 event ID로 연결합니다.
검증 순서: broker 준비 → producer 발행 결과 → topic/partition 기록 → consumer group 진행 위치 → handler의 실제 부작용. API가 성공했다는 사실만으로 이 전체 경로가 성공했다고 결론내리지 않습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 사용자 메모리 저장 뒤 Kafka 생산을 시작한다. broker 생산 성공은 HTTP 응답으로 이어지고 독립 소비 흐름은 group과 handler를 거쳐 모의 업무를 수행한다. HTTP 성공이 소비 완료를 기다린다는 화살표는 없다. 왼쪽 별도 실패선은 emit 생산 자체의 오류이며 현재 코드에는 이미 넣은 메모리 사용자를 자동 취소하는 경로가 없다. 재전달과 외부 업무의 멱등성은 추가 계약이다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | NestJS Kafka 이벤트 발행과 소비 관측 흐름 |
| 25 | 메모리 자동 취소 없음 |
| 96 | 검증 순서: broker 준비 → producer 발행 결과 → topic/partition 기록 → consumer group 진행 위치 → handler의 실제 부작용. API가 성공했다는 사실만으로 이 전체 경로가 성공했다고 결론 내리지 않습니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 메모리 저장 사용자를 메모리에 먼저 저장합니다. | 상태: 사용자 저장 · Kafka: 아직 생산 전 | 업무 메모리 변경이 선행합니다. |
| broker 생산 결과 생산에 성공하면 HTTP 응답을 반환하며, 생산에 실패해도 메모리에 저장한 사용자를 자동으로 취소하지 않습니다. | 성공: HTTP · 실패: 메모리 자동 rollback 없음 | 메모리와 broker 결과가 한 TX가 아님을 드러냅니다. |
| 독립 소비 consumer group·handler가 모의 업무를 수행합니다. | 소비: 별도 흐름 · HTTP: 소비 완료 대기 안 함 | 생산 성공과 소비 효과를 구분합니다. |
NestJS Kafka 이벤트 발행과 소비 관측 흐름
메모리 사용자 저장·broker 생산·HTTP 성공과 독립 소비 handler 상태를 구분하며 HTTP가 소비 완료를 기다리지 않는다.
Nest Kafka · 생산과 소비의 경계
HTTP 응답, broker 기록, 소비자 업무 결과를 따로 확인합니다
현재 예제는 emit() 완료로 생산자 오류를 관측하고 메일 발송은 모의 실행합니다. outbox·event ID·멱등 처리·retry·DLQ와 전체 trace는 추가 구현할 운영 계약입니다.
요청 진입부터 소비 완료까지
-
HTTP 경계
POST /users를 받은UsersController가 사용자를 생성합니다. 사용자 저장 성공과 이벤트 전달 성공은 별도 결과이므로 둘을 하나의 성공으로 간주하지 않습니다. -
NestJS 이벤트 발행
emit에 토픽'user_created', 업무 키, payload를 전달합니다. 이는 reply topic이 없는 이벤트 방식이므로subscribeToResponseOf가 필요하지 않습니다. -
Broker 기록과 partition 선택
생산이 성공하면 broker가 선택된 partition 로그에 record와 offset을 남깁니다. 같은 키의 순서는 그 키가 같은 partition으로 갈 때 해당 partition 안에서만 정의됩니다.
-
Consumer group 배분
notification-group의 인스턴스들은 partition을 나눠 맡습니다. 다른 group은 같은 스트림을 독립적으로 읽지만, group 자체가 재전달이나 업무 중복을 제거하지는 않습니다. -
NestJS 이벤트 처리
@EventPattern과 토픽'user_created'가 연결되고,@Payload로 value를 받아 예제의 환영 메일 모의 실행을 합니다. 성공 후 offset을 확정하는 경계에서도 장애 시 재처리가 생길 수 있습니다.
응답 시점을 계약으로 정한다
emit은 hot Observable로 즉시 발행을 시도합니다. 완료를 기다리면 생산자 오류를 요청 경계에서 관측할 수 있지만 소비자 처리 완료까지 기다리는 것은 아닙니다. 먼저 HTTP를 반환한다면 미발행 복구 책임을 별도로 둡니다.
로컬 이름과 Kafka 계약을 구분한다
'KAFKA_SERVICE'는 Nest DI token입니다. clientId는 클라이언트를 식별하고 brokers는 bootstrap 주소를 제공하며, groupId는 소비 진행 위치와 partition 배분의 단위가 됩니다.
저장은 승인·복제·보관 설정에 달려 있다
Broker는 topic의 partition 로그를 보관합니다. 생산자 acks, ISR과 복제 계수는 승인 가능한 장애 범위를 정하고, retention은 소비 여부와 별개로 재읽기 가능한 기간을 정합니다.
Consumer group은 중복 제거 장치가 아니다
같은 group 안에서는 한 partition을 한 시점에 한 member가 담당하지만, 처리 후 commit 전에 중단되면 record가 다시 전달될 수 있습니다. 메일·정산 같은 부작용은 event ID로 멱등하게 처리합니다.
이벤트가 멈춘 경계를 순서대로 좁힌다
1 · Runtime / broker
컨테이너 상태와 broker 로그, bootstrap 포트 9092, advertised listener를 확인합니다.
2 · Producer
HTTP trace와 event ID, emit 완료 또는 오류, 기록된 topic·partition·offset을 남깁니다.
3 · Topic / group
토픽의 log end offset, group의 current offset과 lag, partition assignment와 rebalance를 확인합니다.
4 · Handler
@EventPattern 진입, payload 검증, 업무 성공, retry와 DLQ 전환을 같은 event ID로 연결합니다.
검증 순서: broker 준비 → producer 발행 결과 → topic/partition 기록 → consumer group 진행 위치 → handler의 실제 부작용. API가 성공했다는 사실만으로 이 전체 경로가 성공했다고 결론내리지 않습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 사용자 메모리 저장 뒤 Kafka 생산을 시작한다. broker 생산 성공은 HTTP 응답으로 이어지고 독립 소비 흐름은 group과 handler를 거쳐 모의 업무를 수행한다. HTTP 성공이 소비 완료를 기다린다는 화살표는 없다. 왼쪽 별도 실패선은 emit 생산 자체의 오류이며 현재 코드에는 이미 넣은 메모리 사용자를 자동 취소하는 경로가 없다. 재전달과 외부 업무의 멱등성은 추가 계약이다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | NestJS Kafka 이벤트 발행과 소비 관측 흐름 |
| 25 | 메모리 자동 취소 없음 |
| 96 | 검증 순서: broker 준비 → producer 발행 결과 → topic/partition 기록 → consumer group 진행 위치 → handler의 실제 부작용. API가 성공했다는 사실만으로 이 전체 경로가 성공했다고 결론 내리지 않습니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 메모리 저장 사용자를 메모리에 먼저 저장합니다. | 상태: 사용자 저장 · Kafka: 아직 생산 전 | 업무 메모리 변경이 선행합니다. |
| broker 생산 결과 생산에 성공하면 HTTP 응답을 반환하며, 생산에 실패해도 메모리에 저장한 사용자를 자동으로 취소하지 않습니다. | 성공: HTTP · 실패: 메모리 자동 rollback 없음 | 메모리와 broker 결과가 한 TX가 아님을 드러냅니다. |
| 독립 소비 consumer group·handler가 모의 업무를 수행합니다. | 소비: 별도 흐름 · HTTP: 소비 완료 대기 안 함 | 생산 성공과 소비 효과를 구분합니다. |
위 그림은 사용자를 메모리에 저장한 뒤 Kafka 생산을 시작하고, 브로커가 생산을 승인하면 HTTP 응답이 나가는 흐름과, 이와 따로 진행되는 소비 흐름을 보여 줍니다. HTTP 성공이 소비 완료를 기다린다는 화살표가 없다는 점이 핵심입니다. 왼쪽의 별도 실패선은 emit() 생산 자체가 실패한 경우인데, 지금 코드에는 이미 메모리에 넣은 사용자를 자동으로 취소하는 경로가 없습니다. 아래쪽 표는 이벤트가 어디서 멈췄는지를 브로커, 생산자, 토픽·그룹, handler 순서로 좁혀 가는 방법을 정리합니다.
NestJS에서 Kafka 설정과 구현
NestJS는 @nestjs/microservices 패키지로 Kafka 전송 계층을 지원합니다. 시나리오는 다음과 같습니다. Users 서비스에서 새 사용자가 생성되면 Kafka에 이벤트를 발행하고, Notifications 서비스가 이 이벤트를 구독해 사용자에게 환영 메일을 보냅니다.
먼저 로컬에서 Kafka 브로커를 실행해야 합니다. Docker Compose 파일을 다음과 같이 만듭니다.
# 로컬 학습용 단일 KRaft broker
services:
kafka:
image: apache/kafka:4.4.0
ports:
- "9092:9092"공식 이미지의 기본 단일 노드 KRaft 구성은 로컬 학습용입니다. 이미지 태그와 시작 방법은 Apache Kafka 공식 Quickstart에서 확인할 수 있으며, 현재 Quickstart도 apache/kafka:4.4.0을 사용합니다. 운영 환경에서는 controller와 broker 역할, 복제 계수, ISR, 인증, 보관 정책을 따로 설계해야 합니다.
Kafka 사용자 이벤트 서비스 만들기
새 사용자가 생성될 때 Kafka에 이벤트를 발행하는 서비스입니다. 이번에는 같은 users-service를 gRPC 서버에서 HTTP 생산자로 바꿉니다. 앞 글의 Users gRPC 프로세스와 Orders HTTP 프로세스를 종료하고 설정을 바꿉니다. 이 글에서는 Users가 HTTP 3000번을 쓰므로 Orders를 같은 포트로 함께 실행하지 않습니다. Kafka 브로커, Users HTTP 생산자, Notifications 소비자는 각각 다른 실행 단위입니다.
users-service 프로젝트에 Kafka 드라이버를 설치합니다.
npm install kafkajsNestJS의 Kafka transporter는 실행 중에 kafkajs 드라이버를 쓰므로, 공식 문서대로 애플리케이션 의존성으로 함께 설치합니다. 현재 버전은 2.2입니다. @nestjs/microservices는 7장 첫 글에서 이미 설치했고, HTTP 서버에 필요한 @nestjs/platform-express와 @types/express는 새 프로젝트에 처음부터 들어 있습니다. 설치한 뒤 npm ls @nestjs/core @nestjs/microservices kafkajs와 lockfile로 실제 조합을 확인합니다.
사용자 서비스는 클라이언트의 HTTP 요청을 받아 사용자를 만든 뒤 Kafka 이벤트를 발행하므로, main.ts는 HTTP 서버로 되돌립니다.
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// 이 장의 다른 예제와 맞춰 HTTP 3000번 포트를 사용합니다.
await app.listen(3000);
console.log('Users Service (HTTP Server) is listening on port 3000');
}
void bootstrap();main.ts가 다시 NestFactory.create()를 쓰므로 Users는 HTTP 요청을 받는 일반 서버가 되고, Kafka와는 아래에서 등록할 클라이언트로만 통신합니다. 즉 Users는 Kafka 메시지를 받지 않고 보내기만 합니다. ClientsModule로 Kafka 클라이언트를 등록하고, 이를 UsersController에 주입해 이벤트를 발행합니다.
import { Module } from '@nestjs/common';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
imports: [
ClientsModule.register([
{
name: 'KAFKA_SERVICE', // Kafka 클라이언트 토큰
transport: Transport.KAFKA, // Kafka 전송 방식 사용
options: {
client: {
clientId: 'user-producer', // 클라이언트 ID
brokers: ['localhost:9092'], // Kafka 브로커 주소
},
producer: {
allowAutoTopicCreation: true, // 토픽이 없으면 자동으로 생성
},
producerOnlyMode: true, // 이벤트 발행 전용 클라이언트
},
},
]),
],
controllers: [UsersController],
providers: [UsersService],
})
export class UsersModule {}name: 'KAFKA_SERVICE': 이 클라이언트 프록시를 의존성 주입으로 참조할 token입니다.transport: Transport.KAFKA: Kafka 전송 방식을 씁니다.options.client: Kafka 클라이언트 설정으로, 클라이언트를 식별하는clientId와 처음 연결할 브로커 목록brokers를 지정합니다.options.producer: Kafka 생산자 설정입니다. 예제는allowAutoTopicCreation으로 토픽이 없으면 자동으로 만들게 합니다.producerOnlyMode: 이벤트를 발행하기만 하는 클라이언트이므로 consumer group에 참여하지 않게 합니다.
사용자를 만들 때 Kafka의 emit()으로 이벤트를 발행합니다.
import { Body, Controller, Get, Inject, OnModuleInit, Param, Post } from '@nestjs/common';
import type { ClientKafkaProxy } from '@nestjs/microservices';
import { UsersService } from './users.service';
import { lastValueFrom } from 'rxjs';
interface User {
id: number;
name: string;
email: string;
}
@Controller('users') // HTTP 엔드포인트
export class UsersController implements OnModuleInit {
constructor(
private readonly usersService: UsersService,
@Inject('KAFKA_SERVICE') private readonly client: ClientKafkaProxy,
) {}
async onModuleInit() {
await this.client.connect();
}
@Post()
async createUser(@Body() userDto: { name: string; email: string }): Promise<User> {
const newUser = this.usersService.create(userDto);
console.log(`Users Service: New user created: ${JSON.stringify(newUser)}`);
// emit()은 reply topic을 만들지 않는 이벤트 발행입니다.
// 같은 사용자의 이벤트가 같은 파티션으로 가도록 key를 함께 보냅니다.
await lastValueFrom(
this.client.emit('user_created', {
key: String(newUser.id),
value: newUser,
}),
);
return newUser;
}
// 기존 사용자 조회 엔드포인트 (HTTP)
@Get(':id')
getUserById(@Param('id') id: string): User | null {
const userId = parseInt(id, 10);
return this.usersService.findOne(userId);
}
}ClientKafkaProxy: Kafka 전송 방식에서는ClientProxy대신 이 타입을 씁니다. 앞 글의ClientGrpc처럼 인터페이스이므로, 데코레이터가 붙은 생성자에서 쓰려면import type으로 가져와야 컴파일 오류가 나지 않습니다.onModuleInit():client.connect()를 호출해, 애플리케이션이 시작할 때 브로커 연결 실패가 바로 드러나게 합니다.emit(): 이벤트 발행용 hotObservable을 반환합니다.lastValueFrom()으로 완료를 기다리면 이 요청 안에서 생산자 오류를 확인할 수 있지만, 소비자의 업무 처리 결과까지 기다리는 것은 아닙니다.subscribeToResponseOf():send()와@MessagePattern()을 쓰는 요청-응답 방식에서 응답 토픽을 구독할 때만 필요합니다.emit()과@EventPattern()에는 쓰지 않습니다.
위 예제는 생산자의 승인을 확인하지만, 사용자 저장과 Kafka 기록을 하나의 원자적 트랜잭션으로 묶지는 않습니다. 사용자를 저장했다면 이벤트 발행도 반드시 이어져야 하는 업무라면, 같은 데이터베이스 트랜잭션에 outbox 레코드를 남기고 별도 relay가 Kafka에 발행하도록 설계합니다.
users.service.ts와 AppModule은 앞 글과 같습니다.
알림 서비스 만들기
user_created 이벤트를 구독해 처리하는 서비스입니다. 공통 상위 폴더에서 새 프로젝트를 만듭니다.
npx @nestjs/cli@12 new notifications-service --no-observe
cd notifications-service
npm install @nestjs/microservices@12 kafkajsCLI 질문에서는 앞의 두 프로젝트와 같이 CommonJS와 npm을 고릅니다. main.ts를 Kafka 마이크로서비스 서버로 설정합니다.
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.KAFKA, // Kafka 전송 방식 사용
options: {
client: {
clientId: 'notification-consumer', // 실제 값: notification-consumer-server
brokers: ['localhost:9092'], // Kafka 브로커 주소
},
consumer: {
groupId: 'notification-group', // 실제 값: notification-group-server
},
},
});
await app.listen();
console.log('Notifications Microservice (Kafka Consumer) is listening.');
}
void bootstrap();transport: Transport.KAFKA: Kafka 전송 방식을 씁니다.options.client: Kafka 클라이언트 설정입니다.options.consumer.groupId: 생략하면 Nest가 기본값을 주므로 문법상 필수는 아닙니다. 하지만@EventPattern()으로 토픽을 구독하는 운영 서비스는 목적별로 안정된 ID를 명시해야, 배포한 뒤에도 같은 offset과 파티션 배분 단위를 유지합니다. 같은 group ID를 쓰는 인스턴스들은 파티션을 나누어 소비합니다.- 공식 문서에 따르면 Nest는 클라이언트와 서버의 이름이 겹치지 않도록
clientId와groupId뒤에 역할을 붙입니다. 서버 쪽은 기본적으로-server를 붙이므로 Kafka에 실제로 등록되는 그룹 이름은notification-group-server입니다. 접미사를 바꾸려면postfixId옵션을 씁니다.
@EventPattern() 데코레이터로 특정 Kafka 토픽의 이벤트를 처리합니다.
import { Controller } from '@nestjs/common';
import { EventPattern, Payload } from '@nestjs/microservices'; // EventPattern, Payload 임포트
import { NotificationsService } from './notifications.service';
interface UserCreatedEvent { // user_created 이벤트의 페이로드 구조 정의
id: number;
name: string;
email: string;
}
@Controller()
export class NotificationsController {
constructor(private readonly notificationsService: NotificationsService) {}
// 'user_created' 토픽의 이벤트를 처리하는 핸들러
@EventPattern('user_created')
async handleUserCreated(@Payload() data: UserCreatedEvent) {
console.log(`Notifications Service: Received 'user_created' event for user: ${JSON.stringify(data)}`);
// 여기에서 실제 알림 로직 (예: 이메일 전송)을 구현합니다.
await this.notificationsService.sendWelcomeEmail(data.email, data.name);
}
}@EventPattern('topic_name'): 특정 Kafka 토픽에 발행된 이벤트를 구독해 처리합니다.send()와 달리 응답을 돌려줄 필요가 없습니다.@Payload(): 이벤트 메시지의 값(value)을 꺼내 메서드 인자로 넣어 줍니다.
알림 서비스는 메일 발송을 흉내만 냅니다.
import { Injectable } from '@nestjs/common';
@Injectable()
export class NotificationsService {
async sendWelcomeEmail(email: string, name: string): Promise<void> {
console.log(`Sending welcome email to ${name} (${email})...`);
// 실제 이메일 전송 로직 (예: Nodemailer, SendGrid 등 사용)
await new Promise(resolve => setTimeout(resolve, 1000)); // 1초 지연 시뮬레이션
console.log(`Welcome email sent to ${name}!`);
}
}sendWelcomeEmail()은 시작 로그를 남기고 1초를 기다린 뒤 완료 로그를 남기므로, 소비자 콘솔에는 두 줄이 약 1초 간격으로 찍힙니다. handler가 이 메서드를 await하므로 두 번째 로그가 찍혀야 handler 실행도 끝납니다. NotificationsModule과 AppModule을 구성합니다. 알림 서비스에는 HTTP 경로가 없고, NotificationsController의 @EventPattern() handler만 Kafka 서버에 등록됩니다.
import { Module } from '@nestjs/common';
import { NotificationsController } from './notifications.controller';
import { NotificationsService } from './notifications.service';
@Module({
controllers: [NotificationsController],
providers: [NotificationsService],
})
export class NotificationsModule {}import { Module } from '@nestjs/common';
import { NotificationsModule } from './notifications/notifications.module';
@Module({
imports: [NotificationsModule],
controllers: [],
providers: [],
})
export class AppModule {}더 깊이: 키 없이 보낸 이벤트는 어느 파티션으로 갈까
Nest의 Kafka 클라이언트는 실제 생산을 KafkaJS 생산자에 맡기므로, 레코드가 들어갈 파티션도 KafkaJS의 파티셔너가 정합니다. KafkaJS 생산 문서에 따르면 메시지에 파티션을 직접 적었다면 그 파티션을 쓰고, 파티션 없이 키가 있으면 키의 murmur2 해시로 파티션을 고르며, 둘 다 없으면 라운드 로빈으로 돌아가며 고릅니다. 이 글의 emit()은 Nest 문서가 안내하는 대로 key와 value를 가진 객체를 보내고 사용자 ID를 key로 쓰므로, 같은 사용자의 이벤트는 같은 파티션으로 갑니다. 키 없이 사용자 객체만 보냈다면 토픽의 파티션이 여러 개일 때 같은 사용자에 대한 이벤트도 서로 다른 파티션에 들어갈 수 있고, 파티션 안에서만 순서가 정해지므로 사용자별 순서를 기대할 수 없게 됩니다.
키에서 파티션을 고르는 방식은 KafkaJS 2.0.0에서 바뀌었습니다. 그 전의 기본값이던 방식은 이제 LegacyPartitioner라는 이름으로 남았고, 지금의 기본 파티셔너는 Java Kafka 클라이언트의 기본 파티셔너와 호환되도록 만들어졌습니다. 그래서 다른 언어로 만든 생산자와 같은 키를 쓰면 같은 파티션에 모이므로, 키가 같은 두 토픽을 함께 처리하는 소비자가 파티션 배치를 맞출 수 있습니다. KafkaJS 1.x에서 올린 생산자는 같은 키라도 다른 파티션으로 갈 수 있으므로, 키별 순서에 기대는 소비자가 있다면 전환 시점을 따로 계획합니다.
실행하며 이벤트 흐름 확인하기
아래 순서는 브로커에 실제로 기록되었는지와 소비자 handler가 실행되었는지를 구분해서 확인하는 실습입니다. 알림 예제는 잠시 기다린 뒤 로그를 출력하는 모의 메일 처리이며, SMTP나 메일 서비스의 실제 발송 결과를 확인하는 코드는 없습니다.
- Docker Compose 파일이 있는 폴더에서
docker compose up -d로 Kafka 브로커를 실행하고,docker compose ps로 컨테이너가 떠 있는지 확인합니다. notifications-service에서npm run start:dev를 실행하고, 콘솔에Notifications Microservice (Kafka Consumer) is listening.이 찍히는지 확인합니다.users-service에서npm run start:dev를 실행하고, 콘솔에Users Service (HTTP Server) is listening on port 3000이 찍히는지 확인합니다.POST http://localhost:3000/users에Content-Type: application/json헤더와 다음 본문을 보내 사용자를 만듭니다.
{
"name": "David",
"email": "[email protected]"
}사용자 서비스 콘솔에는 곧바로 Users Service: New user created: {"id":3,"name":"David","email":"[email protected]"}가 찍힙니다. 잠시 뒤 Kafka 브로커를 거쳐 메시지가 전달되면, 알림 서비스 콘솔에 Notifications Service: Received 'user_created' event for user: {"id":3,"name":"David","email":"[email protected]"}와 Sending welcome email to David ([email protected])..., Welcome email sent to David!가 차례로 찍힙니다.
이 과정으로 Users 서비스가 Notifications 서비스를 직접 호출하지 않고도, Kafka를 거쳐 이벤트가 비동기로 전달되고 처리되는 것을 확인했습니다. Kafka에서는 API 응답보다 이벤트가 브로커를 지나 소비자까지 도착했는지가 더 중요합니다.
Kafka 기반 설계에서는 연결 자체보다, 실패한 뒤에도 같은 상태로 수렴하는지가 더 중요합니다. 아래 그림은 토픽 설계, 소비자 처리, 재시도와 DLQ(dead letter queue)를 하나의 흐름으로 묶어 점검하는 기준입니다.
Kafka 전달 계약과 재처리 수렴 설계
DB user/outbox·중복 가능 relay·consumer 효과/inbox·offset commit·재시도/DLQ의 확장 처리 상태이다.
Nest Kafka · 재처리와 수렴
발행과 부수 효과의 단절점을 복구할 계약을 정합니다
아래 outbox·inbox·외부 멱등 키·DLQ는 확장 설계입니다. 현재 코드의 사용자 메모리 저장과 Kafka 생산 승인만으로 이 원자성이나 소비 중복 안전성이 구현되지는 않습니다.
업무 사건을 설명한다
Topic은 user_created처럼 이미 일어난 사실로 이름 짓고, payload에는 event ID, 발생 시각, 업무 식별자와 소비자가 필요한 최소 데이터를 둡니다. schema 변경에는 호환성·version 규칙이 필요합니다.
순서 범위를 먼저 고른다
일관된 key와 partitioner는 같은 사용자의 record를 한 partition으로 모읍니다. 보장 범위는 그 partition log의 offset 순서이며, retry 때 생산자 전송 순서까지 보존하려면 idempotence와 동시 in-flight 같은 ordering 조건도 맞춰야 합니다.
복구 창을 보관 정책에 맞춘다
cleanup.policy=delete에서는 retention.ms/bytes를 넘은 오래된 segment가 소비 여부와 무관하게 삭제 대상이 됩니다. 그 뒤의 재생은 보장할 수 없으며, compact는 key별 과거 값을 별도 규칙으로 제거합니다.
발행과 소비의 두 단절점을 복구 가능하게 만든다
-
업무 상태와 outbox를 함께 확정한다
사용자 저장과 outbox record를 같은 데이터베이스 트랜잭션에 넣습니다. 그러면 저장만 성공하고 발행 의도가 사라지는 간격을 relay가 나중에 복구할 수 있습니다.
-
Relay가 key와 event ID를 유지해 발행한다
Relay는 broker의 생산자 승인을 관측한 뒤 outbox를 발행 완료로 표시합니다. 실패하거나 확인이 모호하면 다시 발행할 수 있으므로 event ID가 소비 멱등성의 기준이 됩니다.
-
Consumer가 부작용을 멱등하게 처리한다
DB 변경은 inbox의 event ID와 업무 상태를 같은 트랜잭션에 기록합니다. 이메일·결제 같은 외부 부작용은 별도 단절점이므로 downstream idempotency key나 notification outbox·relay로 보호합니다.
-
업무 성공 뒤 offset을 commit한다
처리 뒤 commit 전에 중단되면 같은 record가 재처리될 수 있습니다. 반대로 업무 성공 전에 진행 위치를 확정하면 실패한 작업을 건너뛸 수 있으므로 경계를 명시적으로 선택합니다.
-
일시 실패는 제한적으로 retry하고 영구 실패는 격리한다
Backoff와 최대 횟수를 둔 뒤 schema 오류나 반복 실패를 DLQ topic으로 보냅니다. DLQ는 Kafka의 자동 복구 보장이 아니라 애플리케이션이 구현하고 운영해야 할 정책입니다.
승인은 강도를 높일 뿐 절대 무손실은 아니다
acks=all은 사용 가능한 가장 강한 broker 승인입니다. 복제 계수, ISR과 min.insync.replicas, 디스크·운영 장애와 오류 처리를 함께 설계해야 하며, producer idempotence도 DB와 Kafka를 하나의 트랜잭션으로 만들지는 않습니다.
Consumer group과 idempotency는 다른 책임이다
같은 group의 member들은 partition을 분담하지만 업무 성공 뒤 offset commit 완료 전에 중단되거나 outbox가 재발행되거나 운영 replay를 하면 같은 사건을 다시 볼 수 있습니다. DB 중복 억제는 event ID와 업무 상태의 원자적 기록으로 구현합니다.
재시도가 partition 진행을 막을 수 있다
NestJS Kafka에서 처리되지 않은 event handler 예외는 KafkaJS로 전파되고 offset이 commit되지 않아 record가 재전달될 수 있습니다. 오류 분류, backoff, 최대 횟수와 timeout을 정하고 같은 partition의 뒤 record가 얼마나 기다릴지도 판단합니다.
격리 뒤의 소유자와 replay를 준비한다
DLQ에는 원래 topic·key·payload·schema version·실패 이유와 시도를 남깁니다. 알림, 담당자, 수정·재생 도구, 재생 시 멱등성 검증이 없으면 DLQ는 복구가 아니라 적체가 됩니다.
업무 목적마다 독립 group을 둔다
메일, 분석, 감사 로그가 모두 같은 event를 필요로 하면 서로 다른 groupId를 사용해 각자의 offset과 lag를 가집니다. 한 group 안에서 인스턴스를 늘리면 partition을 분담하지만, consumer 수가 partition 수를 넘으면 유휴 인스턴스가 생길 수 있습니다.
운영 신호: publish 실패와 ack 지연, outbox 적체, topic log end offset, group lag·rebalance, handler 성공률, retry 횟수, DLQ 건수, 중복 억제 건수와 commit 진행 위치를 같은 event ID로 연결합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 이는 현재 모의 코드에 추가할 확장 설계다. 사용자와 outbox는 같은 DB commit으로 확정하고 Relay는 broker 승인 전후 모호함 때문에 같은 event ID로 재발행할 수 있다. 소비 DB 효과와 inbox는 함께 확정하며 업무 성공 뒤 offset을 commit한다. handler 실패는 원인과 예산을 분류해 제한 retry 또는 애플리케이션이 구현한 DLQ/운영 replay로 보낸다. 왼쪽 점선은 재처리, 오른쪽 점선은 실패 분류이고 DB와 Kafka 전체가 한 트랜잭션이라는 뜻이 아니다. 외부 메일/결제는 별도 멱등 키나 outbox가 필요하다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | Kafka 전달 계약과 재처리 수렴 설계 |
| 6 | 이는 현재 모의 코드에 추가할 확장 설계다. 사용자와 outbox는 같은 DB commit으로 확정하고 Relay는 broker 승인 전후 모호함 때문에 같은 event ID로 재발행할 수 있다. 소비 DB 효과와 inbox는 함께 확정하며 업무 성공 뒤 offset을 commit한다. handler 실패는 원인과 예산을 분류해 제한 retry 또는 애플리케이션이 구현한 DLQ/운영 replay로 보낸다. 왼쪽 점선은 재처리, 오른쪽 점선은 실패 분류이고 DB와 Kafka 전체가 한 트랜잭션이라는 뜻이 아니다. 외부 메일/결제는 별도 멱등 키나 outbox가 필요하다. |
| 66 | DB 변경을 처리할 때는 inbox의 event ID와 업무 상태를 같은 트랜잭션에 기록합니다. 이메일·결제 같은 외부 부작용은 별도 단절점이므로 downstream idempotency key나 notification outbox·relay로 보호합니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| DB outbox 확정 확장 설계가 user·outbox를 같은 commit에 넣습니다. | 상태: 확장 설계 · DB: user·outbox | 전달 근거와 업무를 원자적으로 만듭니다. |
| 중복 전달·소비 DB 같은 event ID가 재발행되어도 inbox·업무 효과를 같은 TX로 묶어 처리합니다. | 전달: 중복 가능 · 소비: inbox·효과 | 전달 시도와 업무 효과를 구분합니다. |
| offset·복구 업무 성공 뒤 offset commit, 실패는 제한 retry·DLQ·운영 replay입니다. | offset: 업무 성공 뒤 · 실패: 분류·예산 · 외부 효과: 별도 키 필요 | 완료 근거와 재처리 경계를 명시합니다. |
Kafka 전달 계약과 재처리 수렴 설계
DB user/outbox·중복 가능 relay·consumer 효과/inbox·offset commit·재시도/DLQ의 확장 처리 상태이다.
Nest Kafka · 재처리와 수렴
발행과 부수 효과의 단절점을 복구할 계약을 정합니다
아래 outbox·inbox·외부 멱등 키·DLQ는 확장 설계입니다. 현재 코드의 사용자 메모리 저장과 Kafka 생산 승인만으로 이 원자성이나 소비 중복 안전성이 구현되지는 않습니다.
업무 사건을 설명한다
Topic은 user_created처럼 이미 일어난 사실로 이름 짓고, payload에는 event ID, 발생 시각, 업무 식별자와 소비자가 필요한 최소 데이터를 둡니다. schema 변경에는 호환성·version 규칙이 필요합니다.
순서 범위를 먼저 고른다
일관된 key와 partitioner는 같은 사용자의 record를 한 partition으로 모읍니다. 보장 범위는 그 partition log의 offset 순서이며, retry 때 생산자 전송 순서까지 보존하려면 idempotence와 동시 in-flight 같은 ordering 조건도 맞춰야 합니다.
복구 창을 보관 정책에 맞춘다
cleanup.policy=delete에서는 retention.ms/bytes를 넘은 오래된 segment가 소비 여부와 무관하게 삭제 대상이 됩니다. 그 뒤의 재생은 보장할 수 없으며, compact는 key별 과거 값을 별도 규칙으로 제거합니다.
발행과 소비의 두 단절점을 복구 가능하게 만든다
-
업무 상태와 outbox를 함께 확정한다
사용자 저장과 outbox record를 같은 데이터베이스 트랜잭션에 넣습니다. 그러면 저장만 성공하고 발행 의도가 사라지는 간격을 relay가 나중에 복구할 수 있습니다.
-
Relay가 key와 event ID를 유지해 발행한다
Relay는 broker의 생산자 승인을 관측한 뒤 outbox를 발행 완료로 표시합니다. 실패하거나 확인이 모호하면 다시 발행할 수 있으므로 event ID가 소비 멱등성의 기준이 됩니다.
-
Consumer가 부작용을 멱등하게 처리한다
DB 변경은 inbox의 event ID와 업무 상태를 같은 트랜잭션에 기록합니다. 이메일·결제 같은 외부 부작용은 별도 단절점이므로 downstream idempotency key나 notification outbox·relay로 보호합니다.
-
업무 성공 뒤 offset을 commit한다
처리 뒤 commit 전에 중단되면 같은 record가 재처리될 수 있습니다. 반대로 업무 성공 전에 진행 위치를 확정하면 실패한 작업을 건너뛸 수 있으므로 경계를 명시적으로 선택합니다.
-
일시 실패는 제한적으로 retry하고 영구 실패는 격리한다
Backoff와 최대 횟수를 둔 뒤 schema 오류나 반복 실패를 DLQ topic으로 보냅니다. DLQ는 Kafka의 자동 복구 보장이 아니라 애플리케이션이 구현하고 운영해야 할 정책입니다.
승인은 강도를 높일 뿐 절대 무손실은 아니다
acks=all은 사용 가능한 가장 강한 broker 승인입니다. 복제 계수, ISR과 min.insync.replicas, 디스크·운영 장애와 오류 처리를 함께 설계해야 하며, producer idempotence도 DB와 Kafka를 하나의 트랜잭션으로 만들지는 않습니다.
Consumer group과 idempotency는 다른 책임이다
같은 group의 member들은 partition을 분담하지만 업무 성공 뒤 offset commit 완료 전에 중단되거나 outbox가 재발행되거나 운영 replay를 하면 같은 사건을 다시 볼 수 있습니다. DB 중복 억제는 event ID와 업무 상태의 원자적 기록으로 구현합니다.
재시도가 partition 진행을 막을 수 있다
NestJS Kafka에서 처리되지 않은 event handler 예외는 KafkaJS로 전파되고 offset이 commit되지 않아 record가 재전달될 수 있습니다. 오류 분류, backoff, 최대 횟수와 timeout을 정하고 같은 partition의 뒤 record가 얼마나 기다릴지도 판단합니다.
격리 뒤의 소유자와 replay를 준비한다
DLQ에는 원래 topic·key·payload·schema version·실패 이유와 시도를 남깁니다. 알림, 담당자, 수정·재생 도구, 재생 시 멱등성 검증이 없으면 DLQ는 복구가 아니라 적체가 됩니다.
업무 목적마다 독립 group을 둔다
메일, 분석, 감사 로그가 모두 같은 event를 필요로 하면 서로 다른 groupId를 사용해 각자의 offset과 lag를 가집니다. 한 group 안에서 인스턴스를 늘리면 partition을 분담하지만, consumer 수가 partition 수를 넘으면 유휴 인스턴스가 생길 수 있습니다.
운영 신호: publish 실패와 ack 지연, outbox 적체, topic log end offset, group lag·rebalance, handler 성공률, retry 횟수, DLQ 건수, 중복 억제 건수와 commit 진행 위치를 같은 event ID로 연결합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 이는 현재 모의 코드에 추가할 확장 설계다. 사용자와 outbox는 같은 DB commit으로 확정하고 Relay는 broker 승인 전후 모호함 때문에 같은 event ID로 재발행할 수 있다. 소비 DB 효과와 inbox는 함께 확정하며 업무 성공 뒤 offset을 commit한다. handler 실패는 원인과 예산을 분류해 제한 retry 또는 애플리케이션이 구현한 DLQ/운영 replay로 보낸다. 왼쪽 점선은 재처리, 오른쪽 점선은 실패 분류이고 DB와 Kafka 전체가 한 트랜잭션이라는 뜻이 아니다. 외부 메일/결제는 별도 멱등 키나 outbox가 필요하다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | Kafka 전달 계약과 재처리 수렴 설계 |
| 6 | 이는 현재 모의 코드에 추가할 확장 설계다. 사용자와 outbox는 같은 DB commit으로 확정하고 Relay는 broker 승인 전후 모호함 때문에 같은 event ID로 재발행할 수 있다. 소비 DB 효과와 inbox는 함께 확정하며 업무 성공 뒤 offset을 commit한다. handler 실패는 원인과 예산을 분류해 제한 retry 또는 애플리케이션이 구현한 DLQ/운영 replay로 보낸다. 왼쪽 점선은 재처리, 오른쪽 점선은 실패 분류이고 DB와 Kafka 전체가 한 트랜잭션이라는 뜻이 아니다. 외부 메일/결제는 별도 멱등 키나 outbox가 필요하다. |
| 66 | DB 변경을 처리할 때는 inbox의 event ID와 업무 상태를 같은 트랜잭션에 기록합니다. 이메일·결제 같은 외부 부작용은 별도 단절점이므로 downstream idempotency key나 notification outbox·relay로 보호합니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| DB outbox 확정 확장 설계가 user·outbox를 같은 commit에 넣습니다. | 상태: 확장 설계 · DB: user·outbox | 전달 근거와 업무를 원자적으로 만듭니다. |
| 중복 전달·소비 DB 같은 event ID가 재발행되어도 inbox·업무 효과를 같은 TX로 묶어 처리합니다. | 전달: 중복 가능 · 소비: inbox·효과 | 전달 시도와 업무 효과를 구분합니다. |
| offset·복구 업무 성공 뒤 offset commit, 실패는 제한 retry·DLQ·운영 replay입니다. | offset: 업무 성공 뒤 · 실패: 분류·예산 · 외부 효과: 별도 키 필요 | 완료 근거와 재처리 경계를 명시합니다. |
위 그림은 지금의 모의 코드에 더할 확장 설계입니다. 위쪽 흐름에서는 사용자와 outbox를 같은 데이터베이스 커밋으로 확정하고, relay가 같은 event ID로 Kafka에 발행하며, 소비자는 inbox 기록과 업무 상태를 함께 확정한 뒤에 offset을 커밋합니다. handler가 실패하면 원인과 시도 횟수로 분류해, 일시적인 실패는 제한적으로 재시도하고 영구적인 실패나 횟수를 다 쓴 경우는 DLQ로 보내 운영자가 다시 재생합니다. 아래쪽 칸들은 공식 문서의 설명처럼 Nest Kafka handler에서 처리되지 않은 예외가 KafkaJS로 전달되면 offset이 커밋되지 않아 같은 레코드가 다시 전달될 수 있다는 점, 그리고 업무 목적마다 별도의 consumer group을 두는 이유를 정리합니다.
Kafka 기반 아키텍처는 서비스 사이의 동기 호출을 줄이고 이벤트 스트림을 중심으로 비동기 흐름을 구성합니다. 구현할 때는 토픽 설계, 파티션 키, consumer group, 재시도, DLQ, 중복 처리 기준을 함께 정하고, 같은 이벤트가 다시 전달되어도 안전하게 같은 상태로 수렴하는지 확인해야 합니다.
정리
- 이벤트 기반 아키텍처에서는 서비스가 서로를 직접 호출하지 않고 이벤트를 발행·구독하며, Kafka는 이벤트를 파티션 로그에 보관해 생산과 소비의 시점을 떼어 놓습니다.
- 레코드 순서는 파티션 안에서만 정해지므로, 같은 업무 키는 같은 파티션으로 가도록
key를 함께 보냅니다. - NestJS는 Kafka 드라이버로
kafkajs를 쓰며, 생산자는ClientKafkaProxy.emit()으로, 소비자는@EventPattern()으로 이벤트를 다룹니다. - Nest는
clientId와groupId뒤에-server나-client를 붙이므로, Kafka에서 실제 그룹 이름을 확인할 때 이 접미사를 고려합니다. - 생산 승인은 소비 완료나 메일 발송을 뜻하지 않으므로, 저장과 발행은 outbox로, 재전달은 event ID 기반 멱등 처리로 대비합니다.
연습 문제
1. 생산 승인과 메일 완료는 같은가
새 사용자 POST에서 createUser()가 메모리 배열에 사용자를 넣었고, 이어서 emit()의 생산이 성공해 HTTP 응답도 성공했습니다. 그런데 Notifications 소비자가 처리하던 중 종료되었습니다. 사용자 생성, 브로커 기록, 메일 발송 가운데 확정할 수 있는 결과는 무엇일까요? 생산 단계 자체가 실패했다면, 이미 저장한 사용자가 이 코드에서 자동으로 취소될까요?
해설 보기
첫 상황에서는 지금 Users 프로세스의 사용자 생성과 생산 성공까지만 확정할 수 있습니다. 그것만으로 소비자가 업무를 끝냈거나 메일이 실제로 발송되었다고 볼 수는 없습니다. 예제는 메일을 흉내만 내며, 실제 외부 부수 효과의 멱등성도 따로 구현해야 합니다.
생산 단계가 실패한 두 번째 상황에서도, 앞서 메모리 배열에 넣은 사용자를 되돌리는 코드가 없습니다. HTTP 실패를 사용자 저장의 롤백으로 읽으면 안 됩니다. 클라이언트가 POST를 다시 보내면 같은 사람이 다른 ID로 또 만들어질 수 있으므로, 저장과 발행 사이의 틈을 줄이는 outbox와 요청의 멱등 처리를 따로 설계해야 합니다.
2. 그룹을 찾을 수 없다
운영자가 Kafka의 consumer group 목록에서 notification-group을 찾았지만 보이지 않았습니다. 알림 서비스는 정상적으로 이벤트를 처리하고 있습니다. 왜 그럴까요?
해설 보기
Nest의 Kafka 서버는 이름 충돌을 막으려고 groupId 뒤에 기본 접미사 -server를 붙입니다. 그래서 Kafka에 실제로 등록된 그룹 이름은 notification-group-server이고, 목록에서도 이 이름으로 보입니다. lag나 offset을 확인하는 모니터링 설정도 이 이름을 기준으로 해야 합니다.
접미사 없이 정확한 이름을 쓰고 싶다면 postfixId 옵션으로 접미사를 바꿀 수 있지만, 이미 운영 중인 그룹의 이름을 바꾸면 새 그룹으로 취급되어 offset이 처음부터 다시 정해진다는 점에 주의합니다.
3. 인스턴스를 늘렸는데 하나만 일한다
user_created 토픽은 자동 생성되어 파티션이 하나입니다. 처리량을 높이려고 알림 서비스 인스턴스를 두 개로 늘렸는데, 한 인스턴스만 이벤트를 받습니다. 또 분석 서비스를 새로 만들어 같은 토픽을 analytics-group으로 구독하게 했다면, 사용자 하나가 생성될 때 이벤트는 어디로 전달될까요?
해설 보기
같은 consumer group 안에서는 한 파티션을 한 시점에 한 인스턴스만 맡습니다. 파티션이 하나뿐이므로 두 번째 알림 인스턴스는 맡을 파티션이 없어 쉬게 됩니다. 인스턴스를 늘려 처리량을 높이려면 토픽의 파티션 수를 늘려야 합니다.
분석 서비스는 다른 group이므로 자신의 offset으로 같은 스트림을 독립적으로 읽습니다. 따라서 사용자 하나가 생성되면 이벤트는 알림 그룹의 한 인스턴스와 분석 그룹에 각각 한 번씩 전달됩니다.
다음 글 API 게이트웨이 패턴 구현에서는 지금까지 만든 여러 서비스 앞에 하나의 진입점을 두고, 라우팅·인증·응답 집계를 맡기는 방법을 다룹니다.