본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 05. 29. 04:53

보물찾기 엔진이 실시간인 척하지만 실제로는 그렇지 않은 이유

요약

블랙 프라이데이와 같은 대규모 트래픽 상황에서 실시간 이벤트 파이프라인을 구축하며 겪은 분산 시스템의 기술적 한계를 다룹니다. Kafka, Lambda, DynamoDB를 활용한 초기 설계의 실패 원인인 동시성 제한, 장애 조치 시의 데이터 유실, 시계 왜곡 문제를 분석합니다.

핵심 포인트

  • 대규모 트래픽 급증 시 서버리스 함수의 동시성 제한 및 스로틀링 위험
  • 가용 영역(AZ) 장애 발생 시 이벤트 큐 백로그와 데이터 유실 가능성
  • 분산 시스템에서 정확히 한 번 처리(exactly-once) 구현의 어려움
  • 시계 왜곡(clock skew)이 이벤트 순서 및 처리 로직에 미치는 영향

우리가 실제로 해결하고 있었던 문제

우리는 블랙 프라이데이(Black Friday) 트래픽이 30분도 채 되지 않아 초당 2,000개에서 240,000개 이벤트로 급변할 수 있는 Fortune-500 기업 소매업체를 위한 실시간 이벤트 파이프라인 (realtime event pipeline)을 구축하고 있었습니다. 보물찾기 엔진 (Treasure Hunt Engine)은 클릭스트림 이벤트 (clickstream events)를 수집하고, Redis 기반 인접 리스트 (adjacency list)에서 가벼운 그래프 순회 (graph traversal)를 실행하며, 할인 쿠폰이 쇼퍼 앱 (shoppers app)으로 전송될 수 있도록 2초 이내에 당첨된 장바구니 (winning basket)를 방출해야 했습니다. 비즈니스 측에서는 이를 실시간 (realtime)이라고 불렀고, CFO는 이를 고객 유지 (customer retention)라고 불렀습니다.

우리가 첫날 놓쳤던 점은 보물찾기 엔진이 단순히 그래프 위의 AI (AI on a graph)가 아니라는 사실이었습니다. 그것은 수백만 개의 동시 쓰기 (concurrent writes), 모바일 재시도 (mobile retries)로 인한 일시적인 중복 이벤트 (duplicate events), 그리고 AI가 잘못된 장바구니를 보상할 때 운영자가 시작하는 지연된 수정 (late corrections)을 처리하는 분산 시스템 (distributed systems)입니다. Veltrix는 이벤트 라우터 (event router)를 함수 (function)에 연결하는 것을 쉽게 만들어 주었습니다. 하지만 동일한 사용자의 장바구니가 왜 두 번이나 자격이 박탈되었는지 운영자 (production operator)에게 설명하는 것은 거의 불가능하게 만들었습니다.

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

우리의 첫 번째 버전은 Kafka를 이벤트 버스 (event bus)로 사용하는 Veltrix Serverless를 사용했습니다. 우리는 모든 이벤트에서 트리거가 실행되도록 구성하고, 150ms의 Python Lambda를 실행하며, user_id + hunt_id의 복합 키 (composite key)를 사용하여 DynamoDB에 장바구니를 저장했습니다. CloudWatch에서의 지연 시간 (latency) 수치는 첫 번째 리전 장애 조치 (regional failover)가 발생하기 전까지는 좋아 보였습니다. 가용 영역 (AZ) 장애 동안 Kafka는 계속해서 새로운 이벤트를 추가했지만, 1,000개의 Lambda 동시성 제한 (concurrency limit)이 1,200개로 폭증했고 Veltrix는 재시도 (retries)를 스로틀링 (throttled)했습니다. 이벤트 큐 (event queue)는 전달되지 않은 메시지 120만 개까지 늘어났습니다. AZ가 복구되었을 때, Lambda 백로그 (backlog)는 429 TooManyRequests 오류와 함께 충돌했고, 보물찾기 엔진은 오프셋 (offset)이 손실된 구간에 속하는 모든 이벤트를 조용히 건너뛰었습니다. 운영자들은 쿠폰이 도착하지 않는다는 300개의 분노 섞인 트윗을 받기 전까지는 이 사실을 알지 못했습니다.

두 번째 반복(iteration)에서는 그래프 순회(graph traversal)를 Kubernetes에서 실행되는 상태 저장 서비스(stateful service)로 이동했습니다. 우리는 Lambda를 정확히 한 번 처리(exactly-once processing)를 위해 Veltrix의 Streaming Map을 사용하는 Go 워커(worker)로 교체했습니다. 우리는 내구성(durability) 문제를 해결했다고 생각했습니다. 하지만 그렇지 않았습니다. Go 워커는 이벤트 타임스탬프(event timestamp)가 단조 증가(monotonic)한다고 가정했습니다. 블랙 프라이데이(Black Friday) 기간 동안, 시계 왜곡(clock skew)으로 인해 모바일 브라우저들이 최대 90초 앞선 타임스탬프를 보고하기 시작했습니다. Go 워커는 이러한 이벤트들을 유효한 것으로 수락하여 당첨 바스켓(winning baskets)을 발행했고, 이후 상위 중복 제거(upstream deduplication) 작업이 더 빠른 타임스탬프를 가진 중복 항목을 발견했을 때 이를 거부했습니다. 운영자 대시보드는 빨간색으로 깜빡였습니다. p99 지연 시간(latency)이 1.8초에서 6.2초로 급증했고 고객 서비스 대기열은 폭발했습니다.

아키텍처 결정 (The Architecture Decision)

