금요일 오후 3시, 배포를 눌렀다. 주문 서비스 하나를 업데이트했을 뿐인데 재고 서비스가 응답을 멈췄고, 그게 결제 서비스로 번졌고, 결국 알림 서비스까지 타임아웃이 걸렸다. 7개 마이크로서비스 중 4개가 연쇄 장애. 사용자들한테는 그냥 흰 화면.
그 순간 깨달았다. 우리가 2년 넘게 만들어온 건 마이크로서비스 아키텍처가 아니었다. 그냥 분산 모놀리스였다.
REST 체인이 6단계를 넘어서면 생기는 일
우리 팀은 6명짜리 스타트업이었고, 2023년부터 마이크로서비스로 전환했다. 교과서대로 했다. 서비스마다 독립 배포, 각자 DB, API 게이트웨이. 처음 1년은 잘 됐다.
문제는 서비스 수가 늘면서 시작됐다. 사용자가 주문을 누르면 이런 일이 벌어졌다:
- API Gateway → Order Service (주문 생성)
- Order Service → Inventory Service (재고 확인 및 차감)
- Order Service → Payment Service (결제 처리)
- Payment Service → Notification Service (결제 완료 알림)
- Order Service → Analytics Service (이벤트 기록)
- Notification Service → User Service (이메일 주소 조회)
동기식 REST 호출 6단계. 각 서비스의 p99 레이턴시가 100ms라고 가정해도 이론상 600ms인데, 실제로는 네트워크 오버헤드 때문에 훨씬 길었다. 그리고 중간에 하나라도 죽으면 전체가 죽었다.
Circuit breaker를 붙이고, retry 로직도 추가했다. 하지만 근본적인 문제는 해결이 안 됐다. 서비스 A가 서비스 B의 현재 상태를 알아야만 작동하는 구조 자체가 문제였으니까.
나는 그때 이벤트 스트리밍을 그냥 “비동기 HTTP”쯤으로 생각했다. 직접 써보기 전까지는.
명령과 사실의 차이: 처음엔 이걸 완전히 잘못 이해했다
Kafka를 처음 도입했을 때, 나는 그걸 문자 그대로 비동기 REST처럼 썼다. 서비스 A가 서비스 B를 직접 호출하는 대신 Kafka 토픽에 메시지를 발행하고 서비스 B가 구독하는 방식. 레이턴시는 줄었다. 하지만 서비스 B가 죽으면 이벤트가 쌓이기만 했고, 여전히 서비스 간 결합이 남아 있었다.
내가 근본적으로 잘못 이해하고 있었던 건 이벤트의 의미였다.
REST 호출은 명령이다. “재고를 차감해라.” 이벤트는 사실이다. “주문이 생성됐다.” 이 차이가 사소해 보이는데, 아키텍처 관점에서는 얘기가 완전히 달라진다.
명령 방식에서 Order Service는 Inventory Service의 책임을 알고 있다. 이게 결합이다. 이벤트 방식에서 Order Service는 그냥 일어난 일을 선언한다. Inventory Service가 그 이벤트를 구독해서 스스로 판단하고 재고를 차감한다. Order Service는 Inventory Service가 존재하는지조차 몰라도 된다.
# 이전 방식: 명령형 REST 호출
class OrderService:
def create_order(self, order_data):
order = self.db.save(order_data)
# Order Service가 Inventory Service의 존재와 계약을 알아야 함
response = requests.post(
"http://inventory-service/reserve",
json={"product_id": order.product_id, "qty": order.qty},
timeout=5 # 이게 죽으면? 주문은 이미 생성됐는데
)
if response.status_code != 200:
# 롤백? 재시도? 분산 트랜잭션? 답이 없다
self.db.delete(order.id)
raise Exception("재고 예약 실패")
return order
# 이벤트 방식: 사실 선언
class OrderService:
def create_order(self, order_data):
order = self.db.save(order_data)
# 이 서비스의 책임은 여기서 끝. 누가 듣는지 알 필요 없다
self.kafka_producer.send(
topic="orders",
key=str(order.id), # 파티션 키: 같은 주문은 같은 파티션으로
value={
"event_type": "ORDER_CREATED",
"order_id": order.id,
"product_id": order.product_id,
"qty": order.qty,
"timestamp": datetime.utcnow().isoformat()
}
)
return order
# Inventory Service는 독립적으로 이 이벤트를 소비
class InventoryEventHandler:
@kafka_consumer(topic="orders", group_id="inventory-service")
def handle(self, event):
if event["event_type"] == "ORDER_CREATED":
self.reserve_stock(event["product_id"], event["qty"])
이 코드로 바꾸고 나서 처음 든 느낌은 — 이상하게 허전했다. 뭔가 확인이 안 되는 것 같은 불안함. 주문 서비스에서 재고가 실제로 차감됐는지 어떻게 아는 거지?
그게 바로 이벤트 기반 아키텍처의 핵심이자, 처음엔 가장 불편한 부분이다. 최종 일관성(eventual consistency). 즉시 확인은 안 되지만, 결국엔 된다. 이 패러다임 전환을 팀 전체가 받아들이는 데 약 3주 걸렸다.
Kafka 토픽 설계에서 첫 6주 동안 계속 틀렸던 것
이벤트 스트리밍 도입에서 제일 어려운 건 설치가 아니다. 토픽 설계다.
처음에 나는 서비스별로 토픽을 만들었다. order-service-events, inventory-service-events, payment-service-events. 직관적으로 보였다. 6주 후에 이게 왜 나쁜 설계인지 알게 됐다.
컨슈머가 여러 서비스의 이벤트를 읽고 필터링해야 했다. “payment-service-events에서 결제 성공 이벤트만 가져와야 하는데, 결제 실패, 환불 요청, 정산 완료 이벤트가 전부 섞여 있어.” 불필요한 처리가 늘어났고, 컨슈머 로직이 지저분해졌다.
두 번째 시도는 이벤트 타입별 토픽이었다. order-created, payment-completed, inventory-reserved. 훨씬 나았다. 하지만 이것도 한계가 있었다 — 토픽 수가 폭발적으로 늘었고, 관리가 복잡해졌다.
지금까지 유지하고 있는 방식은 도메인 + 집계 루트(aggregate root) 기반 설계다.
orders → 주문 도메인의 모든 이벤트 (ORDER_CREATED, ORDER_CANCELLED, ORDER_SHIPPED)
payments → 결제 도메인 (PAYMENT_COMPLETED, PAYMENT_FAILED, REFUND_REQUESTED)
inventory → 재고 도메인 (STOCK_RESERVED, STOCK_RELEASED, STOCK_UPDATED)
각 토픽 안에서 event_type 필드로 구분한다. 토픽 수는 적고, 도메인 경계는 명확하다. 파티션 키는 집계 루트 ID — 주문 이벤트는 order_id, 결제 이벤트는 payment_id. 덕분에 같은 주문에 대한 이벤트들은 항상 같은 파티션에 순서대로 들어간다.
파티션 키를 처음에 무작위 UUID로 썼던 건 진짜 실수였다. 주문 생성 → 결제 완료 → 배송 시작 순서가 보장되어야 하는데, 다른 파티션에 흩어지면서 순서가 깨졌다. 이걸 발견한 건 QA 단계였다. 다행히.
Redpanda를 3개월 써본 결과
2025년 4분기부터 프로덕션에서 Redpanda를 써봤다. 솔직히 처음엔 회의적이었다. “Kafka 호환 API를 지원하는 또 다른 툴”이라고 생각했으니까. Kafka 코드를 그대로 쓸 수 있다는 건 좋은데, 그게 얼마나 실질적으로 다를까.
Redpanda의 가장 큰 차이는 JVM이 없다는 거다. C++로 작성됐고, ZooKeeper 의존성도 없다. 우리 3인 인프라 팀 입장에서 체감되는 차이는 두 가지였다.
운영 복잡도부터 말하면, Kafka + ZooKeeper 조합은 관리할 게 많다. ZooKeeper 앙상블 따로, Kafka 브로커 따로. Redpanda는 그냥 Redpanda 하나다. KRaft 모드로 전환된 Kafka 3.x도 ZooKeeper를 없앴지만, 설정 복잡도는 여전히 있다.
그리고 레이턴시 차이가 생각보다 컸다 — 우리 워크로드 기준으로 p99가 약 30~40% 낮게 나왔다. 정확한 수치는 워크로드마다 다르고, 이 수치에 과도한 의미를 부여할 생각은 없다.
단점을 숨기지 않겠다. Kafka Connect 에코시스템이 Redpanda에서 완전히 호환되지 않는 커넥터가 아직 있다. 우리가 쓰는 Debezium CDC 커넥터도 초기에 엣지 케이스가 몇 개 있었다 — 주로 트랜잭션 로그 파싱 쪽에서. Kafka Streams나 ksqlDB를 쓰고 싶다면 아직은 Kafka가 더 안정적이다.
6~10명 팀이 운영 부담을 줄이고 싶다면 Redpanda를 진지하게 검토해볼 만하다. 대규모 엔터프라이즈 환경이라면 Kafka가 맞다. 내 판단은 그렇다.
스키마 레지스트리 없이 이벤트 스트리밍하다가 토요일 새벽 2시에 벌어진 일
이건 직접 경험한 얘기다.
이벤트 스트리밍 도입 4개월 차에, 주문 이벤트에 shipping_address 필드를 추가했다. 하위 호환성 고려 없이. Inventory Service는 그 필드를 무시하면 됐으니 괜찮다고 생각했다. 문제는 Analytics Service였다. 그 서비스는 이벤트를 역직렬화할 때 엄격한 스키마를 쓰고 있었고, 예상치 못한 필드가 들어오자 역직렬화 실패가 났다.
토요일 새벽 2시에 알람이 울렸다. 분석 파이프라인 전체가 멈춰 있었고, 그 시간 동안 들어온 주문 데이터가 누락됐다.
그 이후로 Confluent Schema Registry를 붙였다. Avro 스키마로 이벤트를 정의하고, 스키마 변경은 반드시 하위 호환(backward-compatible) 방식으로만 가능하게 강제했다.
{
"type": "record",
"name": "OrderCreated",
"namespace": "com.mycompany.orders",
"fields": [
{"name": "order_id", "type": "string"},
{"name": "user_id", "type": "string"},
{"name": "product_id", "type": "string"},
{"name": "qty", "type": "int"},
{"name": "total_amount", "type": "double"},
{"name": "timestamp", "type": "string"},
{
"name": "shipping_address",
"type": ["null", "string"],
"default": null
}
]
}
default: null을 지정하면 기존 컨슈머들은 이 필드 없이도 정상 작동한다. 단순한 규칙이지만, 팀 전체가 이걸 습관으로 만드는 데 한 달 가까이 걸렸다.
여기서 주목할 건, 이벤트 스키마는 REST API 계약과 똑같이 관리해야 한다는 거다. 오히려 더 엄격하게. REST API는 하나의 클라이언트와 계약하지만, 이벤트는 내가 모르는 컨슈머가 몇 개인지 파악하기 어렵다. Kafka는 메시지를 기본 7일 보관하는데, 그동안 누군가 새 컨슈머를 붙였다면 내가 배포하는 순간 그 서비스가 죽는다.
이벤트 스트리밍, 모든 팀이 필요한 건 아니다
3년 가까이 REST 마이크로서비스를 운영하다가 이벤트 스트리밍으로 옮기면서 배운 것들이다. 한 방에 전환하는 건 현실적으로 불가능하고, 스트랭글러 패턴(Strangler Fig Pattern)이 제일 안전했다. 가장 pain point가 큰 서비스 간 연결부터 이벤트 방식으로 교체하고 — 우리는 주문↔결제 연결부터 시작했다 — 나머지는 그대로 두면서 점진적으로 확장했다.
이벤트 스트리밍이 항상 정답은 아니다. 단순한 CRUD 앱, 팀 2~3명, 요청-응답 패턴이 자연스러운 도메인이라면 굳이 Kafka를 붙일 이유가 없다. 오버엔지니어링이다.
나는 이렇게 판단한다. 서비스 간 동기식 호출이 3단계를 넘어가거나, 한 서비스의 장애가 다른 서비스로 전파되는 걸 막고 싶거나, 이벤트 히스토리를 재생해서 상태를 복원해야 하는 요구사항이 있다면 — 그때가 이벤트 스트리밍을 도입할 시점이다.
처음 금요일 장애 이후 6개월이 지났고, 지금은 주문 서비스 재배포가 재고 서비스에 영향을 주지 않는다. 당연한 얘기처럼 들리겠지만, 연쇄 장애를 한번 경험해보면 이게 얼마나 소중한지 안다.