급증하는 트래픽 안정적으로 처리하기: 개선편(2) 논리적 파티셔닝

같은 Redis 위의 차선 분리 공사 — 논리적 파티셔닝과 Partition별 독립 스케일링

Vita 🪼 • MJ Park (Backend Engineer)

  • 엔지니어링

이 글은 급증하는 트래픽 안정적으로 처리하기: 구현편의 후속편이자 동적 스케일링을 다룬 급증하는 트래픽 안정적으로 처리하기: 개선편(1) 동적 스케일링에 이은 두 번째 글입니다. 아래 내용을 읽기 전 이전 아티클을 읽어보시는 걸 추천드립니다.

개요

이전 글에서는 동적 스케일링을 통해 벌크액션 서버의 트래픽 급증에 대응하는 방법을 소개했습니다. 그러나 단일 Ready Queue 구조에서는 대규모 TaskGroup이 Queue를 점유해 다른 요청의 처리가 지연되는 HOL Blocking 문제가 여전히 남아 있었고 인스턴스 단위 스케일링은 필요 이상의 Worker를 한꺼번에 추가하는 비효율을 안고 있었습니다.

이번 글에서는 이 두 가지 문제를 논리적 파티셔닝과 Coordinator 기반 Partition별 독립 스케일링으로 해결한 과정을 다룹니다.

같은 Redis 안에서 Keyspace만 분리해 Partition 간 격리를 확보하고 Coordinator가 각 Partition의 부하를 관찰하여 Worker 수를 독립적으로 조절하는 구조를 설계했습니다. 그 결과 10만 건 기준 처리 시간을 25분에서 4분으로 84% 단축할 수 있었습니다.

횡적 확장, 파티셔닝

큰 산을 넘었으나 정상까지는 아직입니다. 파티셔닝이죠 [1]. 파티셔닝은 일반적으로 한 머신이 처리할 수 있는 범위를 넘어설 때 횡적 확장을 위해 고려하는 옵션입니다 [2]. 앞서 인스턴스 스케일링을 통해 이러한 횡적 확장을 어느 정도 해결해두었으나 장기적인 방향에서는 파티셔닝 추가를 통해 쉽게 횡적 확장을 할 수 있는 구조가 필요하다고 판단했습니다.

또한 파티셔닝은 동적 스케일링으로 인한 Worker 수 변화에 대한 예측 가능성을 높이기 위한 방법입니다.

기존 벌크액션 서버는 단순히 인스턴스 단위로 CPU 지표 변화에 따라 고정된 Worker 수를 가진 인스턴스가 늘어나는 방법으로 Worker 수를 조절했습니다. 그러나 Consumer 인스턴스의 증가는 예기치 못한 Worker 수 증폭으로 인한 부하 전파를 야기할 수 있습니다. 예를 들어, Worker가 10개만 더 필요한 상황에서도 인스턴스 1대(= 고정 32 Worker)를 통째로 추가하므로 Worker 수가 32 → 64 → 96과 같이 배수로 점프하게 됩니다.

따라서 스케일링 로직이 Partition당 Consumer 개수를 고정하고 오직 Worker 수 스케일링에만 의존하도록 하여 복잡도를 단순화했습니다.

복잡도와 확장성 그 사이 어딘가

그런데 파티셔닝이 정말 필요할까요?

파티셔닝은 인프라에 대한 복잡도를 희생하는 형태로 구현됩니다. 예를 들어, 멤버십 관리(Membership Protocol)나 리더 선출(Leader Election)과 같은 분산 조율 메커니즘을 구현해야 합니다 [2]. 이러한 복잡도는 결국 trade-off에 해당합니다. 복잡도가 필요할 만큼 정밀함 혹은 안정성이 요구되는 상황도 아니었습니다. 실제로, Job Queue의 TPS도 머신 한 대가 버티기에 충분한 수준이었습니다.

