백프레셔의 정의와 흔히 발생하는 사례, 그리고 제어·버퍼링·드롭 같은 완화 전략을 다양한 예시로 설명한다.
URL: https://medium.com/@jayphelps/backpressure-explained-the-flow-of-data-through-software-2350b3e77ce7
읽는 데 10분
2019년 2월 1일
백프레셔(backpressure, 또는 back pressure)는 거의 모든 소프트웨어 엔지니어가 언젠가 마주치게 되는 문제이고, 어떤 사람들에게는 매우 자주 발생하는 문제이기도 합니다. 하지만 그 용어 자체는 그만큼 널리 이해되거나 인지되고 있지는 않습니다.
이 글에서는 백프레셔가 정확히 무엇인지, 언제 흔히 발생하는지, 그리고 이를 완화하기 위해 사용할 수 있는 전략들을 자세히 설명하겠습니다.
최근 ReactiveConf에서 이 주제로 발표를 했습니다:
소프트웨어 세계에서 “백프레셔”는 자동차 배기나 가정 배관처럼 유체역학에서 빌려온 비유입니다.
파이프를 통해 유체가 원하는 방향으로 흐르는 것을 방해하는 저항 또는 힘.
소프트웨어 맥락에서는 이를 소프트웨어 내부의 데이터 흐름에 맞게 약간 바꿔 말할 수 있습니다:
소프트웨어를 통과하는 원하는 데이터 흐름을 방해하는 저항 또는 힘.
소프트웨어의 목적은 입력 데이터를 받아 원하는 출력 데이터로 변환하는 것입니다. 그 출력 데이터는 API의 JSON일 수도 있고, 웹페이지의 HTML일 수도 있으며, 모니터에 표시되는 픽셀일 수도 있습니다.
백프레셔는 그 입력을 출력으로 바꾸는 진행이 어떤 방식으로든 “저항”을 받을 때 발생합니다. 대부분의 경우 그 저항은 계산 속도입니다. 즉, 입력이 들어오는 속도만큼 빠르게 출력을 계산하지 못하는 문제죠. 그래서 백프레셔를 이해할 때 가장 쉬운 관점이기도 합니다. 하지만 다른 형태의 백프레셔도 있습니다. 예를 들어, 소프트웨어가 사용자가 어떤 행동을 할 때까지 기다려야 하는 경우도 그렇습니다.
참고로, 언젠가 누군가 “백프레셔”라는 단어를 소프트웨어를 통과하는 저항받는 데이터 흐름을 제어/처리/회피할 수 있는 능력이라는 뜻으로 쓰는 걸 들을 수도 있습니다.
이는 많은 엔지니어들이 이 글에서 제가 쓰는 방식과 다르게 “백프레셔”를 해석하기 때문입니다. 바람직하지 않은 저항 그 자체를 가리키기보다, 그 저항을 관리하는 행위에 용어를 붙이는 것이죠. 예컨대 누군가가 “백프레셔가 내장된 새로운 라이브러리를 만들었어…”라고 말하는 식입니다.
개인적으로는 유체역학에서의 백프레셔는 (대개) 바람직한 것이 아니기 때문에 그런 용법은 부정확하다고 생각합니다. 다만 이 용어는 공식적으로 엄밀히 정의된 것이 아니니, 그 정의가 객관적으로 틀렸다고 주장하는 건 아닙니다.
이런 정의를 손에 쥐고도 “백프레셔”가 실제로 무엇을 의미하는지 아직은 다소 흐릿할 수 있습니다. 저는 많은 사람들이 몇 가지 예시를 듣고 나서야 “아하!” 하는 순간을 맞이하곤 한다고 느낍니다.
비유로 시작해봅시다. 1950년대 TV 쇼 “I Love Lucy(아이 러브 루시)”에는 루시가 사탕 포장 공장에서 일하는 에피소드가 있습니다. 그녀의 일은 컨베이어 벨트에서 사탕을 집어 종이로 하나씩 포장하는 것이죠. 쉬워 보이지만, 곧 컨베이어 속도가 그녀가 감당할 수 있는 속도보다 빠르다는 걸 깨닫습니다. 그리고 웃긴 상황이 이어집니다.
몇 년 전 이 예시를 제안해준 예전 동료 Randall Koutnik에게 감사를 전합니다!
이것은 백프레셔의 완벽한 예시입니다. 루시는 이를 해결하기 위해 두 가지 서로 다른 방법을 시도합니다. 나중에 처리하려고 일부를 따로 쌓아두는 것(버퍼링), 그리고 결국엔 먹어치우거나 모자 안에 숨기는 것(드롭)입니다. 하지만 초콜릿 공장이라는 맥락에서는 이 둘 다 실용적인 전략이 될 수 없습니다. 대신 그녀는 컨베이어를 느리게 해야 했습니다. 다시 말해, 생산자의 속도를 제어할 수 있어야 합니다. 전략에 대해서는 잠시 후 더 이야기하겠습니다!
이제 소프트웨어 관련 백프레셔를 이야기해봅시다. 가장 흔한 경우는 파일 시스템을 다룰 때입니다.
파일에 쓰는 작업은 파일을 읽는 작업보다 더 느립니다. 예를 들어, 어떤 하드드라이브가 실효 읽기 속도 150MB/s, 쓰기 속도 100MB/s를 제공한다고 해봅시다. 가능한 한 빠르게 파일을 메모리로 읽어들이는 동시에, 가능한 한 빠르게 다시 디스크에 써 넣는다면 매초 50MB를 버퍼링해야 합니다. 이건 계속 커지는 적자입니다! 입력 파일을 전부 읽을 때까지는 그 “빚”을 줄여 따라잡기 시작할 수조차 없습니다.
이번엔 6GB 파일로 상상해봅시다. 파일을 완전히 다 읽어냈을 때쯤이면, 아직 쓰지 못한 2GB 버퍼가 남아있게 됩니다.
엄청난 메모리 낭비입니다. 어떤 시스템에서는 사용 가능한 메모리를 초과할 수도 있습니다. 혹은 웹 서버가 여러 요청에 대해 이 작업을 동시에 수행한다고 상상해보세요. 이런 접근이 많은 경우에 현실적이지 않다는 점이 분명해지길 바랍니다.
하지만 걱정 마세요. 해결책은 간단합니다. “쓸 수 있는 만큼만 읽기”입니다. 거의 모든 I/O 라이브러리는 이를 자동으로 처리할 수 있는 추상화를 제공하는데, 대개 “스트림(stream)”과 “파이프(pipe)” 같은 개념을 통해서입니다. Node.js는 훌륭한 예시입니다.
다음 예시는 서버 간 통신입니다. 오늘날에는 책임을 여러 서버로 분리하는 마이크로서비스 아키텍처가 매우 흔합니다.
이 시나리오에서 백프레셔는 한 서버가 다른 서버가 처리할 수 있는 속도보다 빠르게 요청을 보내는 경우 흔히 발생합니다.
서버 A가 서버 B에 초당 100 rps(requests per second)를 보내는데, 서버 B가 초당 75 rps만 처리할 수 있다면 초당 25 rps의 적자가 생깁니다. 서버 B가 뒤처지는 이유는 자체 처리 때문일 수도 있고, 다운스트림의 다른 서버들과 통신해야 하기 때문일 수도 있습니다.
(전체 크기로 이미지를 보려면 Enter를 누르거나 클릭)

