Event-Carried State Transfer로 마이크로서비스 동기 호출 끊기
마이크로서비스 아키텍처에서 서비스 간 데이터 조회는 자연스럽게 HTTP API 호출로 구현됩니다. 주문 서비스가 배송 서비스에 "이 주문의 배송 상태가 뭐야?"라고 묻고, 상품 서비스가 재고 서비스에 "이 SKU의 현재 재고가 얼마야…
목차
- 개요
- Event-Carried State Transfer 패턴 이해
- 동기 호출이 만드는 결합과 장애 전파
- Event-Carried State Transfer 구현
- 일관성 모델과 트레이드오프
- 운영 환경 적용 시 고려사항
- 맺음말
개요
문제 배경
마이크로서비스 아키텍처에서 서비스 간 데이터 조회는 자연스럽게 HTTP API 호출로 구현됩니다. 주문 서비스가 배송 서비스에 "이 주문의 배송 상태가 뭐야?"라고 묻고, 상품 서비스가 재고 서비스에 "이 SKU의 현재 재고가 얼마야?"라고 묻는 식입니다. 처음에는 단순하고 명확해 보이는 이 구조가, 서비스가 수십 개로 늘어나면 예상치 못한 복잡성을 만들어냅니다. Event-Carried State Transfer(ECST) 패턴은 바로 이 동기 호출 의존성을 이벤트 기반 상태 복제로 대체하는 접근법입니다.
Event-Carried State Transfer는 단순히 "이벤트를 쏜다"는 것을 넘어, 이벤트 안에 상태 변경에 필요한 데이터를 충분히 담아 수신 서비스가 다시 질의 없이 처리할 수 있게 합니다. 발행자는 상태가 바뀔 때마다 변경된 상태 전체(또는 변경된 필드)를 이벤트로 내보내고, 구독자는 그것을 자신의 로컬 프로젝션에 반영합니다. 이로써 런타임 의존성이 사라지고, 각 서비스는 자신이 가진 데이터만으로 비즈니스 로직을 수행합니다.
이 글은 동기 호출이 왜 문제가 되는지 수치로 살펴보고, ECST 패턴의 구조와 구현 방법, 최종 일관성 모델에서의 트레이드오프, 그리고 운영 환경에서 겪는 실제 문제들을 다룹니다.
기존 방식의 한계
REST 기반 동기 호출은 직관적이지만 본질적으로 두 서비스를 런타임에 결합시킵니다. 호출자는 피호출자가 살아 있어야만 정상 동작합니다. 피호출자가 느리면 호출자도 느려지고, 피호출자가 죽으면 호출자도 실패합니다. 서킷 브레이커와 타임아웃으로 일부 완화할 수 있지만, 가용성 손실 자체를 막지는 못합니다.
더 큰 문제는 호출 체인이 깊어질 때 나타납니다. A → B → C → D로 이어지는 4단계 호출 체인에서 D의 99% 가용성은 체인 전체의 가용성을 0.99⁴ ≈ 96%까지 떨어뜨립니다. 각 서비스가 독립적으로는 우수한 SLA를 제공하더라도, 체인 전체의 가용성은 구성원 수에 따라 기하급수적으로 낮아집니다. ECST는 이 런타임 결합 자체를 제거해 각 서비스가 파트너 서비스의 장애와 무관하게 동작하도록 합니다.
Event-Carried State Transfer 패턴 이해
핵심 개념과 동작 원리
Event-Carried State Transfer의 핵심 아이디어는 간단합니다. "필요할 때 물어보는" 방식 대신, "변경이 생기면 알려주는" 방식으로 전환하는 것입니다. 변경을 알릴 때, 단순히 "변경됐어"라는 신호만 보내는 것이 아니라 "변경된 상태가 이것이야"라는 데이터까지 함께 보냅니다.
이것이 유사 패턴들과 구별되는 지점입니다. Event Notification 패턴은 "뭔가 바뀌었어"라는 신호만 보내고, 수신자가 여전히 발신자에게 API를 호출해 상세 데이터를 가져옵니다. Event Sourcing은 상태 변화의 히스토리를 저장하고 재구성하는 데 초점을 맞추며, ECST는 Event Sourcing 없이도 구현 가능합니다. ECST는 이벤트 자체에 현재 상태 데이터를 충분히 담아 수신자가 별도 호출 없이 로컬에 복제본을 유지할 수 있게 하는 데 특화되어 있습니다.
발행-구독 모델에서 생산자 서비스는 도메인 상태가 바뀔 때마다 이벤트를 토픽에 발행합니다. 이 이벤트에는 변경된 엔티티의 식별자와 함께, 구독자가 필요로 하는 필드들을 포함한 상태 스냅샷이 담겨 있습니다. 구독자는 이 이벤트를 소비해 자신의 로컬 데이터베이스(또는 캐시)에 반영합니다. 이후 구독자가 그 데이터를 필요로 할 때는 발행자에게 묻지 않고 자신의 로컬 복제본을 읽습니다.
각 구독자는 발행자와 독립적으로 동작하며, 이벤트 브로커만이 유일한 연결 고리가 됩니다.
주요 구성 요소
ECST 패턴을 구성하는 세 가지 핵심 요소가 있습니다.
이벤트 생산자는 도메인 상태 변경 시 이벤트를 발행합니다. 중요한 것은 이벤트가 단순 알림이 아니라 구독자가 필요로 하는 상태를 포함해야 한다는 점입니다. 이를 위해 생산자는 구독자가 어떤 필드를 필요로 하는지 어느 정도 파악하고 있어야 합니다. 다만 과도하게 많은 필드를 담으면 이벤트 크기가 커지고 불필요한 정보가 노출될 수 있으므로, 공개 인터페이스(published language)를 명확히 정의하는 것이 중요합니다.
이벤트 브로커는 Kafka, Pulsar, RabbitMQ 같은 메시지 시스템으로, 이벤트의 내구성 보장과 발행-구독 라우팅을 담당합니다. 특히 Kafka처럼 메시지를 디스크에 보존하는 브로커는 새 구독자가 합류하거나 기존 구독자가 이벤트를 재처리해야 할 때 히스토리를 재생할 수 있어 ECST에 적합합니다.
로컬 프로젝션은 구독자가 이벤트를 소비해 유지하는 로컬 데이터 복제본입니다. 이것은 발행자 데이터의 완전한 복사본이 아니라, 해당 구독자의 서비스 맥락에서 필요한 필드만 추출한 맥락화된 뷰입니다. 예를 들어 주문 서비스가 필요한 상품 정보는 상품명·가격·VAT 여부뿐일 수 있고, 검색 서비스는 상품명·카테고리·키워드가 필요할 수 있습니다. 같은 이벤트를 소비하더라도 각 구독자의 로컬 스키마는 서비스의 필요에 맞게 다를 수 있습니다.
다른 이벤트 패턴과의 차이
ECST와 혼동하기 쉬운 패턴들을 비교해 두면 설계 결정을 내릴 때 도움이 됩니다.
| 패턴 | 이벤트에 상태 포함 | 수신 후 추가 호출 | 로컬 복제본 유지 | 주 목적 |
|---|---|---|---|---|
| Event Notification | 신호만 | 필요 | 선택 | 변경 알림 |
| ECST | 충분한 상태 | 불필요 | 필수 | 런타임 결합 제거 |
| Event Sourcing | 변경 델타 | 불필요 | 리플레이로 재구성 | 상태 이력 보존 |
| CQRS Read Model | 최신 상태 | 불필요 | 필수 | 읽기 최적화 |
ECST는 CQRS의 읽기 모델 패턴과 목적이 유사하지만, 서비스 경계를 넘어 복제가 이루어진다는 점에서 다릅니다. CQRS 읽기 모델은 단일 서비스 내 쓰기·읽기 분리인 반면, ECST는 서비스 간 경계를 넘어 상태를 복제합니다. 이 차이가 이벤트 계약(event contract) 관리와 스키마 진화 전략에 근본적인 차이를 만들어냅니다.
동기 호출이 만드는 결합과 장애 전파
의존성 연쇄와 장애 도미노
동기 호출 체인의 위험성은 직접 겪어보기 전까지 잘 와닿지 않습니다. 서비스 A가 B를 호출하고, B가 C를 호출하는 구조에서, C가 응답 지연을 일으키면 B의 스레드 풀이 소진되고, 이어서 A의 스레드 풀도 막힙니다. 서킷 브레이커를 설치했더라도, 브레이커가 열리는 순간 A는 B 없이 자신의 기능을 수행하지 못합니다. B가 C의 응답에 의존하기 때문입니다.
이 현상을 **캐스케이딩 장애(cascading failure)**라 부릅니다. 하나의 서비스 장애가 상위 서비스들을 연쇄적으로 끌어내리는 것입니다. 마이크로서비스 환경에서 이 위험성은 서비스 수에 비례해 높아집니다. 수십 개의 서비스가 거미줄처럼 동기 호출로 연결되어 있다면, 어느 하나의 장애가 시스템 전반의 불안정을 초래할 수 있습니다.
장애가 아래에서 위로 전파되며 전체 체인이 연쇄적으로 멈추는 구조입니다.
가용성 손실 계산
수치로 살펴보면 동기 호출 체인의 문제가 더 명확해집니다. 각 서비스가 월간 99.9% 가용성(다운타임 약 43분)을 유지한다고 가정하겠습니다. 이 서비스들이 동기 호출 체인으로 연결되면, 체인 전체의 가용성은 개별 가용성의 곱이 됩니다.
| 체인 깊이 | 계산식 | 합성 가용성 | 월간 다운타임 |
|---|---|---|---|
| 1개 | 99.9% | 99.9% | ~43분 |
| 2개 | 99.9% × 99.9% | 99.8% | ~86분 |
| 4개 | 99.9%⁴ | 99.6% | ~173분 |
| 6개 | 99.9%⁶ | 99.4% | ~259분 |
6개 서비스가 연결되면 개별적으로는 우수한 99.9% SLA를 제공하더라도 체인 전체의 가용성은 99.4%까지 떨어집니다. ECST는 이 가용성 손실 자체를 제거합니다. 구독자 서비스는 발행자 서비스가 일시 중단되어 있어도 로컬 복제본으로 계속 동작합니다. 발행자 복구 후 밀린 이벤트를 처리하면 최종적으로 일관성이 맞춰집니다.
레이턴시 누적 문제
가용성 외에도 레이턴시 누적은 동기 호출의 또 다른 문제입니다. 각 서비스가 P99 레이턴시 20ms를 제공할 때, 6단계 체인의 P99는 단순 합산으로도 120ms입니다. 실제로는 독립적인 서비스들의 꼬리 레이턴시(tail latency)가 합산되므로, 체인 전체의 P99는 각 서비스의 P99 합보다 훨씬 높아질 수 있습니다.
네트워크 왕복 비용, 직렬화·역직렬화 오버헤드, HTTP 커넥션 풀 경합까지 고려하면 실제 호출 비용은 순수 처리 레이턴시보다 상당히 높습니다. ECST를 도입하면 각 서비스는 이벤트를 비동기적으로 처리하고, 요청 경로에서는 로컬 스토어를 조회합니다. 로컬 데이터베이스나 인메모리 캐시 조회는 수 밀리초 이내에 완료되므로, 요청-응답 레이턴시가 크게 줄어듭니다.
로컬 조회 기반 요청 처리는 동기 체인 대비 레이턴시를 한 자릿수 수준으로 줄일 수 있습니다.
Event-Carried State Transfer 구현
이벤트 스키마 설계
좋은 ECST 이벤트 스키마는 세 가지 조건을 만족해야 합니다. 첫째, 수신자가 추가 호출 없이 처리할 수 있을 만큼 충분한 상태를 포함해야 합니다. 둘째, 불필요한 민감 정보나 내부 구현 세부사항을 노출하지 않아야 합니다. 셋째, 스키마가 진화하더라도 기존 소비자가 깨지지 않도록 하위 호환성을 고려해야 합니다.
Avro, Protobuf, JSON Schema 중 스키마 레지스트리와 연동 가능한 포맷을 선택하는 것이 좋습니다. Kafka와 함께 사용할 때는 Confluent Schema Registry가 스키마 진화와 하위 호환성 검증을 자동화해 주어 많이 활용됩니다.
다음은 상품 서비스가 발행하는 ECST 이벤트의 Avro 스키마 예시입니다. 구독자가 필요로 하는 필드만 공개 인터페이스로 포함하고, 내부 구현 세부사항(예: 내부 코스트 구조, 공급자 정보)은 제외합니다.
{
"type": "record",
"name": "ProductStateChanged",
"namespace": "com.example.product.events",
"fields": [
{"name": "eventId", "type": "string"},
{"name": "occurredAt", "type": "long", "logicalType": "timestamp-millis"},
{"name": "productId", "type": "string"},
{"name": "version", "type": "long"},
{"name": "name", "type": "string"},
{"name": "priceKrw", "type": "long"},
{"name": "taxable", "type": "boolean"},
{"name": "category", "type": "string"},
{"name": "status", "type": {
"type": "enum",
"name": "ProductStatus",
"symbols": ["ACTIVE", "DISCONTINUED", "OUT_OF_STOCK"]
}},
{"name": "stockQty", "type": ["null", "long"], "default": null}
]
}
// version 필드: 구독자가 오래된 이벤트를 무시하는 낙관적 잠금에 활용
// stockQty: nullable — 해당 정보가 없는 구독자는 null을 무시version 필드를 포함하는 것은 중요합니다. Kafka 파티션 내 순서는 보장되지만, 여러 파티션에서 소비하거나 재처리 시나리오에서는 오래된 이벤트가 최신 이벤트 이후에 처리될 수 있습니다. 구독자가 version 값을 비교해 더 낮은 버전 이벤트를 무시하면 이 문제를 방지할 수 있습니다. eventId는 별도 중복 처리 추적에도 활용할 수 있습니다.
컨슈머 측 로컬 스토어 구성
구독자 서비스는 이벤트를 소비해 자신의 로컬 데이터베이스에 반영합니다. 이 로컬 프로젝션 테이블은 발행자 서비스의 테이블을 그대로 복사한 것이 아니라, 해당 서비스의 비즈니스 맥락에서 필요한 컬럼만 가진 독립적인 스키마입니다.
주문 서비스가 상품 이벤트를 소비해 유지하는 로컬 프로젝션 테이블과 Kafka 컨슈머 구현 예시를 살펴보겠습니다.
// 주문 서비스 내 상품 프로젝션 엔티티 — 주문에 필요한 필드만 유지
@Entity
@Table(name = "product_projection")
public class ProductProjection {
@Id
private String productId;
private String name;
private long priceKrw;
private boolean taxable;
private String status;
private long version;
private Instant lastUpdated;
public boolean applyEvent(ProductStateChanged event) {
// 낙관적 버전 체크: 오래된·중복 이벤트 무시
if (event.getVersion() <= this.version) {
return false; // 스킵 — 상위에서 경고 로그 기록
}
this.name = event.getName();
this.priceKrw = event.getPriceKrw();
this.taxable = event.getTaxable();
this.status = event.getStatus().name();
this.version = event.getVersion();
this.lastUpdated = Instant.ofEpochMilli(event.getOccurredAt());
return true;
}
}
// Kafka 컨슈머 — @KafkaListener 기반
@KafkaListener(topics = "product-state-changes", groupId = "order-service")
public void consume(ProductStateChanged event) {
productProjectionRepository.findById(event.getProductId())
.ifPresentOrElse(
proj -> {
boolean updated = proj.applyEvent(event);
if (updated) productProjectionRepository.save(proj);
// updated == false 이면 저장 생략 (멱등 처리)
},
() -> productProjectionRepository.save(
ProductProjection.from(event)) // 신규 진입 상품
);
}
// 결과: 처리 성공 시 로컬 DB 반영, 중복·오래된 이벤트는 자동 무시applyEvent 내 버전 비교는 멱등 처리의 핵심입니다. Kafka at-least-once 전달 보장 하에서는 같은 이벤트가 두 번 이상 전달될 수 있으므로, 동일 버전의 이벤트를 재처리해도 최종 상태가 달라지지 않도록 설계해야 합니다. 저장 결과가 동일하다면 저장 호출 자체도 생략해 DB 부하를 줄일 수 있습니다.
이벤트 발행과 트랜잭셔널 아웃박스
생산자 서비스에서 DB 업데이트와 이벤트 발행을 하나의 원자 단위로 처리하는 것은 ECST의 신뢰성을 보장하는 핵심 문제입니다. DB를 업데이트한 후 Kafka에 발행하는 코드를 단순히 순서대로 실행하면, DB 커밋 이후 Kafka 발행이 실패하는 경우 이벤트 유실이 발생합니다. 반대로 Kafka 발행을 먼저 하면 DB 커밋이 실패했을 때 없던 상태 변경이 이벤트로 나가는 문제가 생깁니다.
트랜잭셔널 아웃박스(Transactional Outbox) 패턴은 이를 해결합니다. 이벤트를 직접 Kafka에 발행하는 대신, 동일 DB 트랜잭션 내 outbox_events 테이블에 기록하고, 별도의 릴레이 프로세스(Debezium 등 CDC 도구)가 이 테이블을 감시해 Kafka에 발행합니다. DB와 이벤트 발행이 같은 트랜잭션으로 묶이므로 유실과 중복 발행을 모두 방지합니다.
DB와 아웃박스 테이블이 단일 트랜잭션으로 묶이므로, 이벤트 유실이 원천적으로 차단됩니다.
일관성 모델과 트레이드오프
최종 일관성 수용하기
ECST의 근본적인 트레이드오프는 **최종 일관성(eventual consistency)**입니다. 생산자 서비스의 상태가 변경된 후, 구독자가 그 이벤트를 소비해 로컬 프로젝션에 반영하기까지 시간이 걸립니다. 이 시간 동안 구독자는 이전 상태를 보게 됩니다. 정상 운영 중 이 래그는 수십~수백 밀리초 수준이지만, 컨슈머 그룹이 느리거나 브로커에 장애가 있을 때는 수 분까지 늘어날 수 있습니다.
이 래그가 얼마나 허용 가능한지는 비즈니스 도메인마다 다릅니다. 채팅 메시지 순서처럼 밀리초 수준의 강한 일관성이 필요한 경우에는 ECST가 맞지 않습니다. 반면 상품 정보, 사용자 프로필, 배송지 주소처럼 수 초~수 분의 래그가 허용되는 경우에는 ECST가 훌륭한 선택입니다. 실제로 많은 전자상거래 시스템에서 상품 가격 정보는 수 분의 전파 지연이 비즈니스적으로 수용됩니다.
래그가 허용되지 않는 요구사항에 ECST를 억지로 적용하면, 강한 일관성이 필요한 로직에서 여전히 동기 호출이 남게 되어 패턴의 이점이 희석됩니다.
최종 일관성을 수용할 때 자주 마주치는 시나리오는 "방금 만든 것을 즉시 조회하지 못하는" 문제입니다. 예를 들어, 신규 상품을 등록하고 즉시 주문을 시도하면, 주문 서비스의 로컬 프로젝션에 아직 상품이 없을 수 있습니다. 이런 경우 UI 설계와 협력해 이벤트 전파 후 화면을 노출하거나, 쓰기 후 읽기(Read-Your-Writes) 패턴을 제한적으로 적용하는 방법을 검토할 수 있습니다.
스키마 진화와 하위 호환성
ECST의 또 다른 중요한 트레이드오프는 스키마 관리입니다. 생산자가 이벤트 스키마에 새 필드를 추가하거나 기존 필드를 변경할 때, 이미 그 이벤트를 소비하고 있는 여러 구독자들이 영향을 받습니다. 동기 API에서는 하나의 엔드포인트 변경이 하나의 클라이언트와의 계약을 바꾸지만, ECST에서는 하나의 토픽 스키마 변경이 모든 구독자에게 영향을 미칩니다. 구독자가 10개라면 10개 팀 모두와 마이그레이션 일정을 조율해야 합니다.
Avro나 Protobuf 같은 스키마 레지스트리 연동 포맷에서는 **하위 호환 변경(backward-compatible change)**만 허용하는 정책을 강제할 수 있습니다. 새 필드 추가는 기본값과 함께, 기존 필드 제거는 지양하거나 deprecated 표시 후 충분한 마이그레이션 기간을 두는 방식이 일반적입니다.
| 변경 유형 | 하위 호환성 | 권장 접근법 |
|---|---|---|
| 선택적 필드 추가 (기본값 있음) | 호환 | 허용 |
| 필수 필드 추가 | 비호환 | 새 토픽 버전으로 분리 |
| 기존 필드 타입 변경 | 비호환 | 새 필드 추가 후 점진적 전환 |
| 기존 필드 제거 | 조건부 호환 | deprecated 후 구독자 마이그레이션 완료 시 제거 |
| 열거형 값 추가 | 조건부 호환 | 구독자 UNKNOWN 처리 코드 필수 |
중복 이벤트와 멱등 처리
Kafka의 at-least-once 전달 보장은 같은 이벤트가 두 번 이상 소비될 수 있음을 의미합니다. 컨슈머가 이벤트를 처리하고 오프셋을 커밋하기 전에 크래시가 발생하면, 재시작 시 동일 이벤트가 다시 처리됩니다. 따라서 이벤트 처리 로직은 반드시 **멱등(idempotent)**해야 합니다.
버전 필드를 활용한 낙관적 잠금이 가장 단순한 방법입니다. 처리 중인 이벤트의 버전이 현재 로컬 상태의 버전보다 클 때만 업데이트를 적용하면, 동일 이벤트 재처리 시 결과가 달라지지 않습니다. Exactly-once 처리가 정말 필요하다면 eventId를 별도 테이블에 기록해 처리 여부를 추적하는 방식도 있지만, 대부분의 상황에서 버전 비교로 충분합니다.
버전 비교로 오래된 이벤트와 중복 이벤트를 무시하고, 오프셋은 항상 커밋해 컨슈머가 멈추지 않도록 합니다.
운영 환경 적용 시 고려사항
스냅샷과 신규 구독자 합류
ECST에서 새 서비스가 기존 토픽을 구독하기 시작할 때, 해당 토픽의 처음부터 모든 이벤트를 재생해 현재 상태를 구성해야 합니다. 서비스가 오래됐고 이벤트 히스토리가 방대하다면 이 초기 재생에 수 시간이 걸릴 수 있습니다. 수백만 건의 이벤트를 순차 처리하는 동안 서비스는 트래픽을 받지 못하거나, 오래된 상태로 서비스를 시작해야 하는 딜레마에 빠집니다.
이를 해결하는 방법은 스냅샷(snapshot) 전략입니다. 생산자 서비스는 주기적으로 전체 현재 상태를 스냅샷 토픽이나 별도 오브젝트 스토리지에 기록합니다. 신규 구독자는 최신 스냅샷을 먼저 적재한 후, 스냅샷 이후 시점의 증분 이벤트만 재생해 현재 상태를 맞춥니다. 스냅샷이 없으면 처음부터 수백만 건의 이벤트를 재생해야 하는 반면, 스냅샷과 증분 재생을 조합하면 초기 합류 시간을 수 분 이내로 줄일 수 있습니다.
스냅샷과 증분 이벤트 재생을 조합해 초기 합류 비용을 수용 가능한 수준으로 줄입니다.
모니터링과 컨슈머 래그
ECST를 운영하면서 가장 중요하게 관찰해야 할 지표는 **컨슈머 래그(consumer lag)**입니다. 래그가 높다는 것은 구독자의 로컬 프로젝션이 발행자의 현재 상태와 멀리 떨어져 있다는 의미입니다. 정상 운영 중에는 수 초 이내여야 하며, 장애나 처리 지연으로 수 분~수십 분까지 늘어나면 비즈니스 로직에서 오래된 데이터를 읽게 됩니다.
컨슈머 래그를 단순히 숫자(메시지 수)로만 보면 오해가 생깁니다. 평소에 처리량이 낮은 토픽에서 래그 100은 큰 문제일 수 있고, 처리량이 높은 토픽에서 래그 10,000이 2초짜리 지연에 불과할 수도 있습니다. 따라서 래그의 **시간 환산값(lag-in-time)**을 함께 추적하는 것이 중요합니다. Kafka의 __consumer_offsets 토픽과 메시지 타임스탬프를 조합하면 현재 처리 중인 메시지가 몇 초 전에 발행된 것인지 계산할 수 있습니다.
| 지표 | 의미 | 권장 알람 임계값 |
|---|---|---|
| consumer-group-lag | 파티션별 미처리 메시지 수 | 비즈니스 허용 래그 초과 시 |
| consumer-lag-time | 최신 이벤트와 현재 처리 메시지의 시간 차 | 60초 이상 지속 |
| event-processing-error-rate | 처리 실패 이벤트 비율 | 0.1% 이상 |
| projection-last-updated-age | 로컬 프로젝션 마지막 업데이트 후 경과 시간 | 도메인별 SLA 기준 |
비즈니스 크리티컬한 프로젝션에는 projection-last-updated-age 알람을 설정해, 특정 시간 이상 업데이트되지 않은 레코드가 발생하면 즉시 알림을 받도록 하는 것이 좋습니다. Grafana와 Prometheus, 혹은 Datadog에서 Kafka 컨슈머 그룹 래그를 대시보드로 구성하는 것이 일반적입니다.
점진적 이관 전략
기존 동기 호출 코드를 ECST로 전환할 때는 빅뱅 방식보다 점진적 이관이 안전합니다. 전환 과정에서 잠시 두 경로를 함께 유지하는 스트랭글러 피그(strangler fig) 패턴을 적용할 수 있습니다. 각 단계마다 롤백 경로를 열어 두고, 다음 단계로 넘어가기 전 충분히 안정성을 검증합니다.
1단계에서 생산자 서비스는 기존 API 응답과 동시에 이벤트를 발행하기 시작합니다. 이 시점에 이벤트 발행이 실패해도 기존 API는 정상 동작합니다. 2단계에서 구독자 서비스가 이벤트를 소비해 로컬 프로젝션을 구성하기 시작합니다. 단, 아직 실제 비즈니스 로직에는 사용하지 않고, 프로젝션 데이터와 기존 API 응답을 비교해 일관성을 검증합니다. 3단계에서 로컬 프로젝션의 데이터가 충분히 안정적임이 확인되면, 비즈니스 로직이 동기 API 대신 로컬 프로젝션을 읽도록 전환합니다. 4단계에서 충분한 모니터링 기간 이후 기존 동기 API 호출 코드를 완전히 제거합니다.
각 단계마다 롤백 경로가 열려 있어, 문제 발생 시 이전 단계로 되돌릴 수 있습니다.
맺음말
핵심 요약
Event-Carried State Transfer 패턴은 마이크로서비스 간 동기 호출을 이벤트 기반 상태 복제로 대체해 런타임 결합을 제거합니다. 핵심 원리는 세 가지입니다. 첫째, 이벤트에 상태를 충분히 담아 수신자가 추가 호출 없이 처리할 수 있게 합니다. 둘째, 각 서비스는 필요한 데이터를 로컬 프로젝션으로 유지하며, 이 프로젝션은 발행자 스키마의 복사본이 아니라 서비스 맥락에 맞게 재단된 뷰입니다. 셋째, 최종 일관성을 수용하되, 비즈니스 요구사항에 맞는 래그 허용 범위를 명확히 정의합니다.
구현에서는 트랜잭셔널 아웃박스 패턴으로 이벤트 유실을 방지하고, 버전 필드로 멱등 처리를 보장하며, 스냅샷 전략으로 신규 구독자의 초기 합류 비용을 줄입니다. 운영에서는 컨슈머 래그 시간 환산값을 핵심 지표로 관찰하고, 점진적 이관으로 전환 위험을 최소화합니다.
적용 판단 기준
ECST는 다음 조건이 충족될 때 강력한 선택지가 됩니다. 서비스 간 동기 호출로 인한 장애 전파나 가용성 손실을 경험하고 있을 때, 조회 데이터의 최신성이 수 초~수 분 수준의 지연을 허용할 때, Kafka 등 이벤트 브로커를 이미 운영하거나 도입 계획이 있을 때 특히 효과적입니다.
반면 수십 밀리초 이내의 강한 일관성이 요구되는 도메인, 상태 변경 빈도가 극히 낮아 프로젝션 유지 비용이 불필요한 경우, 또는 팀이 메시지 브로커 운영 경험이 전혀 없는 상황에서는 ECST의 이점보다 운영 복잡도가 앞설 수 있습니다. 모든 동기 호출을 일괄 제거하는 것이 목표가 아니라, 가용성과 성능에 실질적인 문제를 일으키는 호출부터 선별해 적용하는 것이 현실적인 접근입니다.