← 블로그로 돌아가기

Backpressure: 데이터가 너무 빨리 올 때 벌어지는 일

Observable이란 무엇인가

프로그래밍에서 데이터를 다루는 방식은 크게 두 가지로 나뉜다. 하나는 pull 방식이다. 내가 필요할 때 데이터를 가져온다. 배열을 순회하거나, DB에 쿼리를 날리거나, API를 호출하는 것. 내가 주도권을 쥐고 있다.

다른 하나는 push 방식이다. 데이터가 준비되면 나한테 알아서 날아온다. 유저가 언제 버튼을 클릭할지, 서버가 언제 메시지를 보낼지 나는 모른다. 그저 “올 때 이렇게 처리해줘"라고 등록해두고 기다릴 뿐이다.

Observable은 이 push 방식을 일반화한 추상이다. “시간에 걸쳐 도착하는 값들의 스트림"을 하나의 객체로 표현한다. 이벤트도 Observable이고, HTTP 응답도 Observable이고, 웹소켓 메시지도 Observable이다. 심지어 [1, 2, 3] 같은 배열도 Observable로 감쌀 수 있다. 핵심은 “값이 언제, 몇 개 올지 모르는 비동기 데이터 소스"를 통일된 인터페이스로 다룬다는 것이다.

그런데 여기서 근본적인 질문이 하나 생긴다. 데이터가 push되는 거라면, 누가 속도를 통제하는가? 이 질문이 이 글 전체를 관통한다.

Cold Observable과 Hot Observable

Observable에는 두 종류가 있다. 이 구분이 뒤에 나올 메시지 시스템과 backpressure를 이해하는 데 결정적이다.

Cold Observable - 신문 구독

Cold Observable은 신문과 같다. 내가 구독을 시작하면 1호부터 순서대로 배달된다. 늦게 구독해도 1호부터 받는다. 구독자마다 독립적인 스트림이 만들어진다.

또 다른 특징은, 구독자가 없으면 신문사는 인쇄를 시작하지도 않는다는 것이다. 즉, subscribe하기 전에는 데이터 생산 자체가 일어나지 않는다. HTTP 요청이 대표적이다. 누군가 subscribe해야 비로소 요청이 날아간다. 두 명이 subscribe하면 요청도 두 번 간다.

이 구조에서는 backpressure가 비교적 단순하다. Producer와 Consumer가 1:1이고, Consumer가 구독을 시작해야 데이터가 흐르니까. Consumer가 느리면? Producer도 자연스럽게 느려지거나, 아니면 의도적으로 제어할 수 있다.

Hot Observable - TV 생방송

Hot Observable은 TV 생방송이다. 방송은 내가 채널을 돌리든 말든 진행된다. 내가 중간에 켜면 그 시점부터의 방송만 볼 수 있다. 이미 지나간 장면은 못 본다. 그리고 모든 시청자가 같은 방송을 본다.

주식 시세 피드, 마우스 이벤트, 센서 데이터가 이런 유형이다. Producer는 Consumer의 존재 여부와 관계없이 데이터를 쏟아낸다. 구독자가 0명이어도 데이터는 생산되고, 소비되지 않은 채 사라진다.

바로 여기서 backpressure 문제가 본격적으로 터진다. Producer는 자기 페이스로 데이터를 밀어내고, Consumer가 느린 건 Producer의 관심사가 아니다. 이 “무관심"이 시스템을 터트리는 원인이다.

메시지 시스템: RabbitMQ, Kafka, Redis Pub/Sub

현실의 분산 시스템에서 Producer와 Consumer는 서로 다른 서버, 다른 프로세스에 존재한다. 이 둘을 연결하는 것이 메시지 시스템이고, 각 시스템은 Observable의 cold/hot 특성, 그리고 backpressure에 대해 근본적으로 다른 철학을 갖고 있다.

Redis Pub/Sub - 확성기

Redis Pub/Sub은 가장 단순한 모델이다. 말 그대로 확성기다. Publisher가 채널에 메시지를 쏘면, 그 순간 해당 채널을 구독 중인 모든 Subscriber에게 즉시 전달된다.

핵심적인 특성이 하나 있다: 메시지가 어디에도 저장되지 않는다. Subscriber가 없으면 메시지는 공중에서 사라진다. Subscriber가 잠시 연결이 끊겼다가 복귀해도, 그 사이의 메시지는 영영 없다. 완전한 Hot Observable이다.

이것이 해결하는 문제는 “실시간 알림"이다. 채팅 메시지, 실시간 알림, 캐시 무효화 신호처럼 “지금 이 순간"에만 의미가 있는 데이터.

