← 목록으로

BullMQ로 알림 시스템 운영하기 2편 - Rate Limit과 그에 따른 설계

들어가며

솔직히 말하면 처음에는 rate limit을 신경 쓰지 않았다. 그 정도로 많은 알림톡을 처리하게 될 거라고는 상상하지 못했기 때문이었다.

서드파티 발송 API의 한도는 5초당 100건이었다. BullMQ의 limiter 옵션에 그대로 옮겨 적었다.

new Worker(name, handler, {
  limiter: { max: 100, duration: 5000 },
});

카운터는 Redis에 있으니 워커를 몇 대로 띄우든 합산해서 한도를 지킨다. 1편에서 정리한 그대로다. 우리 평균 발송량은 20 req/s였고, 종이 위에서는 정확히 한도 이내였다.

그런데 429 Too Many Requests가 산발적으로 찍히기 시작했다.

장애 — 한도를 지켰는데 429가 온다

처음 든 생각은 “limiter가 동작하지 않나”였다. 카운터를 확인해봐도 우리 쪽 집계는 5초당 100건을 넘지 않았다. 워커 인스턴스를 늘려도 합산은 정확했다. limiter는 제 일을 하고 있었다.

문제는 우리가 세는 5초와 서드파티가 세는 5초가 같은 5초가 아니었다는 데 있었다.

fixed-window의 정렬 문제

우리 limiter도, 서드파티의 한도도 둘 다 fixed-window 카운터다. 두 윈도우의 시작 시각은 정렬되어 있지 않다.

우리 윈도우:    [---0~5s---][---5~10s---]
발송 패턴:            ████  ████          ← 윈도우 끝/시작에 몰림
서드파티 윈도우:    [---2~7s---]          ← 이 구간에서 200건 관측 → 429

우리 윈도우의 후반부에 100건을 몰아 보내고, 다음 윈도우가 열리자마자 다시 100건을 보낸다. 우리 기준으로는 두 윈도우 각각 100건이니 위반이 아니다. 그런데 그 경계를 걸치는 서드파티의 5초 윈도우에서 보면 200건이 관측된다.

fixed-window의 고전적인 함정이다. 한도를 지켰다는 사실은 내 윈도우 안에서만 참이고, 상대의 윈도우에서는 최대 두 배까지 관측될 수 있다.

사실 이건 이미 알려진 문제다. 『가상 면접 사례로 배우는 대규모 시스템 설계 기초』 4장 “처리율 제한 장치의 설계”는 고정 윈도 카운터의 단점으로 정확히 이 상황을 예로 든다. 분당 5개 한도에서 2:00:30부터 2:01:00 사이에 5개, 2:01:00부터 2:01:30 사이에 5개를 처리하면, 2:00:30부터 2:01:30까지의 1분 구간에서는 10개가 처리된다. 책의 표현으로는 “허용 한도의 2배”다.

책이 대안으로 제시하는 알고리즘도 있다. 이동 윈도 로깅은 “아주 정교”하지만 거부된 요청의 타임스탬프까지 보관해 메모리를 많이 쓴다. 이동 윈도 카운터는 메모리 효율이 좋은 대신 직전 구간의 요청이 균등하게 분포한다고 가정하는 추정치다. 토큰 버킷은 버스트를 다룰 수 있지만 버킷 크기와 공급률 두 인자를 튜닝해야 한다.

읽으면서 든 생각은 우리에게 필요한 정확도가 어느 정도냐는 것이었다.

윈도우를 잘게 쪼갠다

- limiter: { max: 100, duration: 5000 }
+ limiter: { max: 20, duration: 1000 }

평균 처리량은 20 req/s로 동일하다. 대신 윈도우가 1초로 짧아지면서 발송이 경계에 몰려도 상대 윈도우에서 관측되는 누적 최대치가 200건에서 40건으로 줄어든다.

정직하게 말하면 이것도 여전히 fixed-window다. 문제의 본질을 없앤 게 아니라 문제의 크기를 안전 마진 안으로 줄인 것이다. token bucket이나 sliding window까지 갈 수도 있었지만, 운영 복잡도 대비 효용을 따져 윈도우를 잘게 쪼개는 선에서 멈췄다.

이 절충을 택할 수 있었던 건 요구사항이 “평균 20 req/s를 낸다”였지 “정확히 100건을 다 쓴다”가 아니었기 때문이다. 마지막 한 건까지 한도를 소진해야 하는 상황이었다면 이 선에서 멈출 수 없었을 것이다.

