급증하는 트래픽 안정적으로 처리하기: 개선편(1) 동적 스케일링

Spike 트래픽에 흔들리지 않는 Worker 스케일링 알고리즘 만들기

Nero • Core BE - API

  • 엔지니어링

이 글은 급증하는 트래픽 안정적으로 처리하기: 구현편 에서 다루었던 내용의 확장으로 실제 프로덕션 운용중에 생긴 문제들과 개선 과정의 일부를 담고 있습니다. 아래 내용을 읽기 전 이전 아티클을 읽어보시는 걸 추천드립니다.

채널톡은 약 20만 고객사가 사용하는 B2B 제품으로 주요한 기능들은 대규모 Spike Load와 관련이 있습니다. (저희는 이를 내부적으로 벌크액션이라고 불러요.)

급증하는 대용량 트래픽이랑 관련이 있다는 게 어떤 의미일까요? 잠시, 아래와 같은 문제를 어떻게 처리하면 좋을지 고민해봅시다.

  1. 채널톡을 도입하고 우리가 가지고 있는 고객 데이터를 채널톡에 업로드해서 관리하고 싶어요. 고객은 약 100만명 정도예요.

  2. 최근 구매 금액이 만원 이상인 고객들에게 프로모션 이벤트를 진행하고 싶어요. 대상 고객은 약 10만명이에요.

  3. 내부 태그 정책이 바뀌었습니다. 1년 동안 진행한 상담에 일괄적으로 'A' 태그를 부착하고 싶어요.

*개션편은 동적 스케일링을 다룬 본 글과 논리적 파티셔닝을 다룬 급증하는 트래픽 안정적으로 처리하기: 개선편(2) 논리적 파티셔닝 총 두 편으로 나누어 업로드됩니다.

개요

채널톡은 약 20만 고객사가 이용하는 B2B SaaS 제품인만큼 여러 형태의 요청을 받을 수 있습니다. 저희는 그 중 벌크액션이라는 - 한 번의 요청으로 적게는 수백 건 많게는 수백만 건의 요청을 안정적으로 처리해야하는 - 문제를 해결했습니다.

벌크액션 서버는 받은 요청을 정해진 TPS 내에서 최대한으로 잘 처리합니다. 이를 위해 우리는 1. 똑똑한 backoff 방식을 제안하고, 2. Throttled Job을 Non-ready Queue 로 보내 HOL Blocking[1] 문제를 최소화하고, 3. TPS를 준수하며 정해진 시간 내에 처리할 수 있는 메커니즘을 제공합니다.

이 구조 하에서도 여전히 문제점은 존재했습니다. 특히 Job Queue에 Long Lived Job이 많은 경우, 모든 Worker들이 Busy하게 되고 여유 자원이 없어 Exhaustion이 발생할 수 있습니다.

이러한 문제는 보통 Worker 수를 조정하는 것으로 혹은 Pod의 스케일링을 조정하는 방식으로 방지할 수 있습니다. 다만 이는 벌크액션 서버를 사용하는 클라이언트의 요청 패턴에 의존적이기 때문에 지속적으로 할 수 있는 조치는 아니었습니다.

따라서 좀 더 세밀한 스케일링 정책이 필요하다는 의견이 대두되었습니다.

문제점

저희는 똑똑한 스케줄링 및 Aging 기법을 이용해서 Task를 적절한 순서로 처리할 수 있는 일종의 Fair Queue를 제공했습니다.[2] 그럼에도 사용자가 많고 Task를 처리하는 Worker 수 자체가 부족한 상황은 Job Queue의 Depth를 깊어지게 만들 수 있습니다.

벌크액션 서버로 들어오는 요청 특성 상, 클라이언트(요청자)가 보내는 Job 하나의 형태는 기본적으로 여러 단일 처리의 묶음인 경우가 대부분입니다. 단일 처리 당 하나의 Job으로 관리할 수도 있습니다만 일반적으로 I/O 비용 최적화 등의 이유를 들어 복수의 처리를 지향합니다. (이는 의도된 바이기도 합니다.)

이러한 패턴은 Job 하나의 실행 시간이 수 초 수준으로 지연될 수 있음을 의미합니다. 늘어난 Busy Worker와 느려진 속도로 인해 오히려 TPS 제한은 준수하지만 동시에 지연된 Task가 Non-ready Queue로 적재되지 않기도 합니다. 똑똑한 스케줄링을 통한 이점을 잘 이용하지 못 하는 느린 처리 속도는 꼬리 지연을 야기했습니다.

