트래픽이 넘칠 때 빨리 거절하기 — 부하 흘리기와 동시성 제한
트래픽이 세 배로 몰리자 서버는 살아 있는데 응답이 30초를 넘어요. 사용자는 이미 떠났는데 서버는 그 요청을 성실히 처리하고 있고요. 일부를 빨리 거절해 나머지를 살리는 방법을 담았어요.
트래픽이 평소의 세 배로 몰렸어요. 서버는 죽지 않았는데 응답 시간이 30초를 넘어가요. 사실상 아무도 서비스를 못 쓰는 상태예요.
큐에는 수만 개의 요청이 쌓여 있어요. 서버는 그걸 하나씩 성실하게 처리하고 있고요.
그런데 30초 전에 들어온 요청의 사용자는 이미 떠났어요. 브라우저가 타임아웃됐거나 새로고침을 눌렀거나요. 서버는 아무도 기다리지 않는 응답을 만들고 있어요.
용량을 넘는 요청이 들어왔을 때, 전부 받아서 다 같이 느려지는 것과 일부를 빨리 거절하고 나머지를 제대로 처리하는 것. 이 선택을 미리 하지 않으면 시스템이 전자를 고릅니다.
왜 그런가
처리 능력에는 한계가 있어요. 그 한계를 넘는 요청이 들어오면 어딘가에 쌓여요. 애플리케이션 큐, 스레드 풀 대기열, 커널의 연결 대기열, 로드 밸런서 버퍼 중 어딘가에요.
여기서 중요한 게 있어요. 쌓인 요청은 처리될 때까지 기다리는데, 대기 시간은 큐 길이에 비례해요. 큐가 길수록 지연이 커지고, 어느 순간부터는 처리해도 의미가 없는 응답이 됩니다.
그리고 되먹임이 붙어요. 응답이 늦어지면 클라이언트가 타임아웃되고 재시도해요. 새 요청이 들어오니 큐가 더 길어지고, 지연이 더 커지고, 재시도가 더 늘어요. 앞서 본 재시도 증폭이 여기서도 작동합니다.
무제한 큐가 특히 나빠요. 메모리가 허락하는 한 계속 쌓이니 지연이 무한정 늘어나요. 그리고 결국 메모리가 터지면서 그때까지 쌓인 요청이 전부 사라져요. 하나도 처리 못 하고 전부 잃는 최악의 결과예요.
그래서 유효 처리량이라는 개념이 필요해요. 처리한 요청 수가 아니라 의미 있게 처리한 요청 수요. 아무도 안 기다리는 응답은 처리했어도 0 이에요. 큐를 무한정 늘리면 처리량은 유지되는 것처럼 보이지만 유효 처리량은 0 으로 갑니다.
코드로 보면
큐에 상한을 두고 넘으면 거절한다:
무제한 큐
지연이 무한정 늘고, 메모리가 터지면 전부 잃는다
· 1) 큐 상한 — 넘으면 즉시 거절한다
if queue.size() > MAX: return 503 (Retry-After: 5)
빨리 거절하는 게 늦게 실패하는 것보다 낫다
· 2) 오래된 요청은 버린다
큐에서 꺼낼 때 마감 시각이 지났으면 처리하지 않는다
아무도 안 기다리는 응답을 만들지 않는다
· 3) 동시 실행 수를 제한한다
처리 중인 요청 수에 상한을 두면 큐가 안 길어진다
· 4) 무엇을 먼저 버릴지 정한다
결제 > 조회 > 추천 > 통계
전부 똑같이 대하면 중요한 것부터 죽는다✕ 문제 지점 · ✓ 해법 · 그대로 돌려 보기 전에 자기 환경에 맞게 고쳐 주세요.
실전에서는
부하 흘리기의 설계에서 정해야 할 것들이 있어요.
무엇을 기준으로 거절할지가 첫 번째예요. 큐 길이, 동시 처리 수, 응답 시간, CPU 사용률 중에 고를 수 있어요. 큐 길이나 동시 처리 수가 반응이 빠르고 이해하기 쉬워요. 응답 시간을 기준으로 하면 이미 나빠진 뒤에 반응하게 됩니다.
무엇을 먼저 버릴지가 두 번째예요. 요청에 우선순위를 두면 중요한 기능을 지킬 수 있어요. 결제는 받고 추천은 거절하는 식이요. 이걸 정하지 않으면 무작위로 버려지고, 하필 결제가 버려질 수 있어요.
거절할 때 무엇을 돌려줄지도 정해야 해요. 재시도 가능함을 알리는 응답과 함께 언제 다시 오라는 정보를 주면, 클라이언트가 무작정 재시도하지 않아요. 그리고 앞서 본 대로 지터를 섞어야 같은 시각에 다시 몰리지 않습니다.
그리고 마감 시각 개념이 유용해요. 요청이 들어올 때 "언제까지 처리되어야 의미가 있는가"를 정해서 함께 넘겨요. 큐에서 꺼낼 때 이미 지났으면 처리하지 않고 버려요. 앞 편에서 본 타임아웃 예산과 같은 발상이에요.
이 모든 게 앞 편들과 연결돼요. 서킷 브레이커는 나가는 요청을 막고, 부하 흘리기는 들어오는 요청을 막아요. 재시도 예산은 증폭을 막고요. 셋이 함께 있어야 시스템이 과부하에서 살아남습니다.
마지막으로 관점 하나가 중요해요. 거절은 실패가 아니에요. 100% 를 받아 전부 30초 걸리게 하는 것보다, 70% 를 받아 제대로 처리하고 30% 를 빨리 거절하는 게 모두에게 더 나은 결과예요. 거절당한 사람도 빨리 알고 다시 시도할 수 있으니까요.
처방
- 모든 큐에 상한을 두세요. 무제한 큐는 지연을 무한정 늘리고 결국 전부 잃습니다.
- 동시 실행 수를 제한하세요. 큐가 길어지기 전에 막는 게 더 낫습니다.
- 넘치면 빨리 거절하세요. 재시도 가능함을 알리고 언제 다시 오라고 알려 주세요.
- 요청에 우선순위를 두세요. 무엇을 먼저 버릴지 정하지 않으면 중요한 것이 버려집니다.
- 마감 시각을 함께 넘기세요. 이미 지난 요청은 처리하지 마세요.
- 유효 처리량을 지표로 보세요. 처리한 수가 아니라 의미 있게 처리한 수요.
- 서킷 브레이커·재시도 예산과 함께 쓰세요. 셋이 함께 있어야 과부하를 견딥니다.
- 거절 비율을 알림으로 걸되, 거절 자체를 실패로 세지 마세요. 그건 시스템이 자신을 지킨 겁니다.
- 거절을 최대한 싸게 만드세요. 거절하면서 로그를 잔뜩 남기거나 DB 를 조회하면 그것도 부하가 됩니다.
- 낮은 우선순위에도 최소 몫을 보장하세요. 안 그러면 영원히 처리되지 않는 굶주림이 생깁니다.
지금 확인해 보세요
유효 처리량을 본다:
· 처리량이 아니라 유효 처리량
마감 시각 안에 응답한 요청 수
총 처리량은 유지되는데 유효 처리량이 0 으로 가는 구간이 있다
· 큐가 어디에 있는지 전부 찾는다
애플리케이션 큐 · 스레드 풀 대기열
커널 연결 대기열 · 프록시 버퍼
하나만 막으면 다른 곳에 쌓인다
· 큐에서 꺼낼 때까지 걸린 시간
이 값이 처리 시간보다 크면 큐가 병목이다
· 거절 비율 — 알림은 걸되 실패로 세지 않는다
상시로 높으면 용량 문제다
· 거절 자체가 비싸지 않은지
거절하면서 로그·DB 조회를 하면 그것도 부하다핵심 한 줄다 받아서 다 같이 느려지는 것보다, 일부를 빨리 거절하는 게 낫다.
동시성 제한이 없으면 다 같이 느려진다
트래픽이 두 배가 됐는데 응답 시간이 열 배가 됐어요. 처리량은 오히려 줄었고요.
서버는 죽지 않았어요. 모든 요청을 받아서 전부 처리하고 있어요. 다만 하나도 제때 끝내지 못할 뿐이에요.
CPU 를 보면 100% 인데, 실제로 일하는 시간보다 문맥을 바꾸는 시간이 더 많아요. 수천 개의 요청이 조금씩 번갈아 진행되면서 아무것도 완료되지 않는 상태예요.
동시에 처리하는 양에 상한이 없으면 이 구간에 들어가요.
그리고 이건 앞에서 본 커넥션 풀 문제와 정확히 같은 구조예요. 이름만 다를 뿐 원인도 처방도 같아요. 자원에는 한계가 있고, 그 한계를 무시하면 전부 느려집니다.
왜 그런가
동시에 처리할 수 있는 양에는 물리적 한계가 있어요. CPU 코어 수, 메모리, 디스크 대역폭 같은 것들이요.
그 한계를 넘는 요청을 동시에 진행시키면 어떻게 될까요. 각 요청이 조금씩 번갈아 진행돼요. 겉보기에는 전부 처리 중이지만, 각자의 진행 속도는 동시 실행 수에 반비례해서 느려져요.
여기서 앞서 본 리틀의 법칙이 다시 나와요. 처리 능력은 고정인데 시스템 안의 요청 수만 늘리면 체류 시간이 비례해서 늘어나요.
그리고 현실은 그보다 나빠요. 문맥 전환 비용, 캐시 지역성 파괴, 메모리 경합이 더해지니 처리량이 평평해지는 게 아니라 꺾여서 내려가요.
여기서 되먹임이 붙어요. 응답이 느려지면 클라이언트가 타임아웃되고 재시도해요. 새 요청이 들어오니 동시 실행이 더 늘고, 더 느려지고, 재시도가 더 늘어요. 앞 편들에서 반복해서 본 그 고리예요.
그래서 대응은 명확해요. 동시 실행 수에 상한을 두고, 넘는 건 큐에서 기다리게 하거나 거절해요. 그러면 진행 중인 요청들은 제 속도로 끝나요.
코드로 보면
동시에 몇 개까지 진행할지 정한다:
들어오는 대로 전부 동시에 처리
각자 조금씩 진행하며 아무것도 안 끝난다
· 동시 실행 수에 상한을 둔다
semaphore = Semaphore(MAX_CONCURRENT)
with semaphore: # 넘으면 여기서 기다린다
handle(request)
· 대기에도 상한을 둔다 — 무한 대기는 큐와 같다
if not semaphore.acquire(timeout=200ms):
return 503 (Retry-After)
· 상한 값은 재서 정한다
부하를 올리며 처리량이 평평해지는 지점
코어 수 × N 같은 공식은 출발점일 뿐이다
· 자원별로 따로 둔다
DB 호출 · 외부 API · 이미지 처리 각각✕ 문제 지점 · ✓ 해법 · 그대로 돌려 보기 전에 자기 환경에 맞게 고쳐 주세요.
실전에서는
상한 값을 정하는 방법이 중요해요. 공식으로는 안 나옵니다.
부하를 계단식으로 올리면서 처리량과 응답 시간을 같이 그려요. 처리량이 평평해지기 시작하는 지점이 그 시스템의 무릎이고, 그 근처가 상한이에요. 그보다 높이면 처리량은 안 늘고 지연만 커져요.
여기서 중요한 관점이 있어요. 동시 실행을 제한하면 처리량이 오히려 늘어요. 직관에 반하지만, 경합이 줄어들면서 각 요청이 빨리 끝나고, 빨리 끝나니 다음 요청을 받을 수 있어요. 제한이 성능을 높이는 거예요.
그리고 자원별로 따로 두는 게 좋아요. DB 를 호출하는 작업, 외부 API 를 부르는 작업, CPU 를 태우는 작업은 각각 다른 한계를 가져요. 하나로 묶으면 느린 외부 API 하나가 CPU 작업까지 막아요. 이걸 격벽이라고 부르는데, 침몰하는 배에서 한 구획이 차도 다른 구획은 안전한 구조에서 온 이름이에요.
대기 처리도 정해야 해요. 상한에 걸린 요청을 기다리게 할지 거절할지요. 짧게 기다리는 건 괜찮지만 무한정 기다리면 앞 편에서 본 무제한 큐와 같아져요. 대기에도 상한을 두고 넘으면 거절하세요.
그리고 이건 앞 편들과 한 묶음이에요. 커넥션 풀 크기, 스레드 풀 크기, 부하 흘리기, 백프레셔가 전부 "동시에 얼마나 진행할지"를 정하는 장치예요. 이름만 다르고 같은 문제를 다루고 있어요.
마지막으로 비동기 코드에서 특히 중요해요. 동기 코드는 스레드 수가 자연스러운 상한이 되는데, 비동기는 그런 게 없어요. 수만 개의 작업을 동시에 띄울 수 있고, 그러면 이 문제가 극단적으로 나타납니다.
처방
- 동시 실행 수에 상한을 두세요. 없으면 반드시 이 구간에 들어갑니다.
- 상한 값은 재서 정하세요. 부하를 올리며 처리량이 평평해지는 지점을 찾으세요.
- 자원별로 따로 두세요. DB·외부 API·CPU 작업이 서로를 막지 않게요.
- 대기에도 상한을 두고 넘으면 거절하세요. 무한 대기는 무제한 큐와 같습니다.
- 제한이 처리량을 늘린다는 걸 확인하세요. 실제로 재 보면 직관과 반대로 나옵니다.
- 비동기 코드에서 특히 주의하세요. 스레드 수라는 자연스러운 상한이 없습니다.
- 진행 중인 요청 수를 지표로 보세요. 이 값이 상한에 붙어 있으면 용량 문제입니다.
- 커넥션 풀·스레드 풀·부하 흘리기와 함께 설계하세요. 전부 같은 문제를 다루는 장치입니다.
- 제한 지점이 병목보다 앞에 있는지 확인하세요. 우리 상한이 100 인데 DB 가 20 만 받으면 80 개가 DB 앞에서 기다립니다.
- 거절이 생겼다고 지표가 나빠진 게 아니라는 걸 팀이 함께 이해하세요. 전부 받아서 다 같이 느린 것보다 나은 상태입니다.
지금 확인해 보세요
상한이 맞게 잡혀 있는가:
· 진행 중인 요청 수와 대기 시간
상한에 붙어 있고 대기가 길면 용량 문제
상한에 못 미치는데 느리면 다른 곳이 병목
· 처리량 곡선의 무릎을 찾는다
부하를 계단식으로 올리며 처리량과 p99 를 같이 그린다
처리량이 평평해지는 지점 근처가 상한이다
· 제한을 걸었을 때 처리량이 오히려 느는지 확인
직관과 반대지만 실제로 그렇다
· 자원별로 나뉘어 있는가
외부 API 하나가 느려질 때 CPU 작업까지 막히면
격벽이 없는 것이다
· 하류의 능력과 맞는가
우리 상한 100, DB 능력 20 이면 80 이 DB 앞에 선다핵심 한 줄동시에 다 하려 하면 아무것도 안 끝난다. 상한을 정하라.