그래도 실패는 발생한다

아무리 예방해도 외부 API의 일시적 실패는 일어난다. 그래서 실패한 Job을 일정 기간 보존하도록 설정했다.

await queue.add(name, data, {
  attempts: 3,
  backoff: { type: 'exponential', delay: 5000 },
  removeOnFail: { age: 24 * 3600 }, // 실패 Job을 24시간 보존
});

removeOnFail을 시간으로 걸어두면 실패한 Job이 곧바로 사라지지 않는다. 나중에 실패분만 골라 재시도하는 게 수월해진다.

이 정책이 실제로 빛을 본 사건이 따로 있다. NAT Gateway 인프라 변경으로 알림톡이 한순간에 전부 막힌 적이 있는데, 그때 보존된 실패 Job 덕에 사후 복구가 가능했다.

한도를 우리만 쓰는 게 아니었다

윈도우를 쪼개고 나서도 429가 완전히 사라지지는 않았다. 남은 원인은 우리 코드 밖에 있었다.

같은 서드파티 계정을 다른 팀도 쓰고 있었다.

여기서 1편에서 짚은 성질이 문제로 돌아온다. BullMQ의 rate limiter는 큐 단위다. 같은 큐를 구독하는 워커들끼리는 카운터를 공유하지만, 큐 밖의 호출은 알지 못한다. 다른 팀의 애플리케이션은 우리 Redis를 보지 않는다.

