백프레셔가 없으면 메모리가 먼저 죽는다
큐에 쌓아 두면 언젠가 다 처리될 줄 알았는데, 유입이 넘친 날 서버가 메모리 부족으로 줄줄이 죽었어요. 쌓인 것도 전부 잃었고요. 못 받을 때 못 받는다고 알리는 장치를 정리했어요.
메시지를 받아 처리하는 서비스예요. 유입이 늘어도 큐에 쌓이면서 결국 다 처리되니 괜찮다고 생각했어요.
그런데 유입이 처리 능력을 넘긴 날, 프로세스가 메모리 부족으로 죽었어요. 그것도 여러 인스턴스가 차례로요.
재시작하면 다시 뜨는데, 쌓여 있던 것을 한꺼번에 받으면서 또 죽어요. 죽고 뜨고를 반복하면서 아무것도 처리하지 못해요.
받는 속도를 처리 속도에 맞추는 장치가 없으면, 초과분은 어딘가에 쌓여요. 그리고 그 어딘가는 대개 메모리예요.
큐에 쌓이면 결국 다 처리된다는 생각이 틀린 지점이 바로 여기예요. 쌓이는 데도 한계가 있습니다.
왜 그런가
데이터가 흐르는 구조에는 항상 두 속도가 있어요. 들어오는 속도와 처리하는 속도.
처리가 더 빠르면 문제없어요. 유입이 더 빠르면 차이만큼 어딘가에 쌓여요.
백프레셔는 "나 지금 더 못 받아"를 상류에 알리는 장치예요. 이게 있으면 유입 자체가 느려져요. 없으면 초과분이 계속 쌓이고, 쌓이는 곳은 대개 메모리예요.
메모리가 차면 두 가지가 일어나요. 가비지 컬렉션이 자주 돌면서 정지 시간이 길어지고, 결국 부족해서 프로세스가 죽어요.
여기서 중요한 게 있어요. 죽는 순간 쌓여 있던 것을 전부 잃어요. 처리 중이던 것도, 큐에 있던 것도요. 그래서 유입이 잠깐 넘친 것이 대량 유실로 이어집니다.
그리고 재시작 뒤에 더 나빠져요. 그동안 밀린 것이 한꺼번에 들어오니 정상 상태보다 유입이 더 많아요. 죽고 뜨고를 반복하는 고리가 만들어져요.
무제한 큐가 이 문제의 핵심이에요. 앞 편에서 본 부하 흘리기와 같은 얘기고, 여기서는 그게 데이터 흐름 안에서 일어나는 거예요.
정리하면 유입과 처리의 속도 차이는 반드시 어딘가에 저장돼요. 그 저장소를 우리가 정하지 않으면 시스템이 메모리를 고릅니다.
코드로 보면
못 받으면 못 받는다고 말한다:
무제한 큐 — 초과분이 메모리에 쌓인다
차면 GC 가 길어지고 결국 죽는다
죽으면 쌓여 있던 걸 전부 잃는다
· 1) 유한 큐 + 가득 차면 받기를 멈춘다
큐가 가득 차면 소비를 일시 정지
여유가 생기면 재개
· 2) 요청 기반 흐름 — 처리할 수 있는 만큼만 요청
소비자가 '10개 더 주세요' 하고 그만큼만 받는다
리액티브 스트림의 기본 모델
· 3) 상류에 신호를 보낸다
TCP 수신 창 · HTTP 429 · 큐 폴링 중단
프로토콜에 이미 있는 장치를 쓰는 게 안전하다
· 4) 못 받으면 버릴지 기다릴지 정한다
최신 값만 중요하면 오래된 걸 버려도 된다✕ 문제 지점 · ✓ 해법 · 그대로 돌려 보기 전에 자기 환경에 맞게 고쳐 주세요.
실전에서는
백프레셔를 구현하는 방법이 계층마다 있어요.
가장 확실한 건 프로토콜에 이미 있는 장치를 쓰는 거예요. TCP 는 수신 창으로 흐름을 조절해요. 받는 쪽이 읽지 않으면 창이 줄고 보내는 쪽이 자동으로 느려져요. 소켓에서 읽는 속도를 처리 속도에 맞추면 이게 저절로 동작합니다.
메시지 큐를 쓴다면 가져오는 속도를 조절하면 돼요. 처리가 밀리면 가져오기를 멈추고, 여유가 생기면 재개해요. 앞 편에서 본 컨슈머 판정 문제와 균형을 잡아야 하지만요.
HTTP 라면 429 로 거절하는 게 백프레셔예요. 앞 편의 부하 흘리기와 같은 얘기죠.
애플리케이션 안에서는 유한 큐와 차단이 기본이에요. 큐가 가득 차면 넣으려는 쪽이 기다리거나 실패해요. 무제한 큐를 쓰지 않는 것만으로도 절반은 해결됩니다.
요청 기반 모델도 있어요. 소비자가 "몇 개 더 처리할 수 있다"고 알리고 그만큼만 받는 방식이요. 리액티브 스트림 같은 표준이 이 모델을 쓰는데, 흐름 제어가 명시적이라 안전하지만 프로그래밍 모델이 달라집니다.
그리고 못 받을 때 무엇을 할지 정해야 해요. 기다리게 할지, 버릴지, 오래된 것부터 버릴지요. 실시간 위치처럼 최신 값만 중요하면 오래된 걸 버려도 돼요. 주문처럼 하나도 잃으면 안 되면 기다리게 하거나 영속 큐로 밀어내야 하고요.
여기서 중요한 원칙이 있어요. 어딘가에는 반드시 한계가 있어야 해요. 어느 지점에도 한계가 없으면 그 시스템은 반드시 메모리에서 죽습니다.
처방
- 무제한 큐를 없애세요. 모든 큐에 크기 상한을 두는 것만으로 절반이 해결됩니다.
- 큐가 가득 찼을 때의 동작을 정하세요. 기다릴지, 버릴지, 오래된 것부터 버릴지요.
- 상류에 신호를 보내세요. 폴링 중단, 429 응답, 소켓 읽기 중단 중 상황에 맞는 것을 쓰세요.
- 프로토콜에 있는 장치를 먼저 쓰세요. 직접 만드는 것보다 안전합니다.
- 잃으면 안 되는 데이터는 영속 큐로 밀어내세요. 메모리에 쌓아 두면 죽을 때 전부 잃습니다.
- 큐 길이와 메모리 사용률을 함께 보세요. 둘이 같이 오르면 백프레셔가 없다는 뜻입니다.
- 재시작 후 밀린 것이 한꺼번에 들어오는 상황을 대비하세요. 재개 속도를 제한하는 장치가 필요합니다.
- 유입이 처리 능력을 넘는 시험을 하세요. 정상 범위만 시험하면 이 문제를 못 잡습니다.
- 큐 제한을 개수만이 아니라 바이트로도 거세요. 큰 메시지 하나가 개수 제한을 통과하면서 메모리를 채웁니다.
지금 확인해 보세요
어디에 쌓이고 있는가:
· 큐 길이와 메모리를 함께 본다
둘이 같이 오르면 백프레셔가 없다는 뜻이다
· 데이터 경로의 모든 버퍼를 나열한다
애플리케이션 큐 · 라이브러리 내부 버퍼
소켓 수신 버퍼 · 직렬화 중간 결과 · 로그 버퍼
하나만 제한하면 다른 곳에 쌓인다
· 큐 제한이 개수인가 바이트인가
개수만 제한하면 큰 메시지에 무력하다
· 유입과 처리 속도를 나란히
유입 > 처리 가 지속되면 언젠가 반드시 터진다
· 재시작 후 밀린 것이 몰릴 때를 대비했는가
재개 속도 제한이 없으면 뜨자마자 다시 죽는다핵심 한 줄어딘가에는 반드시 한계가 있어야 한다. 없으면 메모리가 그 한계가 된다.