기존에는 Pod의 Horizontal Scaling을 통해 일부분 해결할 수 있었습니다만, 실제 위와 같은 지연이 직접적인 CPU/Mem 지표로 표현되기는 어려웠습니다.

구현 디테일

적정 메트릭을 찾아서

보통 스케일링은 특정 임계치(Threshold)를 넘어갔을 때 적용하는 게 기본적인 메커니즘입니다. 저희도 이 임계치를 찾아야 했습니다.

스케일링 기준 후보에는 Job Queue의 Depth, 현재 실행 중인 Worker 수, Latency(p99, p50) 등 다양한 지표가 들어갈 수 있습니다. 그러나 이 후보군은 너무나도 많고 다양합니다. 그렇기에 설명이 직관적이고 부하를 온전히 표현할 수 있는 선행 지표를 찾기 위해서 여러가지 테스트를 진행했습니다.

저희가 테스트한 주요 후보 지표는 다음과 같습니다.

  1. Job Queue Depth (대기 작업 수)

가장 직관적인 지표입니다. 큐에 쌓인 Task가 많다면 Worker가 부족하다는 뜻이니까요.

실제로 Queue Depth가 1만을 넘었을 때 Worker를 2배로 늘리는 단순한 규칙을 적용해보았습니다.

Worker 스케일링 적용 전 / 후의 Queue Depth

기존

  • Task: 10만 건

  • Worker: 8개

→ 처리 시간: 약 25분

스케일링 규칙 적용 후

  • Task: 10만 건

  • Worker: 8개 → (Queue Depth 1만 초과) → 16개

→ 처리 시간: 약 14분

하지만 Queue Depth만을 스케일링 기준으로 삼기에는 부족했습니다. 벌크액션 서버에는 Rate Limit 메커니즘이 존재하는데, 새로운 Task들이 큐에 유입되면 실행되기 전까지는 Rate Limit 기준을 알 수 없어 최소 한 번씩은 실행 시도 후 스로틀링(제한)됩니다. 이 때 "초기 탐색" 구간에서는 Queue Depth가 실제 부하와 무관하게 급등하므로 이때 Worker를 늘리면 Rate Limit에 걸려 아무 일도 하지 못하는 Worker만 늘어나게 됩니다.

  1. In-Flight Queue Size (실행 중인 Task 수)

현재 실행 중인 Task의 수로 Worker 활용률을 측정하려는 시도였습니다.

스로틀링되는 Task가 실행 중인 Task 수로 집계되는 문제

그러나 Rate Limit에 의해 스로틀링되는 Task도 잠깐이나마 In-Flight 상태를 거치기 때문에 실제 Worker 부하를 정확하게 반영하지 못했습니다. 이 메트릭은 오히려 Worker 수와 함께 선형적으로 증가하기만 했지 실제 Worker의 “바쁨”을 측정하진 못했습니다.

  1. Job Execution Latency (작업 실행 시간, p95)

실시간으로 처리되고 있는 Task의 실행 시간입니다. 같은 Queue Depth라도 Task 하나가 50ms 안에 끝나는지 Task 하나가 처리되는데 10초가 걸리는지에 따라 상황이 완전히 다르기 때문에 Queue Depth를 시간 관점으로 해석하는 데 유용한 지표였습니다.

큐 대기열에서 대기하는 Task 소요 시간 예측 불가

다만 이 지표는 이미 실행 중인 Task에 대한 지표라서 선행 지표로 활용하기에는 한계가 있었습니다. 큐 안에는 서로 다른 벌크액션의 Task들이 섞여 있고 각 Task들의 실행 시간은 천차만별일 수 있습니다. 대기 중인 Task가 실제로 얼마나 걸릴지는 처리해보기 전까지 알 수 없기 때문에 이 지표만으로 앞으로의 부하를 예측하기는 어려웠습니다.

한편, 위 지표들을 보완하기 위한 목적으로 Rate Limit Throttle Rate(스로틀링 비율)를 사용할 수 있을지 조사했습니다. 이 값이 높은 구간에서는 Worker를 늘려도 실질적인 처리량 향상이 없으므로 Scale Up을 억제하는 보조 지표로 활용할 수 있을 것 같았습니다. 하지만 Rate Limit 초기 탐색 구간에서 지표가 80~90%까지 치솟았다가 스로틀링 이후 급격히 떨어지는 등 변동이 너무 심해 안정적인 기준으로 쓰기 어려웠습니다.