저희는 현재 Redis를 Job Queue로 사용하고 있습니다. Redis 인프라에서는 일반적으로 횡적 확장을 위해 클러스터를 두곤 합니다. 그러나 현 상황에서는 Redis 머신 자체를 늘릴 만큼의 트래픽은 아니라고 판단했습니다. 오히려 이를 처리하는 Worker 및 벌크액션 인스턴스의 확장이 주요 쟁점이었죠.

팀에서는 벌크액션의 Job Queue Consumer에 대한 파티셔닝도 진행하기로 하였습니다. 추후 Redis 머신으로의 트래픽이 높아지는 것을 대비해 기본적으로 Partition-aware한 구조를 확립하고 논리적 파티셔닝을 통해 Consumer들을 분산 배치해 확장 가능한 구조를 만들어두는 것에 집중하였습니다.

구현 디테일

그렇다면 실제로 구현 디테일에 대해 더 이야기해봅시다.

파티셔닝 기준

파티셔닝을 적용하려면 먼저 Partition에 무엇을 기준으로 Job을 분배할 것인가 결정해야 합니다.

후보는 크게 세 가지였습니다.

1. 고객사(Channel) 단위

같은 고객사의 모든 벌크액션이 동일한 Partition에 배치됩니다. 장애 추적이 쉽고("이 고객사의 Job은 Partition 3만 보면 된다") 같은 고객사 내에서 Job 간 우선순위 비교도 자연스럽습니다. 다만 대형 고객사의 여러 벌크액션이 하나의 Partition에 집중되면서 해당 Partition만 과부하 상태가 되고 같은 Partition에 배치된 다른 고객사까지 영향을 받을 수 있다는 우려가 있었습니다.

2. 벌크액션 유형(Type) 단위

"메시지 보내기", "상담 종료" 등의 Job 종류별로 Partition을 나누는 방식입니다. 특정 유형이 시스템에 유독 부하를 많이 주는 경우에는 유용할 수 있지만 같은 유형 내에서도 Job 규모의 차이가 크기 때문에 — 10건짜리 메시지 발송과 100만 건짜리 메시지 발송 — 격리 효과가 제한적이었습니다.

물론 상위 트래픽을 차지하는 요청을 격리시키고 더 좋은 머신 혹은 Partition에 두는 멀티 레벨 전략은 유효합니다. 이러한 패턴은 Discord 혹은 X(전 Twitter) 등에서도 찾아볼 수 있는 일반적인 패턴입니다. [3], [4] 그러나 저희는 관리 복잡도를 줄이기 위해 최대한 사용자의 요청 형태에 의존적이지 않은 구조를 고민하려고 했습니다.

3. 벌크액션(TaskGroup) 단위

각각의 벌크액션 요청을 독립적으로 Partition에 분배합니다. 고객사 단위보다 분배의 단위가 세밀하기 때문에 Partition 간 부하가 더 고르게 분산됩니다. 대형 고객사가 여러 벌크액션을 동시에 요청하더라도 각 요청이 서로 다른 Partition으로 분산될 수 있어 특정 Partition에 부하가 몰리는 현상을 완화할 수 있습니다.

사실 저희 팀에서 선택하는 파티셔닝 기준은 고객사(Channel)의 ID 기준인 경우가 대부분입니다. 데이터를 제한하는 단위(권한) 역시 Channel 단위이며 같은 테넌시에만 있도록 유지하는 게 직관적이니까요. 하지만 그렇게 되면 벌크액션을 자주 사용하는 고객사가 있을 때에 어떤 고객사는 해당 고객사와 같은 Partition에 있다는 이유만으로 손해를 볼 수 있는 구조이기는 합니다. 또한 한 Partition으로만 계속 요청이 가는 구조는 바람직하지 못하다고 판단했습니다.

적절히 요청을 분산하기 위해 결과적으로 벌크액션(TaskGroup)을 Partition 기준, 즉 Partition Key로 결정하였습니다.

