급증하는 트래픽 안정적으로 처리하기, 구현편

대용량 트래픽을 안정적으로 처리하는 방법

Hammer • Backend Engineer

  • 테크 인사이트

이 글은 약 4개월 동안 백엔드 엔지니어 후, 두기 그리고 해머 이 세명이서 급증하는 트래픽을 관리하기 위해 만들었던 서비스에 대해 설명합니다. 채널톡이 임의 시점에 급증하는 트래픽을 어떻게 관리하는지, 어떻게 문제를 해결했는지에 대해 다룹니다.

일명 spike load라고 부르죠. 이번 글에서는 채널톡이 기존에 이러한 문제를 어떻게 풀고 있었는지, 그리고 더 나아가 이를 어떻게 개선했는지를 알아봅시다.

벌크액션이란?

채널톡은 약 20만 고객사가 사용하는 B2B 제품으로, 주요한 기능들은 대규모 spike load와 관련이 있습니다. (저희는 이를 내부적으로 벌크액션이라고 불러요.) 급증하는 대용량 트래픽이랑 관련이 있다는 게 어떤 의미일까요? 잠시, 아래와 같은 문제를 어떻게 처리하면 좋을지 고민해봅시다.

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

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

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

여러 고객 정보를 한 번에, 적절히 처리하고 싶은 비즈니스 니즈는 보통 B2B 제품에서는 더러 있습니다. 저희는 이러한 비지니스 로직을 모두 벌크액션으로 취급하고 있습니다. 위에 해당하는 케이스는 사실 그냥 요청만 반복해서 잘 처리하면 되는 거 아닌가 싶은 생각이 들 수 있습니다. 하지만, 저희 팀은 임의 시점에 이러한 요청을 (1) 안정적으로 놓치지 않고, (2) 적절한 속도로, (3) DB 부하없이 해결해야 합니다. 그렇기에 이러한 문제를 푸는 기술은 꽤나 까다롭습니다.

문제 정의

자 그러면 이제 다시 한 번 문제를 정의해보겠습니다.

(1) 고객사는 원하는 임의의 시점에 벌크 액션을 실행합니다.

(2) 고객사는 자신의 요청이 하나도 놓쳐지지 않기를 바랍니다.

(3) 고객사는 자신들의 요청을 빠르게 처리해주기를 바랍니다.

여기서 채널톡은 여러 벌크액션 기능들을 개발하면서 몇 가지 문제에 봉착하게 됩니다. 기존에 잘 풀기 위해서 저희 백엔드 개발자들은 각 기능별로 다양한 인프라와 다양한 해결 방법을 통해서 문제를 풀어왔습니다. 하지만, 이렇게 개발이 지속되면서 아래와 같은 문제가 발생했습니다.

(4) 매 벌크액션 기능 마다 통일된 인터페이스 없이 구현되어 패턴이 제 각각입니다.

(5) 특정 큰 고객사의 요청이 마무리될 때까지 다른 고객사의 요청을 처리하지 못하는 경우가 있습니다.

(6) 특정 고객사의 큰 요청이 전체 서비스 품질에도 영향을 미치는 경우가 있습니다.

벌크액션 서버를 개발하게 된 이유도 통일된 인터페이스안정적인 처리 과정을 제공하고자 하는 목적이 컸습니다. 따라서, 저희 팀은 벌크액션을 잘 풀기 위해서 위 6가지 문제를 잘 풀어야 했습니다. 이번 글에서는 이 문제를 어떻게 해결했는지에 대해 이야기하고, 글 말미에는 다시 한 번 짚어보면서 정말로 잘 해결했는지 살펴봅시다.

아키텍처 개요

그림 1. 대략적인 아키텍처 개요

1. Fair queuing

채널톡은 20만개의 고객사가 사용하는 제품인만큼, 다양한 기능과 다양한 벌크액션 요청들이 존재합니다. 만약, 큰 고객사가 매우 큰 트래픽의 요청을 날려서 다른 고객사의 요청이 대기하는 Head-of-line Blocking 문제가 생긴다면, 서비스 품질에 큰 영향을 줄 것입니다.[1]

