2026년 이벤트 기반 아키텍처: 마이크로서비스가 이벤트 스트리밍으로 전환하는 이유

작년 이맘때쯤 우리 팀은 결제 서비스와 주문 서비스 사이에서 발생하는 데이터 불일치 문제로 꽤 고생했다. REST API로 두 서비스를 연결했는데, 네트워크 장애가 발생할 때마다 “결제는 됐는데 주문이 안 생성됐어요”라는 고객 문의가 쏟아졌다. 당시에는 재시도 로직을 강화하고, 보상 트랜잭션을 구현하고, 수많은 임시방편을 덧붙였다. 그러다 어느 순간 깨달았다. 우리는 동기식 통신으로는 절대 풀 수 없는 문제를 동기식 방법으로 풀려 하고 있었다.

그게 우리 팀이 이벤트 기반 아키텍처로 본격 전환한 계기였다.


동기식 마이크로서비스의 근본적인 문제

마이크로서비스를 처음 도입할 때 대부분의 팀이 기존 모놀리식 사고방식을 그대로 가져온다. 서비스만 분리했을 뿐, 통신 방식은 여전히 “A가 B를 호출한다”는 구조다. HTTP REST, gRPC 모두 본질적으로 같은 문제를 안고 있다—호출하는 순간 두 서비스가 결합된다.

내가 겪은 가장 고통스러운 순간은 하나의 사용자 요청이 7개 서비스를 순차 호출하는 흐름이었다. 각 서비스의 가용성이 99.9%라고 해도, 7개를 직렬로 연결하면 전체 가용성은 99.3%로 떨어진다. 이론적으로는 그렇다. 실제로는 더 나빴다.

문제는 단순히 가용성만이 아니다. 동기 호출 체인에서는 세 가지가 동시에 망가진다.

  • 시간적 결합: 호출받는 서비스가 반드시 그 순간에 살아있어야 한다
  • 강한 의존성: 응답 스키마가 바뀌면 호출하는 쪽도 함께 바꿔야 한다
  • 확장 어려움: 병목 서비스 하나가 전체 처리량을 제한한다

이벤트 스트리밍은 이 세 가지를 한꺼번에 건드린다. 완전히 해결한다고는 못 하겠지만—새로운 문제가 생기니까—적어도 동기 호출이 만드는 결합 구조에서는 벗어날 수 있다.


이벤트 스트리밍이 다른 이유

이벤트 기반 아키텍처의 핵심은 간단하다. 서비스가 “무언가 일어났다”는 사실을 이벤트로 발행하고, 그 이벤트에 관심 있는 서비스가 구독해서 반응한다. 생산자는 소비자가 누구인지 모른다. 소비자는 생산자가 언제 이벤트를 발행했는지 신경 쓰지 않는다.

앞서 말한 결제-주문 문제로 돌아가 보자. 이벤트 기반으로 바꾸면 흐름이 이렇게 된다.

# 결제 서비스 - 이벤트 발행
from confluent_kafka import Producer
import json

def process_payment(payment_data: dict) -> dict:
    # 결제 처리 로직
    result = payment_gateway.charge(payment_data)

    if result.success:
        event = {
            "event_type": "payment.completed",
            "payment_id": result.payment_id,
            "user_id": payment_data["user_id"],
            "amount": payment_data["amount"],
            "timestamp": result.completed_at.isoformat(),
            "correlation_id": payment_data["correlation_id"]
        }

        producer.produce(
            topic="payment-events",
            key=str(payment_data["user_id"]),
            value=json.dumps(event),
            callback=delivery_report
        )
        producer.flush()

    return result

# 주문 서비스 - 이벤트 구독
from confluent_kafka import Consumer

consumer = Consumer({
    "bootstrap.servers": "kafka-cluster:9092",
    "group.id": "order-service",
    "auto.offset.reset": "earliest",
    # 중요: enable.auto.commit을 False로 설정
    # 처리 완료 후 수동으로 커밋해야 at-least-once 보장
    "enable.auto.commit": False
})

consumer.subscribe(["payment-events"])

while True:
    msg = consumer.poll(timeout=1.0)
    if msg is None:
        continue

    event = json.loads(msg.value())

    if event["event_type"] == "payment.completed":
        try:
            create_order(event)
            consumer.commit()  # 처리 성공 후에만 커밋
        except Exception as e:
            # 실패 시 커밋하지 않음 -> 재처리 보장
            logger.error(f"주문 생성 실패: {e}")
            # Dead Letter Queue로 보내거나 알림 발송

