백프레셔와 버퍼 관리
빠른 서버를 만들 때 의외로 사람을 괴롭히는 문제가 있습니다. 보내는 쪽은 빠른데 받는 쪽이 느릴 때입니다. 이때 데이터가 갈 곳을 잃고 어딘가에 쌓이는데, 잘못 다루면 메모리가 폭발합니다. 이 현상을 백프레셔라 부릅니다. 이 장에서 그 정체를 직접 확인하고, 어떻게 다스리는지 배웁니다. 고성능 서버와 보통 서버를 가르는 중요한 주제입니다.
데이터는 어디에 쌓이는가
send나 sendall을 부르면 데이터가 곧장 상대에게 날아간다고 생각하기 쉽습니다. 하지만 실제로는 운영체제의 송신 버퍼에 일단 담깁니다. 운영체제가 그 버퍼에서 조금씩 꺼내 네트워크로 내보내고, 상대가 받아 읽어 갑니다.
문제는 상대가 느릴 때입니다. 상대가 데이터를 제때 읽어 가지 않으면, 내 송신 버퍼가 점점 찹니다. 버퍼가 가득 차면 운영체제는 더 받을 수 없습니다. 이때 우리의 send는 어떻게 될까요. 블로킹 소켓이라면 버퍼에 자리가 날 때까지 멈춰 기다립니다. 논블로킹 소켓이라면 앞 장에서 본 그 에러, 곧 지금은 못 보낸다는 신호를 냅니다. 이렇게 받는 쪽의 느림이 보내는 쪽으로 거꾸로 압력을 가하는 것이 백프레셔입니다.
직접 확인해 보기
말로만 들으면 와닿지 않으니 직접 봅니다. 서버가 데이터를 쉴 새 없이 보내는데, 클라이언트는 일부러 읽지 않는 상황을 만들어 봅니다.
# 새 파일: backpressure_demo.pyimport socketimport threadingimport timedef server(stop): srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind(("127.0.0.1", 9095)) srv.listen(1) conn, _ = srv.accept() conn.setblocking(False) total = 0 chunk = b"x" * 65536 while True: try: sent = conn.send(chunk) total += sent except BlockingIOError: print(f"백프레셔 발생: 송신 버퍼가 가득 참 (지금까지 {total}바이트 보냄)") break conn.close() srv.close()stop = threading.Event()threading.Thread(target=server, args=(stop,), daemon=True).start()time.sleep(0.3)client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)client.connect(("127.0.0.1", 9095))time.sleep(1.0)client.close()
서버는 논블로킹 소켓으로 6만 바이트 덩어리를 끝없이 보냅니다. 클라이언트는 연결만 하고 일부러 1초 동안 가만히 있습니다. 읽지 않는 것입니다. 실행하면 이렇게 나옵니다.
백프레셔 발생: 송신 버퍼가 가득 참 (지금까지 319020바이트 보냄)
약 31만 바이트를 보내자 송신 버퍼가 가득 차서, 논블로킹 send가 더는 못 보낸다는 에러를 냈습니다. 받는 쪽이 읽지 않으니 보내는 쪽이 막힌 것입니다. 백프레셔를 눈으로 확인한 셈입니다. 보낼 수 있는 양에는 한계가 있고, 그 한계는 받는 쪽의 속도가 정한다는 것을 보여 줍니다.
잘못 다루면 메모리가 터진다
여기서 위험한 유혹이 생깁니다. send가 막힌다고, 못 보낸 데이터를 프로그램이 자기 메모리에 따로 쌓아 두면 어떻게 될까요. 받는 쪽이 계속 느리면, 보내려던 데이터가 우리 메모리에 끝없이 쌓입니다. 결국 메모리가 바닥나 서버가 죽습니다.
특히 한 클라이언트가 여러 클라이언트에게 데이터를 전하는 채팅이나 방송 서버에서 이 문제가 잘 터집니다. 느린 클라이언트 한 명 때문에, 그에게 보내려던 데이터가 쌓이고 쌓여 서버 전체를 무너뜨립니다. 빠른 서버를 만들었다고 안심했다가, 느린 클라이언트 하나에 당하는 것입니다.
백프레셔를 다스리는 법
그래서 백프레셔는 무시하는 것이 아니라 존중해야 합니다. 받는 쪽이 느리면, 보내는 쪽도 속도를 늦춰야 합니다. 몇 가지 방법이 있습니다.
가장 기본은 보내는 양을 조절하는 것입니다. send가 막히면 무작정 쌓아 두지 말고, 보내기를 잠시 멈췄다가 버퍼에 자리가 났을 때 다시 보냅니다. 앞 장에서 본 asyncio의 writer.drain()이 바로 이 일을 합니다. 보낸 데이터가 충분히 빠져나갈 때까지 기다려, 버퍼가 넘치지 않게 합니다. asyncio로 서버를 짤 때 write 뒤에 await writer.drain()을 붙이는 이유가 이것입니다.
쌓아 둘 데이터에 상한을 두는 방법도 있습니다. 한 클라이언트에게 보내려고 대기 중인 데이터가 일정 크기를 넘으면, 그 클라이언트가 너무 느리다고 판단해 연결을 끊어 버립니다. 느린 한 명을 포기해 전체를 지키는 것입니다. 실제 고성능 서버들이 흔히 쓰는 전략입니다.
핵심은 하나입니다. 보내는 쪽은 받는 쪽의 속도를 존중해야 한다는 것입니다. 이 균형을 잡는 것이 견고한 고성능 서버의 조건입니다.
백프레셔까지 다뤘습니다. 다음 장에서는 리눅스 비동기 I/O의 가장 최신 흐름인 io_uring을 소개하며, 고성능의 미래를 잠깐 엿봅니다.