따라서, 저희는 일종의 Fair Queueing 기법을 도입해야합니다.[2] 특정 고객사의 요청에 서버 자원이 모두 소모되지 않도록 적절히 Job Queue를 유지해야합니다. 후술하겠지만, 저희는 총 3개의 Job Queue를 유지하여 자원 관리를 하고 있습니다. 이에 대한 설명은 구현 설명에서 더 계속하겠습니다.

2. Backpressure

앞서 말씀드렸다시피 고객사는 자신들의 작업이 빠르게 처리 되기를 바랍니다. 하지만, 엔지니어 입장에서는 서버와 DB에 부하없이 이러한 요청을 처리하고 싶죠. 이 둘 사이에서 저희는 적절한 trade-off를 찾아야합니다. 속도 제어 메커니즘은 이러한 이유로 필수이죠.

저희는 내부적으로 이를 Rate Limiting 기법을 이용해 처리할겁니다.[3] Fixed Window 알고리즘을 사용하여 특정 작업의 RPS(Request per second)를 제한하는 방식이면 충분할 거 같습니다.

3. Worker

저희 서버는 golang으로 작성되어 있으며, goroutine이 가진 강력한 추상화 정도와 퍼포먼스를 이용해 한 인스턴스마다 적절한 worker pool을 구성하도록 합니다.

4. Aggregator

벌크액션 서버에서 한 작업 단위(Job)를 처리하는 것은 worker를 통해 진행됩니다. worker는 Job Queue에서 job을 가져와 실행시키는 역할을 합니다. job은 일종의 HTTP req, RPC, Function 등이 될 수 있습니다.

실제로 특정 고객사 요청이 모두 마무리 되었다면, 이를 callback의 형태로 요청자에게 알려주어야 합니다. 다량의 정보를 병렬적으로 처리하고 합치는 과정은 우리 모두 익숙한 프레임워크를 압니다. [4]

일종의 MapReduce 인터페이스를 제공하면서 모든 작업이 끝났다면, 이 결과를 집계해서 특정 서버에 다시 callback 형태로 전송합니다.

5. Handling failures

분산 시스템 환경에서는 어떠한 실패도 일어난다라고 가정하는 게 좋습니다. 그렇다면 이러한 실패를 잘 처리하는 방법은 무엇일까요? 또한 그렇다면 고객의 요청을 놓치지 않고 처리하기 위해서 어떻게 해야할까요?

  1. job에서 특정 서버로 보내는 요청이 네트워크 분단으로 인해 수행되지 않을 수 있습니다.

  2. 클라이언트 측에 잘못된 job 구성으로 실패할 수 있습니다.

  3. 예상치 못한 오류로 workerpanic에 빠질 수 있습니다.

이러한 문제를 해결하는 방법에는 여러가지가 있지만, 저희는 고객의 요청이 빠짐없이 실행되기를 기대합니다. 실패했다면 이를 잘 기록해서 보장해야합니다. 저희는 ACK, Retry, at-least once semantic 등을 이용해 보장합니다. 자세한 구현은 추후에 설명하겠습니다.

다루지 않는 기능

동적 흐름 제어

기본적으로 벌크액션은 외부 서버로의 부하 요청이기 때문에 실제로 이 또한, RPS 조절이 되어야할 수도 있습니다. 따라서, 이러한 job의 평균 latency 등을 추적해 추가적인 동적 흐름 제어를 필요로할 수 있습니다. 다만, 이러한 구현은 이번에 다루지 않습니다.

클라이언트의 중복 요청

Amazon SQS 등에서 제공하는 deduplication ID 기능은 따로 제공하지 않습니다.[15] 이번 구현에서는 클라이언트가 이를 신경써서 구현해야합니다.

멱등성

클라이언트가 제공하는 job은 at-least once sematic 하에 여러 번 시도될 수 있습니다. 이러한 job을 멱등적으로 관리하는 건 클라이언트의 몫입니다. 또한 이러한 구현을 어느정도 강제합니다.

Job Queue의 수평적 확장

Redis를 이용하는 Job Queue의 수평적 확장 방법에 대해서는 이야기하지 않습니다. 당장은 하나의 Job Queue를 이용한다고 가정합니다.

구현 디테일

1. Fair Queue

Redis Sorted Set

