이 글은 FastAPI의 문서를 읽다가 호기심 레이더에 걸려 헷갈리던 개념들을 제대로 이해하게 된 기록이다.
FastAPI를 쓰다 보면 async, await는 거의 자동으로 따라온다.
나도 처음엔 그냥 “비동기니까 빠르겠지” 정도로만 이해하고 썼다.
그런데 막상 파고들다 보니, 머릿속에서 계속 걸리는 질문들이 있었다.
- async면 동시에 실행되는 거 아닌가?
- await을 하면 결국 기다리는 건데, 그럼 동기랑 뭐가 다른 거지?
- CPU 많이 쓰는 작업도 await으로 감싸면 해결되는 거 아닌가?
이 질문들을 하나씩 따라가다 보니, 내가 그동안 async를 문법으로만 이해하고 있었다는 걸 깨닫게 됐다.
이 글은 그 생각의 흐름을 그대로 정리한 기록이다.
처음 내가 했던 가장 큰 오해는 이거였다.
비동기 = 동시에 실행
이 말은 반은 맞고, 반은 틀리다.
async/await는 병렬 실행이 아니다.
정확히 말하면 동시성(concurrency) 이다.
async는 후자다.
즉,
async는 여러 일을 동시에 실행한다가 아니라, 기다리는 시간 동안 다른 일을 처리한다에 가깝다.
이것도 처음엔 꽤 헷갈렸다.
async def foo():
print("hello")
foo()
이걸 실행하면 hello가 출력될까?
아니다.
foo()는 실행이 아니라 코루틴 객체 생성이다.
실제로 실행되려면 반드시 이게 필요하다.
await foo()
여기서 await는 지금 실행하라는 버튼이라기보다는, 이 코루틴을 이벤트 루프에게 맡긴다 에 더 가깝다고 할 수 있다.
FastAPI에서는 이 await를 내가 직접 쓰지 않아도, 프레임워크와 ASGI 서버가 내부적으로 처리해 주기 때문에 그냥 함수처럼 실행되는 것처럼 보였을 뿐이다.
여기서 가장 중요한 오해를 하나 정리해야 한다.
await을 하면 이 작업이 끝날 때까지 다른 작업들이 기다리는 거 아니야?
아니다. 완전히 반대다.
await를 만나는 순간, 다른 작업들이 멈추는 게 아니라 현재 작업만 멈춘다
정확히 말하면,
"나는 지금 할 일이 없으니까 잠깐 멈출게. 그동안 다른 작업들 먼저 처리해도 돼.”
이게 async/await의 본질이다.
동기 코드에서는 하나가 기다리면 전부 멈추지만, async 코드에서는 하나가 기다리면 그것만 멈춘다.
이 차이 때문에 서버 처리량이 완전히 달라진다.
그런데 나는 또 이렇게도 생각해봤다.
CPU 많이 쓰는 작업도 await으로 감싸면 되는 거 아닌가?
결론부터 말하면 안 된다.
이유는 간단하다.
await은 “기다릴 수 있는 작업” 에만 의미가 있다.
CPU bound 작업은 중간에 멈출 수 없고, 기다림이 없고, CPU를 계속 점유한다.
즉, await할 수 있는 지점 자체가 없다!
심지어 CPU 작업을 async def로 감싸도, 내부에 await가 없으면 이벤트 루프는 계속 붙잡힌다.
이 순간 async는 동기보다 더 나빠진다.
이 질문 하나로 정리가 된다.
위와 같은 작업들은 async / await가 의미있다.
위와 같은 작업들은 이벤트 루프 밖으로 보내야 한다.
이걸 구분하지 않으면, async 서버를 쓰면서도 성능이 망가진다.
정답은 하나다.
이벤트 루프 밖으로 내보낸다
방법은 상황에 따라 다르다고 할 수 있다.
가벼운 작업은 thread pool로, 무거운 작업은 worker 프로세스로, 매우 무거운 작업은 별도 서비스로 처리한다.
이 부분에서 머릿속 그림이 하나 생겼다.
A 작업을 하다가 await을 만나면
B 작업으로 갔다가
다시 A로 돌아오는 느낌
이 그림 자체는 맞다.
다만 조금 더 정확히 정리하면,
이 조금 더 적합한 것 같다.
그래서 async는 협력적 멀티태스킹이라고 부른다.
이 과정을 거치고 나서, 내 머릿속에 남은 문장은 이거다.
async는 빠르게 만드는 기술이 아니라 기다림을 설계하는 기술이다.
그리고
CPU 작업은 async로 해결하는 문제가 아니라 어디서 실행할지의 문제다.
이 글은 async/await를 설명하려고 쓴 글이라기보다는, 내가 헷갈렸던 지점을 하나씩 정리한 기록에 가깝다.
만약 예전의 나처럼
이 흐름이 한 번쯤은 도움이 될 것 같다.