트레이드오프는 명확하다. 신뢰성을 완전히 포기하는 대신, 극도로 낮은 latency와 단순한 구조를 얻는다. 메시지가 유실돼도 괜찮은 경우에만 쓸 수 있다. 그리고 backpressure 메커니즘이 사실상 없다. Subscriber가 느리면? 메시지가 쌓이다가 결국 연결이 끊긴다. Redis 입장에서는 “니가 알아서 빨리 처리해"다.

RabbitMQ - 우체국

RabbitMQ는 우체국이다. 보내는 사람(Producer)이 편지를 우체국(Exchange)에 맡기면, 우체국이 규칙에 따라 적절한 우편함(Queue)에 넣고, 받는 사람(Consumer)이 우편함에서 꺼내간다. 편지는 받는 사람이 가져갈 때까지 우편함에 안전하게 보관된다.

이 모델의 철학은 “smart broker, dumb consumer"다. 브로커(RabbitMQ)가 메시지 라우팅, 우선순위, 재전달, 순서 보장 등 복잡한 일을 다 처리한다. Consumer는 그저 꺼내가면 된다.

결정적인 특징: 메시지가 소비되면 큐에서 삭제된다. 한 번 읽으면 끝이다. 같은 메시지를 다른 Consumer가 또 읽을 수 없다(fanout 등 별도 설정을 하지 않는 한). 이건 “작업 분배” 패턴에 최적화된 설계다. 10개의 주문이 들어오면 5개의 worker가 2개씩 나눠 처리하는 식이다.

이것이 해결하는 문제는 “작업의 안전한 전달과 분배"다. 결제 처리, 이메일 발송, 주문 처리. 한 건도 빠뜨리면 안 되고, 중복 처리도 피해야 하는 작업들.

트레이드오프: 메시지 보존과 안전한 전달을 얻는 대신, 처리량(throughput)이 상대적으로 낮다. 메시지를 디스크에 쓰고, ack/nack을 관리하고, 라우팅 규칙을 평가하는 오버헤드가 있다. 그리고 메시지가 소비 후 삭제되기 때문에 “같은 데이터를 나중에 다시 읽기"가 불가능하다.

Backpressure 측면에서는 꽤 우아하다. Consumer가 prefetchCount를 설정하면, “나는 한 번에 N개까지만 받겠다"고 선언할 수 있다. ack을 보내야 다음 메시지가 온다. Consumer가 느려지면 자연스럽게 메시지 수신 속도가 줄어든다. 큐가 너무 차면 Producer에게도 신호를 줄 수 있다.

Kafka - 도서관의 열람실

Kafka는 도서관이다. 메시지를 소비한다기보다 열람한다. Producer가 보낸 메시지는 topic의 partition에 순서대로 기록되고, 설정된 보존 기간(retention) 동안 그대로 남아있다. Consumer는 자기가 어디까지 읽었는지(offset)를 추적하면서 데이터를 읽어간다.

같은 데이터를 여러 Consumer가 각자의 속도로, 각자의 offset에서 읽을 수 있다. 한 Consumer가 느려도 다른 Consumer에게 영향이 없다. 심지어 새로운 Consumer가 나중에 합류해서 처음부터(offset 0) 다시 읽을 수도 있다.

철학은 RabbitMQ의 정반대인 “dumb broker, smart consumer"다. Kafka 브로커는 메시지를 순서대로 저장하고 꺼내주는 것 이상을 하지 않는다. 라우팅 로직, 재처리, offset 관리 같은 건 Consumer 쪽 책임이다.

이것이 해결하는 문제는 “대용량 이벤트 스트리밍과 이벤트 소싱"이다. 하루 수십억 건의 로그, 클릭 이벤트, 트랜잭션 기록. 한 번 쓰고 여러 시스템이 각자 필요한 방식으로 소비하는 패턴.

트레이드오프: 높은 throughput과 데이터 재처리 가능성을 얻는 대신, 운영 복잡도가 높다. Zookeeper(또는 KRaft) 클러스터 관리, partition 설계, Consumer Group rebalancing 등을 다뤄야 한다. 그리고 메시지 단위의 세밀한 라우팅은 약하다. “이 메시지는 A에게, 저 메시지는 B에게” 같은 복잡한 라우팅은 RabbitMQ가 훨씬 유연하다.

Backpressure는 pull 기반으로 자연스럽게 해결된다. Consumer가 자기 페이스로 fetch한다. 느리면 lag이 쌓이는데, 이건 브로커가 알아서 보관하고 있으니 데이터가 유실되지 않는다. 대신 lag 모니터링을 통해 Consumer를 수평 확장하는 게 운영의 핵심이다.

한눈에 비교

