Tokio/Rayon의 함정과 Async/Await가 동시성에 실패하는 이유
async/await 구문은 비동기 코드를 쉽게 작성할 수 있게 해주지만, I/O 작업과 동시성을 혼동하게 만들어 스레드 정체와 OOM(메모리 부족) 같은 심각한 생산 환경 장애를 유발합니다. 결국 개발자는 I/O와 CPU 연산 작업을 수동으로 분리하고 런타임을 관리해야 하는 부담을 떠안게 되어 근본적인 추상화에 실패하게 됩니다. 안정적인 시스템 운영을 위해 아키텍처의 구조적 복잡성을 이해하고 스케줄링 한계를 인지하는 것이 중요합니다.
Tokio/Rayon의 함정과 Async/Await가 동시성에 실패하는 이유 2026년 4월 22일
지난 10년 동안 async/await는 사용이 매우 쉽기 때문에 비동기 프로그래밍의 패권을 쥐었습니다. 이를 통해 개발자들은 동기식 코드와 거의 동일해 보이는 비동기 코드를 작성할 수 있게 되었습니다. 하지만 이 친숙한 구문 이면에는 거대한 구조적 복잡성이 숨어 있습니다. 이는 제어 흐름을 감추고, 하드웨어의 실제 상황을 모호하게 만들며, 궁극적으로 스케줄링의 부담을 다시 개발자에게 전가합니다. Rich Hickey는 그의 강연 'Simple Made Easy'에서 이를 완벽하게 설명했습니다. "Easy(쉬움)"이란 친숙하고 손에 닿는 것을 의미하는 반면, "Simple(단순함)"이란 구조적으로 얽혀 있지 않은 것을 의미합니다. async/await는 작성하기는 쉽지만, 운영하기는 극도로 복잡합니다.
Rob Pike는 2023년 GopherConAU 기조연설에서 이러한 아키텍처의 전환에 대해 이야기했습니다. "고루틴(goroutines), 채널(channels), 셀렉트(select)와 비교했을 때, async/await는 언어 구현자가 빌드하기에 더 쉽고 작습니다... 하지만 이는 복잡성의 일부를 프로그래머에게 다시 떠넘기며, 흔히 Bob Nystrom이 '색칠된 함수(colored functions)'라고 불렀던 결과를 초래합니다. [...] 하지만 어떤 동시성 모델을 제공하든 정확히 한 번만 제공하는 것이 중요합니다. 왜냐하면 여러 동시성 구현을 제공하는 환경은 문제가 될 수 있기 때문입니다." Pike의 "여러 동시성 구현"에 대한 언급과 async/await는 바로 오늘날 실무 환경에서 실패하고 있는 부분입니다.
생산 환경의 함정: 동시성(Concurrency)과 비동기성(Asynchrony)의 혼동 async/await의 근본적인 함정은 비동기성(I/O를 기다리는 동안 실행을 양보하는 것)과 동시성(여러 작업을 한 번에 처리하는 것)을 혼동한다는 것입니다. 이 구문은 교차로 실행되는 상태 머신을 격리된 순차적 스레드인 것처럼 위장하기 때문에 하나의 함정이 됩니다. 이 환상에 빠진 개발자는 네트워크를 통해 데이터베이스 레코드를 가져온 직후 즉시 데이터를 처리하는 식으로, 블로킹 코드를 작성하듯 똑같이 async 함수를 작성합니다. 하지만 그 데이터 처리 작업이 10MB JSON 페이로드 파싱, 거대한 컬렉션 순회 또는 연산 집약적인 암호화 증명 실행과 관련된 경우 어떻게 될까요? 협력적 실행자(cooperative executor)가 정지됩니다.
[기대했던 상황] 빠른 I/O 작업들이 빠르게 양보됨. 높은 처리량. 수신되는 네트워크 트래픽 -> 작업 대기열 (안정적 / 낮음) 실행자 (1개의 OS 스레드) -> I/O 양보, I/O 양보
[현실: 컴퓨팅 함정] 단 하나의 CPU 작업이 실행자를 멈춤. 대기열 폭발. 수신되는 네트워크 트래픽 -> 작업 대기열 (무제한) -> OOM(메모리 부족) 크래시 실행자 (1개의 OS 스레드) -> 무거운 컴퓨팅 작업 (bcrypt 해싱, 파싱 등) -> 스레드 정지(THREAD HALTED)
Rust의 Tokio나 Node.js와 같은 협력적 런타임에서는 await 지점에 도달할 때까지 스레드가 실행을 양보하지 않습니다. 함수 내에서 50밀리초가 걸리는 CPU 바운드 작업은 실행 스레드 전체를 멈추게 합니다. 순식간에 수많은 관련 없는 네트워크 요청의 지연 시간이 급증하고 시스템이 응답하지 않게 됩니다. 그 와중에 하드웨어는 거의 활용되지 않습니다.
깨진 약속: 인간 스케줄러 이러한 지연 시간 스파이크가 발생할 때마다 해결책은 항상 똑같습니다. 런타임을 분리하라는 것입니다. I/O에는 Tokio를 사용하고, CPU 바운드 작업은 Rayon과 같은 전용 스레드 풀로 보내는 식입니다. 최근의 장애 사후 분석 보고서들은 이로 인해 발생한 재앙을 강조합니다. PostHog와 Meilisearch의 엔지니어링 팀은 프로덕션 환경에서 이러한 복잡성을 해체하는 고통스러운 현실을 문서화했습니다. 개발자는 각 함수가 "I/O 풀"에 속하는지 "연산 풀"에 속하는지 결정하기 위해 모든 함수를 주의 깊게 분석한 다음, 그 경계 사이의 메시지 전달을 수동으로 조율해야 합니다. 만약 개발자가 I/O와 연산 작업을 수동으로 분할하고, 교착 상태를 막기 위해 경계를 엄격하게 감시하며, 두 가지 다른 사고방식을 가진 두 개의 다른 런타임 간에 데이터를 전달해야 한다면 async 추상화는 실패한 것입니다. 언어의 기능은 동시성의 복잡성을 숨기겠다고 약속했습니다. 그러나 오히려 애플리케이션 개발자를 스케줄링을 수동으로 개입해야 하는 '인간 스케줄러'로 만들어 버렸습니다.
기본값이 무제한(Unbounded)이라는 것은 기본값이 OOM이라는 뜻입니다 async/await 런타임의 두 번째 실패 방식은 무제한 용량을 너무나도 마찰 없이 만든다는 것입니다. tokio::spawn(...)을 호출하는 비용은 매우 저렴합니다. 트래픽 스파이크 동안 다운스트림 데이터베이스가 느려지면, 수신 네트워크 루프는 아무 문제 없이 계속해서 연결을 수락합니다... (이하 생략)