본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 05. 27. 07:43

이벤트를 유용하게 활용하려다 망가뜨린 시스템

요약

이벤트 기록 시스템 구축 과정에서 겪은 CDC, Kafka, PostgreSQL 기반 파이프라인의 실패 사례를 다룹니다. 데이터 순서 역전, 스키마 드리프트, 과도한 메시지 크기 및 파티셔닝 문제 등 프로덕션 규모에서 발생하는 기술적 난관을 분석합니다.

핵심 포인트

  • 단순한 벤더 템플릿 설정이 프로덕션 규모에서 성능 병목을 유발할 수 있음
  • 전체 상태를 포함하는 메시지 직렬화는 급격한 백로그 증가의 원인이 됨
  • 스키마 변경 시 파괴적 변경과 무해한 추가를 구분하는 전략이 필수적임
  • 커스텀 데몬 도입 시 기본 키 순환(wrap around) 등 예외 케이스 고려 필요

우리가 실제로 해결하려 했던 문제

우리의 초기 목표는 사소했습니다. 모든 버튼 클릭, API 호출, 백그라운드 작업을 다운스트림 (downstream) 팀이 분석을 위해 다시 재생(replay)할 수 있는 방식으로 기록하는 것이었습니다. CTO는 엔지니어가 아닌 사람들도 SQL 테이블처럼 이벤트를 쿼리(query)할 수 있을 만큼 단순하게 만들기를 원했습니다. 우리는 CDC (Change Data Capture)를 위해 Debezium을, 전송을 위해 Kafka를, 저장을 위해 PostgreSQL을 선택했는데, 이는 벤더가 제공한 CloudFormation 템플릿의 기본 설정이었기 때문입니다. 프로덕션 (production) 규모에 도달하는 순간, 파이프라인 (pipeline)은 우리가 약속했던 것과 정반대가 되었습니다. 이벤트는 순서가 뒤바뀐 채 도착했고, 새로운 클라이언트 빌드로 인한 스키마 드리프트 (schema drift)가 다운스트림 컨슈머 (consumer)를 망가뜨렸으며, UI 토픽 (topic)의 단일 파티션 (partition)은 브로커 (broker)가 리밸런싱 (rebalancing)될 때마다 50ms의 쓰기 작업을 30초의 지연으로 바꿔놓았습니다. 진짜 문제는 이벤트를 캡처하는 것이 아니었습니다. 마케팅 팀에서 지난 분기의 퍼널 (funnel) 지표를 요청했을 때, 3개월 후에도 해당 이벤트를 여전히 검색할 수 있도록 보장하는 것이 문제였습니다.

우리가 처음 시도했던 것 (그리고 실패한 이유)

백로그 (backlog)를 해결하기 위해 우리가 처음 시도한 방법은 돈을 쏟아붓는 것이었습니다. 클러스터 (cluster)를 각각 50개의 파티션을 가진 20개의 브로커로 업그레이드하고, 계층형 스토리지 (tiered storage) 플러그인을 추가했으며, 보관 주기 (retention)를 30일로 설정했습니다. 백로그는 줄어들었지만, 컨슈머 랙 (consumer lag) 그래프는 플래시 크래시 (flash crash)가 발생한 주식 차트처럼 보였습니다. 문제는 용량이 아니었습니다. 모든 아웃박스 (outbox) 쓰기 작업이 다운스트림 컨슈머가 신경 쓰지 않는 컬럼 (column)을 포함하여 행 (row)의 전체 상태를 포함하는 Kafka 메시지를 생성한다는 점이 문제였습니다. 단 하나의 Nullable 필드에 변경이 생기면, 프로듀서 (producer)는 전체 블롭 (blob)을 직렬화 (serialize)하여 이벤트당 1.2 KB를 밀어 넣었고, 백로그는 우리가 확장하는 속도보다 더 빠르게 증가했습니다. 설상가상으로 스키마 레지스트리 (schema registry)는 스키마 멱등성 (schema-idempotence)을 가정했습니다. 모바일 팀이 선택적 필드 (optional field)를 도입했을 때, 컨슈머는 이전 스키마를 기대하고 브로커는 파괴적 변경 (breaking change)과 무해한 추가를 구분할 수 없었기 때문에 전체 파이프라인이 빨간불이 들어오며 멈춰버렸습니다.

우리는 Debezium을 Rust로 작성된 커스텀 CDC (Change-Data-Capture) 데몬으로 교체하려고 시도했습니다. 이는 순진한 실수였습니다. 새로운 데몬은 초당 5만 개의 이벤트(50k events/sec)를 처리하며 실행되었지만, 새로운 장애 모드(failure mode)를 유발했습니다. 이 데몬은 기본 키(primary key)가 단조 증가(monotonic)한다고 가정하고, 삭제(delete)에 대해 툼스톤 이벤트(tombstone events)를 방출했습니다. 우리의 32비트 정수형 사용자 ID에서 기본 키가 한 바퀴 돌자(wrapped around), 파티셔너(partitioner)가 모든 삭제 이벤트를 동일한 브로커(broker)로 보냈고, 해당 브로커의 로그 세그먼트(log segment)가 폭발적으로 증가했으며, 두 시간 만에 디스크 가득 참 경고(disk fill alerts)가 다시 발생했습니다.

아키텍처 결정 (The Architecture Decision)

우리는 마침내 기본 파이프라인을 복구할 수 없음을 인정하고, 단 하나의 원칙을 중심으로 이벤트 스파인(event spine)을 재설계하는 데 2주를 보냈습니다: 필요하지 않은 데이터는 절대 보내지 말고, 수신한 순서를 절대 신뢰하지 마라.

