JavaScript에서 Python으로 전환하는 것은 표지판은 같지만 교통 법규가 다른 도시로 이사하는 것과 비슷합니다. 문법은 친숙하고 익숙해 보입니다. 문법 안에 async와 await가 그대로 자리 잡고 있는 것을 보면, 멘탈 모델이 그대로 적용될 것이라고 생각하기 쉽습니다. 하지만 그렇지 않습니다. JavaScript에서 습관적으로 사용하던 패턴 하나가 에러를 발생시키거나 로그를 남기지도, 코드 리뷰에서 눈에 띄지도 않은 채 Python의 성능을 조용히 떨어뜨립니다.
JavaScript가 가르쳐주는 '시작하고 잊어버리기'
JavaScript에서 async 함수 호출은 즉시 실행(eager)됩니다. 함수를 호출하는 순간 엔진은 Promise를 생성하고 작업이 즉시 시작됩니다. 이벤트 루프는 이미 달리기 시작한 상태입니다. JavaScript 개발자들이 자연스럽게 다음과 같은 코드를 작성하는 이유가 바로 이것입니다.
const userPromise = fetchUser(id);
const ordersPromise = fetchOrders(id);
const user = await userPromise;
const orders = await ordersPromise;
두 네트워크 요청 모두 첫 번째 await에 도달하기 전에 이미 진행 중입니다. 첫 번째 await는 fetchUser가 해결(resolve)될 때까지 현재 함수를 일시 중단하지만, fetchOrders는 이전 줄부터 이미 백그라운드에서 돌아가고 있습니다. orders 변수가 필요할 때쯤이면 두 번째 요청은 이미 완료되었을 수도 있습니다. 이 패턴은 JavaScript에서 매우 자연스럽기 때문에 많은 개발자가 이를 동시성 기술이라고 생각조차 하지 않습니다. 그저 async가 작동하는 방식일 뿐입니다.
Python의 반전: 차가운 코루틴(A Cold Coroutine)
Python은 다른 계약 방식을 사용합니다. Python에서 async def 함수를 호출하면 아무런 작업도 시작되지 않습니다. 대신 코루틴 객체를 받게 됩니다. 이를 종이에 적힌 레시피라고 생각하십시오. 재료는 나열되어 있고 단계도 명확하지만, 오븐 안에는 아무것도 들어있지 않은 상태입니다. 무언가가 명시적으로 해당 코루틴을 이벤트 루프를 통해 구동시키기 전까지는 아무런 동작도 하지 않습니다.
여기에 함정이 있습니다. 사용자 정보와 주문 내역이 모두 필요한 JavaScript 엔지니어는 Python에서 다음과 같이 작성할 수 있습니다.
user_coro = fetch_user(id)
orders_coro = fetch_orders(id)
user = await user_coro
orders = await orders_coro
병렬로 실행되는 것처럼 보이고, 병렬처럼 느껴지지만, 실제로는 완전히 순차적입니다.
첫 번째 줄은 잠자고 있는 코루틴을 user_coro에 할당합니다. 두 번째 줄은 또 다른 잠자고 있는 코루틴을 orders_coro에 할당합니다. 실행이 await user_coro에 도달하면, Python은 그제서야 첫 번째 작업을 시작하고 완료될 때까지 실행합니다. fetch_user가 끝난 후에야 인터프리터는 await orders_coro에 도달하여 두 번째 작업을 시작합니다. 총 실행 시간은 두 I/O 작업 중 더 긴 시간이 아니라, 두 작업의 합계가 됩니다. 병렬로 실행한 것이 아니라, 불필요한 단계를 거쳐 하나씩 차례대로 실행한 것입니다.
이 버그가 보이지 않는 이유
이것은 몇 달 동안이나 발견되지 않고 지속될 수 있는 성능 저하 유형입니다. 코드는 유효한 Python 코드입니다. 타입 체커도 통과합니다. 결과값도 정확하게 반환합니다. 단지 속도가 절반으로 느려지거나, 그보다 더 나쁠 뿐입니다. 스택 트레이스도 없고 경고도 없기 때문에, 엔지니어링 팀은 종종 다른 곳에서 원인을 먼저 찾습니다. Redis 캐시를 추가하거나, 데이터베이스 티어를 업그레이드하거나, 호스팅 리전을 변경하기도 합니다. 진짜 원인은 await가 실제로 무엇을 하는지에 대한 기대치 사이의 미묘한 불일치입니다.
Python을 실제로 병렬로 실행하게 만드는 세 가지 방법
이를 해결하려면 Python의 이벤트 루프에 작업을 즉시 스케줄링하도록 알려줘야 합니다. 단순한 코루틴보다 더 능동적인 무언가가 필요합니다. 바로 Task가 필요합니다.
1. asyncio.create_task
JavaScript 패턴을 가장 직접적으로 옮겨오는 방법은 코루틴을 Task로 감싸는 것입니다. Task는 생성되는 즉시 이벤트 루프에 스케줄링됩니다. 이는 실행 중인 JavaScript Promise에 가장 가까운 Python의 대응 방식입니다.
user_task = asyncio.create_task(fetch_user(id))
orders_task = asyncio.create_task(fetch_orders(id))
user = await user_task
orders = await_orders_task
이제 첫 번째 await에 도달하기 전에 fetch_user와 fetch_orders가 모두 진행 중입니다. await user_task에 도달하면 해당 Task가 완료될 때까지만 일시 중단될 뿐, 다른 Task는 계속 실행됩니다. 만약 fetch_orders가 먼저 완료된다면, 그 결과는 사용자가 요청할 때까지 orders_task 내에서 대기합니다.
하지만 주의하세요. Task를 생성하고 한 번도 await 하지 않으면, Python은 파괴된 대기 중인 Task(destroyed pending task)에 대한 에러를 발생시킵니다. 반드시 결과를 수집해야 합니다.
2. asyncio.gather
다음 단계로 넘어가기 전에 완료되어야 하는 여러 코루틴이 있다면, asyncio.gather가 번거로운 작업을 대신 처리해 줍니다. 내부적으로 각 코루틴을 Task로 스케줄링하고 함께 await 합니다.
user, orders = await asyncio.gather(fetch_user(id), fetch_orders(id))
이 방식은 간결하고 가독성이 좋습니다. 작업들이 서로 독립적이고, "이 모든 것을 실행한 다음 모든 결과를 달라"는 의도를 한 줄로 표현하고 싶을 때 빛을 발합니다. 또한 하위 작업들이 서로 다른 순서로 완료되더라도, 반환되는 리스트나 튜플 내 인자의 순서를 유지합니다.
3. asyncio.TaskGroup
Python 3.11은 표준 라이브러리에 구조화된 동시성(structured concurrency)을 가져오는 TaskGroup을 도입했습니다. 태스크를 수동으로 생성하는 대신, 생성된 모든 태스크가 적절히 종료되도록 보장하는 컨텍스트 매니저를 사용합니다. 만약 하나의 태스크에서 예외가 발생하면, 다른 태스크들은 자동으로 취소됩니다.
async with asyncio.TaskGroup() as tg:
user_task = tg.create_task(fetch_user(id))
orders_task = tg.create_task(fetch_orders(id))
user = user_task.result()
orders = orders_task.result()
이 패턴은 복잡한 워크플로우에 매우 유용합니다. 태스크가 고아(orphaning)가 될 위험을 제거하며, 관련 작업들의 생명주기를 하나의 논리적인 범주 아래로 그룹화합니다. 코드베이스가 Python 3.11 이상에서 실행된다면, 이는 팬아웃(fan-out) 동시성을 구현하기 위한 가장 깔끔한 아키텍처가 될 것입니다.
멘탈 모델: await는 "지금 이것을 실행하라"는 의미입니다
핵심적인 교훈은 언어적인 측면에 있습니다. JavaScript에서 await는 "그동안에(meanwhile)"라고 읽을 수 있습니다. 작업을 시작하고, 다른 일을 하다가, 값이 필요할 때만 일시 정지하는 방식입니다. 반면 Python에서 await는 "이 코루틴을 다음 중단점(suspension point)이나 완료 시점까지 실행하라"는 의미입니다. 만약 코루틴이 아직 스케줄링되지 않았다면, await가 바로 그것을 스케줄링하는 역할을 합니다. 이것이 바로 두 개의 가공되지 않은(raw) 코루틴을 시작한 뒤 나중에 await를 할 수 없는 이유입니다. 그 사이 동안 이벤트 루프에 실행할 작업을 전달하지 않았기 때문입니다.
Python 코루틴을 제너레이터 함수(generator functions)처럼 생각하세요. 제너레이터를 호출한다고 해서 바로 반복(iterate)이 시작되지는 않습니다. 루프를 돌리거나, next()를 호출하거나, 소비자(consumer)에게 전달해야 합니다. 비동기(Async)도 같은 방식으로 작동합니다. asyncio.create_task는 "지금 당장 이것을 이벤트 루프에 올려라"라고 말하는 소비자 역할을 합니다. 그 뒤에 오는 await는 단순히 완료 신호를 기다리는 것입니다.
도움이 되는 구체적인 습관 하나는 다음과 같습니다. await 없이 비동기 함수 호출을 변수에 할당할 때마다, 스스로에게 스케줄링을 했는지 물어보세요. 만약 우변(right-hand side)이 create_task, gather, 또는 TaskGroup으로 감싸져 있지 않다면, 그것은 실행 중인 것이 아닙니다. 그저 조리대 위에 놓인 레시피일 뿐입니다.
핵심 요약
Python의 비동기 런타임은 강력하지만, 명시적인 의도를 요구합니다. 단순히 함수를 호출했다고 해서 언어가 배경 작업을 시작해주지는 않습니다. JavaScript에서 넘어왔다면, 코루틴을 변수에 저장했다가 나중에 await 하는 모든 곳을 점검해 보세요. 먼저 이를 Task로 승격시키지 않았다면, 여러분은 비동기라는 옷을 입힌 순차적 코드를 작성한 것입니다. Task로 작업을 시작한 다음, 그 결과를 기다리세요. 그것이 Python 비동기를 조용한 병목 현상이 아닌, 진정한 동시성 도구로 바꾸는 방법입니다.