우선, 실행될 작업이 저장되는 영구 저장소는 Redis로 운영할 수 없지만, Job Queue는 다릅니다. 저희는 Redis에 Sorted Set과 lua script를 통한 atomic 연산을 이용해 Job Queue를 구현했습니다.[6][7] Redis Sorted Set 은 다양한 오퍼레이션을 제공하고 있으며, lua script와 함께 이용한다면 atomicity를 보장해 여러 오퍼레이션을 구현할 수 있습니다. 또한 high throughput 환경에서의 프로덕션 운용 역시 팀 내에 많은 자신감을 가지고 있던 터라 좋은 후보군이었습니다.

Fairness

자 그러면, 이제 실제로 fairness를 어떻게 지원할지 알아봅시다. 앞서 설명한 것처럼 job queue는 기본적으로 FIFO를 표방하지만, fairness를 위해 특정 요청만 자원이 소모되는 걸 막아야합니다. 이에 대한 여러가지 접근법이 있을 수 있겠지만 저희는 몇몇 레퍼런스를 참고하여 일종의 aging 기법을 통해 Priority Queue 를 운영하면 해결되는 문제로 판단했습니다. [8][9]

위에서 말은 쉽게했지만, 생각보다 쉬운 일은 아니었습니다. 적절한 스케쥴링 기법을 찾는다는 게 어려운 일이죠. 이후 저희는 여러번의 부하테스트 및 실험을 통해 적당한 priority 식을 찾으려고 했습니다.

Go

위에는 실제 저희가 사용하고 있는 priority 계산식의 일부입니다. (group은 실제 벌크액션의 단위, 하나의 고객사 요청) 집중해야할 것은 마지막 줄에 실제 group에 남은 job 개수를 통해 SJF(Shortest job first)를 구현하는 부분인데요.[10] 이러한 구현이 왜 들어갔는지 아래 그림을 통해 예시를 더 들어보겠습니다.

SJF 적용 전에는 FIFO 전략 하에서는 아래 그림 2.와 같이 group 2가 완료하기까지 job 들이 얼마 안 남았지만, retry 및 throttle 정책으로 인해 남은 job들이 가장 뒤로 들어갈 수 있습니다. 이렇게 되면 또 다시 언급했던 HOL Blocking 문제가 발생할 수 있어 group 2 의 남은 job 개수를 기준으로 priority boost 를 받도록 해야합니다. 저희가 기대했던 상황은 그림 3.과 같은 적당한 fairness를 보장하는 것이었습니다. 그렇기에 job 개수를 추적하여 남아있는 job의 개수가 적은 경우 boost를 받을 수 있도록 구성하여, SJF를 구현했습니다.

그림 2. Head-of-line Blocking 상황

그림 3. priority boost 적용 후

실제로 수차례 부하 테스트를 통해 기대했던만큼의 fairness 보장을 지원할 수 있었는데요. 만약에 유즈케이스가 더 늘어난다면 좀 더 똑똑한 스케쥴링 기법이 필요할 수 있습니다.

2. Backpressure

Rate Limiting

저희는 앞서 설명했듯이 RPS를 보장하기 위해 fixed window rate limiting 기법을 사용합니다. 자세한 제한 방식은 아래와 같습니다.

예를 들어, 특정 테이블을 접근하는 벌크액션은 전체 고객사 기준으로 최대 10,000 RPS를 보장한다고 해봅시다. 우선, 전체 자원에 대한 gross capacity를 10,000 RPS로 설정하고, 최대 N개의 고객사가 동시에 해당 테이블에 접근하는 벌크액션을 진행한다면 이론적으로는 (10,000/N) RPS를 각 고객사 요청마다 제한할 수 있을 겁니다. 이런식으로 특정 데이터베이스 등의 자원 소모를 제한하고 관리하는 게 기본적인 메커니즘입니다.

Ready Queue/Non-ready Queue

그림 4. Ready Queue/Non-ready Queue

앞서 설명드렸던 것처럼 rate limiting을 통해 최대 RPS를 넘는 경우를 컨트롤하는데, 저희는 throttle로 판단하여 job 실행을 후순위로 미룹니다. 다만, 낮은 RPS를 지원해야하는 job의 경우 너무 많이 빠르게 재시도를 한다면 과한 자원 소모를 막을 수 없습니다. 그렇기 때문에 저희는 일종의 backoff를 구현해야합니다. [11]

