← 블로그 목록

Developer Knowledge

개발자가 알아야 할 지식: 백프레셔, 빠른 생산자가 느린 소비자를 무너뜨리지 않게 하는 법

트래픽 폭증과 비동기 처리에서 무한 큐가 지연과 장애를 키우는 이유, 그리고 백프레셔·로드 셰딩·버퍼 제한을 실무적으로 설계하는 방법을 설명한다.

Martin Thompson
  • 개발자가 알아야 할 지식
  • Software Engineering
  • Reliability
  • Performance
  • Distributed Systems

왜 개발자가 알아야 하나

서비스가 느려질 때 많은 팀이 가장 먼저 큐를 늘린다. 요청이 몰리면 잠깐 쌓아두었다가 나중에 처리하면 된다고 생각하기 쉽다. 짧은 스파이크라면 맞는 말이다. 하지만 들어오는 속도가 처리 속도보다 오래 더 빠른 상태라면 큐는 완충 장치가 아니라 장애 증폭기가 된다. 대기열이 커질수록 사용자는 이미 의미 없는 응답을 기다리고, 서버는 끝낼 수 없는 일을 계속 붙잡고, 결국 메모리와 스레드가 고갈된다.

백프레셔(backpressure)는 이 상황에서 “더 받지 말라”는 신호를 시스템 안팎으로 전달하는 설계다. 핵심은 처리할 수 없는 일을 무한히 받아들이지 않는 것이다. 소비자가 느려졌다면 생산자가 속도를 줄이거나, 호출자가 재시도 시점을 늦추거나, 일부 요청을 빠르게 거절해야 한다. 이것은 무례한 실패가 아니라 이미 받은 요청을 끝까지 처리하기 위한 자기보호다. 과부하 상태에서 모든 요청을 받아 모두 느리게 실패시키는 것보다, 일부를 명확히 거절하고 나머지의 지연을 지키는 편이 사용자와 운영자 모두에게 낫다.

실무에서 백프레셔가 중요한 이유는 현대 시스템이 거의 모두 비동기 경계 위에 있기 때문이다. HTTP 서버 앞단에는 커넥션 큐가 있고, 애플리케이션 안에는 스레드 풀과 작업 큐가 있고, 서비스 사이에는 메시지 브로커와 스트림이 있다. 이 경계마다 “잠시 보관”이라는 이름의 버퍼가 생긴다. 버퍼가 작으면 스파이크에 약해 보이고, 버퍼가 크면 안전해 보인다. 그러나 버퍼 크기는 용량을 만들어내지 않는다. 처리량은 CPU, I/O, 락 경합, 외부 의존성, 데이터베이스 커넥션 같은 실제 병목이 결정한다.

백프레셔를 모르면 장애 대응도 흐려진다. 대시보드에서 요청 수와 에러율만 보면 “아직 에러는 적은데 왜 사용자는 느리다고 하지?”라는 일이 생긴다. 사실은 큐 안에서 대기 시간이 폭발하고 있을 수 있다. 반대로 백프레셔를 설계한 시스템은 과부하를 더 일찍, 더 작게 드러낸다. 429 Too Many Requests, 503 Service Unavailable, 메시지 소비 속도 조절, 스트림의 demand 신호처럼 불편하지만 관찰 가능한 형태로 압력을 표현한다. 그래서 개발자는 백프레셔를 단순 성능 튜닝이 아니라 신뢰성 설계의 기본 언어로 이해해야 한다.

핵심 개념

백프레셔의 출발점은 “도착률이 처리율보다 높으면 어딘가에 일이 쌓인다”는 단순한 사실이다. 초당 1,000개의 요청이 들어오는데 시스템이 안정적으로 처리할 수 있는 양이 초당 700개라면, 초당 300개는 큐, 메모리, 커넥션, 재시도, 사용자 대기 시간 어딘가에 누적된다. 이 상태가 몇 초면 완충이고, 몇 분이면 장애다. Martin Thompson은 과부하 상태에서 무한 큐가 지연을 키우고 결국 메모리 고갈로 이어질 수 있다고 설명한다. 중요한 판단 기준은 “평균 처리량”이 아니라 “지속 가능한 처리율을 넘은 요청을 어디에서 어떻게 멈출 것인가”다.

백프레셔에는 여러 형태가 있다. 가장 직접적인 방식은 생산자를 느리게 하는 것이다. 스트림 처리에서는 소비자가 “지금 N개를 받을 수 있다”고 demand를 선언하고, 생산자는 그만큼만 보낸다. Reactive Streams가 비동기 스트림에서 non-blocking back pressure를 표준화하려 한 이유가 여기에 있다. 빠른 데이터 소스가 느린 목적지를 압도하지 않도록, 양쪽이 처리 가능량을 프로토콜로 주고받는 것이다.

