비동기 압박이 느껴지지 않는다

ko생성일: 2025. 9. 16.갱신일: 2025. 9. 16.

여러 언어와 런타임의 async 환경에서 백프레셔와 흐름 제어의 핵심 개념과 중요성을 설명한다. asyncio의 write/drain, 세마포어와 준비 상태, 스트리밍 프로토콜(HTTP/2)의 플로우 제어 등에서 기본값과 설계상의 함정이 어떻게 시스템 과부하와 메모리 폭주로 이어지는지 살피고, 라이브러리/프레임워크 설계 시 백프레셔를 1급 시민으로 다룰 것을 촉구한다.

제목: 비동기 압박이 느껴지지 않는다

작성일: 2020년 1월 01일

Async가 대세다. Async Python, async Rust, Go, Node, .NET… 어떤 생태계를 고르든 거기엔 어김없이 async가 있다. 이 async라는 게 얼마나 잘 작동하느냐는 생태계와 언어 런타임에 크게 좌우되지만, 전반적으로 꽤 괜찮은 이점이 있다. 시간이 걸릴 수 있는 연산을 기다리는 일을 아주 단순하게 만들어 준다. 너무나 단순하게 만들어 주는 나머지, 발등을 수없이 많고 새로운 방식으로 스스로 쏘게도 만든다. 여기서 이야기하려는 건 시스템이 과부하되기 시작할 때까지는 자신이 발을 쏘고 있다는 걸 깨닫지 못하는 그 경우, 즉 백프레셔(back pressure) 관리라는 주제다. 프로토콜 설계에서 관련된 용어로는 흐름 제어(flow control)가 있다.

백프레셔란 무엇인가

백프레셔에 대한 설명은 많고, 그중 추천할 만한 글 하나는 Backpressure explained — the resisted flow of data through software다. 여기서 자세히 파고들기보다는 짧은 정의와 설명만 해 보자. 백프레셔는 시스템을 통과하는 데이터의 흐름에 저항으로 작용하는 힘이다. 듣기에 꽤 부정적으로 들린다 — 막힌 파이프로 인해 욕조가 넘쳐흐르는 장면을 떠올리기 쉽다 — 하지만 실상은 우리를 구해 주는 존재다.

우리가 다루는 설정은 거의 모든 경우 비슷하다. 여러 컴포넌트가 파이프라인으로 이어진 시스템이 있고, 그 파이프라인은 일정 수의 들어오는 메시지를 받아야 한다.

이걸 공항의 수하물 처리 과정으로 비유해 보자. 짐이 도착하고, 분류되고, 항공기에 실렸다가, 최종적으로 내린다. 어느 순간이든 개별 수하물은 운반을 위해 다른 수하물과 함께 컨테이너에 담긴다. 컨테이너가 가득 차면 누군가 가져가야 한다. 컨테이너가 더 이상 남아 있지 않다면, 그게 자연스러운 백프레셔의 예다. 이제 수하물을 컨테이너에 넣어야 하는 사람은 컨테이너가 없어 넣을 수 없다. 결정을 내려야 한다. 하나는 기다리는 것 — 흔히 큐잉(queueing) 또는 버퍼링이라고 한다. 다른 하나는 컨테이너가 올 때까지 일부 수하물을 버리는 것 — 드롭핑(dropping)이라고 한다. 나쁘게 들리지만, 왜 이게 때로는 중요해지는지는 뒤에서 이야기하겠다.

여기에는 또 다른 요소가 있다. 수하물을 컨테이너에 넣는 임무를 맡은 사람이 아주 오랫동안(예: 일주일) 컨테이너를 받지 못한다고 상상해 보자. 만약 그동안 짐을 버리지 않았다면 정리하지 못한 수하물이 산더미처럼 쌓일 것이다. 결국 보관할 물리적 공간이 부족해질 만큼 쌓이게 된다. 그 지점이 되면 이 사람은 컨테이너 문제가 해결될 때까지 공항에 더 이상 수하물을 받지 말라고 통보하는 게 낫다. 이를 보통 흐름 제어(flow control)라고 하며, 네트워킹에서 결정적으로 중요한 개념이다.

이런 처리 파이프라인은 보통 일정한 시간당 메시지(이 예에서는 수하물)량을 기준으로 스케일링되어 있다. 이 숫자가 이를 초과하거나 — 더 나쁘게는 — 파이프라인이 멈춰 버리면 끔찍한 일이 벌어질 수 있다. 현실 세계의 예로, 런던 히스로 터미널 5 개장 당시 10일에 걸쳐 42,000개의 가방이 제대로 라우팅되지 못했다. IT 인프라가 제대로 작동하지 않았기 때문이다. 항공편 500편 이상이 취소됐고, 한동안 항공사들은 기내 반입 수하물만 허용하기도 했다.