여기서 그러면 자연스레 문제가 생깁니다. backoff 시간동안 해당 job들은 어디서 보관해야할까요? 바로 기존 job queue 에 넣는다면, backoff 동안은 worker가 가져가지 않도록 해야합니다. 아니라면 계속해서 재시도를 진행할테니까요. 저희는 이러한 이유로 앞서 설명드렸던 것처럼 job queue를 여러개 관리하는 전략을 가져갔습니다. 잠시 backoff 시간 동안 worker가 가져가진 않지만 안전하게 보관할 공간이 필요했죠.

예측할 수 있는 에러들을 핸들링하고, 적절한 backoff 시간 동안 보관할 공간이 필요했고 저희는 이를 위해 job queue를 분할해, Ready Queue/Non-ready Queue로 관리하였습니다.

Ready Queue는 직관적입니다. 앞서 설명드렸던 Fair Queue이며, 계속해서 worker들이 queue에서 job을 가져와서 실행합니다. 만약 job 이 throttle 되었거나, 에러가 발생한 경우 Non-ready Queue에 job을 넣습니다.

Non-ready Queue는 역시 Redis 의 Sorted Set이지만, priority가 실제 backoff 까지의 unix milli입니다. 적절한 시간이되면 Non-ready QueueReady Queue 로 옮기는 작업을 내부적으로 dispatch라고 부르며, Dispatcher라는 goroutine worker가 이를 담당합니다. 자세한 설명은 3. Worker 섹션에서 이어하겠습니다.

똑똑한 backoff를 위한 혼잡 제어

말씀드렸던 것처럼 쓰로틀링이 일어나 job은 Non-ready Queue에서 일정시간 대기해야합니다. 그런데 이 기다리는 시간은 어떻게 결정할까요?

직관적으로 가장 간단한 방법은 고정된 시간으로 두는 것입니다. 이 전략은 만약 실행하려는 job의 총 개수가 TPS 보다 훨씬 높은 경우 효과적이지 않습니다. 예를 들어, 10,000 개의 job이 Ready Queue에 대기하고 있으나 10TPS까지 허용한다고 가정해보겠습니다. Ready Queue에서 차례로 job 실행 시도를 하면서 10개는 첫 1초에 통과되지만 나머지 9,990개는 쓰로틀링이 일어납니다. 다음 1초 window의 시작에 9,990개 job은 다시 실행 준비되어 Ready Queue에 들어옵니다. 하지만 이 중에서 10개만 통과하고 다시 9,980개는 속도 제한에 걸립니다. 이것이 계속 반복되면서, job은 Non-ready QueueReady Queue 사이를 오고가지만 대부분은 쓰로틀링을 겪게 되어 유의미하지 않은 작업에 시간을 사용하게 됩니다. 저희는 이러한 현상을 공회전이라고 부르고 있습니다.

아래는 실제로 1개 group에 200개의 job을 추가하고, 10TPS 설정을 한 경우입니다.

그림 5. 200개를 처리하는데, queue operation은 1200회 가량 기록

그림 6. Success 보다 Throttle이 9배 많음

성공보다 쓰로틀링이 9배 가량 많아 공회전이 일어나고 있음을 확인할 수 있습니다. 처리해야 할 job보다 TPS가 훨씬 낮다면, 실행 계획을 세워 각 job이 기다리는 시간을 서로 다르게 하는 것이 필요합니다. 앞선 예시로 돌아가면, 9,990개 job 중 10개는 1초 기다리고, 10개는 2초 기다리고, …, 와 같이 대기 시간을 구성하면 쓰로틀링이 일어나지 않도록 실행 계획을 세울 수 있습니다. 대기 시간을 결정하는 아이디어는 다음과 같습니다.

Plaintext
<대기 시간> = 1s + floor(<같은 rate limit의 영향을 받는 job 중, 이미 non-ready queue에서 대기하는 job 개수> / <rate limit 제한 속도>)

예를 들어, 9,990개 task에 쓰로틀링이 일어나 Non-ready Queue에서 대기하도록 하는 과정을 생각해보면,

  • 첫 10개 job: 1s + floor(0 jobs / 10TPS) = 1s, 1s + floor(1 jobs / 10TPS) = 1s, … → 1초 대기합니다.

  • 다음 10개 task: 1s + floor(10 jobs / 10TPS) = 2s, … → 2초 대기합니다.