두 번째 방식은 경계를 제한하는 것이다. 스레드 풀, DB 커넥션 풀, HTTP 클라이언트 풀, 메시지 큐의 in-flight 메시지 수에 상한을 둔다. 상한은 불편하지만 정직하다. 제한이 없으면 시스템은 실제 용량보다 많은 일을 받아들인 척하다가 더 큰 비용으로 실패한다. 제한이 있으면 초과분을 대기, 거절, 우선순위 조정 중 하나로 처리할 수 있다. 특히 애플리케이션 내부의 무제한 큐는 위험하다. 메모리를 많이 쓰는 것만 문제가 아니라, 오래된 요청이 뒤늦게 실행되어 이미 취소된 사용자 의도나 만료된 비즈니스 조건을 처리할 수 있기 때문이다.

세 번째 방식은 로드 셰딩(load shedding)이다. 이는 처리할 수 없는 요청을 빠르게 버리는 전략이다. AWS Builders Library의 과부하 회피 글도 서비스가 일정 시점에 정해진 용량을 가지며, 과부하 시 어떤 요청을 받아들이고 어떤 요청을 돌려보낼지 판단해야 한다고 설명한다. 로드 셰딩은 백프레셔와 경쟁하는 개념이 아니라 함께 쓰이는 도구다. 백프레셔가 호출자에게 “천천히 보내라”고 말한다면, 로드 셰딩은 이미 한계를 넘은 순간 “지금은 받을 수 없다”고 명확히 말한다.

네 번째 요소는 재시도 정책이다. 백프레셔가 있는 서버라도 클라이언트가 즉시 무한 재시도하면 압력은 더 커진다. 그래서 서버는 Retry-After 같은 힌트를 줄 수 있고, 클라이언트는 지수 백오프와 지터를 적용해야 한다. 메시지 브로커에서는 실패한 메시지를 즉시 같은 큐에 되돌리는 대신 지연 재시도, dead-letter queue, 소비자 동시성 제한을 함께 설계한다. 백프레셔는 한 컴포넌트의 옵션 하나가 아니라 생산자, 브로커, 소비자, 호출자까지 이어지는 계약이다.

작은 예시 또는 체크리스트

이미지 썸네일 생성 서비스를 생각해보자. API 서버는 업로드가 끝나면 thumbnail-jobs 큐에 작업을 넣고, 워커 10대가 이미지를 읽어 리사이즈한 뒤 저장한다. 평소에는 초당 100개의 작업이 들어오고 워커는 초당 150개를 처리한다. 그런데 이벤트가 열려 초당 500개의 업로드가 20분 동안 들어온다.

백프레셔가 없으면 큐는 빠르게 커진다. “어차피 나중에 처리되겠지”라고 생각할 수 있지만 실제 사용자는 썸네일을 몇십 분 뒤에 보게 된다. 워커는 오래된 작업을 계속 처리하고, 재시도와 타임아웃이 겹치면 같은 파일을 여러 번 만질 수도 있다. 장애가 끝난 뒤에도 밀린 작업 때문에 회복이 늦다.

백프레셔를 넣은 설계는 다르게 움직인다.

  • 큐 길이와 작업 대기 시간을 별도로 측정한다. 단순히 큐에 몇 개가 있는지보다 가장 오래된 작업이 몇 초를 기다렸는지가 중요하다.
  • 큐 대기 시간이 30초를 넘으면 업로드 API가 “썸네일 생성 지연” 상태를 사용자에게 명확히 보여준다.
  • 대기 시간이 2분을 넘으면 무료 플랜의 비긴급 작업은 429 또는 지연 처리로 돌리고, 유료 플랜이나 사용자 화면에 바로 필요한 작업을 우선 처리한다.
  • 워커의 동시성은 DB와 스토리지의 실제 처리량을 기준으로 제한한다. 워커 수를 무작정 늘려 하위 시스템을 더 세게 때리지 않는다.
  • 실패한 작업은 즉시 재큐잉하지 않고 지수 백오프를 적용한다.
  • 작업이 너무 오래되면 실행 전에 아직 필요한지 확인한다. 사용자가 이미 파일을 삭제했다면 조용히 폐기한다.

간단한 의사코드로 표현하면 다음과 같다.

if queue.oldest_wait_seconds > 120:
    if request.priority == "low":
        return 429, Retry-After: 60

if in_flight_jobs >= MAX_SAFE_CONCURRENCY:
    stop_polling_new_messages()
else:
    process_next_message()

이 코드는 특별해 보이지 않지만 중요한 태도를 담고 있다. 시스템이 감당 가능한 동시성과 대기 시간을 알고, 그 경계를 넘으면 더 받지 않는다. 백프레셔는 “빠르게 만들기”보다 먼저 “망가지지 않게 만들기”에 가깝다.