채널톡의 벌크액션 시스템에서 격리하고 싶은 대상은 "특정 고객사"가 아니라 "특정 대규모 요청" 그 자체입니다. 100만 건짜리 벌크액션 하나가 문제라면 그 벌크액션을 격리하는 것이 가장 직접적인 해법이었죠. 같은 Partition 안에 다른 벌크액션이 함께 배치되는 경우도 생기겠지만, Partition이 충분히 나뉘어 있다면 대규모 요청이 영향을 미치는 범위가 전체에서 일부 Partition으로 제한됩니다.

논리적 파티셔닝

앞서 Redis 머신 자체의 성능은 충분하다고 판단했다고 말씀드렸습니다. 벌크액션 서버는 채널톡의 메인 서비스와 Redis를 공유하고 있으며 Redis CPU 사용률을 확인해봐도 여유가 있었습니다.

Redis CPU 사용률 - 5일간(25/12/22 - 25/12/26)의 트래픽 구간에서 CPU 사용률이 15% 미만으로 유지되고 있음

그렇다면 문제는 어디에 있었을까요? Redis의 get/set 성능이 아니라 단일 Ready Queue를 여러 TaskGroup이 공유하는 구조 자체였습니다.

모든 컴포넌트가 단일 Ready Queue에 의존하는 기존 벌크액션 서버 구조

파티셔닝 없이는 모든 TaskGroup의 Job이 하나의 Ready Queue(Redis Sorted Set [5])에 적재됩니다. 대량 TaskGroup(예: 100만 건)의 Job이 Queue에 쌓이면 Worker들이 해당 Job들을 집중 처리하느라 다른 TaskGroup의 처리가 밀리게 됩니다. Priority 기반 정렬이 있지만 물량 차이 자체를 해결하지는 못합니다.

Redis 성능은 충분하므로 같은 Redis 안에서 Keyspace를 분리하는 것 — 즉, Queue를 여러 개로 나누는 것이 해법이 됩니다. Redis 인스턴스를 물리적으로 분리(Redis Cluster)하는 것이 아니라 같은 Redis 안에서 키 접두사(prefix)만 분리해서 독립된 Queue를 만드는 방식입니다.

Plaintext
이전:  /{stage}/queue/{resourceKey}                ← Queue 1개
이후:  /{stage}/queue/partition_{id}/{resourceKey} ← Partition 수만큼 독립된 큐

이것이 저희가 "논리적 파티셔닝"이라고 부르는 이유입니다.

TaskGroup의 Job이 각 Partition으로 분배되는 과정

라우팅의 핵심은 Partition Key로 taskGroupID를 사용한다는 점입니다. 같은 TaskGroup의 Job은 hash(taskGroupID) % N에 의해 항상 같은 Partition으로 라우팅되며 Partition마다 독립된 Consumer + Worker 파이프라인을 가집니다. 하나의 TaskGroup이 여러 Partition에 흩어지는 일이 없으므로 Partition 내에서 TaskGroup 단위의 우선순위 관리나 상태 추적이 자연스럽게 이루어집니다. TaskGroup A의 대량 Job이 Partition 0에 몰려도 Partition 1, 2의 처리에는 영향을 주지 않습니다.

스케일링 복잡도 단순화

앞서 설명한 인스턴스 단위 스케일링의 문제를 파티셔닝이 어떻게 해결하는지 비교해 보겠습니다.

파티셔닝 도입 전 CPU 기반 인스턴스 Auto-scaling

파티셔닝을 도입한 후에는 Partition 당 Consumer 수는 1개로 고정하고 프로세스 내부의 Worker(goroutine) 수만 동적으로 조절합니다.

파티셔닝 도입 후 Partition 단위 Worker Auto-scaling

Worker 수 조절 API는 이미 구현되어 있었기 때문에, 필요한 만큼만 Worker를 늘리거나 줄일 수 있었습니다. 인스턴스 수는 변하지 않고 스케일링은 Partition별로 독립적으로 진행되므로 서로 간섭하지 않습니다.

그렇다면 각 Partition의 Worker 수는 누가 결정할까요?

→ Coordinator가 이 역할을 합니다!