자연스럽게 1초에 TPS만큼 실행하도록 대기 시간이 지정됩니다.

이 아이디어를 구현할 때 계산하기 까다로운 수치는 <같은 rate limit의 영향을 받는 job 중, 이미 non-ready queue에서 대기하는 job 개수> 입니다. 여러 머신에서 worker pool을 가지고 있기 때문에, job 개수를 계산하기 위해 Redis처럼 공유되는 저장소를 사용해야 할 수도 있습니다. 하지만 현재 구현은 인스턴스끼리 별도 저장소를 통해 소통하지 않고 각자 job 개수를 계산하고, 인스턴스의 최소 개수(2개)를 곱해서 대기하는 job 개수의 예상치를 얻습니다. 이렇게 얻은 예상치는 정확한 값보다 작을 가능성이 높지만, 한두번 정도 더 쓰로틀링이 일어나는 것은 괜찮습니다.

이 구현을 검증하기 위해 3개 group에 각각 5,000개씩 총 15,000개의 job을 추가하고, TPS는 10으로 설정했습니다. 따라서, 이상적인 총 처리 시간은 5,000 jobs / 10 TPS = 500초 (8분 20초) 입니다.

그림 7. 혼잡 제어 적용 후 부하 테스트

  • 실제 처리 소요 시간: 약 12분 (이상적인 경우와 비교해 +44%)

  • 평균 throttle 횟수: 21770⁄15000 = 1.45회

  • 실행 속도는 가장 높은 수준에서 분당 1,550개 (25 TPS) 정도로 유지됨

공회전 문제를 겪는 상황이라면 job 마다 수십 번 이상 쓰로틀링이 일어나며 Non-ready QueueReady Queue 사이를 오갑니다. 이 아이디어를 적용한 이후에는 평균 1.45회만 쓰로틀링이 발생하였으므로, 공회전 문제는 효과적으로 해결되었습니다. 또한, 대기 시간을 지나치게 보수적으로 잡아 필요 이상으로 대기하고 있다고 보기도 어렵습니다. 이상적인 속도의 80% 이상으로 처리가 이루어졌습니다.

3. Woker

Worker Pool

goroutine은 경량 스레드로 강력한 추상화와 성능을 제공합니다. 비교적 다루기 쉬운 사용성부터, 관리 자체도 편하고 좋은 성능을 제공하기에 저희는 실제로 go에서 지원하는 goroutinechannel 등을 이용해 100줄 내외의 코드를 작성하여 이를 구성할 수 있었습니다.

또한, 앞서 설명했던 backpressure 메커니즘 덕분에 기본적으로 동적인 worker 자원을 관리할 필요가 없었고, 하나의 인스턴스 당 고정된 worker를 할당하여 오토스케일링을 통해서만 자원 조절을 하도록 했습니다.

Worker/Dispatcher

그림 8. Dispatcher/Worker

Non-ready QueueReady Queue 사이의 job 이동은 여러 goroutine들이 담당합니다. dispatcher와 worker는 내부에서 Fetcher라고 불리는 single goroutine이 polling 하면서 worker/dispatcher pool에 새로운 job을 submit 하고 이를 worker/dispatcher 가 실행하는 구조로 되어있습니다.

4. Aggregator

Aggregator

결과 집계는 단순 실패/성공 개수를 저장하는 것일 수도 있고, 어떠한 엑셀 파일을 만드는 등의 여러 작업으로 확장될 수 있습니다. 실제로 저희 벌크액션 중에는 특정 데이터를 전부 읽고 다운로드를 하는 경우도 있기 때문에 Aggregator는 의도적으로 MapReduce 인터페이스를 제공하고 있습니다.

여기서 재밌는 포인트는 그렇다면, 이 Aggregator는 어떻게 모든 작업이 끝나서 이제 결과를 가져와야 하는 상황인 걸 인지할 수 있을까요? 마지막 패킷에 마지막 패킷이라고 이름을 써놔야할까요?

Watcher

그림 9. group의 상태 전이표