실무에서 자주 생기는 오해

  • “큐를 크게 잡으면 안전하다”는 오해: 큐는 짧은 변동을 흡수할 뿐 지속적인 용량 부족을 해결하지 못한다. 큰 큐는 에러를 늦게 보이게 만들고, 사용자가 체감하는 지연을 키운다. 큐 크기보다 대기 시간과 처리 가능률을 같이 봐야 한다.

  • “거절은 실패고, 대기는 친절하다”는 오해: 사용자가 5분 뒤 실패 응답을 받는 것보다 즉시 429와 재시도 가능 시간을 받는 편이 나을 수 있다. 특히 모바일 앱, 결제, 검색, 실시간 피드처럼 사용자의 현재 의도가 중요한 요청은 오래 대기시키는 것이 오히려 나쁜 경험이다.

  • “워커를 늘리면 해결된다”는 오해: 병목이 CPU라면 워커 증설이 도움이 될 수 있다. 하지만 병목이 DB 커넥션, 외부 API rate limit, 스토리지 IOPS, 락 경합이라면 워커 증설은 하위 시스템을 더 빨리 무너뜨린다. 먼저 병목과 포화 지표를 확인해야 한다.

  • “백프레셔는 스트리밍 라이브러리에서만 쓰는 개념”이라는 오해: Reactive Streams나 Node.js stream처럼 명시적인 프로토콜이 있는 곳에서 잘 보일 뿐, 개념은 HTTP API, 배치 작업, 이벤트 소비자, 로그 파이프라인, CI 작업 큐에도 똑같이 적용된다.

  • “모든 요청을 같은 기준으로 줄이면 공정하다”는 오해: 과부하 상태에서는 우선순위가 필요하다. 헬스 체크, 결제 완료, 사용자 인터랙션 요청, 내부 크롤러 요청의 중요도는 같지 않다. 단, 우선순위 규칙이 서비스마다 충돌하면 시스템 전체 효율이 나빠질 수 있으므로 팀 차원의 합의가 필요하다.

  • “에러율이 낮으면 괜찮다”는 오해: 백프레셔가 없는 시스템은 에러율이 낮은 채로 지연만 폭발할 수 있다. p95·p99 지연, 큐 대기 시간, in-flight 수, 드롭된 요청 수, 재시도율을 함께 봐야 한다.

오늘 바로 적용해보기

오늘 코드베이스에서 비동기 경계 하나를 골라보자. 예를 들어 HTTP 요청을 받아 내부 큐에 넣는 곳, 메시지 브로커에서 가져와 처리하는 컨슈머, 외부 API를 병렬 호출하는 배치 작업이 좋다. 그리고 다음 질문에 답해보면 된다.

  • 이 경계의 최대 in-flight 작업 수는 어디에 정의되어 있는가?
  • 큐가 가득 차거나 오래 대기하면 호출자는 무엇을 보게 되는가?
  • 가장 오래된 작업의 대기 시간을 모니터링하고 있는가?
  • 재시도는 백오프와 지터를 쓰는가, 아니면 즉시 몰려오는가?
  • 요청을 거절할 때 429, 503, Retry-After처럼 호출자가 행동할 수 있는 신호를 주는가?
  • 오래된 작업을 실행하기 전에 아직 유효한지 확인하는가?
  • 과부하 때 반드시 살려야 할 요청과 미뤄도 되는 요청이 구분되어 있는가?

작게 시작하려면 무제한 큐를 하나 찾아 상한을 두는 것부터 해도 좋다. 단, 상한만 넣고 초과 시 무조건 프로세스를 죽이면 안 된다. 초과 상태를 어떤 응답, 어떤 로그, 어떤 메트릭, 어떤 사용자 메시지로 드러낼지 함께 정해야 한다. 백프레셔는 사용자를 밀어내는 장치가 아니라 시스템의 약속을 정직하게 표현하는 장치다.

코드 리뷰에서는 “이 PR은 더 많은 일을 동시에 받게 만드는가?”라는 질문을 추가해보자. 새 스레드 풀, 새 큐, 새 병렬 처리, 새 재시도 로직이 들어올 때마다 용량 제한과 실패 신호가 같이 들어왔는지 확인한다. 성능 개선 PR도 마찬가지다. 평균 처리 시간이 줄었더라도 포화 상태에서 어떻게 동작하는지 모르면 운영 안정성은 좋아졌다고 말하기 어렵다.

더 알아보기

오늘의 takeaway

백프레셔는 일을 덜 하자는 핑계가 아니라, 감당할 수 있는 일을 끝까지 잘 처리하기 위해 초과분을 정직하게 늦추거나 거절하는 신뢰성 설계다.