주문 서비스는 결제 서비스가 살아있는지 신경 쓰지 않는다. Kafka가 메시지를 보관하고 있으니, 주문 서비스가 잠깐 다운됐다 올라와도 밀린 이벤트를 처리하면 된다. “결제는 됐는데 주문이 안 생성됐어요” 문의가 구조적으로 사라진다.


내가 실제로 저지른 실수들

이벤트 기반 아키텍처가 좋다는 건 알겠는데, 막상 도입하면 새로운 종류의 문제들이 생긴다. 내가 직접 겪은 실수를 솔직하게 정리했다.

첫 번째 실수: 이벤트 스키마를 무시했다.

처음에는 이벤트를 그냥 딕셔너리로 발행했다. 필드 하나 추가할 때마다 소비자 코드도 같이 바꾸는 게 귀찮아서 느슨하게 운영했다. 세 달 후, 어떤 이벤트에 어떤 필드가 있는지 아무도 몰랐다. 서비스가 10개로 늘어나자 스키마 불일치로 인한 장애가 월 2-3회씩 발생했다.

해결책은 Confluent Schema Registry와 Avro를 도입하는 것이었다. 스키마를 강제하니 호환성 문제가 배포 전에 잡혔다. 귀찮더라도 스키마 레지스트리는 처음부터 도입하길 강력히 권한다.

두 번째 실수: 이벤트 순서를 당연하게 생각했다.

Kafka는 파티션 내에서만 순서를 보장한다. 같은 사용자의 이벤트를 여러 파티션에 분산시켰더니, account.created 이벤트보다 account.updated 이벤트가 먼저 도착하는 상황이 생겼다. 소비자가 업데이트할 계정을 찾지 못해 에러가 발생했다—이걸 프로덕션에서 발견했다는 게 지금 생각해도 아찔하다.

같은 엔티티의 이벤트는 반드시 같은 파티션으로 보내야 한다. 위 코드에서 key=str(payment_data["user_id"])로 설정한 이유가 바로 이것이다. 같은 user_id는 항상 같은 파티션으로 간다.

세 번째 실수: 멱등성을 고려하지 않았다.

at-least-once 전달 방식에서 이벤트는 중복 도착할 수 있다. 소비자가 이벤트를 처리하고 커밋하기 직전에 죽으면, 재시작 후 같은 이벤트를 다시 받는다. 처음에는 이 사실을 몰라서 결제가 두 번 처리되는 심각한 버그가 발생했다. 고객 입장에서는 같은 금액이 두 번 빠져나간 것이고—당연히 난리가 났다.

소비자 로직은 반드시 멱등하게 설계해야 한다. 같은 event_id로 처리 여부를 체크하거나, 데이터베이스 유니크 제약을 활용하는 방식으로 중복 처리를 막아야 한다.


이벤트 소싱과 CQRS: 더 깊이 들어가면

이벤트 스트리밍을 도입하다 보면 자연스럽게 이벤트 소싱(Event Sourcing)과 CQRS(Command Query Responsibility Segregation) 패턴을 만나게 된다. 처음엔 과하다고 생각했다. 지금은 적합한 도메인에서는 이 패턴이 정말 강력하다고 느낀다.

이벤트 소싱의 핵심 아이디어는 현재 상태 대신 상태 변화 이력을 저장하는 것이다. 계좌 잔액을 balance: 50000으로 저장하는 대신, 입금 100000, 출금 30000, 출금 20000이라는 이벤트를 저장한다. 현재 잔액은 이벤트를 순서대로 재생해서 계산한다.

이점은 상당하다. 언제, 어떻게 현재 상태가 됐는지 완벽한 감사 로그가 생긴다. 특정 시점으로 상태를 되돌리는 게 쉬워진다. 비즈니스 이벤트가 명시적으로 코드에 표현되어 도메인 전문가와 소통하기도 좋아진다.

단점도 분명하다. 이벤트가 쌓일수록 현재 상태 계산이 느려진다. 이를 해결하기 위해 스냅샷을 주기적으로 저장하는데, 이 자체가 또 복잡도를 높인다. 팀 전체가 이벤트 소싱의 개념을 이해해야 코드를 제대로 짤 수 있다. 학습 곡선이 가파르다.