저희는 고객사의 요청을 group이라는 형태로 취급하고 있습니다. 위는 이 group의 상태 전이 과정인데요. 여기서 이제 상태 전이에 대한 몇 가지 가정을 해야합니다.

  1. 클라이언트(메인 백엔드 서버) 요청은 언제든 실패할 수 있고, 오래 걸릴 수 있습니다. 따라서 일종의 timeout 가정이 필요합니다. 또한, 클라이언트는 온전히 job 전송에 성공했다면 이를 알려주어야 합니다.

  2. 클라이언트가 보낸 전체 job의 개수를 안다면 주기적으로 이를 확인하면서 실제로 success + failed 개수가 전체 job 개수와 동일한지 보고, Aggregator를 호출해야합니다.

조금 더 추상화해보면, 모든 상태 전이는 결국 주기적으로 특정 상태로 전이해도 되는지 여부를 확인하고, 전이하는 방식으로 진행될 수 있습니다. 따라서, 여기서는 이를 polling 하며 전이 여부를 판단하는 또 하나의 Worker를 관리하면 됩니다. 저희는 이걸 내부적으로 Watcher라고 부릅니다.

추가로 재밌는 문제는 이러한 worker들이 매 인스턴스마다 떠있기 때문에 상태 전이를 여러번 시도하는 상황이 있을 수 있는데요, 내부적으로는 상태 전이 시도를 분산락을 통해 관리하여 공유 자원에 대한 경합 문제와 자원 소모를 최소화하고 있습니다. [16]

5. Handling failures

acknowledgement

그림 10. reliable queue

앞서 설명했던 바와 같이 각각의 job을 도는 worker는 모종의 이유로 panic에 빠져 비정상 종료할 수 있습니다. 이는 일종의 Orphaned Jobs 로 취급되며 놓칠 수 있는 포인트는 아래와 같습니다.

  • Ready Queueworker

  • workerNon-ready queue

  • Non-ready queueDispatcher

  • DispatcherReady queue

Ready Queue에서 pop되는 job들을 놓치지 않고, 안정적으로 처리하자가 주요한 목표였습니다. 그러기 위해서 일종의 ACK 메커니즘이 필요했습니다. 이러한 오퍼레이션을 제공하는 Priority Queue를 내부적으로는 Reliable Queue라고 명명했었는데요. 이는 목적에 맞게, 내부적으로 두 개의 Queue 를 동시에 사용합니다. Ready Queuepop과 동시에(atomic 하게) In-flight Queue 에 timeout 시간을 적어 enqueue 합니다.

그리고, 해당 timeout 을 기반으로 in-flight queue는 worker로부터 해당 timeout 동안 ACK을 받지 못한 경우 놓친 것으로 간주하고 orphaned jobs를 모두 Ready Queue 다시 넣어줍니다.(SQS visibility timeout 가정과 유사합니다.[12]) 이 과정 역시 Dispatcher가 담당하며 timeout 된 job들을 in-flight queue 에서 Ready Queue 로 옮겨줍니다.

at-least once semantic, retry + idempotence

기존 벌크 액션 기능들은 재시도 가능성이나 Message Queue를 잘 이용하지 못하는 경우가 있었는데요. 기본적인 delivery semantics 중에서 놓치지 않고, 잘 처리하기 위해 at-least once semantic을 지원하도록 구현했습니다.

내부에서 retry 로직을 통해 네트워크 분단 문제등으로 인한 실패를 다시 재시도 하도록하였고, 여러번의 재시도 이후에도 최종 실패한다면 이를 기록하는 형식으로 인터페이스를 제공했습니다.

다만, 언급했던 것처럼 실제로 하나의 job의 멱등성은 클라이언트에서 보장하도록 하여 구현복잡도를 낮췄고, 개발자들에게 일종의 effectively once를 구현하도록 강요했습니다. [13][14]

다시 돌아보며

이로써 벌크액션을 잘 처리하기 위한 벌크액션 서버를 구현하는 과정에서 저희가 정의한 문제와 해결과정 정리했습니다. 마지막으로 다시 한 번 이 프로젝트의 목적을 되새겨 봅시다.

(1) 고객사는 원하는 임의의 시점에 벌크 액션을 실행합니다.

  • 임의의 시점에도 안정적으로 처리하기 위해 특정 DB 자원에 대한 요청량을 조절합니다. 전체 서비스 품질에 영향가지 않을 TPS와 동일한 벌크 액션들의 동시 요청량 이 두 가지를 조절하여 해결합니다.