flowchart LR
    accTitle: 서드파티 계정을 공유할 때의 한도 초과
    accDescr: 우리 워커는 자신의 limiter를 정확히 지키지만, 같은 계정을 쓰는 다른 팀의 호출이 합산되어 서드파티가 관측하는 요청량은 한도를 넘는다.
    W["우리 Worker
20/s (준수)"] --> API["서드파티 계정
한도 20/s"] T["다른 팀 애플리케이션
제어 불가"] --> API

우리 쪽 로그만 보면 한도는 완벽하게 지켜진다. 그런데 계정 단위로 세는 서드파티 입장에서는 초과다. 어느 쪽 코드에도 버그가 없는데 429가 온다.

한도를 나누는 게 아니라, 워커를 통일한다

처음에 떠올린 해법은 계정 한도를 팀별로 배분하는 것이었다. 우리가 12, 다른 팀이 8을 쓰기로 합의하고 각자 limiter에 그 값을 넣는 식이다.

그런데 이 방식은 문제를 옮기기만 한다. 배분은 두 limiter가 각자의 몫을 정확히 지킨다는 가정 위에 서 있는데, 그 가정이 깨지는 이유가 바로 앞 절에서 본 정렬 문제다. 우리 윈도우와 다른 팀 윈도우의 시작 시각도 어긋나 있으므로, 12와 8을 각자 지켜도 계정 단위로는 그 이상이 관측될 수 있다. 게다가 상대가 한도를 지키는지 우리는 확인할 방법이 없다. 429가 떴을 때 누구 탓인지 가릴 수도 없다.

애초에 카운터를 둘로 나눌 이유가 없었다. 외부 API를 호출하는 지점을 하나로 통일하면 된다.

여기서 책 10장의 구조가 그대로 쓸모가 있었다. 책은 알림 시스템을 알림 서버작업 서버로 나눈다. 알림 서버는 발송 요청을 받아 수신자 정보를 검증하고, 캐시와 DB에서 템플릿과 사용자 설정을 가져와 보낼 내용을 만든 뒤 큐에 넣는다. 작업 서버는 큐에서 꺼내 서드파티로 전달하기만 한다.

렌더링과 발송을 다른 서버로 분리하는 설계다. 큐에 들어가는 것은 “이 사용자에게 이 템플릿을 보내라”가 아니라, 이미 완성된 발송 페이로드다.

// 알림 서버 — 렌더링까지만 한다. 외부 API를 호출하지 않는다.
const template = await templateRepository.find(templateCode);
const payload = renderAlimtalk(template, user, variables); // 템플릿 조합·전화번호 정규화
assertSendable(user);                                      // 수신 동의·유효성 검증
await alimtalkQueue.add('send', payload);

// 발송 워커 — 외부 API 호출만 한다. limiter가 붙는 유일한 지점이다.
new Worker('alimtalk', async (job) => alimtalkClient.send(job.data), {
  connection,
  limiter: { max: 20, duration: 1000 },
});

렌더링 서버는 나누고, 발송 워커만 합친다

여기서 걸리는 게 하나 있다. 알림 서버를 하나로 두려면 그 서버가 렌더링에 필요한 데이터를 전부 알아야 한다. 그런데 팀마다 조회해야 하는 DB가 다르다. 다른 팀의 알림 내용을 우리 알림 서버가 만들려면 그쪽 도메인 스키마와 DB 접근 권한을 우리가 가져야 하는데, 그건 rate limit 하나 지키자고 치르기에는 너무 큰 비용이다.

그래서 통일하는 범위를 최소로 잡았다. 알림 서버는 팀마다 따로 두고, 발송 워커만 공유한다.

각 팀의 알림 서버는 자기 DB를 보고 자기 템플릿으로 페이로드를 만든다. 만들어진 페이로드는 도메인 지식이 필요 없는 발송 지시일 뿐이므로, 그때부터는 같은 큐에 넣어도 무방하다. 큐 뒤의 발송 워커는 어느 팀에서 온 페이로드인지 알 필요가 없다.

flowchart LR
    accTitle: 팀별 알림 서버와 공유 발송 워커
    accDescr: 팀마다 자기 DB를 보는 알림 서버가 따로 있고, 각 서버가 만든 완성된 페이로드는 같은 알림톡 큐로 모인다. 외부 API 호출은 공유된 단일 발송 워커만 수행한다.
    DB1[("우리 DB")] --> N1["우리 알림 서버
검증·템플릿 렌더링"] DB2[("다른 팀 DB")] --> N2["다른 팀 알림 서버
검증·템플릿 렌더링"] N1 --> Q["알림톡 큐
완성된 페이로드"] N2 --> Q Q --> W["공유 발송 Worker
limiter 20/s"] W --> API["서드파티 계정
한도 20/s"]

이게 렌더링과 발송을 분리해서 얻는 진짜 값어치였다. 분리하지 않았다면 “발송 워커를 공유하자”는 곧 “도메인 로직까지 공유하자”가 되어 성립하지 않았을 것이다. 경계를 페이로드 완성 시점에 그었기 때문에, 각 팀은 자기 도메인을 그대로 쥐고 있으면서 한도만 함께 지킬 수 있다.

계정에 가해지는 부하는 다시 하나의 Redis 카운터로 표현된다. 배분값도, 합의도, 총합 검증도 필요 없다. limiter 하나가 계정 한도 그대로다.

이 분리가 rate limit에 주는 이득이 두 가지 더 있다.

limiter가 세는 숫자와 외부 호출 수가 1:1로 맞는다. 워커가 하는 일이 외부 API 호출뿐이면 Job 하나가 곧 요청 하나다. 렌더링이 워커 안에 남아 있으면 템플릿 조회나 개인화에 걸리는 시간이 처리 시간에 섞여 들어가고, concurrency와 limiter를 함께 튜닝할 때 무엇 때문에 처리량이 안 나오는지 분리해서 보기 어려워진다.

실패의 성격이 갈린다. 템플릿 코드가 잘못됐거나 전화번호 형식이 틀린 것은 몇 번을 재시도해도 결과가 같다. 반면 429는 기다렸다가 다시 보내면 성공한다. 렌더링과 발송이 한 워커에 있으면 이 둘이 같은 attempts를 나눠 쓴다. 재시도해봐야 소용없는 실패가 재시도 횟수를 갉아먹고, 그만큼 정작 재시도가 필요한 429에 쓸 몫이 줄어든다. 앞단에서 검증을 끝내면 큐에 들어온 Job의 실패는 대부분 발송 실패이고, 재시도 정책을 그 성격에 맞춰 잡을 수 있다.

같은 책 4장이 분산 환경의 문제로 짚는 동기화 이슈가 정확히 이 이야기다. 웹 계층은 무상태이므로 요청이 매번 다른 제한 장치로 갈 수 있고, 제한 장치들이 서로의 카운터를 모르면 제한이 무의미해진다. 책의 해결책은 고정 세션이 아니라 레디스 같은 중앙 집중형 저장소를 두는 것이다.

BullMQ의 limiter는 이미 그 구조인데, 처음에는 그 중앙 저장소를 우리 팀 안에서만 쓰고 있었다. 저장소를 조직 경계 밖까지 넓히는 방법이 큐를 공유하는 것이었다. 한도를 나눠 갖는 대신, 한도를 지키는 지점을 하나로 만든 셈이다.

책이 알림 서버와 작업 서버를 나눈 이유는 결합도와 버퍼링이지만, 그 결과로 서드파티 호출자가 하나로 좁혀진다는 성질이 rate limit에는 그대로 쓸모가 있었다.

물론 공짜는 아니다. 다른 팀이 우리 발송 워커에 의존하게 되므로 그 워커가 양쪽의 장애 지점이 되고, 페이로드 스키마와 큐 운영 책임도 누군가는 져야 한다. 그 팀이 우리 Redis에 접근할 수 없는 조직이라면 이 방법 자체가 성립하지 않는다.

애초에 계정을 분리할 수 있다면

여기까지는 계정을 공유해야 한다는 전제 위에서의 이야기다. 그 전제부터 의심해볼 만하다.

서드파티 계정을 팀별로 분리할 수 있다면 그게 가장 깔끔한 해법이다. 계정이 나뉘면 한도도 나뉘므로 카운터를 공유할 이유가 사라진다. 각 팀이 자기 계정에 자기 limiter를 걸면 끝이고, 429가 떴을 때 책임 소재도 명확하다. 조직 간 결합도 생기지 않는다.

계정을 나눌 수 없는 이유는 대개 기술이 아니라 계약이나 비용이다. 발신 프로필이 계정에 묶여 있거나, 요금제가 계정 단위라 나누면 단가가 올라가거나, 이미 그 계정으로 템플릿 심사를 다 받아둔 경우다. 이런 제약이 없다면 큐를 공유하기 전에 계정 분리부터 검토하는 편이 낫다.

정리하면 선택지는 셋이고, 위에서부터 검토하면 된다.

방식 성립 조건 대가
계정 분리 계약·요금제가 허용한다 없음. 가능하면 이게 답이다
발송 워커 공유 양쪽이 같은 Redis에 닿는다 조직 간 결합, 공유 워커가 장애 지점
한도 배분 위 둘 다 불가능하다 정렬 문제로 여전히 초과 가능, 검증 불가

우리는 계정을 나눌 수 없어서 두 번째를 택했다. 배분값은 한도를 정확히 나눈 값보다 낮게 잡아야 한다는 점에서, 세 번째는 마지막까지 남겨둘 선택지다.

한 가지 짚어둘 게 있다. 10장에도 “전송률 제한”이 나오지만 그건 다른 종류의 제한이다. 책의 전송률 제한은 한 사용자가 받는 알림의 빈도를 제한하는 것이다. 알림이 너무 잦으면 사용자가 알림을 아예 꺼버리기 때문이다. 사용자 피로도 문제지 외부 API 쿼터 문제가 아니다. 이 글에서 다루는 rate limit은 4장 쪽에 가깝다.

정리

한도를 지켰는데 429가 온 이유는 두 가지였고, 성격이 서로 달랐다.

  • fixed-window의 정렬 문제 — 우리 윈도우와 상대 윈도우의 시작 시각이 다르면 상대 기준으로 최대 두 배가 관측된다. 윈도우를 잘게 쪼개 크기를 마진 안으로 줄였다. 본질적 해결이 아니라는 걸 알고 멈췄다.
  • 계정 공유 — limiter는 큐 단위이므로 우리 큐 밖의 호출은 세지 못한다. 계정을 나눌 수 없어서, 렌더링과 발송을 분리한 뒤 알림 서버는 팀마다 두고 발송 워커만 공유했다. 외부 API 호출 지점이 하나가 되면 계정 부하도 다시 하나의 카운터로 표현된다.

둘 다 결론은 같은 곳으로 모인다. limiter가 지키는 것은 우리가 관측하는 숫자이지, 외부 API가 관측하는 숫자가 아니다. 두 숫자가 어긋나는 지점이 어디인지를 찾는 게 설계의 대부분이었다.

이어서

3편에서는 발송 이력을 다룬다. 이 글에서 removeOnFail로 남긴 실패 Job은 어디까지나 큐의 임시 보관이라, 보존 기간이 지나면 사라진다. 무엇을 언제 누구에게 보냈고 결과가 어땠는지는 별도로 남겨야 하는 기록이다.

참고