async/await 뒤에서 벌어지는 일 — 코루틴의 정체
async 함수를 호출했는데 아무 일도 안 일어난다
async def greet():
print("hello")
return 42
result = greet()
print(result)이 코드를 실행하면 hello 는 출력되지 않고 <coroutine object greet at 0x...> 같은 게 출력됩니다. 처음 보면 버그처럼 느껴집니다. 실제로는 정확히 의도된 동작입니다. async def 로 선언한 함수를 호출하는 것은 함수를 "실행"하는 게 아니라 코루틴 객체를 "생성"하는 겁니다. 이 객체는 실행 계획서에 가깝습니다 — 아직 아무 코드도 돌지 않았습니다.
이 객체를 실제로 실행시키려면 세 가지 방법 중 하나를 써야 합니다. await 로 기다리거나, asyncio.create_task() 로 스케줄링하거나, asyncio.run() 에 최상위 진입점으로 넘기는 것입니다. 위 코드를 고치면:
import asyncio
async def greet():
print("hello")
return 42
async def main():
result = await greet()
print(result)
asyncio.run(main())이번엔 hello 와 42 가 순서대로 출력됩니다. 참고로 첫 번째 코드처럼 코루틴 객체를 만들어놓고 아무 데도 쓰지 않으면, 파이썬은 가비지 컬렉션 시점에 RuntimeWarning: coroutine 'greet' was never awaited 경고를 띄웁니다. 실무에서 이 경고를 무시하면 안 됩니다 — 거의 항상 await 를 빼먹었다는 신호입니다.
await가 진짜로 하는 일
await 를 "결과를 기다린다"는 뜻으로만 이해하면 절반만 아는 겁니다. 더 정확히는 "이 지점에서 실행을 멈추고, 값이 준비되면 정확히 여기서부터 재개해달라고 이벤트 루프에 등록한다"는 뜻입니다. 이걸 가장 잘 보여주는 방법은 await 없이 코루틴을 손으로 직접 구동해보는 것입니다. 코루틴은 내부적으로 제너레이터와 같은 프로토콜(send, throw, close)을 씁니다.
async def simple():
print("시작")
return "완료"
coro = simple()
try:
coro.send(None) # 코루틴을 한 스텝 실행
except StopIteration as e:
print("반환값:", e.value)coro.send(None) 이 호출되는 순간 simple() 내부 코드가 실행되기 시작하고, return 을 만나면 StopIteration 예외가 발생하며 그 안에 반환값이 담깁니다. await, asyncio.run(), 이벤트 루프 — 이 모든 것이 결국 이 send/StopIteration 프로토콜을 편리하게 감싼 것에 불과합니다. 코루틴 안에서 다른 코루틴을 await 하면, 안쪽 코루틴이 끝날 때까지 바깥쪽 코루틴도 재개되지 않습니다. 그래서 await 체인이 길어지면 "누가 언제 멈추고 재개되는지"가 함수 여러 개를 넘나들며 이어집니다.
async 함수 안에서 async 함수를 호출만 하고 await를 빼먹는 사고
앞서 본 "호출만 하면 실행되지 않는다"는 성질은 코드 리뷰에서 놓치기 쉬운 버그를 만듭니다.
async def save_log(entry: str):
await asyncio.sleep(0.1) # DB 쓰기라고 가정
print(f"저장됨: {entry}")
async def handle_request(entry: str):
save_log(entry) # await를 빠뜨렸다 — 로그는 저장되지 않는다
return "ok"handle_request 는 정상적으로 "ok" 를 반환하고 요청도 성공한 것처럼 보입니다. 하지만 save_log 코루틴 객체는 만들어지기만 했지 한 번도 실행되지 않았습니다. 이런 버그는 테스트에서도 잘 안 걸립니다 — 예외가 나지 않고, 반환값도 정상이기 때문입니다. 배포 후 로그가 슬금슬금 비어 있는 걸 발견하고 나서야 원인을 찾는 경우가 많습니다. 린터(ruff의 RUF006, 또는 flake8-async)를 CI에 걸어두면 이 클래스의 버그를 정적으로 잡아낼 수 있습니다 — 개인적으로는 async를 쓰는 프로젝트라면 거의 필수라고 봅니다.