Redis Pub/Sub RabbitMQ Kafka
비유 확성기 우체국 도서관
메시지 저장 안 함 소비 시 삭제 보존 기간 동안 유지
Consumer 모델 Push (Hot) Push (Smart Broker) Pull (Smart Consumer)
같은 메시지 재소비 불가 불가 (기본) 가능
Backpressure 없음 prefetch로 제어 Consumer가 pull 속도 조절
강점 극저 latency 안전한 작업 분배 대용량 스트리밍
약점 유실 가능 throughput 한계 운영 복잡도

Backpressure라는 문제

여기서 다시 처음의 질문으로 돌아온다. 데이터가 push되는데, 받는 쪽이 처리를 못 따라가면 어떻게 되는가?

수도꼭지에서 물이 쏟아지는데 배수구가 그 속도를 못 따라간다고 상상해보자. 물탱크(버퍼)가 있으면 잠시 버틸 수 있지만, 물탱크도 결국 넘친다. Backpressure란 이 상황, 그리고 이 상황을 다루는 메커니즘 전체를 가리킨다.

무시하면 세 가지 참사가 벌어진다.

첫째, 메모리 폭발. 처리 못한 데이터가 큐나 버퍼에 무한히 쌓인다. 프로세스가 OOM(Out of Memory)으로 죽는 건 시간문제다.

둘째, latency 급증. 큐에 쌓인 아이템이 많아질수록 각 아이템의 대기 시간이 길어진다. 실시간 시스템에서는 “3초 전 데이터"는 이미 쓸모가 없다.

셋째, cascading failure. 한 컴포넌트가 터지면 상류(upstream)로 에러가 전파된다. 마이크로서비스 아키텍처에서 하나의 느린 서비스가 전체 시스템을 먹통으로 만드는 시나리오.

Backpressure에 대응하는 세 가지 전략

Lossy: 감당 안 되면 버린다

가장 과격하지만 가장 단순한 전략이다. 처리 못할 데이터는 그냥 드롭한다. “최신 값만 의미 있는” 데이터에 적합하다.

주식 시세를 생각해보자. 1ms마다 가격이 갱신되는데 화면은 100ms마다만 렌더링할 수 있다면, 중간의 99개 값은 화면에 보여줄 필요가 없다. 마지막 값만 있으면 된다. 마우스 움직임, 센서 데이터, 실시간 대시보드가 전부 이런 유형이다.

Redis Pub/Sub이 본질적으로 이 전략이다. Subscriber가 느리면 메시지가 쌓이다가 결국 버려진다. 그리고 애초에 그래도 괜찮은 유스케이스에 Redis Pub/Sub을 쓰는 거다.

대가는 분명하다: 데이터 유실. 모든 이벤트가 비즈니스적으로 중요한 경우(결제, 주문)에는 절대 쓸 수 없다.

Lossless: 한 건도 안 버리고, 속도를 맞춘다

모든 데이터가 중요한 경우. 결제 트랜잭션, 주문 처리, 로그 수집. 한 건이라도 잃으면 안 되는 경우에는 Consumer의 처리 완료 신호에 맞춰 다음 데이터를 보내는 방식을 쓴다.

핵심은 피드백 루프다. Consumer가 “하나 처리 끝났어"라고 신호(ack)를 보내면, 그때서야 Producer(또는 브로커)가 다음 데이터를 넘긴다. Consumer가 느리면 전체 파이프라인이 느려지지만, 데이터는 한 건도 잃지 않는다.

RabbitMQ의 prefetchCount가 정확히 이 메커니즘이다. “나는 한 번에 5개까지만 받을게. ack 보낼 때마다 하나씩 더 줘.” Consumer가 느려지면 큐에 메시지가 쌓이지만, 각 Consumer가 감당할 수 있는 만큼만 가져가니까 Consumer가 죽지는 않는다.

Kafka도 비슷하다. Consumer가 pull 방식으로 자기 속도에 맞춰 가져가니까, 느린 Consumer는 lag이 쌓일 뿐 터지지 않는다.

대가: 전체 시스템의 throughput이 가장 느린 Consumer에 맞춰진다. 빠른 쪽이 느린 쪽을 기다려야 한다.

Buffering: 일시적 불균형을 흡수한다

Producer와 Consumer의 평균 속도는 비슷한데, 순간적으로 burst가 오는 경우. 점심시간에 주문이 몰리지만 하루 전체로 보면 처리 가능한 양인 상황이다.

이때 버퍼(큐)를 중간에 두면 burst를 흡수할 수 있다. Producer는 자기 속도로 버퍼에 넣고, Consumer는 자기 속도로 버퍼에서 꺼내 처리한다. 버퍼가 “시간차"를 벌어준다.