백프레셔는 중요하다

히스로의 참사에서 우리가 배우는 것은, 백프레셔를 소통할 수 있는 능력이 핵심적이라는 점이다. 현실과 컴퓨팅 모두에서 시간은 항상 유한하다. 결국 누군가는 무엇인가를 기다리다 포기한다. 특히 내부적으로 영원히 기다리는 셈이더라도, 외부에서는 그렇지 않다.

현실의 예를 들어 보자. 당신의 가방이 런던 히스로를 경유해 파리의 목적지로 가야 하지만, 당신이 파리에 머무는 기간이 7일뿐이라면, 당신의 수하물이 10일 지연되어 도착하는 건 전혀 의미가 없다. 사실상 당신은 수하물이 다시 출발 공항으로 환적되기를 원할 것이다.

실은, 과부하 상태임을 인정하고 항복하는 편이 — 작동하는 척하면서 끝없이 버퍼링하는 것보다 — 낫다. 한 시점이 되면 후자가 상황을 더 악화시킬 뿐이기 때문이다.

그렇다면 왜 갑자기 백프레셔가 화두가 되었을까? 수년간 스레드 기반 소프트웨어를 쓸 때는 이런 이야기가 별로 나오지 않았는데? 여러 요인이 결합한 결과이며, 그중 일부는 쉽게 발등을 쏘기 쉬운 부분들이다.

나쁜 기본값들

비동기 코드에서 백프레셔가 왜 중요한지 이해하기 위해, Python의 asyncio로 작성한 겉보기에는 단순한 코드 조각을 하나 보자. 여기에는 우리가 부주의하게 백프레셔를 잊어버리는 상황이 몇 가지 담겨 있다:

python
from asyncio import start_server, run async def on_client_connected(reader, writer): while True: data = await reader.readline() if not data: break writer.write(data) async def server(): srv = await start_server(on_client_connected, '127.0.0.1', 8888) async with srv: await srv.serve_forever() run(server())

async/await 개념이 낯설다면, await가 호출되는 지점에서 함수가 그 표현식이 완료될 때까지 일시 중단된다고 생각하면 된다. 여기서 Python의 asyncio가 제공하는 start_server 함수는 내부적으로 보이지 않는 accept 루프를 실행한다. 소켓을 리슨하고, 연결되는 각 소켓마다 on_client_connected 함수를 실행하는 독립적인 태스크를 스폰한다.

겉보기에는 아주 간단하다. 모든 awaitasync 키워드를 제거해도 스레드로 코드를 작성할 때와 아주 비슷한 모양이 된다.

하지만 여기에는 핵심적인 문제가 숨어 있다. 바로 await가 붙지 않은 함수 호출들이다. 스레드 코드에서는 어떤 함수든 블록될 수 있다. 비동기 코드에서는 async 함수만 그럴 수 있다. 예를 들어 writer.write 메서드는 블록될 수 없다는 뜻이다. 그럼 어떻게 동작하나? 운영체제의 소켓 버퍼에 데이터를 바로 쓰려고 시도하며, 그 동작은 논블로킹이다. 그런데 버퍼가 가득 차서 소켓이 블록될 상황이면 어떻게 될까? 스레드 방식이라면 여기서 블록되게 하는 것이 이상적이다. 그래야 어느 정도 백프레셔가 적용되기 때문이다. 하지만 여기에는 스레드가 없으니 그렇게 할 수 없다. 남는 선택지는 버퍼링하거나 드롭하는 것뿐이다. 데이터를 드롭하는 건 대개 끔찍하므로, Python은 대신 버퍼링을 택한다. 이제 누군가가 데이터를 잔뜩 보내기만 하고 읽지 않는다면? 그 경우 버퍼는 커지고 커지고 또 커진다. 이런 API 결함 때문에 Python 문서는 write만 단독으로 쓰지 말고 반드시 drain을 따라 붙이라고 말한다:

python
writer.write(data) await writer.drain()

drain은 버퍼의 과잉을 일정 부분 비워 준다. 버퍼 전체를 비우는 게 아니라, 통제 불능으로 커지지 않도록 할 만큼만 비운다. 그럼 왜 write가 암묵적으로 drain을 하지 않을까? 이는 거대한 API의 간과(oversight)이며, 나도 왜 그런 설계가 되었는지 정확히는 모르겠다.