어쨌든 서버 B는 어떻게든 백프레셔를 처리해야 합니다. 그 25 rps 적자를 버퍼링하는 것도 한 가지 옵션이지만, 그 증가가 계속 일정하게 유지된다면 곧 메모리가 바닥나서 다운될 것입니다. 요청을 드롭하는 것도 다른 옵션이지만, 요청 드롭이 허용되지 않는 요구사항은 아주 흔합니다.
이상적인 옵션은 서버 B가 서버 A가 요청을 보내는 속도를 제어할 수 있는 것입니다. 하지만 이것 역시 항상 가능하진 않습니다. 서버 A가 사용자 대신 요청하는 경우, 사용자가 느리게 하도록 말하거나 제어할 수 없는 경우가 많기 때문입니다(가끔은 가능하지만요!). 그럼에도 종종 요청하는 쪽 서버가 버퍼링하도록 하는 것이 더 낫습니다. 그러면 스트레스가 걸리는 다운스트림 쪽에 메모리 부담을 더 잘 분산시킬 수 있고, 다른 요청자에게 미치는 영향도 줄일 수 있습니다.
이 글쓴이의 업데이트를 받으려면 Medium에 무료로 가입하세요.
예를 들어 세 가지 서로 다른 유형의 서비스(A, B, C)가 모두 동일한 다운스트림 서비스(Z)에 요청을 보낸다고 해봅시다. 네 개 중 하나(A)가 높은 부하를 겪고 있다면, 서비스 Z는 서비스 A에게 “속도를 줄여라”(생산자 제어)라고 사실상 말할 수 있고, 그러면 서비스 A가 요청을 버퍼링하게 됩니다. 이것이 계속되면 결국 서비스 A는 메모리가 부족해질 수 있지만, 다른 두 서비스(B, C)는 계속 살아있을 것이고, 다운스트림 서비스 Z도 한 문제 있는 서비스가 다른 서비스들의 동등한 접근을 막지 못하도록 했기 때문에 계속 살아있을 것입니다. 이 경우 장애는 피할 수 없을 수도 있지만, 영향 범위를 제한하고 연쇄적인 서비스 거부(DoS)로 번지는 것을 막은 것입니다.
이는 생산자를 제어하고, 그 생산자가 (사용자라는) 자신의 생산자를 제어할 수 없기 때문에 버퍼링을 하는 사례입니다. 이 경우 누군가는 버퍼링해야 하는데, 중요한 건 “누가” 버퍼링하느냐입니다.
제가 넷플릭스에서 일할 때는 이런 종류의 백프레셔가 흔했습니다. 저는 발표 중 하나에서 이에 대해 이야기합니다. 궁극적으로 백프레셔를 제어하기 위해 어떤 전략을 쓸지는 유스케이스에 달려 있었습니다. 때로는 RSocket이나 gRPC 같은 것을 이용해 생산자를 제어할 수 있었습니다.
대규모 서비스 운영에 관심이 있다면, 카오스 엔지니어링(Chaos Engineering)과 운영 환경에서 의도적으로 이런 실패를 유도해 복원력과 장애 조치/폴백을 테스트하는 방법에 대해 더 알아보는 것이 좋습니다.
백프레셔의 마지막 예시는 UI 앱 렌더링입니다. 필요한 속도로 렌더링할 수 없을 때 백프레셔가 발생합니다. 거대한 리스트를 렌더링하려는 시도, 빠른 키보드 이벤트를 디바운스하는 것처럼 단순한 경우일 수도 있고, 초당 20,000개의 이벤트를 내보내는 WebSocket 출력물을 표시하려는 것처럼 더 복잡한 상황일 수도 있습니다.
이 종류의 백프레셔는 작은 문제들이 누적되어 치명적이 되는 형태로 매우 복잡해질 수 있으니, 어디서 어떻게 잘못될 수 있는지 보기 쉬운 WebSocket 예시에 집중해봅시다.