Key Metric

Per-Worker Queue Depth = queueDepth / currentWorkers

여러 후보에 대해 실험한 끝에, 저희가 선택한 지표는 Queue Depth(대기 작업 수)를 현재 Worker 수로 나눈 Per-Worker Queue Depth(이하 PWQD) 수치입니다.

이 값이 직관적으로 상황을 잘 드러내는 이유는, 동일한 Queue Depth라도 현재 Worker 수에 따라 상대적인 수치를 잘 표현하기 때문입니다.

적은 Worker - 상대적으로 높은 부하

  • queueDepth = 100

  • currentWorkers = 8

→ Per-Worker Queue Depth = 100 / 8 = 12.5 (High)

많은 Worker - 상대적으로 낮은 부하

  • queueDepth = 100

  • currentWorkers = 64

→ Per-Worker Queue Depth = 100 / 64 = 1.6 (Low)

위 계산 결과에서 보이는 바와 같이 큐에 쌓인 Task의 개수가 동일하더라도 Worker가 많은 쪽이 PWQD 수치상 상대적으로 여유로운 상황입니다. 단순 Queue Depth의 한계였던 "절대적인 임계치를 어디에 설정할 것인가"라는 문제도, 기준을 Worker 수 대비 상대적인 값으로 전환하면서 자연스럽게 해결되었습니다.

또한 PWQD 메트릭은 사용자가 어떤 패턴으로 Job을 전달하는지에 최대한 의존적이지 않은 값입니다. 특정 서비스가 벌크액션 서버를 쓸 거니, Latency가 어느정도 될 것이니, 그에 맞는 스케일링 대비를 해두는 등의 고민을 하지 않아도 되는 것이 큰 이점이기도 합니다.

미리 언급해보자면, 이 PWQD 값이 임계치를 연속으로 초과하면 Scale Up을 트리거합니다. "연속으로"라는 조건은 순간적인 Spike에 과잉 반응하지 않기 위해 추가한 속성입니다. 앞서 문제가 되었던 Rate Limit 초기 탐색 구간에서의 Queue Depth 급등 상황에서도 Task들이 적절히 다시 스케줄링되면서 이 비율이 빠르게 안정되므로 불필요한 Scale Up을 방지할 수 있었습니다. 자세한 설명은 후술하겠습니다.

스케일링 알고리즘

우선 측정할 메트릭은 명확해졌습니다. 그러면 이제 실제 스케일링 알고리즘 이야기를 해봅시다.

이를 고민하기 위해선 클라이언트의 요청 패턴을 파악해야합니다. 벌크액션으로 들어오는 Job의 특성을 나열해봅시다.

  • Long Lived Job이 존재할 수 있음.

  • Spiky한 요청 구조, 근소한 시간 차이로 여러 서버에서 요청이 들어올 수도 있음.

  • TPS를 준수하는 최종 수행까지는 긴 시간이 걸릴 수 있음.

여기서 중요한 포인트는, 언제나 Spiky한 트래픽이 올 수 있으며 앞서 설명했던 것처럼 초기 Job Queue 적재 과정에서는 자연스럽게 Queue Depth가 깊어질 수 있다는 사실입니다. 일반적으로 PWQD 메트릭은 벌크액션이 등록되는 시점에는 높아지는 게 자연스럽습니다. 이는 섣불리 Worker 수를 올렸을 때 적절하지 않은 스케일링 지시가 될 수 있음을 의미합니다.

Spiky한 트래픽에 민감하게 반응해야 하지만 초기 등록 과정은 보수적으로 접근해야한다.

두 가지 상황은 서로 상반되는 것 같지만 적절한 Trade-off하에 같이 해결해야했습니다.

실험 0. 비율 기반 지수적 증감

실험을 통해 천천히 접근해봅시다.

처음 접근했던 방식은 단순한 지수적 증감입니다. PWQD 메트릭이 특정 임계치(Threshold)보다 올라간다면 바로 그리고 지수적으로 Worker 수를 올리는 방식입니다.