Coordinator는 모든 Partition을 주기적으로 순회하면서 Partition별 부하 상태를 독립적으로 관찰하고, 스케일링이 필요하다고 판단되면 해당 Partition의 Scaling Command Queue에 커맨드를 발행합니다. Consumer는 자신의 Command Queue를 감시하다가 커맨드가 도착하면 Worker 수를 조절합니다.

Coordinator가 각 Partition Consumer의 Worker 수를 조절하는 과정

Coordinator는 관찰과 결정만 하고 실제 실행은 각 Consumer가 독립적으로 수행합니다. 이 구조 덕분에 Partition 간 간섭 없이 스케일링이 이루어집니다.

한 가지 더 고려해야 할 점이 있습니다. 동적 스케일링에 의해 Worker 수가 조절되고 있는 상황에서 Consumer 프로세스가 재시작되면, 해당 Consumer가 이전에 몇 개의 Worker를 사용하고 있었는지 알 수 없습니다. 항상 기본값으로 시작하면 스케일링이 다시 수렴할 때까지 시간이 소요됩니다.

이를 해결하기 위해 Worker 수를 주기적으로 Redis에 저장(checkpoint)하고 재시작 시 이를 읽어 복구하도록 했습니다. Partition별로 checkpoint 키가 분리되어 있으므로 특정 Consumer가 재시작되어도 해당 Partition의 checkpoint만 읽으면 됩니다.

물리적 파티셔닝으로의 확장

현재는 논리적 파티셔닝만으로 충분하지만 추후 Redis 자체의 부하가 높아지면 물리적 파티셔닝(Redis 인스턴스 분리)이 필요할 수 있습니다.

현재 구조에서는 이 전환이 용이합니다.

벌크액션 서버의 파티셔닝 구조: 현재 - 논리적 파티셔닝과 미래 - 물리적 파티셔닝

전환이 용이한 이유는 다음과 같습니다.

  • Keyspace가 /queue/partition_{id}/로 이미 완전히 분리되어 있어 Partition 간 cross-key 의존이 없습니다.

  • Queue Pool이 Partition별 Queue를 독립적으로 관리하고 있어, 각 Queue의 Redis 연결 대상만 변경하면 물리적 분리를 달성할 수 있습니다.

  • 각 Consumer가 이미 하나의 Partition만 처리하므로 Redis 접속 주소만 교체하면 됩니다.

즉, 변경 범위가 Redis 연결 설정 한 곳으로 한정되며 비즈니스 로직은 수정 없이 동작합니다.

배포 전략

코드가 준비되었다면, 이를 운영 중인 시스템에 부작용 없이 어떻게 적용할 것인가를 고민해야 합니다. 파티셔닝을 활성화하면 Redis Keyspace가 달라지므로 기존 Queue에 남아있는 Job을 어떻게 처리할지가 핵심 과제였습니다.

논리적 파티셔닝을 적용한 벌크액션 서버 최초 배포 과정

별도의 데이터 마이그레이션이나 레거시 Keyspace의 Job 처리를 마무리하는 Drain용 Consumer를 따로 배포할 필요가 없었습니다. 인프라 단에서 트래픽 라우팅을 조절하여 새로운 요청은 새 Producer로만 보내고, 기존 서버는 레거시 Keyspace의 Job을 소진한 뒤 자연스럽게 종료하면 되었습니다.

Coordinator

마지막으로 소개할 컴포넌트는 Coordinator입니다. 일반적으로 Coordinator는 파티셔닝된 혹은 분산 시스템을 조율하기 위한 컴포넌트로 소개되며, 복잡도를 크게 올리는 시스템이기도 합니다. 그러나 저희 설계에서 Coordinator를 두는 건 좀 더 이후 확장성을 위한 전략이기도 했습니다.

Coordinator가 Consumer의 스케일링 지시 및 상태 저장소 역할까지 하도록 구성하였고, 추후 물리적 파티셔닝 및 파티셔닝에 대한 확장을 Coordinator 노드가 맡게 되는 구조를 고려하였습니다. 다만 어떤 방식으로 설계해도 Coordinator의 존재를 위해서는 시스템의 단순함을 희생할 수밖에 없습니다. 그렇기에 Coordinator 구현은 최대한 단순하게 해야 했습니다.