새로운 스파인은 세 가지 명시적인 계층을 가집니다:

  1. 이벤트 소싱 계층 (Event sourcing layer): 서비스들은 애그리거트 ID(aggregate ID), 이벤트 유형(event type), 버전(version), 타임스탬프(timestamp)만을 포함하는 엄격한 엔벨로프 스키마(envelope schema)를 사용하여 팬인 토픽(fan-in topic)에 이벤트를 발행합니다. 우리는 전체 로우 블롭(row blob)을 제거했습니다. 이제는 델타(delta, 차이값)만이 중요합니다. 엔벨로프 크기는 1.2 KB에서 128 바이트로 줄어들었으며, 덕분에 동일한 3개 브로커 클러스터(Broker cluster)가 백프레셔(backpressure)가 발생하기 전까지 초당 300만 개의 이벤트(3 M events/sec)를 처리할 수 있습니다.

[IMG:1]

  1. 명시적 순서가 보장된 재생 가능 로그 (Replayable log with explicit ordering): 우리는 애그리거트 유형(aggregate type)당 단일 파티션(single partition)을 사용하도록 전환하고, 파티션 키(partition key)를 애그리거트 ID로 강제했으며, 컨슈머(consumer)를 위한 마이크로 배치(micro-batching)라는 개념을 도입했습니다. 컨슈머가 토픽 오프셋(topic offsets)을 폴링(poll)하게 두는 대신, 우리는 Rewind라는 작은 Go 서비스를 구축하여 PostgreSQL에 읽기 최적화된 뷰(read-optimized view)를 구현했습니다. 이 뷰는 이벤트당 하나의 로우(row), 안정적인 시퀀스 번호(sequence number), 그리고 변경된 필드만을 담은 JSONB 페이로드를 가집니다. 이 뷰는 원시 Kafka 토픽을 소비하고, 압축된 상태 머신(compacted state machine)을 적용하며, 마케팅 팀이 일반 SQL 클라이언트로 쿼리할 수 있도록 HTTP 엔드포인트를 노출하는 스트리밍 작업(streaming job)에 의해 매분 재구축됩니다. 재생 지연 시간(replay latency)은 최악의 경우 60초이지만, 쿼리 지연 시간(query latency)은 평균 30ms입니다. 이는 기존의 기본 파이프라인에서는 결코 달성할 수 없었던 수치입니다.

트레이드오프(tradeoff)는 단순성 대 정확성이었습니다. 새로운 스파인(spine)은 더 많은 구성 요소(moving parts)를 포함합니다: 추가적인 레지스트리 서비스(registry service), 리플레이 레이어(replay layer), 그리고 전용 스키마 검증기(schema validator)가 그것입니다. 우리는 단일 YAML 파일이라는 환상을 잃었지만, 이벤트가 실제로 발견 가능하고(discoverable), 리플레이 가능하며(replayable), 안전하게 진화할 수 있는(safe to evolve) 시스템을 얻었습니다.

이후의 수치들이 말해주는 것

새로운 스파인이 가동된 지 12시간 이내에 백로그(backlog)는 0에 도달했으며, 이후 발생하는 모든 트래픽 급증(traffic spike) 상황에서도 그 상태를 유지했습니다. 팬인(fan-in) 토픽의 처리량(throughput)은 동일한 3-브로커(3-broker) 클러스터에서 초당 20만(200k) 이벤트에서 초당 320만(3.2M) 이벤트로 급증했습니다. 엔벨로프(envelope) 크기 감소는 모든 행(row)의 모든 리비전(revision)을 더 이상 저장하지 않게 됨으로써 클라우드 스토리지 비용을 40% 절감했습니다.

과거에는 30초 동안 타임아웃(timeout)이 발생하던 다운스트림(downstream) 분석 쿼리들이 이제는 30밀리초(ms) 내에 반환되는데, 이는 리와인드 뷰(Rewind view)가 상태 머신(state machine)을 사전 계산(pre-computes)하기 때문입니다. 지난주 모바일 팀이 선택적 필드(optional field)가 포함된 새로운 기능을 출시했을 때, 레지스트리(registry)는 그들이 새로운 시맨틱 버전(semantic version)을 발행할 때까지 해당 변경 사항을 거부했습니다. 그 결과 어떤 컨슈머(consumer)도 깨지지 않았고, 어떤 알람(alert)도 발생하지 않았습니다.

스키마 드리프트(schema drift) 사고는 4개월 만에 주 단위에서 0건으로 감소했습니다. 유일하게 남은 실패 모드(failure mode)는 서비스가 미래의 타임스탬프(timestamp)를 가진 이벤트를 발행하는 경우입니다. 우리의 새로운 엔벨로프에는 타임스탬프가 브로커(broker) 시계의 5초 이내여야 한다는 검증 규칙이 포함되어 있습니다. 브로커가 메시지를 즉시 거부하므로, 잘못된 시계로 인해 데이터를 잃는 일은 결코 발생하지 않습니다.

내가 다르게 했을 것이라면

퀵 데모(quick demo)가 아무리 매력적으로 보이더라도, 다시는 기본 이벤트 파이프라인(default event pipeline)으로 시작하지 않을 것입니다. 누군가 그저 Debezium을 Kafka에 연결하고 끝내자고 말하는 순간, 저는 이제 한 가지 질문을 던질 것입니다: "다음 분기에 스키마가 변경될 때 당신의 리플레이 시나리오(replay story)는 어떤 모습입니까?" 만약

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0