(2) 고객사는 자신의 요청이 하나도 놓쳐지지 않기를 바랍니다.

  • at-least once semantic 하에 재시도하여 관리합니다. 멱등성은 클라이언트의 몫입니다.

(3) 고객사는 자신들의 요청을 빠르게 처리하길 바랍니다.

  • 벌크액션 서비스에서는 안정성을 확보한 최선의 속도를 확보합니다.

(4) 매 벌크액션 기능마다 통일된 인터페이스 없이 구현되어 패턴이 제각각입니다.

  • 일관적인 인터페이스로 특정 구현(멱등성 등)을 강요합니다. 안정적인 트래픽 처리를 위해 어플리케이션 개발자는 반드시 해당 요청의 RPS를 고려해야합니다.

(5) 특정 큰 고객사의 요청이 마무리될 때까지 다른 고객사의 요청을 처리하지 못하는 경우가 있습니다.

  • Fair queueing을 통해 지원합니다. 기본적으로 FIFO 전략을 표방하지만, 해당 고객사의 요청은 정해진 RPS를 준수하며, 실행되지 못한 경우 Non-ready Queue에서 대기하기에 자원소모를 최소화합니다.

(6) 큰 고객사의 요청이 전체 서비스 품질에도 영향을 미치는 경우가 있습니다.

  • 전체 서비스 품질에 영향이 가지 않을 전체 자원 확보가 필요합니다. 다만, 추후에는 여유가 있는 경우 해당 자원을 더 끌어다 쓰는 똑똑한 방법이 필요해질 수 있습니다.

마치며

이 프로젝트는 꽤나 복잡한 문제를 풀었고, 이를 팀 내에서 약 4개월간 3명의 엔지니어만이 주요 업무와 병행하면서 진행했습니다. 비교적 중간에 붕뜨는 경우도 있었지만 엔지니어들이 주도적으로 제안해서 릴리즈까지 첫 단추를 잘 꿰었다 생각합니다.

그러나 이제 시작인만큼 개선할 과제들이 아직 많습니다. 실제로 다음 글에서는프로덕션에 운영 중인 기존 기능을 새롭게 마이그레이션하는 과정에 대해서 소개하려고 합니다. 이번에 다루지 않았지만, 실제 멱등성은 어떻게 클라이언트 단에서 관리하는지, 달리는 차에 바퀴를 어떻게 바꾸는지 설명합니다.

References

[1] https://en.wikipedia.org/wiki/Head-of-line_blocking

[2] https://en.wikipedia.org/wiki/Fair_queuing

[3] https://en.wikipedia.org/wiki/Rate_limiting

[4] https://en.wikipedia.org/wiki/MapReduce

[5] https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/using-messagededuplicationid-property.html

[6] https://redis.io/docs/latest/develop/data-types/sorted-sets/

[7] https://redis.io/docs/latest/develop/interact/programmability/eval-intro/

[8] https://engineering.workable.com/scheduling-jobs-with-priority-aging-7a00d503b138

[9] https://en.wikipedia.org/wiki/Aging_(scheduling)

[10] https://en.wikipedia.org/wiki/Shortest_job_next

[11] https://en.wikipedia.org/wiki/Exponential_backoff#Rate_limiting

[12] https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-visibility-timeout.html

[13] https://en.wikipedia.org/wiki/Idempotence

[14] https://x.com/viktorklang/status/789036133434978304

[15] https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/using-messagededuplicationid-property.html

[16] https://channel.io/ko/blog/articles/abc2d95c


채널톡 엔지니어들은 이와 같은 문제를 풀기 위해 여러 고민을하고 있습니다. 그럼에도 저희팀에는 아직도 해결해야 할 것들이 많습니다. 이러한 문제를 깊이 탐구하고 해결하는 여정을 함께하고 싶다면 채널톡 엔지니어에 지원해주세요

We Make a Future Classic Product

채널팀과 함께 성장하고 싶은 분을 기다립니다

사이트에 무료로 채널톡을 붙이세요.

써보면서 이해하는게 가장 빠릅니다

회사 이메일을 입력해주세요