Coordinator 노드 구현은 Job Queue 구성을 재활용하는 방식으로 작성했습니다. 이는 Kafka의 KRaft 구조에서 많은 영감을 받았습니다 [6].

Kafka는 오랫동안 클러스터 메타데이터 관리(브로커 목록, Partition 리더 선출, 설정 변경 등)를 외부 시스템인 ZooKeeper에 위임했습니다. 이 구조는 잘 동작하지만, 운영해야 할 분산 시스템이 Kafka와 ZooKeeper 두 개가 되는 셈이라 배포/모니터링/장애 대응 복잡도가 높았습니다.

이 문제를 "이미 가지고 있는 도구를 재사용하자"는 발상으로 해결합니다.

Kafka가 가장 잘하는 일은 토픽에 레코드를 쓰고 읽는 것이니, 메타데이터도 내부 토픽(__cluster_metadata)에 이벤트로 기록하고 Controller 노드들이 이를 Raft 합의로 복제하는 방식으로 해결했습니다.

브로커 입장에서는 평소 데이터 토픽을 소비하듯 메타데이터 토픽을 따라가기만 하면 최신 클러스터 상태를 알 수 있습니다.

Coordinator가 Consumer에게 지시를 전달하기 위해 별도의 RPC 채널이나 외부 조율 시스템(etcd, ZooKeeper 등)을 도입하는 대신, 이미 존재하는 Job Queue 파이프라인을 재사용했습니다. Coordinator는 각 Partition의 Job Queue에 스케일링 지시 Job을 발행하고 Consumer는 기존과 동일한 방식으로 폴링하다가 커맨드를 수신하면 Worker 수 조절이나 자신의 상태를 저장합니다.

결과적으로 Coordinator 노드는 Consumer의 상태를 추적하고, PWQD 메트릭을 수집하며, 스케일링을 지시합니다. 기존 코드를 재사용함으로써 1000줄 이내의 코드로 Coordinator 노드를 구현할 수 있었습니다.

JSON

결과

여기까지 논리적 파티셔닝과 동적 스케일링의 설계 및 구현을 살펴보았습니다. 그렇다면 실제로 얼마나 효과가 있었을까요? 10만 건 규모의 부하 테스트를 통해 확인해 보았습니다.

처리 성능

논리적 파티셔닝 및 동적 스케일링 도입 후 처리 시간이 눈에 띄게 감소하였습니다.

약 4분간 10만건의 Job을 처리하면서 Queue Depth가 순간적으로 증가했다가 감소하는 그래프

지표

Before

After

처리 시간 (10만건 기준)

25분

4분

개선율

84% 단축

Partition 격리 효과

파티셔닝 도입의 핵심 목적이었던 HOL Blocking 해소도 확인할 수 있었습니다.

파티셔닝이 적용된 Job Queue

이전에는 대량 TaskGroup이 Queue를 점유하면 다른 TaskGroup의 처리가 밀렸습니다. 하지만 파티셔닝 이후에는 각 Partition이 독립적으로 동작하므로 특정 Partition에 대량 Job이 몰려도 다른 Partition의 처리에는 영향을 주지 않습니다.

동적 스케일링

기존의 인스턴스 단위 점프(32 → 64 → 96)와 달리, Coordinator의 동적 스케일링을 통해 Partition별로 필요한 만큼만 Worker를 늘리거나 줄이는 것을 확인할 수 있었습니다.

Partition별 Worker 수가 부하에 따라 독립적으로 증감하는 모습

  • 평상시: 기본 Worker 수 유지

  • 부하 증가 시: 최대 8배까지 자동 Scale Up

  • 부하 감소 시: 자동 Scale Down

운영 관점