WebSocket이 초당 2만, 10만, 심지어 20만+ 메시지를 방출한다면, 들어오는 대로 그 메시지를 하나하나 모두 렌더링하는 건 불가능합니다. 절대 못 합니다. 그래서 어떤 형태로든 백프레셔 전략이 필요합니다. 서버에서 전송 속도를 제어할 수 없다면, 클라이언트 측에서 버퍼링하거나 드롭해야 합니다.
버퍼링의 경우, 들어오는 메시지를 배열에 쌓아두고 requestAnimationFrame마다 주기적으로 플러시하여 DOM에 렌더링하는 방법을 생각해볼 수 있습니다. 또는 1초마다처럼 일정 시간 동안 버퍼링할 수도 있습니다. 기존 테이블에 계속 덧붙이는 방식이라면, 10만 행 렌더링 자체가 큰 병목이자(그리고 백프레셔의 한 형태이기도 한) 문제가 되므로 테이블 가상화 같은 기법도 아마 필요할 것입니다.
실제로 들어오는 이벤트 수에 따라서는 이런 접근 중 하나가 통할 수도 있습니다. 그렇지 않다면 남은 옵션은 “드롭”뿐입니다. 즉, 들어오는 메시지 중 일부 비율만 샘플링하고 나머지는 필터링하는 것입니다.
사용 가능한 연산 자원을 확장(스케일업)하는 것 외에, 백프레셔를 처리하는 방법은 대체로 아래 세 가지 옵션으로 요약할 수 있습니다:
기술적으로는 네 번째 옵션도 있습니다 — 백프레셔를 무시하는 것입니다. 솔직히 말해 백프레셔가 치명적인 문제를 일으키지 않는다면 나쁘지 않은 선택일 수 있습니다. 복잡성을 도입하는 것 또한 비용이 들기 때문입니다.
생산자 제어는 단연 최고의 옵션입니다. 실제로 가능하기만 하다면, 제어 메커니즘 자체의 오버헤드를 제외하면 별다른 트레이드오프가 없습니다. 과도한 메모리를 사용해 버퍼링할 필요도 없고, 데이터 손실을 감수하며 드롭할 필요도 없습니다.
하지만 당연히 생산자를 항상 제어할 수 있는 것은 아닙니다. 가장 명확한 사례는 사용자 입력입니다. 우리가 아무리 노력해도 사용자를 제어하는 건 쉽지 않습니다.
버퍼링은 대부분 사람들이 다음으로 선택하는 방식입니다. 하지만 버퍼가 무제한(unbounded)일 때 — 즉, 크기나 시간 제한이 없을 때 — 버퍼링은 위험하다는 점을 항상 기억해야 합니다. 무제한 버퍼는 서버에서 메모리 크래시의 흔한 원인입니다.
버퍼를 사용할 때는 항상 자문해야 합니다. “버퍼가 증가하는 속도가, 버퍼를 비우는 속도를 의미 있는 시간 동안이라도 초과할 가능성이 있는가?” 위의 서버 통신 예시는 이것이 어떻게 문제가 되는지를 보여줍니다.
사실 어떤 사람들은 버퍼를 절대로 무제한으로 두어서는 안 된다고 주장합니다. 저는 그 정도로 엄격하진 않지만, 완전히 넘어져버리는 것(메모리 부족)보다는 드롭을 시작하는 편이 더 나은 경우가 많다고 말하고 싶습니다.
드롭은 마지막 전략이며, 언급했듯 버퍼링과 함께 쓰이는 경우도 많습니다. 가장 흔한 방식은 시간 기반 샘플링입니다. 예를 들어 초당 데이터의 10%만 처리하는 식입니다.
어떤 접근을 택할지 결정할 때 사용자 경험(UX)이 종종 좋은 길잡이가 됩니다. 설령 가능하더라도 테이블을 초당 10만 번 업데이트하는 게 좋은 UX일까요? 아마 아닐 겁니다. 사용자는 1초에 한 번 업데이트되는 샘플을 더 선호할까요? 아니면 스트림을 데이터베이스로 보내고 사용자가 필요할 때 더 느린 속도로 조회하도록 아키텍처를 바꾸는 편이 나을까요? 정답은 상황에 따라 다르지만, UX가 방향을 제시해줄 수 있다는 점을 기억하세요.
개발자들이 성능 튜닝에 많은 시간을 들였는데, 결과적으로 나쁜 UX가 되어버리는 일은 매우 흔합니다. 애초에 좋은 UX를 선택했다면 성능 문제가 없었을 수도 있는데 말이죠.
이 글의 대부분은 특정 언어나 플랫폼에 종속되지 않게 유지하고 싶었습니다. 백프레셔는 우리 모두가 다뤄야 하는 문제니까요. 하지만, 생태계 전반에 걸쳐 반복해서 보게 되는 패턴들이 있고, 제 독자들 중에는 웹 개발자가 많을 거라 생각합니다.
“스트림(stream)”이라는 용어는 안타깝게도 모호합니다. 이를 모두 자세히 풀어내는 것은 다음 글로 미루어야겠지만, 요약하자면:
pull 기반 스트림에서는 소비자(consumer)가 생산자(producer)를 제어합니다. 보통은 1:1 요청 → 응답 스타일이지만, RxJava의 Flowable처럼 request(n) 패턴도 있습니다. 다른 pull 기반 스트림으로는 Node.js Streams, Web Streams, Async Iterators가 있습니다. (JS를 쓰는 분들께는 IxJS가 훌륭한 async iterator 라이브러리이며, 다른 언어로의 포트도 있습니다.)
누구와 이야기하느냐에 따라, 응답이 비동기로 push되는 경우 이들 중 다수는 하이브리드 “push-pull”로 간주되기도 합니다. 어떤 사람들은 일반적인 동기식 이터레이터를 전통적인 “pull” 스트림으로 보기도 하고, 어떤 사람들은 비동기/동기 구분 없이 둘 다 그냥 “pull”이라고 부르기도 합니다.
push 기반 스트림에서는 생산자가 주도권을 가지고, 데이터가 준비되는 즉시 소비자에게 밀어 넣습니다(push). push 스트림은 사용자 입력을 다룰 때 자주 쓰이는데, 사용자를 제어할 수 없으므로 생산자의 특성을 정확히 모델링하기 때문입니다. 라이브러리는 매우 많지만, 가장 인기 있는 것은 Reactive Extensions(일명 Rx) 구현체들입니다. RxJS, RxJava, 그리고 아마도 RxYourFavoriteLanguage도 있을 겁니다.
처음 “백프레셔”라는 용어를 들었을 때, 솔직히 저는 겁을 먹었습니다. 똑똑해 보이려고 쓰는 전문용어(jargon)처럼 느껴졌고 — 불행히도 때로는 действительно 그렇기도 합니다. 하지만 사실 백프레셔는 실재하는 현상이며, 이를 알고 대응하는 방법을 익히면 더 큰 문제를 해결하고 확장하는 데 큰 힘이 됩니다. 빠른 마우스 이동 같은 작은 문제부터 수천 대의 서버를 운영하는 문제까지, 백프레셔는 어디에나 있습니다.
이 글이 백프레셔를 충분히 설명했나요? 피드백이 있나요? 댓글이나 트위터: @_jayphelps로 알려주세요.
백프레셔 해결책으로 스케일링을 언급하라는 댓글의 리마인드에 감사드립니다!