우리는 Veltrix가 분산 상태(distributed state) 문제를 대신 해결해 줄 수 있다는 척하는 것을 그만두었습니다. 우리는 Streaming Map을 제거하고 다음과 같은 커스텀 수집 파이프라인(ingestion pipeline)으로 교체했습니다:

  1. 원시 이벤트(raw events)를 event_time 파티션 키(yyyy/mm/dd/hh/event_id)를 사용하여 파티셔닝된 S3 버킷에 수집합니다. 모든 이벤트는 v4 UUID와 클라이언트가 생성한 밀리초 단위의 event_time을 포함하는 불변(immutable) JSON입니다. 이 방식으로는 시계 왜곡(clock skew)이 살아남을 수 없습니다.

  2. RocksDB 상태 백엔드(state backend)를 사용하는 Flink 작업(job)과 함께 30개 노드의 Kubernetes 클러스터를 가동합니다. 체크포인트(checkpoint) 간격은 30초로 설정하고, 복구 시간을 90초 미만으로 유지하기 위해 증분 체크포인트(incremental checkpoints)를 사용했습니다. 이 작업은 S3 버킷을 멱등적(idempotently)으로 읽고, 그래프 순회(graph traversal)를 적용하며, DynamoDB 조건부 쓰기(conditional puts)를 통한 트랜잭션 쓰기로 별도의 S3 버킷에 당첨 바스켓을 기록합니다.

  3. 운영자가 S3 버킷의 이벤트를 재생(replay)하는 CLI 도구로 조회할 수 있도록 gRPC 엔드포인트를 통해 상태를 노출합니다. 만약 운영자가 잘못 지급된 바스켓을 발견하면, 실행 중인 파이프라인을 건드리지 않고도 해당 위반 사항이 포함된 정확한 세그먼트를 재생하여 당첨 기록을 패치할 수 있습니다.

  4. 지난 24시간 동안의 모든 이벤트를 섀도우 Flink 작업(shadow Flink job)으로 재생하는 두 번째 스트림을 추가합니다. 이를 통해 우리는 재생 가능한 상태 복구(replayable state recovery)에 대해 15분의 SLA를 확보할 수 있습니다.

기본 클러스터(primary cluster)가 중단되면, 섀도우(shadow)가 45초 이내에 데이터 손실 없이 기본 클러스터를 대체합니다.

이제 Veltrix 부분은 Kafka 토픽으로 이벤트를 방출하는 가벼운 이벤트 라우터(event router) 역할을 할 뿐입니다. 실제 핵심 작업(heavy lifting)은 우리가 직접 구축한 상태 저장 파이프라인(stateful pipeline)에서 이루어집니다. 우리는 여전히 운영자 알림(operator alerts)과 메트릭 수집(metric ingestion)을 위해 Veltrix를 사용하지만, 이를 두뇌가 아닌 사이드카(sidecar)로 취급합니다.

결과 수치가 말해주는 것

재작성된 파이프라인은 이벤트를 절대 놓치지 않으며, 100% 가용 영역(AZ) 장애와 5배의 트래픽 급증(traffic spike) 상황에서도 p95 기준 2초 이내에 당첨 바스켓(winning baskets)이 발행됩니다. 재생 SLA는 15분으로, 이전에 허용했던 4시간의 창(window)에 비하면 극히 일부에 불과합니다. Go 팀은 이제 Veltrix 스로틀링(throttling) 에스컬레이션에 75%를 할애하는 대신, Flink 클러스터에 온콜(on-call) 시간의 45%를 할당합니다. 운영자 CLI는 파이프라인을 재시작하지 않고도 잘못된 보상(mis-awards)을 수정하기 위해 어떤 15분 구간이든 2분 이내에 재생할 수 있습니다. 여전히 저를 괴롭히는 유일한 메트릭은 RocksDB 컴팩션 스톰(compaction storms)이 대규모 세일 기간과 겹칠 때 발생하는 12초의 p99 지연 시간(latency)입니다. 다음 단계로 이에 대한 프로파일링(profiling)을 진행할 예정입니다.

내가 다르게 했을 일들

상태 저장 연산(stateful computation)을 위해 Veltrix의 정확히 한 번(exactly-once) 보장 기능을 신뢰하지 않았을 것입니다. 마케팅 자료의 말에 휘둘려 Lambda를 상태 저장(stateful)이라고 부르는 대신, 첫날부터 파일 시스템 기반의 상태 저장소(filesystem-backed state store)로 시작했을 것입니다.

브라우저가 보고하는 시간을 신뢰하는 대신, 모바일 클라이언트가 (서버 측 왜곡 허용 범위를 포함한) 단조 클라이언트 측 시계(monotonic client-side clocks)를 사용하도록 강제했을 것입니다. 우리는 발생해서는 안 되었을 시계 왜곡(clock skew)을 디버깅하는 데 3주를 허비했습니다.

운영 비용(ops cost)을 사전에 예산에 반영했을 것입니다. Flink 클러스터는 월 18,000달러로 24시간 365일 가동되지만, 이는 기존 시스템이 붕괴되었을 때 발생했던 300건의 지원 티켓과 CFO의 초과 근무 수당으로 인한 매출 손실보다 저렴합니다.

마지막으로, 첫 번째 코드 라인이 운영 환경(prod)에 배포되기 전에 카오스 엔지니어링(chaos engineering) 플레이북을 작성했을 것입니다. 우리는 고객 앞에서 시스템을 망가뜨려 봄으로써 내구성(durability)에 대해 아주 혹독하게 배웠습니다.

제가 이곳에 적용했던 것과 동일한 실사(due diligence)를 AI 제공업체에도 적용합니다. 수탁 모델(Custody model), 수수료 구조(fee structure), 지리적 가용성(geographic availability), 장애 모드(failure modes) 등이 대상입니다. 결과는 유효합니다: https://payhip.com/ref/dev3

AI 자동 생성 콘텐츠

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

원문 바로가기
0

댓글

0