이는 꽤나 직관적이면서도 단순합니다. 그리고 실제로 높은 트래픽에 대해서도 잘 대응할 수 있습니다.

다만, 적절한 Worker 수를 찾는 과정이 순탄치 않았습니다. Spiky 상황에 Worker 수를 지수적으로 올리는 형식은 좋았지만 지표가 특정 임계치보다 아래인 경우 Scale Down 과정에서 Oscillation 현상이 매우 크게 일어났습니다. 실제 좌하단에 Worker 수 조정이 일어나며 진폭이 커지는 과정이 지속되는 것을 보고 Scale Down을 보수적으로 할 필요성이 있었습니다.

실험 1. 혼잡제어 기반 알고리즘

몇 가지 스케일링 기준을 고민하다, 유사한 패턴을 가진 문제가 있진 않을까 고민하며 찾았던 것은 TCP 혼잡제어 알고리즘이었습니다.

특히, Oscillation 문제를 해결하기 위해 TCP AIMD[3]의 영감을 받아 고안한 이 해결법이 꽤나 유효할 것으로 기대했으며 Scale Down에 선형적 감소를 적용하면 첫 실험에서 있었던 진폭 문제를 효과적으로 해결할 수 있다고 판단했습니다.

실험 결과 실제로 진폭이 줄어든 것을 확인할 수 있습니다.

그러나 이 알고리즘 역시 최종 선택은 아니었습니다. 알고리즘을 그대로 적용할 경우 ssthresh 시점부터는 선형적 증감을 적용하는데, 벌크액션이 초기에 들어온 경우에는 즉각적으로 반응하지만 동시에 비슷한 규모 혹은 더 큰 요청이 들어오는 경우 그 이후에는 Worker 수가 보수적으로 증가하였기 때문입니다.

실험 2. Exponential & Time Decay

다시 벌크액션 요청의 특징을 짚어보겠습니다.

벌크액션 서버는 Spiky한 요청에는 즉각적으로 대응해야 하지만 보수적인 Scale Down을 같이 고려해야 합니다.

또한 PWQD 메트릭은 초기 Job 등록과정에서 빠르게 증가하여 성급한 스케일링 지시를 할 수도 있습니다. 오판할 수 있습니다. 따라서 실제로 부하가 있고 Worker 수가 부족하다는 것을 알기 위해서는 "지속성"을 표현할 수 있어야했습니다. 이에 따라 PWQD가 지속적으로 높은 부하를 나타내고 있는 경우 Scale Up하고, Scale Down은 Time Decay를 적용해 보수적으로 진행하도록 적용했습니다.

결과적으로 느린 감소를 이용해 추가적인 요청에 대비하고 빠른 Worker 수 증가와 Oscillation 문제도 회피가 가능했습니다.

결론 및 마무리

여러 실험을 통해 PWQD(Per-Worker Queue Depth)라는 핵심 메트릭을 도출했고, Exponential Scale Up과 Time Decay 기반 Scale Down을 결합한 동적 스케일링 알고리즘을 완성했습니다.

이를 통해 벌크액션 서버에 들어오는 트래픽을 적절한 TPS 준수하에 처리할 수 있도록 했습니다. Worker 수가 부하에 따라 자동으로 스케일링되면서 개발자의 수동 개입이 줄어들었고, Spike 트래픽에도 Oscillation 없이 안정적인 스케일링이 가능해졌습니다.

하지만 동적 스케일링만으로는 해결되지 않는 구조적 한계가 남아 있었습니다.

모든 Job이 하나의 Job Queue를 공유하는 구조에서는 대규모 벌크액션이 Job Queue를 점유해 다른 요청의 처리가 밀리는 HOL Blocking 문제가 여전히 남아 있었습니다.

다음 글에서는 이 문제를 논리적 파티셔닝과 Coordinator 기반 Partition별 독립 스케일링으로 해결한 과정과 구체적인 성과를 함께 다루겠습니다.

감사합니다.

참고 문서

[1] "Head-of-line blocking," Wikipedia. [Online]. Available: https://en.wikipedia.org/wiki/Head-of-line_blocking

[2] "Aging (scheduling)," Wikipedia. [Online]. Available: https://en.wikipedia.org/wiki/Aging_(scheduling)

[3] "TCP congestion control," Wikipedia. [Online]. Available: https://en.wikipedia.org/wiki/TCP_congestion_control

We Make a Future Classic Product