여기서 아주 중요한 점이 하나 있다. 대부분의 소켓은 TCP 기반이며, TCP에는 내장된 흐름 제어가 있다. 작성자는 읽는 쪽이 수용하려는 속도만큼만(버퍼링의 영향은 조금 있지만) 쓸 수 있다. 이는 개발자에게 완전히 숨겨져 있다. BSD 소켓 라이브러리조차 이 암묵적 흐름 제어를 API로 드러내지 않는다.

그렇다면 이제 백프레셔 문제가 해결된 걸까? 스레드 세계에서는 이게 어떻게 보일지 생각해 보자. 스레드 세계에서는 보통 고정된 수의 스레드가 실행 중이며, accept 루프는 요청을 처리할 스레드가 하나 비워질 때까지 기다렸을 것이다. 하지만 우리의 async 예제에서는 처리할 의사가 있는 연결 수에 상한이 없다. 이는 곧, 시스템이 잠재적으로 과부하될 수 있더라도 매우 많은 연결을 받아들이겠다는 뜻이다. 이 간단한 예제에서는 문제가 덜할 수 있지만, 데이터베이스 접근을 한다고 상상해 보라.

데이터베이스 커넥션 풀에서 최대 50개의 커넥션을 내줄 수 있다고 하자. 그렇다면 10,000개의 연결을 받아들여도 대부분은 그 커넥션 풀에서 병목이 걸릴 텐데, 그게 무슨 소용인가?

기다리기 vs 기다리기 위한 기다림

이제야 처음에 하려던 이야기로 돌아왔다. 대부분의 async 시스템, 특히 내가 Python에서 접한 대부분의 것들은 소켓 수준의 버퍼링 행동을 모두 고친다 해도, 결국 백프레셔를 고려하지 않고 async 함수들을 줄줄이 연결하는 세계에 도달한다.

커넥션이 50개뿐인 데이터베이스 커넥션 풀 예를 다시 보자. 이는 우리의 코드가 동시에 가질 수 있는 데이터베이스 세션이 최대 50개라는 뜻이다. 애플리케이션의 많은 작업이 데이터베이스와 무관하다고 예상해, 그보다 4배 많은 요청을 동시에 처리하고 싶다고 하자. 한 가지 방법은 토큰 200개짜리 세마포어를 만들어 시작할 때 하나를 획득하는 것이다. 토큰이 바닥나면, 세마포어에서 토큰이 풀릴 때까지 기다린다.

하지만 잠깐. 우리는 다시 큐잉으로 돌아왔다! 다만 조금 더 앞단에서 큐잉할 뿐이다. 시스템이 심하게 과부하되면, 이제 우리는 맨 앞에서부터 줄을 서게 된다. 모두가 자신이 감내할 수 있는 최대 대기 시간을 기다리다가 포기할 것이다. 더 나쁜 점: 서버는 클라이언트가 사라져 응답에 더 이상 관심이 없다는 걸 깨달을 때까지 한동안 계속 그 요청을 처리할 수도 있다.

그러니 곧장 기다리는 대신 피드백이 필요하다. 우체국에서 번호표를 뽑는 걸 상상해 보자. 이 번호표는 언제쯤 차례가 올지 꽤 그럴듯한 신호를 준다. 대기 시간이 너무 길면, 번호표를 버리고 나중에 다시 오기로 결정할 수 있다. 우체국에서 당신 차례가 오기까지의 대기 시간은 요청 처리에 필요한 대기 시간(예: 누군가가 당신의 소포를 가지러 가고, 서류를 확인하고, 서명을 받는 데 필요한 시간)과는 독립적이라는 점에 주목하자.

자, 다음은 우리가 기다리고 있다는 사실만 알 수 있는 순진한 버전이다:

python
from asyncio.sync import Semaphore semaphore = Semaphore(200) async def handle_request(request): await semaphore.acquire() try: return generate_response(request) finally: semaphore.release()

handle_request async 함수를 호출하는 쪽은 우리가 기다리고 있다는 것 외에는 아무것도 알 수 없다. 과부하 때문에 기다리는 건지, 응답 생성이 오래 걸려서 기다리는 건지 알 수 없다. 우리는 사실상 서버가 결국 메모리가 바닥나 크래시할 때까지 끝없이 버퍼링하고 있는 셈이다.

그 이유는 백프레셔를 위한 통신 채널이 없기 때문이다. 이걸 어떻게 고칠 수 있을까? 하나의 방법은 간접층을 추가하는 것이다. 불행히도 여기서 asyncio의 세마포어는 소용이 없다. 기다리게만 할 뿐이기 때문이다. 하지만 세마포어에 남은 토큰이 얼마나 되는지 물어볼 수 있다고 상상해 보자. 그러면 다음과 같이 할 수 있다:

python
from hypothetical_asyncio.sync import Semaphore, Service semaphore = Semaphore(200) class RequestHandlerService(Service): async def handle(self, request): await semaphore.acquire() try: return generate_response(request) finally: semaphore.release() @property def is_ready(self): return semaphore.tokens_available()

이제 시스템이 조금 바뀌었다. RequestHandlerService라는 것을 만들었고, 여기에 약간의 추가 정보가 있다. 특히 준비 상태(readiness)라는 개념이 생겼다. 이 서비스는 준비되었는지 물어볼 수 있다. 이 작업은 본질적으로 논블로킹이며 최선의 추정치일 뿐이다. 그럴 수밖에 없다. 레이스가 내재되어 있기 때문이다.

호출자는 다음과 같던 코드를:

python
response = await handle_request(request)

다음과 같이 바꿀 수 있다:

python
request_handler = RequestHandlerService() if not request_handler.is_ready: response = Response(status_code=503) else: response = await request_handler.handle(request)

방법은 여럿이지만, 핵심 아이디어는 같다. 실제로 어떤 작업에 착수하기 전에, 성공 가능성을 가늠할 방법을 두고, 과부하 상태라면 그 사실을 상위로 전달하는 것이다.

참고로 이 서비스의 정의는 내가 고안한 게 아니다. 이 설계는 Rust의 toweractix-service에서 온 것이다. 둘 다 매우 유사한 서비스 트레이트 정의를 가지고 있다.

물론 레이스 때문에 여전히 세마포어에 호출이 몰릴 여지가 있다. 그 리스크를 감수하거나, handle이 호출되었을 때도 실패하도록 만들 수 있다.

asyncio보다 이 부분을 더 잘 해결한 라이브러리는 trio다. trio는 세마포어의 내부 카운터를 노출하고, 용량 제한 목적에 최적화된 CapacityLimiter를 제공하는데, 이는 흔한 함정을 어느 정도 막아 준다.

스트림과 프로토콜

위의 예는 RPC 스타일 상황을 해결해 준다. 각 호출마다 시스템이 과부하 상태인지 미리 알 수 있다. 이런 프로토콜의 상당수는 서버가 부하 상태임을 비교적 직접적으로 알릴 방법이 있다. 예를 들어 HTTP에서는 503을 내보낼 수 있고, 여기에 언제 다시 시도하면 좋을지 알려 주는 retry-after 헤더를 실을 수 있다. 이 재시도는 그 사이에 요청 내용이 바뀌었는지, 그대로 재시도하는 게 맞는지 재평가할 자연스러운 포인트를 제공한다. 예컨대 15초 뒤에야 재시도할 수 있다면, 끝없이 로딩 아이콘만 보여 주는 대신 이 불가함을 사용자에게 드러내는 편이 나을 수 있다.

하지만 요청/응답 스타일 프로토콜만 있는 것은 아니다. 많은 프로토콜은 지속적인 연결을 유지한 채 많은 데이터를 스트리밍하도록 한다. 전통적으로 이런 프로토콜의 상당수는 TCP 기반이며, 앞서 언급했듯 TCP에는 내장된 흐름 제어가 있다. 그러나 이 흐름 제어는 소켓 라이브러리를 통해 실제로 노출되지 않기 때문에, 상위 수준의 프로토콜은 보통 자체적인 흐름 제어를 추가해야 한다. 예를 들어 HTTP/2는 단일 TCP 연결 위에 여러 독립 스트림을 다중화하기 때문에, 사용자 정의 흐름 제어 프로토콜을 갖춘다.

흐름 제어가 무대 뒤에서 조용히 처리되는 TCP 배경에서 출발하면, 개발자는 소켓에서 바이트를 읽고 쓰는 것만이 전부라고 착각하는 위험한 길로 들어서기 쉽다. 그러나 TCP API는 오도적이다. API 관점에서 흐름 제어는 사용자에게 완전히 숨겨져 있기 때문이다. 자신만의 스트리밍 기반 프로토콜을 설계할 때는 반드시 양방향 통신 채널을 마련하고, 송신자가 그냥 보내기만 하는 것이 아니라 계속 읽으면서 계속 전송해도 되는지 확인해야 한다.