배포 전략 섹션에서 설명한 4단계 전환은 서비스 중단 없이 완료되었습니다. 또한, 이전에는 부하 상황에서 개발자가 직접 Worker 수를 조정해야 했지만, 동적 스케일링 도입 이후에는 수동 개입 없이 시스템이 자동으로 부하에 대응하게 되었습니다.

한계 및 향후 과제

현재 구조에서 알려진 한계점도 있습니다.

  • Partition 수 고정: 현재는 Partition 수가 설정 시점에 결정되며 운영 중 동적으로 변경할 수 없습니다. Partition 추가/제거 시에는 재배포가 필요합니다.

  • Hot Partition 가능성: 해시 기반 라우팅은 대체로 균등한 분배를 보장하지만, 극단적으로 서로 다른 TaskGroup들이 특정 Partition에 집중되는 경우 해당 Partition에 부하가 몰릴 수 있습니다. 이 문제를 해결하기 위해 논리적 파티셔닝으로 수많은 Partition을 나누고 Shuffle Sharding 도입을 고려해볼 수도 있습니다 [7][8].

  • 물리적 파티셔닝 전환 기준: 논리적 파티셔닝에서 물리적 파티셔닝으로 전환해야 하는 Redis 부하 임계점은 아직 구체적으로 정해지지 않았습니다. 현재로서는 Redis CPU 사용률을 모니터링하며 필요 시 전환할 계획입니다.

결론 및 마무리

앞선 글 - 급증하는 트래픽 안정적으로 처리하기: 개선편(1) 동적 스케일링 - 과 이번 글에서는 벌크액션 서버가 프로덕션에서 마주한 두 가지 문제 — 1. Worker 수의 수동 관리2. 단일 Queue의 HOL Blocking — 를 다루었습니다. 현재 트래픽에서는 Redis Cluster나 분산 조율 메커니즘까지 도입하는 것은 과한 선택이었기에, 애플리케이션 레벨의 Keyspace 분리와 Coordinator 기반 스케일링으로 문제를 풀어보았습니다.

그 결과

  • 10만건 기준 처리 시간이 25분에서 4분으로 84% 단축되었고

  • Partition 간 독립 처리로 HOL Blocking이 해소되었으며

  • Worker 수도 부하에 따라 최대 8배까지 자동으로 조절되어

더 이상 개발자가 수동으로 개입할 필요가 없어졌습니다.

저희의 경험이 Job Queue의 확장성이나 대량 트래픽 처리 구조를 고민하고 계신 분들에게 참고가 되었으면 합니다.

감사합니다.

참고 문서

[1] "Partition (database)," Wikipedia. [Online]. Available: https://en.wikipedia.org/wiki/Partition_(database)

[2] M. Kleppmann, *Designing Data-Intensive Applications: The Big Ideas Behind Reliable, Scalable, and Maintainable Systems*. Sebastopol, CA, USA: O'Reilly Media, 2017, ch. 6.

[3] Discord Engineering, “How Discord Indexes Trillions of Messages,” Discord Blog, 2023. [Online]. Available: https://discord.com/blog/how-discord-indexes-trillions-of-messages

[4] R. Krikorian, “Timelines at Scale,” QCon San Francisco, San Francisco, CA, USA, 2012. [Online]. Available: https://www.infoq.com/presentations/Twitter-Timeline-Scalability/

[5] "Sorted sets," Redis Documentation. [Online]. Available: https://redis.io/docs/latest/develop/data-types/sorted-sets/

[6] "KRaft: Apache Kafka Without ZooKeeper," Confluent Developer. [Online]. Available: https://developer.confluent.io/learn/kraft/

[7] "Handling billions of invocations – best practices from AWS Lambda," AWS Compute Blog. [Online]. Available: https://aws.amazon.com/ko/blogs/compute/handling-billions-of-invocations-best-practices-from-aws-lambda/

[8] C. MacCárthaigh, "Workload isolation using shuffle-sharding," Amazon Builders' Library. [Online]. Available: https://aws.amazon.com/builders-library/workload-isolation-using-shuffle-sharding

We Make a Future Classic Product