과부하 : 백프레셔와 서킷 브레이커
저번 포스팅에서는, 한쪽으로 쏠리는 문제를 공부했습니다. 이번에는 흐름입니다. 들어오는 양이 처리할 수 있는 양을 넘어설 때 무슨 일이 벌어지는지를 다룹니다. 쏠림이 "누가 더 많이 받았는가" 의 문제였다면, 이번 편은 "받은 것을 감당할 수 있는가" 의 문제입니다.
앞 편의 증권 데이터 플랫폼을 그대로 두고 보겠습니다.
1. 이용률이 올라가면 대기 시간이 폭증한다
먼저 짚고 갈 것이 있습니다. 이것을 모르면 용량 계획이 계속 틀립니다.
1.1. 직관과 다른 곡선
CPU 사용률이 50%에서 60%로 오르면 응답 시간도 그만큼 조금 나빠질 것 같습니다. 그런데 그렇지 않습니다.
대기 시간 ∝ ρ / (1 - ρ) ρ = 이용률
이용률 50% → 상대 대기 1
70% → 2.3
80% → 4
90% → 9
95% → 19
99% → 99
50%에서 90%로 가면 부하는 1.8배인데 대기 시간은 9배가 됩니다.
왜 위험한가. 용량 계획을 평균 이용률로 세우면 반드시 틀립니다. "CPU 가 70% 니까 30% 더 받을 수 있다" 는 계산이 실제로는 "지금도 대기가 2.3배인데 여기서 더 받으면 급격히 무너진다" 가 됩니다.
그리고 이 곡선은 왜 장애가 갑자기 오는지도 설명합니다. 80%까지는 지표가 서서히 나빠지다가, 그 위에서는 작은 부하 증가에도 응답 시간이 무너집니다. "어제까지 멀쩡했는데 오늘 갑자기" 의 정체가 대개 이것입니다.
어떻게 하나. 이용률을 70% 근처로 유지합니다. 낭비가 아니라 대기 시간을 감당 가능한 범위에 묶어두기 위한 선택입니다. 그리고 용량 경보를 100% 가 아니라 70~80% 에 겁니다.
1.2. 리틀의 법칙
용량을 볼 때 함께 쓰는 관계식입니다.
L = λ × W
시스템 안의 건수 = 도착률 × 체류 시간
이 식이 왜 쓸모 있나. 필요한 동시 처리 수를 계산할 수 있습니다.
평소 초당 4,000 요청 x 0.02초 = 동시 80개
장애 시 초당 4,000 요청 x 3초 = 동시 12,000개 필요
가용 스레드 20 Pod x 200 = 4,000개
→ 8,000개 부족 → 줄이 늘어남
체류 시간이 150배가 되면 필요한 동시 처리 수도 150배가 됩니다. 스레드 풀이나 커넥션 풀을 그보다 작게 잡아두었다면 거기서 막힙니다. 반대로도 씁니다. 큐에 5,000건이 쌓여 있고 초당 500건을 처리한다면, 지금 들어온 요청은 10초 뒤에 처리됩니다.
2. 백프레셔
2.1. 없으면 어디로 가는가
처리 속도보다 들어오는 속도가 빠를 때, 그 차이는 어딘가에 쌓입니다. 문제는 어디에 쌓이느냐입니다.
상황. 개장 30초의 유입 폭증입니다.
평상시 유입 초당 30,000 건
9시 정각 피크 초당 180,000 건 (6배, 약 90초 지속)
Flink 처리 능력 초당 약 60,000 건
90초 동안 초과분 (180,000 - 60,000) x 90 = 약 1,080만 건
1.2KB 기준 약 13GB
백프레셔가 없었다면 이 13GB 가 TaskManager 메모리에 쌓입니다. 전체 힙이 12대 × 16GB = 192GB 인데 상태를 이미 40GB 쓰고 있으니, 유입이 조금만 더 길어지면 OOM 입니다.
왜 위험한가. 느려지는 것보다 죽는 것이 나쁩니다. OOM 으로 죽으면 그 순간 메모리에 있던 처리 중 데이터가 사라지고 재시작하면서 체크포인트부터 다시 처리해야 합니다. 그리고 재시작 직후에는 밀린 양이 더 많으므로 또 죽을 가능성이 높습니다. 실제로는 백프레셔가 소스 쪽으로 전파되어 Kafka 에서 당겨오는 속도가 줄어듭니다. 초과분은 Kafka 디스크에 안전하게 남습니다.
9시 00분 랙 0
9시 03분 랙 약 1,000만 건 (피크)
9시 09분 랙 0 (따라잡음)
무엇을 봐야 하나. 랙이 산 모양이면 정상이고 계속 우상향하면 용량 부족입니다. 위 예에서 9분에 0으로 돌아오지 않으면 병렬도를 올려야 합니다.
2.2. 이미 쓰고 있는 백프레셔
상황. Flink 태스크매니저 12대가 초당 18만 건을 받는 피크 구간입니다. 싱크가 느려져 읽는 속도가 초당 9만 건으로 떨어졌는데도 소스는 계속 받아옵니다. 이때 멈춰 세우는 것이 무엇인지 보면 대부분 TCP 입니다.
가장 널리 쓰이는 백프레셔는 TCP 에 들어 있습니다. 수신 측은 자기 버퍼에 남은 공간을 수신 윈도우로 매 응답마다 알려줍니다. 송신 측은 그 값을 넘겨 보내지 않습니다. 버퍼가 차면 윈도우가 0 이 되고 송신이 멈춥니다.
받는 쪽의 사정이 보내는 쪽의 속도를 직접 제어하는 구조입니다. 백프레셔의 원형이라고 할 수 있습니다.
2.3. 애플리케이션에서 만드는 방법
- 경계가 있는 큐 : 가장 단순하고 확실합니다. 무한 큐는 백프레셔가 없는 것과 같습니다.
- 요구 기반(pull) : 받는 쪽이 "n개 보내라" 고 요청해야 보냅니다. 소비 속도가 곧 생산 속도를 정합니다.
- 크레딧 기반 : Flink 가 쓰는 방식입니다. 하류가 버퍼 여유만큼 크레딧을 주고 상류는 크레딧이 있을 때만 보냅니다.
- 커밋 시점 제어 : Kafka 처럼 로그를 읽는 구조에서는 큐가 이미 브로커에 있습니다. 저장소가 완충 역할을 하므로 이 구조 자체가 백프레셔를 내장한 셈입니다.
왜 중요한가. 위 2.1 의 사례에서 데이터가 살아남은 것은 4번 구조 덕분입니다. 어디에 쌓일 것인지를 설계 단계에서 정해두는 것이 백프레셔의 본질입니다.
2.4. 그런데 버퍼가 크면 오히려 나쁘다
반대 방향의 함정입니다. "버퍼를 크게 잡으면 안전하겠지" 라는 생각입니다.
상황.
큐 크기 10만 건, 처리 능력 초당 5,000 건
큐가 가득 찬 상태에서 새로 들어온 요청의 대기 시간
100,000 / 5,000 = 20초
클라이언트 타임아웃 3초
→ 17초를 더 기다린 뒤 처리되지만 아무도 안 받아감
왜 위험한가. 처리는 되는데 의미가 없습니다. 사용자는 이미 떠났고 타임아웃도 지났습니다. 그런데 시스템은 그 요청을 처리하느라 자원을 쓰고 그만큼 새 요청이 더 밀립니다. 버린 일을 하느라 살릴 수 있는 일을 못 하는 상태가 됩니다.
네트워크 장비 쪽에서는 이 현상을 버퍼블로트라고 부릅니다.
어떻게 하나. 큐는 순간적인 변동을 흡수하기 위한 것이지, 지속적인 초과를 담기 위한 것이 아닙니다. 큐 크기를 타임아웃 안에 처리 가능한 양으로 제한합니다. 위 예라면 3초 × 5,000 = 1.5만 건이 상한입니다. 그리고 큐에서 꺼낼 때 이미 타임아웃이 지난 요청은 버립니다.
3. 재시도가 장애를 키운다
3.1. 재시도 폭풍
상황. 배포 중 새 Pod 의 기동이 평소 15초에서 90초로 늘었습니다.
Pod 20개, 각 초당 200 요청 처리, 유입 초당 4,000
maxUnavailable 25% → 롤링 중 가용 Pod 15개
처리 능력 초당 3,000 → 초과분 초당 1,000건 타임아웃
재시도 백오프 1초 고정, 지터 없음
1초 뒤 그 1,000건이 동시에 재시도 → 유입 초당 5,000
또 초과 → 재시도 누적 → 6,000, 7,000 ...
왜 위험한가. 원래는 회복될 수 있었던 일시적 지연이 전면 장애가 됩니다. 각 클라이언트는 합리적으로 행동하는데 전체로 보면 시스템을 무너뜨립니다. 그리고 한 번 이 순환에 들어가면 부하를 줄이지 않는 한 스스로 빠져나오지 못합니다.
어떻게 푸나. 세 방향에서 동시에 막습니다.
- 배포 쪽 :
maxUnavailable: 0,maxSurge: 1로 용량을 줄이지 않습니다. startupProbe 로 기동 시간을 넉넉히 줍니다. - 클라이언트 쪽 : 지수 백오프에 지터를 넣습니다.
- 서버 쪽 : 4장의 부하 차단으로 감당 가능한 만큼만 받습니다.
3.2. 여러 층이 겹치면 곱해진다
상황. 호출 경로가 A → B → C 인데 각 구간에서 3번씩 재시도합니다.
C 에 도달하는 요청 3 x 3 = 9배
층이 하나 더 있으면 27배
원래 초당 4,000 이면 C 에서 초당 36,000
왜 위험한가. 각자는 "3번만 재시도" 라고 생각하는데 맨 아래에서는 수십 배로 증폭됩니다. 그리고 가장 안쪽 서비스가 대체로 가장 무거운 일을 하므로, 가장 약한 곳에 가장 큰 증폭이 걸립니다.
어떻게 푸나. 재시도는 한 층에서만 합니다. 보통 가장 바깥이나 가장 안쪽 한 곳만 맡습니다. 그리고 재시도하지 않는 층에서는 실패를 그대로 위로 올립니다.
3.3. 지수 백오프와 지터
간격을 점점 늘리는 것만으로는 부족합니다. 모두가 같은 시각에 실패했다면 같은 시각에 재시도합니다.
지터 없음 1초 뒤 1,000건 동시, 2초 뒤 또 동시 → 파도처럼 몰림
지터 있음 0~1초 사이에 흩어짐 → 초당 부하가 고르게 퍼짐
왜 중요한가. 지터 하나로 순간 부하가 1,000건에서 초당 수백 건 수준으로 평탄해집니다. 백오프만 넣고 지터를 빠뜨리면 간격만 늘어날 뿐 몰림은 그대로입니다. 같은 시각에 여럿이 몰리는 문제는 다른 곳에서도 나타납니다. 캐시가 한꺼번에 만료되어 모두가 원본을 조회하는 현상이 대표적이고 만료 시각에 임의의 값을 더해두는 것이 같은 해법입니다.
3.4. 재시도해도 되는지부터 판단해야 한다
모든 요청이 재시도해도 되는 것은 아닙니다.
조회는 몇 번을 해도 괜찮습니다. 그런데 주문이나 결제는 다릅니다. 응답을 못 받았을 때 처리가 안 된 것인지, 처리는 됐는데 응답만 유실된 것인지 구분할 수 없습니다.
왜 위험한가. 안전을 위한 재시도가 중복 주문을 만듭니다. 금융에서는 이것이 성능 문제가 아니라 사고입니다.
어떻게 하나. 재시도가 안전하려면 그 작업이 멱등해야 합니다. 이 구분과 해법은 다음 편의 주제입니다.
4. 끊어내고 덜어내기
4.1. 서킷 브레이커
상황. 외부 시세 API 응답이 20ms 에서 3초로 늘었습니다. 타임아웃은 5초입니다.
평소 초당 4,000 x 0.02초 = 동시 80개 사용 (스레드 4,000개 중 2%)
장애 시 초당 4,000 x 3초 = 동시 12,000개 필요
→ 스레드 4,000개가 전부 시세 호출 대기로 묶임
→ 시세와 무관한 주문 조회까지 처리 불가
왜 위험한가. 시세 API 하나가 느려졌을 뿐인데 주문 서비스 전체가 멈춥니다. 그리고 우리 쪽에서 계속 호출하므로 상대는 더 힘들어져 회복하지 못합니다.
어떻게 푸나. 서킷 브레이커는 실패율이 기준을 넘으면 아예 호출을 끊습니다. 상태가 셋입니다.
- 닫힘 : 정상. 요청을 통과시키며 실패율을 셉니다.
- 열림 : 차단. 호출하지 않고 즉시 실패를 반환합니다. 상대에게 회복할 시간을 줍니다.
- 반열림 : 일정 시간 뒤 요청 몇 개만 흘려보고 성공하면 닫힘으로, 실패하면 다시 열림으로 갑니다.
핵심은 빠르게 실패하는 것입니다. 3초를 기다렸다 실패하는 것과 즉시 실패하는 것은, 스레드 점유 면에서 전혀 다릅니다. 위 계산에서 체류가 3초에서 0 으로 줄면 필요한 동시 수가 원래대로 돌아옵니다. 이 경우 마지막 시세를 캐시에서 내주는 대체 경로를 함께 두면, 차단 중에도 서비스가 완전히 멈추지는 않습니다.
4.2. 격벽
상황. 주문 서비스가 A, B, C 세 서비스를 호출하는데 스레드 풀 하나를 공유합니다.
왜 위험한가. C 가 느려지면 스레드가 전부 C 를 기다리는 데 묶이고 A 와 B 호출까지 함께 막힙니다. 문제가 없는 기능까지 함께 죽습니다.
어떻게 푸나. 호출 대상마다 풀을 나눕니다.
공유 풀 4,000개 → C 장애 시 전부 소진
격벽 적용 외부 호출 50개 / 내부 호출 3,950개
→ C 장애 시 50개만 소진, 나머지는 정상
자원을 조금 더 쓰는 대신 장애 범위를 가둡니다.
4.3. 부하 차단
용량을 넘는 요청이 들어오면 일부를 의도적으로 거절합니다.
왜 필요한가. 받아놓고 전부 느리게 처리하면 모두가 타임아웃에 걸려 아무도 응답을 못 받습니다. 전체가 실패하는 셈입니다. 차라리 감당 가능한 만큼만 받고 나머지를 즉시 거절하면, 받은 것만이라도 제대로 처리됩니다.
용량 초당 3,000, 유입 초당 5,000
전부 받으면 5,000건 모두 타임아웃 → 성공 0건
3,000만 받으면 3,000건 성공 + 2,000건 즉시 거절
어떻게 하나. 거절할 때 우선순위를 둡니다. 주문은 받고 추천은 버리는 식입니다. 모든 요청이 같은 가치를 갖지 않는다는 전제를 인정하는 것입니다.
4.4. 타임아웃 예산
상황. 사용자에게 3초 안에 응답해야 하는데 A 서비스의 타임아웃이 5초입니다.
왜 위험한가. A 는 이미 아무도 기다리지 않는 응답을 기다립니다. 그동안 스레드와 커넥션이 묶여 있습니다. 사용자는 3초에 떠났는데 시스템은 5초까지 자원을 붙잡고 있는 것입니다.
어떻게 하나. 남은 시간을 호출과 함께 전달합니다. 3초로 시작해 A 에서 0.5초를 썼다면 B 에게는 2.5초를 넘깁니다. B 는 그 안에서만 시도합니다. 그리고 자주 틀리는 규칙이 하나 있습니다. 바깥쪽 타임아웃은 안쪽보다 길어야 합니다. 반대로 두면 안쪽이 답을 만들고 있는데 바깥이 먼저 포기하는 일이 반복됩니다.
5. 조금 더 들어가보기 : 어디에 줄이 서 있는가
지금까지 큐를 이야기했는데 실제 시스템에는 우리가 만들지 않은 큐가 이미 여러 겹 있습니다.
5.1. 연결이 맺어지기 전의 두 개
서버 프로세스에 도달하기도 전에 큐가 두 개 있습니다.
- SYN 큐 : 핸드셰이크가 진행 중인 연결
- Accept 큐 : 핸드셰이크가 끝나 애플리케이션이 받아가기를 기다리는 연결
상황과 위험. 애플리케이션이 바빠서 새 연결을 받아가지 못하면 Accept 큐가 찹니다. 가득 차면 커널이 새 연결을 버립니다. 클라이언트 쪽에서는 연결 자체가 안 되는 것으로 보이는데 서버 애플리케이션 로그에는 아무것도 남지 않습니다. 요청이 애플리케이션에 도달한 적이 없기 때문입니다.
어떻게 확인하나. "서버는 멀쩡해 보이는데 연결이 간헐적으로 실패한다" 는 증상이면 노드에서 Accept 큐 넘침 카운터를 봅니다. 연결 추적 테이블도 같은 성격입니다.
노드당 초당 2,500 연결, 종료 후 추적 유지 120초
필요한 항목 2,500 x 120 = 300,000
기본 한도 약 262,144 → 초과, 패킷 버림
역시 애플리케이션 로그에는 남지 않고 커널 로그에만 기록됩니다.
5.2. 그다음 겹겹의 큐
상황. 스레드 풀 4,000, 커넥션 풀 50 인 API 서버에 초당 3만 요청이 들어옵니다. 스레드는 남아도는데 응답은 느려집니다. 각 층의 큐를 따로 보지 않으면 "CPU 도 메모리도 여유 있는데 왜 느리냐" 에서 막힙니다.
- 소켓 수신 버퍼 : 읽어가지 않으면 차고 그러면 TCP 윈도우가 줄어 송신이 멈춥니다.
- 스레드 풀 큐 : 처리할 스레드가 없으면 여기서 기다립니다.
- 커넥션 풀 : 데이터베이스 연결을 못 얻어 기다립니다. 여기가 실질적인 동시 처리 한도가 되는 경우가 많습니다.
- 데이터베이스 안의 락 대기 : 가장 안쪽입니다.
어떻게 쓰나. 리틀의 법칙을 각 층에 적용하면 병목이 드러납니다. 각 층에서 "동시에 몇 개가 떠 있을 수 있는가" 를 세어보고 가장 작은 값이 전체 처리량을 정합니다. 스레드가 4,000개인데 커넥션 풀이 50개라면 실질 한도는 50입니다.
5.3. 대기와 처리를 나눠 봐야 한다
응답 시간을 하나의 숫자로 보면 원인을 알 수 없습니다.
상황. 야간 배치가 밀려 오전 9시에 Redshift 에서 돌기 시작했습니다.
대시보드 쿼리 실행 시간 0.3초 (그대로)
대시보드 쿼리 대기 시간 40초 (배치가 메모리를 점유)
사용자가 체감하는 시간 40.3초
왜 중요한가. 실행 시간만 보면 "쿼리는 빠른데 왜 느리지" 가 됩니다. 원인이 쿼리가 아니라 자원 경합인데 합쳐진 숫자만 보면 엉뚱한 곳을 튜닝하게 됩니다.
- 처리 시간이 길다 → 로직이나 쿼리를 봐야 합니다.
- 대기 시간이 길다 → 동시성이나 용량을 봐야 합니다.
어떻게 하나. 두 값을 나눠 기록합니다. 앞서 DMS 의 지연 지표가 읽는 쪽과 쓰는 쪽으로 나뉘어 있던 것도 같은 이유였습니다.
5.4. 커널이 알려주는 압박 지표
상황. EKS 60노드에서 p99 응답이 180ms 에서 1.2초로 늘었는데 CPU 사용률은 60% 로 평소와 같습니다. 사용률 지표만 보면 원인이 안 보입니다.
최근 리눅스에는 PSI(Pressure Stall Information) 가 있어, 프로세스들이 CPU·메모리·I/O 를 기다리느라 멈춰 있던 시간의 비율을 직접 보여줍니다.
왜 유용한가. 사용률은 "얼마나 썼는가" 를 말할 뿐 "부족했는가" 를 말해주지 않습니다. CPU 사용률 60% 가 여유 있는 상태인지 대기가 심한 상태인지는 사용률만으로 알 수 없습니다. PSI 는 그 대기를 직접 재기 때문에 이 편의 주제와 잘 맞는 지표입니다.
결론
흐름을 정리하면서 가장 크게 남은 것은, 이용률과 대기 시간의 관계가 직선이 아니라는 사실이었습니다. 이 곡선을 알기 전에는 "CPU 가 80% 니까 20% 더 쓸 수 있겠다" 고 생각했는데 실제로는 그 구간에서 이미 대기 시간이 몇 배로 벌어지고 있었습니다. 여유를 남기는 것이 낭비가 아니라 설계라는 것을 숫자로 납득하게 되었습니다.
두 번째는 재시도가 선의로 장애를 키운다는 점이었습니다. 각자는 합리적으로 행동하는데 전체로 보면 시스템을 무너뜨립니다. 층마다 재시도를 넣으면 곱해진다는 것, 지터 없는 백오프는 몰림을 그대로 남긴다는 것은 알고 나면 당연한데 모르면 그대로 당하는 종류의 지식이었습니다.
세 번째는 버퍼를 키우는 것이 해결이 아니라는 것이었습니다. 유실을 줄이려고 큐를 키웠더니 지연이 늘어 결국 아무도 못 쓰게 되는 구조는, 안전장치가 다른 방향으로 문제를 만드는 예였습니다. 큐는 변동을 흡수하는 것이지 초과를 담는 것이 아니다라는 구분을 기준으로 삼게 되었습니다.
마지막으로, 제가 만들지 않은 큐가 이미 여러 겹 있다는 것을 확인한 것이 유용했습니다. Accept 큐가 넘쳐 연결이 버려지는데 애플리케이션 로그에는 아무것도 남지 않는 상황은, 로그가 없다는 것이 문제가 없다는 뜻이 아니라는 것을 보여주는 좋은 예였습니다.
공부 참고 자료
📑 1. Queueing Theory 기초 (Wikipedia)
📑 3. AWS Builders' Library - Timeouts, retries, and backoff with jitter
📑 4. AWS Builders' Library - Using load shedding to avoid overload