스트림에서는 고려사항이 보통 다르다. 많은 스트림이 단지 바이트나 데이터 프레임의 흐름이기 때문에, 중간에서 패킷을 임의로 드롭할 수 없다. 더 나쁜 점: 송신자가 속도를 늦춰야 하는지 확인하는 것이 쉽지 않은 경우가 많다. HTTP/2에서는 사용자 수준에서 읽기와 쓰기를 끊임없이 교차(interleave)해야 한다. 거기서는 흐름 제어를 반드시 처리해야 한다. 서버는 당신이 쓰고 있는 도중에도, 계속 써도 되는 시점을 알려 주는 WINDOW_UPDATE 프레임을 보낸다.

이 말은 곧, 스트리밍 코드는 더 복잡해진다는 뜻이다. 들어오는 흐름 제어 정보를 바탕으로 동작할 수 있는 프레임워크를 먼저 스스로 만들어야 하기 때문이다. 예를 들어 Python의 hyper-h2 라이브러리에는 curio 기반의 흐름 제어를 갖춘 파일 업로드 서버 예제가 있는데, 놀랄 만큼 복잡하다. 게다가 그 예제조차 완전하지 않다.

새로운 풋건(footgun)

async/await은 훌륭하지만, 과부하 상황에서 재앙적으로 동작하는 코드를 쓰도록 부추긴다. 한편으로는 큐잉이 너무나도 쉽기 때문이고, 또 한편으로는 어떤 함수를 나중에 async로 바꾸는 것이 API 호환성에 균열을 내기 때문이다. 아마 이것이 Python이 아직도 스트림 writer에 대해 await할 수 없는 write 함수를 갖고 있는 이유일 것이다.

더 큰 이유는, async/await 덕분에 많은 이들이 애초에 스레드로는 쓰지 않았을 코드를 쓰게 되었다는 점이다. 이는 좋은 일이라고 생각한다. 더 큰 시스템을 실제로 작성하는 장벽을 낮추기 때문이다. 하지만 그 이면에는, 이전에 분산 시스템 경험이 거의 없던 개발자들도 이제는 단일 프로그램만 작성하더라도 분산 시스템의 많은 문제를 떠안게 된다는 사실이 있다. 예컨대 HTTP/2는 다중화 특성 때문에 충분히 복잡한 프로토콜이라, 합리적으로 구현하려면 async/await이 사실상 유일한 방법이다.

이 문제는 async/await 코드만의 문제가 아니다. 예를 들어 Dask는 데이터 사이언스에서 쓰이는 Python 병렬성 라이브러리인데, async/await을 쓰지 않음에도 불구하고 백프레셔 부족 때문에 시스템 메모리가 바닥나는 버그 리포트가 있다. 하지만 이런 문제는 보다 근본적이다.

백프레셔의 부재는, 크기가 바주카포에 맞먹는 풋건이다. 너무 늦게서야 괴물을 만들어 버렸다는 걸 깨달으면, 코드베이스에 큰 변화를 주지 않고는 고치기 거의 불가능하다. async가 되었어야 할 함수들을 빠뜨렸을 수도 있기 때문이다. 다른 프로그래밍 환경이라 해서 도움이 되지도 않는다. Go나 Rust처럼 최근에 추가된 환경을 포함해 어디서나 똑같은 문제가 있다. 매우 인기 있는 프로젝트에서도 “흐름 제어 처리”, “백프레셔 처리” 같은 오픈 이슈가 오랜 기간 열려 있는 걸 흔히 보게 된다. 사후에 추가하기가 정말 어렵기 때문이다. 예를 들어 Go에는 2014년부터 모든 파일 시스템 IO에 세마포어를 추가하자는 이슈가 열려 있다. 호스트를 과부하시키기 때문이다. aiohttp에는 2016년으로 거슬러 올라가는 이슈가 있는데, 백프레셔가 충분하지 않아 클라이언트가 서버를 망가뜨릴 수 있다는 내용이다. 이런 예는 무수히 많다.

Python hyper-h2 문서를 보면 “흐름 제어를 처리하지 않는다”, “HTTP/2 흐름 제어를 준수하지 않는다는 결함이 있지만, 그 외에는 동작한다” 따위의 문구가 충격적으로 많이 보인다. 흐름 제어가 일단 표면으로 올라오면 매우 복잡해지고, 문제가 아닌 척하기가 쉬운 탓에 우리가 이런 궁지에 몰렸다고 본다. 흐름 제어는 상당한 오버헤드도 추가하고, 벤치마크에서 보기에도 좋지 않다.

그러니 async 라이브러리 개발자 여러분께 새해 다짐을 권한다. 문서와 API에서 백프레셔와 흐름 제어에 걸맞은 중요성을 부여하자.

이 글의 태그: async, python

copy as / view markdown