그런데 반드시 기억해야 할 것이 하나 있다: 버퍼는 backpressure의 근본 해결이 아니라 시간 벌기다. Producer의 평균 속도가 Consumer의 평균 속도보다 빠르면, 버퍼는 언젠가 반드시 넘친다. 아무리 큰 버퍼를 둬도 마찬가지다. Buffering은 “순간 burst에는 효과적이지만 지속적 과부하에는 무력하다"는 한계가 있다. Bounded buffer(상한이 있는 버퍼)를 쓰고, 상한에 도달하면 lossy나 lossless 전략으로 전환해야 한다.

세 메시지 시스템 모두 일정 수준의 버퍼링을 제공하지만, 버퍼가 꽉 찼을 때의 동작이 다르다. Redis Pub/Sub은 연결을 끊어버린다(lossy). RabbitMQ는 Producer에게 flow control 신호를 보낸다(lossless). Kafka는 디스크에 써놓고 Consumer가 알아서 가져가게 한다. Pull 기반이라 브로커 레벨에선 문제가 안 되지만, Consumer lag이 무한히 쌓일 수는 있다.

큐를 써도 Backpressure는 필요하다

“그냥 중간에 큐 넣으면 되는 거 아냐?”

맞는 말이기도 하고, 틀린 말이기도 하다. 큐는 가장 원시적인 backpressure 버퍼다. Producer가 큐에 넣고, Consumer가 큐에서 꺼내 처리한다. 그런데 단순한 unbounded queue는 Producer에게 아무 신호도 주지 않는다. Producer는 큐가 100만 개든 1000만 개든 신경 쓰지 않고 계속 넣는다. 이건 backpressure가 아니라 “문제를 메모리에 떠넘긴 것"이다.

진짜 backpressure가 있는 시스템은 “피드백 루프"가 있다. Consumer가 바쁘면 그 신호가 Producer까지 전달되어서, Producer의 행동이 바뀐다. 속도를 줄이든, 멈추든, 데이터를 버리든.

핵심은 이거다:

“느려지더라도 죽지는 않는다.”

Fail-safe vs Circuit Breaker

이 “죽지 않는다"라는 원칙을 시스템 설계 차원으로 확장하면 두 가지 패턴이 있다.

Fail-safe: 부하를 견디며 버틴다

Fail-safe는 과부하 상황에서 시스템이 기능을 줄여서라도 계속 동작하는 것이다. 100%의 성능은 못 내더라도 60%는 낸다. 엘리베이터가 과적을 감지하면 문을 안 닫는 것과 같다. 이동은 못 하지만, 탑승자를 가두거나 추락하지는 않는다.

Backpressure의 lossless 전략이 정확히 이것이다. Consumer가 느려지면 전체 파이프라인이 느려지지만, 데이터도 안 잃고 시스템도 안 죽는다. RabbitMQ의 prefetch, Kafka의 consumer lag이 이 패턴이다.

Circuit Breaker: 더 이상 무의미한 시도를 멈춘다

Fail-safe가 “버티기"라면, Circuit Breaker는 “손절"이다. 하류 서비스가 응답을 못 하는 상황에서 계속 요청을 보내는 건 의미 없는 자원 낭비이자, 이미 힘든 하류 서비스를 더 괴롭히는 짓이다.

Circuit Breaker는 전기 회로의 차단기에서 이름을 따왔다. 세 상태를 순환한다.

Closed 상태가 정상이다. 요청이 그대로 통과한다. 하류 서비스의 실패율이 임계점을 넘으면 Open 상태로 전환된다.

Open 상태에서는 하류 서비스에 요청을 아예 보내지 않는다. 즉시 에러를 반환하거나 fallback 응답을 준다. “지금 그쪽 서버 죽었으니까 물어보지도 않을게.” 일정 시간이 지나면 Half-Open 상태로 전환된다.

Half-Open 상태에서는 제한된 수의 요청만 시험 삼아 보내본다. 성공하면 Closed로 복귀하고, 실패하면 다시 Open으로 돌아간다.

Backpressure가 “느려지더라도 계속 처리하는 것"이라면, Circuit Breaker는 “처리 자체를 포기하고 시스템을 보호하는 것"이다. 둘은 상호 보완적이다. 보통 backpressure를 1차 방어선으로 두고, 그래도 상황이 안 되면 Circuit Breaker가 2차 방어선으로 작동한다.

마무리

Backpressure는 “시스템의 정직함"에 관한 이야기다. 처리할 수 없는 양을 처리할 수 있는 척하지 않고, “지금 바빠"라고 말하는 것. 그 신호가 Producer까지 전달되어 전체 시스템이 현실적인 속도로 동작하게 만드는 것.

Observable에서 시작해서 메시지 큐를 거쳐 분산 시스템의 안정성까지, 관통하는 원리는 하나다.

“빠른 시스템"보다 “죽지 않는 시스템"이 낫다.