본문으로 건너뛰기
김도현

지터, 같은 순간을 흩기

같은 순간에 몰리는 요청을 무작위로 펼치기

요청이 실패하면 다시 보낸다. 문제는 언제 보내느냐다. 여럿이 같은 순간에 실패했다면 고정된 규칙으로 정한 재시도 시각은 전부 한곳을 가리킨다.

우연히 맞아떨어진 타이밍

썬더링 허드

서버가 잠깐 멈췄다가 살아난다. 붙어 있던 클라이언트 수천 대는 연결이 끊긴 걸 거의 동시에 알아차리고 거의 동시에 다시 붙는다. 막 복구된 서버는 한꺼번에 몰린 재연결 때문에 다시 과부하에 빠진다. 한꺼번에 깨어난 무리가 한 자원으로 몰려드는 이 현상을 썬더링 허드(thundering herd)라고 한다.

par [첫 요청] par [재연결] 요청 요청 요청 재연결 재연결 재연결 장애로 응답 중단 복구 복구 직후 다시 과부하 클라이언트 A 클라이언트 B 클라이언트 C 서버

캐시 애벌랜치

캐시에서도 같은 일이 생긴다. 배포하면서 한꺼번에 채운 키들은 TTL도 함께 끝난다. 만료되는 그 순간, 모든 요청이 캐시를 비껴 원본 데이터베이스로 내려간다. 여러 키가 같은 시각에 만료돼 생기는 이 쏠림은 캐시 애벌랜치(cache avalanche)라고 부른다.

par [캐시 miss 후 DB 조회] GET user:1 miss SELECT GET post:7 miss SELECT 쿼리가 동시에 쏟아짐 앱 서버 A 앱 서버 B 캐시 데이터베이스

정각마다 도는 크론, 1분 주기 폴링, 메트릭 푸시도 마찬가지다. 각자 따로 돌지만 주기를 맞추는 기준은 같은 벽시계다. 그러니 정각에 모인다.

약속한 적도 없는데 타이밍이 우연히 맞아떨어졌다. 게다가 한 번 맞으면 계속 맞는다. 같은 순간에 실패했으니 다시 시도하는 순간도 같다.

아래 세 절의 데모는 모두 같은 상황에서 돌아간다. 왼쪽 원 열 개가 클라이언트, 오른쪽 원이 서버다. 서버는 짧은 시간 안에 도착한 요청을 셋까지만 받고 넘치면 거절한다. 열 대가 동시에 첫 요청을 보내면서 한 판이 시작되고 모두 성공하면 끝난다. 기다리는 클라이언트는 남은 시간만큼 둘레의 눈금이 줄어든다. 달라지는 건 거절당한 뒤 얼마나 기다리는가 하나뿐이다.

단순 재시도

가장 단순한 방법은 일정한 간격을 두고 다시 거는 것이다.

const delay = 1000;

이래서는 무리가 흩어지지 않는다. 동시에 거절당한 클라이언트들은 정확히 1초 뒤 다시 한꺼번에 몰리고 또 거절당하면 또 1초 뒤에 몰린다.

단순 재시도
서버 0/3
요청 성공 거절
완료 0/10 · 시도 0회 · 0.0초

서버에 도착한 요청을 1초 단위로 세어 보면 봉우리가 그대로 드러난다. 첫 열 대가 한꺼번에 도착하고 거절당한 일곱 대가 다시 한꺼번에 도착한다. 간격은 약 2초다. 대기 시간에 요청과 응답이 오가는 시간까지 더해지기 때문이다.

0 1 2 3 4 5 6 7 8 9 10 11 12 0 2 4 6 8 10 경과(초) 단순 재시도 · 서버 도착 요청

지수 백오프

지수 백오프(exponential backoff)는 실패할 때마다 대기 시간을 두 배씩 늘린다. 무한정 길어지지 않도록 상한(cap)도 둔다.

const delay = Math.min(CAP, BASE * 2 ** attempt);

간격이 빠르게 벌어지니 서버에 요청이 들어오는 빈도는 줄어든다. 하지만 보내는 시각은 여전히 모두 같다. 동시에 거절당한 클라이언트들은 1초 뒤, 2초 뒤, 4초 뒤에 정확히 같이 몰린다.

지수 백오프
서버 0/3
요청 성공 거절
완료 0/10 · 시도 0회 · 0.0초

간격만 벌어졌을 뿐, 봉우리 높이는 그대로다. 시도 횟수도 단순 재시도와 똑같다. 기다리는 시간만 길어졌으니 전부 끝나는 건 오히려 더 느리다.

0 1 2 3 4 5 6 7 8 9 10 11 12 0 2 4 6 8 10 경과(초) 지수 백오프 · 서버 도착 요청

지수 백오프 + 지터

지터(jitter)는 대기 시간에 무작위성을 섞는 것이다. 지수 백오프로 구한 값을 그대로 쓰지 않고 그 구간 안에서 하나를 뽑는다.

const window = Math.min(CAP, BASE * 2 ** attempt);
const delay = Math.random() * window;

같은 순간에 실패해도 돌아오는 시각은 저마다 다르다. 봉우리 자체가 사라진다. 첫 충돌 뒤로는 요청이 한 줄로 흩어져 들어가고 거절당하는 횟수도 따라 줄어든다.

지수 백오프 + 지터
서버 0/3
요청 성공 거절
완료 0/10 · 시도 0회 · 0.0초

다시 실행을 몇 번 눌러 보면 판마다 숫자가 조금씩 다르다. 그래도 도착 분포는 대체로 이런 모양이다. 처음 열 대의 충돌은 피할 수 없다. 그 뒤로는 봉우리 없이 점점 잦아들다가 끝난다.

0 1 2 3 4 5 6 7 8 9 10 11 12 0 2 4 6 8 10 경과(초) 지수 백오프 + 지터 · 서버 도착 요청

AWS는 이 조합을 세 가지로 정리했다.1

const full = Math.random() * window;
const equal = window / 2 + (Math.random() * window) / 2;
const decorrelated = Math.min(CAP, BASE + Math.random() * (previous * 3 - BASE));

Full jitter는 0부터 구간 전체에서 뽑는다. 가장 넓게 흩어지고, AWS의 실험에서 작업량이 가장 적었다. 완료 시간은 Decorrelated jitter가 조금 더 짧았다. Equal jitter는 절반은 고정으로 기다리고 나머지 절반만 무작위로 뽑는다. 최소 대기 시간이 보장되는 셈이다. Decorrelated jitter는 다음 구간을 시도 횟수가 아니라 직전 대기 시간을 기준으로 잡는다. 그래서 한 번 길어졌다고 계속 길어지지 않고 위아래로 출렁인다.

꼭 지수 백오프와 붙여 쓸 필요도 없다. 캐시 TTL을 분산할 때는 고정값에 흔들림만 더하면 된다. 300초 대신 300초에 0~30초를 더하는 식이다. 만료 시점이 흩어지니 한꺼번에 채운 키들도 한꺼번에 비지 않는다.

const ttl = 300 + Math.random() * 30;

지터가 못 하는 일

지터는 부하를 줄이지 않는다. 시간축에 펼칠 뿐이다. 용량 자체가 모자라다면 백오프 상한, 서킷 브레이커, 재시도 예산 같은 장치가 따로 필요하다. 무작위로 기다리는 만큼 최악의 응답 시간도 늘어난다. 그러니 사용자가 기다리는 경로라면 전체 대기 시간에 상한을 따로 두는 편이 좋다.

Footnotes

  1. Exponential Backoff And Jitter ↩

관련 글