내 경험상 이벤트 소싱이 빛을 발하는 영역은 금융 거래, 주문 이력, 감사 로그가 중요한 도메인이다. 단순한 CRUD 서비스에 억지로 적용하면 오히려 복잡도만 늘어난다.


2026년에 이벤트 스트리밍을 어떻게 접근해야 하나

솔직히 말하면, 이벤트 기반 아키텍처가 모든 문제의 답은 아니다. 내가 이 글을 쓰는 이유는 “다들 이걸 써야 한다”가 아니라, 언제 써야 하는지를 명확히 하고 싶어서다.

이벤트 스트리밍이 진짜 효과적인 상황이 있다.

서비스 간 결합을 끊고 싶을 때. 특히 도메인이 명확히 분리돼 있고, 이벤트 발행자가 소비자를 알 필요가 없을 때. 배달 플랫폼에서 주문이 생성되면 알림 서비스, 배달기사 배정 서비스, 정산 서비스가 각자 알아서 반응하는 구조가 좋은 예다.

처리량이 폭발적으로 증가하는 시나리오에서도 효과적이다. Kafka는 파티션을 늘리는 것만으로 처리량을 선형으로 확장할 수 있다. REST API 기반 동기 호출로는 이런 확장이 훨씬 어렵다.

반대로, 강한 일관성이 즉각적으로 필요한 경우라면 피해야 한다. “결제 후 즉시 재고 차감이 반영된 화면을 보여줘야 한다”면 이벤트 기반의 최종 일관성(eventual consistency)은 UX 문제를 낳는다. 이 경우 분산 트랜잭션을 피하기 위한 도메인 모델 재설계나 SAGA 패턴을 신중하게 검토해야 한다.

팀 역량도 현실적으로 고려해야 한다. Kafka 클러스터 운영은 생각보다 많은 인프라 지식을 요구한다. 파티션 리밸런싱, 컨슈머 그룹 관리, 오프셋 모니터링, 레플리케이션 설정… 클라우드 매니지드 서비스(AWS MSK, Confluent Cloud)를 쓰면 부담이 많이 줄지만, 여전히 기본 개념은 팀이 숙지해야 한다.

최근에 주목받는 흐름 하나는 Kafka 대신 Pulsar나 Redpanda를 선택하는 팀이 늘고 있다는 점이다. Redpanda는 JVM 없이 C++로 작성되어 운영 복잡도가 낮고 레이턴시가 뛰어나다. 우리 팀도 신규 프로젝트에서 Redpanda를 평가 중이다. Kafka API 호환이 되니 기존 클라이언트 코드를 그대로 쓸 수 있어 마이그레이션 부담도 적다.


마치며

이 전환을 거치면서 단순히 기술 스택이 바뀐 게 아니었다. 서비스 간 관계를 바라보는 관점이 달라졌다. “A 서비스가 B 서비스를 호출한다”는 명령형 사고에서 “A 서비스에서 일어난 일을 B 서비스가 감지한다”는 반응형 사고로.

쉽지는 않았다. 팀원 설득, 스키마 관리 체계 구축, 멱등성 설계, 로컬 개발 환경 구성—중간에 그냥 REST로 돌아갈까 싶은 순간이 한두 번이 아니었다. 특히 로컬에서 Kafka 띄우고 개발하는 경험은 처음엔 꽤 고통스러웠다(지금은 docker-compose로 정착했다).

그래도 지금은 잘 됐다고 생각한다. 서비스 배포하기” rel=”nofollow sponsored” target=”_blank”>배포 독립성이 높아졌고, 장애 전파가 줄었고, 새로운 소비자를 추가하는 데 기존 코드를 건드릴 필요가 없어졌다.

처음 시작한다면 작은 도메인에서 먼저 시도해보길 권한다. 알림 발송처럼 실패해도 크리티컬하지 않은 부분부터. 거기서 팀이 개념을 익히고, 운영 패턴을 만들고 나서 점진적으로 확장하는 게 현명하다. 우리도 그렇게 시작했다—처음부터 핵심 결제 흐름을 건드렸다면 아마 훨씬 힘들었을 것이다.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top