들어가며
Airbridge는 광고를 통해 유입된 사용자의 행동을 분석하고, 어떤 광고가 실제 성과로 이어졌는지 측정하는 서비스입니다. 이를 위해 광고 클릭과 앱 설치, 구매 같은 여러 이벤트를 실시간으로 매칭합니다.
이 과정에서는 이벤트를 빠르게 처리하는 것만큼 어떤 순서로 처리하는지가 중요합니다. 광고 클릭보다 설치 이벤트를 먼저 처리하거나, 앞선 행동이 반영되기 전에 후속 이벤트를 처리하면 정확한 성과 측정이 어려워질 수 있기 때문입니다. 같은 이벤트를 중복으로 처리하거나 처리 과정에서 유실해서도 안 됩니다.
초기의 Airbridge 분석 시스템은 Python으로 구현된 하나의 거대한 서버에 많은 비즈니스 로직이 모여 있었습니다. 제품과 트래픽이 성장하면서 저희는 이 서버를 워크로드별로 분리하기 시작했습니다. 각 워크로드의 특성에 따라 Rust, Go로 마이그레이션하거나 기존 Python 구현을 유지했고, 성능과 운영 효율을 높일 수 있었습니다.
하지만 서버를 분리한다고 해서 문제가 저절로 해결되지는 않았습니다. 하나의 서버 내에서 제어하던 처리 순서를 여러 서버 사이에서도 지켜야 했고, 서로 처리 속도가 다른 워커들을 쉬지 않게 하면서도 이벤트를 한 번만 처리해야 했습니다.
이 글에서는 저희가 이 문제를 해결하기 위해 만든 Project Differential을 소개합니다. 순서를 최대한 지키면서도 처리 속도를 높이기 위한 구조를 고안했고, 이벤트 유실과 중복 없이 기존 대비 10배 이상의 처리 속도를 달성했습니다.
워크로드 분리 이후의 이벤트 처리 구조
거대한 분석 서버를 분리한 뒤 전체 이벤트 처리 흐름은 다음과 같이 바뀌었습니다.
flowchart LR WAS --kafka--> Preprocessor_Go["Preprocessor(Go)"] Preprocessor_Go --kafka--> gRPC_Worker["gRPC-Worker(Go)"] gRPC_Worker --grpc--> A_Worker_Rust["A-Worker(Rust)"] gRPC_Worker --grpc--> B_Worker_Python["B-Worker(Python)"] gRPC_Worker --grpc--> C_Worker_Go["C-Worker(Go)"]
Mermaid
복사
먼저 Go로 구현한 Preprocessor가 이벤트를 공통 형식으로 전처리한 뒤 Kafka에 전달합니다. gRPC Worker는 이 이벤트를 consume하고, 이벤트 종류에 따라 적절한 비즈니스 로직 워커로 요청을 라우팅합니다. 각 워커가 처리를 완료하고 응답하면 gRPC Worker가 완료된 이벤트의 offset을 Kafka에 commit합니다.
겉으로 보면 단순한 구조지만, 실제 운영에서는 워커마다 특성이 크게 달랐습니다. 어떤 워커는 평균 처리 시간이 짧고 처리량이 높은 반면, 어떤 워커는 복잡한 계산을 수행해 상대적으로 느렸습니다. 특정 이벤트에만 트래픽 스파이크가 발생하는 경우도 있었습니다.
저희가 지켜야 했던 조건은 세 가지였습니다.
1.
비즈니스 로직이 요구하는 처리 순서를 최대한 지켜야 합니다.
2.
처리한 이벤트가 유실되거나 중복 처리되지 않아야 합니다.
3.
워커별 처리 속도 차이를 존중하면서 전체 처리량을 높여야 합니다.
처음에는 이 조건을 마이크로 배치 방식으로 만족시키고 있었습니다.
서버를 늘려도 빨라지지 않았던 이유
기존 gRPC Worker는 Kafka에서 N개의 메시지를 마이크로 배치로 가져온 뒤, 배치 안의 이벤트를 필요한 순서에 따라 처리했습니다. 예를 들어 A 유형을 먼저 처리해야 한다면 배치의 A 이벤트를 모두 처리하고, 그다음 B와 C 이벤트를 처리하는 방식입니다.
이 방식은 이해하기 쉽고 순서를 제어하기도 편합니다. 하지만 워커의 관점에서 보면 비효율이 생깁니다. A 이벤트를 처리하는 동안 B와 C 워커는 할 일이 없습니다. B 이벤트 차례가 오면 이번에는 A와 C 워커가 쉬게 됩니다.
문제는 각 워커의 처리 속도가 다를 때 더 커졌습니다. 배치 안에 느린 이벤트가 하나라도 섞이면 뒤에 있는 모든 이벤트가 기다려야 했습니다. 특정 Kafka partition으로 트래픽이 몰리는 핫 파티션 문제까지 겹치면 대기 시간은 더 길어졌습니다.
서버를 증설해도 처리 속도가 기대만큼 늘지 않은 이유가 여기에 있었습니다. 워커가 부족한 것이 아니라, 일할 수 있는 워커도 앞선 순서를 기다리느라 쉬고 있는 것이 병목이었습니다. 처리 용량을 늘리는 것만으로는 구조적인 대기 시간을 없앨 수 없었습니다.
저희는 배치 단위의 장벽을 없애면서도 순서를 제어할 새로운 방법이 필요했습니다.
Watermark에서 얻은 힌트
실시간 스트림 처리 시스템을 조사하면서 가장 먼저 살펴본 것은 Apache Flink와 Spark의 Watermark였습니다.
Watermark는 이벤트 시간 기준으로 스트림의 진행 상태를 나타내는 개념입니다. 현실의 분산 시스템에서는 오래전에 발생한 이벤트가 네트워크 지연 등의 이유로 뒤늦게 도착할 수 있습니다. 모든 늦은 이벤트를 무한정 기다리면 계산을 끝낼 수 없기 때문에, 일정 범위까지만 기다린 뒤 특정 시점 이전의 계산을 완료된 것으로 간주합니다. Flink에서는 이를 bounded out-of-orderness 같은 전략으로 제공합니다.
Watermark가 저희 문제를 모두 해결하는 답은 아니었습니다. 일반적인 Watermark는 이벤트 시간의 진행과 늦게 도착한 데이터를 다루기 위한 개념입니다. 반면 Airbridge에서 필요한 것은 모든 이벤트를 단순히 시간순으로 정렬하는 일이 아니라, 이벤트 종류 사이의 처리 순서를 지키키면서도 처리 속도를 극대화하는 일이었습니다.
하지만 한 가지 중요한 힌트를 얻었습니다. 대용량 실시간 처리에서 모든 순서를 완벽하게 보장하기 위해 무한정 기다릴 수는 없습니다. 워크로드의 특성을 바탕으로 안전한 범위를 정하고, 그 범위 안에서 best effort로 순서를 지키는 접근이 필요했습니다.
Project Differential
Watermark에서 얻은 힌트를 저희의 워크로드에 적용하면 질문은 하나로 정리됩니다. 각 워커가 자신의 속도로 쉬지 않고 일하면서도, 앞서 처리해야 할 워커를 추월하지 않게 만들 수 있을까?
저희가 떠올린 구조는 마이크로 배치를 없애고, 이벤트 종류별 Consumer가 하나의 Kafka topic을 독립적으로 읽게 하는 것이었습니다. 각 Consumer는 자신의 속도로 이벤트를 처리하되, 뒤따르는 Consumer는 앞선 Consumer의 처리 위치를 추월할 수 없습니다. 이를 통해 Consumer 간 처리 순서를 유지하면서도 워커의 유휴 시간을 줄일 수 있습니다. Project Differential은 이 아이디어에서 출발했습니다.
프로젝트 이름은 자동차의 차동 기어에서 가져왔습니다. 자동차가 회전할 때 안쪽 바퀴와 바깥쪽 바퀴는 서로 다른 거리를 이동합니다. 차동 기어는 두 바퀴가 각자 필요한 속도로 회전할 수 있게 하면서도 하나의 구동계를 함께 사용하도록 만듭니다. 1937년에 제작된 차동 기어 설명 영상은 지금도 이 원리를 매우 직관적으로 보여줍니다.
우리의 이벤트 워커도 이와 비슷했습니다. 모든 워커를 같은 배치 속도에 맞추는 대신, 워커마다 자신의 처리 속도로 일하게 하되 전체 순서가 허용 범위를 벗어나지 않도록 만들고 싶었습니다. 그래서 이 프로젝트를 Differential이라고 이름 붙였습니다.
하나의 topic을 여러 Consumer가 읽는다
Differential의 기본 아이디어는 다음과 같습니다.
•
이벤트 종류별로 Consumer를 정의합니다.
•
각 Consumer는 동일한 Kafka topic을 읽습니다.
•
자신이 처리해야 하는 이벤트는 처리하고, 다른 종류의 이벤트는 다음 Consumer로 넘깁니다.
•
뒤의 Consumer는 앞선 Consumer의 처리 offset을 추월하지 않습니다.
•
두 Consumer 사이에는 offset 또는 event time을 기준으로 안전거리를 둡니다.
이때 순서 보장의 단위는 Kafka topic 전체가 아니라 partition입니다. 그래서 순서가 중요한 동일 사용자 흐름의 이벤트가 가능한 한 같은 partition으로 들어가도록 partition key를 구성했습니다.
예를 들어 A Consumer와 B Consumer가 있을 때, B Consumer는 A Consumer보다 일정 offset 또는 일정 시간 뒤에서 이벤트를 처리합니다. A가 빠르거나 B가 느리더라도 둘을 하나의 배치에 묶지 않습니다. 각 Consumer는 가능한 한 계속 일하지만, B가 A를 추월하려는 순간에는 처리를 제한합니다.
이 구조에서는 배치 전체가 끝날 때까지 기다릴 필요가 없습니다. Consumer마다 처리 속도가 달라도, 각 Consumer는 앞선 Consumer의 처리 위치를 추월하지 않는 범위에서 계속 이벤트를 처리할 수 있습니다. 따라서 느린 이벤트가 전체 배치를 멈추게 하던 대기 시간이 줄어듭니다.
적절한 안전거리 기준은?
Differential은 모든 이벤트에 대해 전역적인 완전 순서를 만드는 시스템이 아닙니다. 그런 보장을 만들려면 가장 늦어질 수 있을 때까지 이벤트를 계속 기다려야 하고, 실질적으로는 실시간 처리가 거의 불가능해집니다.
실제 세상을 생각해보겠습니다. 사용자가 광고 클릭시 Airbridge 서버를 거쳐가면서 이벤트를 남기기 때문에 이벤트가 뒤늦게 수집되는 일은 거의 없습니다. 반면 모바일 앱 이벤트는 모바일 환경 특성상 갑자기 네트워크 지연이 발생하거나 앱이 백그라운드로 이동하는 상황이 발생할 수 있어서 서버에 뒤늦게 도착할 수 있습니다. 또한, SDK에서 이벤트 전송을 할 때 대체로 순서를 지키긴 하지만 종종 순서가 어긋나는 경우가 있긴 합니다.
저희는 이러한 상황을 고려하여 몇 초 정도의 event time 차이를 기준으로 사용하기로 했습니다. 광고 클릭으로부터 몇 초 이후의 앱 이벤트만 처리하도록 하는 식입니다.
적절한 안전거리 기준이 무엇인지는 워크로드에 따라 다를 것입니다. 처리해야할 워크로드 특성에 맞춰 고민이 필요합니다.
버린 메시지를 다음 Consumer가 재사용한다
여러 Consumer가 같은 topic을 각각 읽으면 동일한 메시지를 여러 번 가져오는 낭비가 생길 수 있습니다. A Consumer가 B 이벤트를 발견해 버리고, B Consumer가 같은 이벤트를 Kafka에서 다시 읽는 식입니다.
이를 줄이기 위해 앞선 Consumer가 처리하지 않는 이벤트를 뒤의 Consumer에게 직접 넘기도록 했습니다. 뒤의 Consumer는 전달받은 메시지를 내부 버퍼에 보관한 뒤 자신의 차례에 처리합니다. 정상적인 상황에서는 Kafka를 반복해서 읽지 않고도 앞선 Consumer가 이미 가져온 메시지를 재사용할 수 있습니다.
다만 뒤의 Consumer가 느리거나 트래픽이 급증하면 버퍼가 가득 찰 수 있습니다. 이때 앞선 Consumer까지 멈춰 세우면 다시 기존과 비슷한 병목이 만들어집니다. 그래서 Differential은 상황에 따라 두 가지 모드를 오갑니다.
•
Normal Mode: 앞선 Consumer가 넘긴 메시지를 버퍼에 넣어 처리합니다.
•
Polling Mode: 버퍼가 가득 차면 Kafka에서 직접 메시지를 가져와 처리합니다.
Polling Mode에서도 순서 제약은 유지합니다. Kafka에서 읽는 offset이 앞선 Consumer의 처리 위치를 추월하지 않도록 제한합니다. 뒤의 Consumer가 앞선 Consumer를 따라잡거나 최신 이벤트에 도달해 버퍼가 비면 다시 Normal Mode로 돌아갑니다.
이 전환 덕분에 평소에는 메시지를 재사용해 낭비를 줄이고, 처리 속도 차이가 커지는 순간에는 각 Consumer가 독립적으로 Kafka를 읽으며 앞선 Consumer에 불필요한 backpressure를 주지 않도록 했습니다.
Differential의 내부 구조
실제 시스템은 Kafka partition마다 Consumer chain을 만들고, 이를 하나의 ConsumerSuite가 관리하는 구조입니다.
ConsumerSuite와 PartitionedConsumerChain
ConsumerSuite는 여러 개의 PartitionedConsumerChain을 생성하고 수명 주기를 관리합니다. Kafka는 partition 안에서 메시지 순서를 보장하므로, Differential의 순서 제어 역시 partition 단위로 동작합니다.
PartitionedConsumerChain은 하나의 partition을 처리하는 Consumer의 연결입니다. 이벤트 종류별 Consumer가 앞뒤 관계를 가지고 배치되며, 앞선 Consumer의 진행 상태가 뒤의 Consumer가 넘지 말아야 할 checkpoint가 됩니다.
Airbridge 이벤트는 동일한 사용자 흐름에 속한 이벤트가 같은 partition에서 처리될 가능성이 높도록 partition key를 구성합니다. 따라서 모든 partition 사이에 하나의 전역 순서를 만드는 대신, 실제 매칭 정확도에 중요한 partition 내부의 순서를 제어하는 데 집중했습니다.
HeadConsumer와 TailConsumer
Consumer chain의 첫 번째 HeadConsumer는 Kafka에서 메시지를 가져와 자신이 담당하는 이벤트를 EventProcessor에 전달합니다. 처리하지 않는 이벤트는 뒤의 Consumer가 사용할 수 있도록 넘깁니다.
뒤에 연결되는 TailConsumer는 앞서 설명한 Normal Mode와 Polling Mode를 전환하며 동작합니다. 평소에는 앞선 Consumer가 넘긴 메시지를 처리하고, 버퍼가 가득 차면 Kafka를 직접 polling합니다. 어느 모드에서든 자신에게 주어진 checkpoint를 넘어가지 않도록 제한합니다.
EventProcessor
EventProcessor는 Consumer와 실제 도메인 로직 사이의 경계를 담당합니다. Consumer는 메시지를 언제 읽고 버퍼링할지에 집중하고, EventProcessor는 이벤트를 어떻게 처리할지에 집중합니다.
ConcurrentEventProcessor는 설정한 동시성만큼 이벤트를 병렬로 처리합니다. 워커별 성능에 맞춰 동시성을 독립적으로 조절할 수 있습니다.
LimitedEventProcessor는 여기에 checkpoint 제한을 더한 구현입니다. 앞선 Consumer가 처리를 완료한 위치를 실시간으로 전달받고, 현재 Consumer가 그 위치를 추월하려 하면 실행을 기다립니다. 워커들이 독립적으로 일하면서도 처리 순서를 벗어나지 않게 만드는 핵심 장치입니다.
CommitManager
비동기 처리와 버퍼링을 도입하면 Kafka offset commit도 더 세밀하게 관리해야 합니다. 단순히 마지막으로 읽은 offset을 commit하면, 아직 버퍼에 있거나 처리 중인 이벤트를 완료한 것으로 기록할 수 있기 때문입니다.
CommitManager는 버퍼에 들어온 이벤트, 버퍼에서 나온 이벤트, 처리를 완료한 이벤트의 offset을 각각 추적합니다. 이를 바탕으로 안전하게 commit할 수 있는 연속 구간을 계산하고 일정 주기로 Kafka에 반영합니다.
Differential이 처리량만 높이고 안정성을 잃지 않으려면, Consumer의 진행 위치와 실제 처리 완료 위치를 구분하는 것이 중요했습니다.
결과
구조를 바꿀 때 가장 먼저 확인한 것은 성능이 아니라 안정성이었습니다. 아무리 처리 속도가 빨라져도 이벤트가 유실되거나 중복 처리된다면 사용할 수 없는 시스템이기 때문입니다.
테스트 운영을 통해 Consumer의 모드가 전환되고, 버퍼가 가득 차며, 워커별 처리 속도가 달라지는 상황에서도 이벤트 유실과 중복이 발생하지 않는 것을 확인했습니다. checkpoint 제한과 CommitManager가 의도한 대로 동작하는지도 함께 검증했습니다.
다음으로 워커의 유휴 시간을 확인했습니다. 기존 마이크로 배치 방식에서는 앞선 이벤트 종류의 처리가 끝날 때까지 다른 워커가 쉬는 구간이 반복됐습니다. Differential에서는 배치 장벽이 사라지면서 각 워커가 자신의 처리 가능 범위 안에서 계속 이벤트를 처리했습니다.
그 결과 이벤트 처리 지연 시간이 짧아졌고, 기존 마이크로 배치 방식과 비교해 10배 이상의 처리 속도 향상을 확인했습니다. 단순히 서버를 더 많이 추가한 결과가 아니라, 이미 존재하던 처리 자원을 더 효율적으로 사용하도록 구조를 바꾼 결과였습니다.
아래 이미지는 저희의 이벤트 처리 지연 시간 모니터링 대시보드를 캡처한 것입니다. 중간 중간 지연 시간이 치솟는 것은 보통 갑자기 특정 이벤트 종류의 트래픽이 스파이크를 치는 경우인데, 그런 경우 앞서 처리되어야 할 이벤트의 처리를 기다리느라 뒷 순서의 이벤트 처리가 지연됩니다. 지켜져야할 이벤트 처리 순서는 A, B, C, D 순인데, 빨간색 박스로 표시한 부분을 보면 처리 지연 시간이 함께 늘어나는 것을 확인할 수 있습니다. 기존에는 이런 경우에 전체 파이프라인이 지연됐던 것에서 이제 특정 이벤트들만 처리가 지연됩니다.
앞으로 개선하고 싶은 것
Differential은 현재 Airbridge의 워크로드에 맞춰 순서와 처리량 문제를 해결했지만, 여전히 개선할 부분이 있습니다.
먼저 구조가 복잡합니다. Watermark는 “일정 시간 늦게 도착하는 이벤트까지 기다린다”는 개념으로 비교적 쉽게 설명할 수 있습니다. 반면 Differential은 Consumer chain, 버퍼, 두 가지 polling mode, checkpoint와 offset commit을 함께 이해해야 합니다. 같은 보장을 더 단순한 구조로 제공할 방법이 있는지 계속 고민하고 있습니다.
두 번째는 Spot Instance 환경에서의 안정성입니다. 저희는 비용 최적화를 위해 워크로드에 Spot Instance를 적극적으로 활용하고 있습니다. 하지만 예상보다 많은 인스턴스가 동시에 종료되면 Consumer별 처리 용량과 안전거리가 순간적으로 달라질 수 있습니다. 현재 실행 중인 워커 상태에 따라 동시성과 처리 제한을 자동으로 조정하는 adaptive flow control을 적용한다면 이런 상황에 더 유연하게 대응할 수 있을 것입니다.
마지막으로 더 근본적인 질문도 남아 있습니다. 지금은 처리 순서를 효율적으로 지키는 방법을 개선했지만, 장기적으로는 순차 처리 자체가 필요하지 않도록 데이터 모델과 아키텍처를 바꿀 수 있는지도 검토해볼 수 있습니다.
마치며
분산 처리 시스템에서 순서대로 처리하기와 한 번만 처리하기를 동시에 만족시키는 것은 어려운 문제입니다. 여기에 빠른 처리 속도까지 요구하면 단순히 서버를 늘리거나 동시성을 높이는 것만으로는 해결할 수 없습니다.
저희는 모든 워커를 하나의 배치 속도에 맞추는 대신, 각 워커가 자신의 속도로 움직이면서도 앞선 처리 위치를 추월하지 않는 구조를 만들었습니다. 자동차의 차동 기어에서 얻은 아이디어처럼, 서로 다른 속도를 허용하는 것이 오히려 전체 시스템을 더 빠르게 만들었습니다.
이 글에서 소개한 구조가 모든 스트림 처리 시스템의 정답은 아닙니다. 하지만 워크로드별 성능 차이가 크고, 이벤트 종류 사이의 순서가 중요한 시스템을 운영하고 있다면 하나의 참고 사례가 될 수 있기를 바랍니다.
Airbridge에는 이처럼 제품의 정확도와 대규모 트래픽, 운영 효율 사이의 어려운 문제들이 많이 남아 있습니다. 이런 문제를 함께 깊이 고민하고 풀어나갈 동료를 기다리고 있습니다.
ᴡʀɪᴛᴇʀ
Changkyu Kim @glenn-kim
Software Engineer @AB180




