비동기 프로그래밍은 한 마디로 **"기다리는 동안 다른 일을 할 수 있는 프로그래밍"**입니다. 최근 Python 3.10+ 버전에서 안정적으로 활용할 수 있는 asyncio와 async/await 문법 덕분에, 파이썬으로도 비동기 I/O 처리가 가능해졌습니다. 이러한 비동기 기술은 웹 서버 개발에 큰 변화를 가져왔는데, FastAPI와 같은 최신 프레임워크는 내부적으로 비동기 방식을 활용하여 동시성을 극대화합니다. 이번 포스트에서는 파이썬 비동기 프로그래밍의 기본 개념부터, FastAPI가 내부적으로 비동기 처리를 수행하는 원리까지 자세히 설명하겠습니다. 천천히 따라오시면, 동기와 비동기의 차이부터 ASGI 서버(Uvicorn)의 역할까지 쉽게 이해할 수 있을 것입니다.
동기 vs 비동기: 기다림의 방식 차이
프로그래밍에서 "동기(synchronous)"와 "비동기(asynchronous)"는 작업을 처리하는 방식의 차이를 말합니다. 동기 방식에서는 하나의 작업이 완료될 때까지 다음 작업이 시작되지 않습니다. 즉, 앞선 작업이 끝날 때까지 프로그램은 대기 상태가 됩니다. 반면 비동기 방식에서는 어떤 작업이 진행되는 동안 그 결과를 기다리지 않고도 다른 작업을 진행할 수 있습니다. 비동기 코드는 "나중에 결과가 준비되면 알려줘, 난 그동안 딴 일을 하고 있을게"라고 컴퓨터에게 말하는 것과 같습니다.
예를 들어, 전통적인 동기 코드에서는 파일을 읽거나 웹 API 호출을 할 때 그 작업이 끝날 때까지 프로그램이 멈춰서 기다립니다. 하지만 비동기 코드에서는 파일을 읽는 동안에도 다른 코드를 계속 실행할 수 있습니다. 이렇게 가능하게 해주는 개념이 **이벤트 루프(Event Loop)**와 **코루틴(Coroutine)**입니다. 이벤트 루프는 비동기 작업들을 관리하여, 하나의 작업이 I/O 등으로 기다리는 동안 다른 작업을 수행했다가, 기다리던 작업이 준비되면 다시 돌아가 이어서 처리합니다. 코루틴은 이러한 비동기 함수의 일종으로, 실행을 중간에 일시 정지하고 다른 코루틴에 제어권을 넘길 수 있는 함수를 말합니다. async def로 정의된 함수는 코루틴이 되고, await를 통해 다른 코루틴의 완료를 비동기적으로 기다릴 수 있습니다.
실생활 비유: 비동기 개념을 실생활에 비유하면, 음식점에서 음식을 주문하고 조리가 완성될 때까지 줄곧 카운터 앞에서 기다리는 것이 동기 방식이라면, 주문을 해 놓고 그 시간에 다른 일을 하다가 음식이 다 되면 호출을 받고 찾아오는 것이 비동기 방식입니다. 또 다른 예로, 친구에게 편지를 보내놓고 답장이 올 때까지 아무 것도 안 하고 기다리는 것이 아니라, 답장이 오는 동안 다른 업무를 보는 식이 비동기입니다. 이처럼 비동기적 사고 방식에서는 *"기다림"*이 적극적인 작업 전환으로 대체됩니다.
동시성과 병렬성
비동기 프로그래밍을 얘기할 때 **동시성(concurrency)**과 병렬성(parallelism) 개념도 자주 등장합니다. 둘 다 여러 작업이 동시에 진행된다는 느낌을 주지만, 엄밀히는 다릅니다. 동시성은 한 번에 하나의 작업만 수행하지만 짧은 시간 단위로 작업들을 교차 실행하여 결과적으로 동시에 수행되는 것처럼 보이게 하는 것입니다. 한 사람이 일을 여러 가지 번갈아 가며 처리하는 모습이라고 생각할 수 있습니다. 반면 병렬성은 말 그대로 여러 작업이 같은 시간에 물리적으로 함께 수행되는 것으로, 여러 사람이 각각 일을 한꺼번에 하는 모습에 비유할 수 있습니다. 파이썬의 비동기 asyncio는 기본적으로 한 이벤트 루프가 단일 스레드에서 작업을 교차 수행하므로 동시성을 구현한 것입니다 (한 순간에 한 코루틴만 실행되지만, 대기 시간들을 활용해 여러 작업을 진행). 반면 멀티스레드나 멀티프로세스는 여러 CPU 코어를 활용해 동시에 실행되므로 병렬성에 가깝습니다.
중요한 점은, 비동기 = 동시성이고 멀티스레드 = 병렬성이라고 단정할 수는 없지만, 일반적으로 파이썬의 asyncio 비동기는 동시적 실행에 집중하며, I/O 바운드 작업에서 높은 효율을 보입니다. CPU 연산처럼 병렬 처리가 필요한 작업은 여전히 스레드나 프로세스를 늘려 병렬 실행해야 합니다. 하지만 웹 서버와 같은 I/O 중심 환경에서는 동시성(비동기)만으로도 큰 성능 향상을 얻을 수 있습니다.
Python의 비동기 프로그래밍: asyncio와 async/await
Python에서는 asyncio 모듈과 async/await 문법을 통해 비동기 프로그래밍을 구현합니다. Python 3.4에서 asyncio가 도입되고, 3.5부터 async/await 키워드가 추가되면서 파이썬 코드는 마치 동기 코드처럼 읽히면서도 비동기적으로 동작할 수 있게 되었습니다. async def로 함수를 정의하면 코루틴이 되고, 이 함수는 호출 시 즉시 실행되는 것이 아니라 코루틴 객체를 반환합니다. 이 코루틴 객체는 이벤트 루프에 의해 실행되어야 실제 코드가 진행됩니다. 코루틴 내부에서는 await 키워드를 사용하여 다른 코루틴의 결과를 기다릴 수 있는데, 이 때 단순히 기다리는 동안 이벤트 루프는 다른 코루틴을 수행하게 됩니다.
파이썬에서 비동기 코드를 실행하려면 먼저 이벤트 루프가 필요합니다. 이벤트 루프는 asyncio.run(...) 함수를 사용하여 쉽게 시작할 수 있습니다 (Python 3.7+부터 도입된 편의 함수입니다). 예를 들어 간단한 비동기 코드 예시는 다음과 같습니다:
import asyncio, time
async def delay_print(index, delay):
print(f"{index} 시작")
await asyncio.sleep(delay) # non-blocking sleep
print(f"{index} 완료")
return index
async def main():
tasks = [asyncio.create_task(delay_print(i, 1)) for i in range(1, 6)]
start = time.time()
results = await asyncio.gather(*tasks) # 코루틴들을 동시에 실행
end = time.time()
print("모든 작업 완료:", results)
print(f"총 경과 시간: {end - start:.2f} 초")
asyncio.run(main())
위 코드에서는 delay_print라는 코루틴이 1초간 쉬면서 (await asyncio.sleep(1)) 시작과 완료 메시지를 출력합니다. main 코루틴에서는 이런 코루틴 다섯 개를 한꺼번에 실행(gather)하고 있습니다. 각 작업은 1초간 지연되지만, 동시에 실행되므로 전체 수행 시간도 약 1초 남짓에 불과합니다. 만약 이 작업들을 동기적으로 하나씩 수행했다면 총 5초 이상이 걸렸을 것입니다. 이처럼 asyncio를 활용하면 여러 I/O 작업을 겹쳐서 수행함으로써 프로그램 실행 효율을 높일 수 있습니다.
이 코드의 출력 결과를 살펴보면 다음과 비슷하게 나올 것입니다:
1 시작
2 시작
3 시작
4 시작
5 시작
1 완료
2 완료
3 완료
4 완료
5 완료
모든 작업 완료: [1, 2, 3, 4, 5]
총 경과 시간: 1.01 초
각 작업이 거의 동시에 시작되고 완료되었음을 알 수 있습니다. 여기서 asyncio.sleep()은 **논블로킹(non-blocking)**으로 동작하기 때문에 이벤트 루프는 첫 번째 작업이 sleep으로 기다리는 동안 다른 작업들을 차례로 수행합니다. 만약 time.sleep() 같은 블로킹 함수를 사용했다면, 그 동안 이벤트 루프는 멈춰버려서 동시 실행의 이점을 살릴 수 없게 되었을 것입니다.
요약: async def로 정의된 함수는 이벤트 루프에서 실행되는 코루틴이며, await를 사용해 다른 코루틴의 완료를 비동기적으로 기다립니다. 이를 통해 하나의 스레드(메인 이벤트 루프)로도 여러 작업을 동시에 진행하는 동시성을 얻을 수 있습니다.
웹 서버에서의 비동기 처리와 성능 이점
웹 서버 분야에서 비동기 프로그래밍은 높은 동시 처리 성능과 효율적인 자원 활용을 가능케 합니다. 전통적인 파이썬 웹 프레임워크들은 WSGI(Web Server Gateway Interface)라는 규격을 따르는데, WSGI 웹 서버는 일반적으로 **하나의 요청을 처리할 때 하나의 스레드 (또는 프로세스)**를 사용합니다. 예를 들어 Flask나 Django 같은 WSGI 기반 프레임워크에서는 여러 사용자의 요청을 동시에 처리하려면 스레드나 프로세스를 많이 생성해야 했습니다. 이는 곧 메모리 사용 증가와 문맥 전환 오버헤드로 이어집니다. 또한 Python은 GIL(Global Interpreter Lock) 때문에 동일 프로세스 내에서 진정한 CPU 병렬 처리가 어렵기 때문에, 수많은 스레드를 늘린다고 해서 CPU 바운드 작업 처리 속도가 획기적으로 올라가진 않습니다.
비동기 웹 서버 접근 방식에서는 하나의 스레드에 이벤트 루프를 두고, 그 루프가 여러 요청을 코루틴 단위로 처리합니다. 즉, 한 스레드가 동시에 수천 개의 요청을 받더라도, 각 요청이 I/O를 기다리는 동안 다른 요청을 처리하는 식으로 효율적으로 돌아갑니다. 이러한 새로운 규격이 **ASGI(Asynchronous Server Gateway Interface)**입니다. FastAPI와 Starlette는 바로 이 ASGI 표준을 따르는 프레임워크입니다.
전통적인 동기식 웹 서버 모델: 각 HTTP **요청(Request)**에 대해 별도의 **워커 스레드(Worker)**가 만들어져 작업을 처리합니다. 요청이 많아지면 그만큼 많은 스레드/프로세스가 필요하게 됩니다.
현대적인 비동기식 웹 서버 모델: 하나의 이벤트 루프(Task Manager)가 여러 요청을 관리합니다. 각 요청은 개별 **작업(task)**으로 처리되며, I/O 대기 시 이벤트 루프가 다른 작업을 수행합니다.
위 다이어그램을 보면, 동기식 모델에서는 요청마다 분리된 작업자가 필요하지만, 비동기식 모델에서는 한 이벤트 루프가 여러 작업을 번갈아가며 처리함을 알 수 있습니다. 이러한 비동기 웹 서버의 이점은 다음과 같습니다:
- 높은 동시 처리량: 스레드를 수백 개 만들 필요 없이도, 한 개의 스레드로 많은 요청을 처리할 수 있어요. 예를 들어 1개의 이벤트 루프 스레드로도 수천 개의 클라이언트 연결을 Handling(관리) 가능하다고 알려져 있습니다.
- 낮은 오버헤드: 스레드 컨텍스트 스위칭이나 과도한 메모리 사용을 줄일 수 있습니다. 코루틴 간 전환은 함수 호출 정도의 비용으로 가볍습니다.
- I/O 바운드 작업 최적화: 대부분의 웹 요청 처리는 DB 쿼리, 외부 API 호출, 파일 읽기 등 I/O 작업이 많은데요, 비동기 방식에서는 이러한 대기 시간을 효율적으로 활용하여 전체 응답 속도를 향상시킬 수 있습니다.
- 추가 기능 지원: ASGI의 비동기 구조 덕분에 WebSocket과 같은 실시간 양방향 통신이나 HTTP/2와 같은 프로토콜을 동일한 방식으로 처리할 수 있습니다. 기존 WSGI는 요청-응답 사이클에만 맞춰져 있어 WebSocket 지원이 어려웠지만, ASGI 서버에서는 WebSocket 연결을 코루틴으로 유지하면서 다른 요청도 처리할 수 있습니다.
물론 주의할 점도 있습니다. 비동기 웹 서버라 해도, CPU 집약적인 작업(예: 복잡한 이미지 처리나 대용량 데이터 연산)은 여전히 문제입니다. 이벤트 루프를 돌리는 메인 스레드에서 CPU를 많이 쓰면 다른 코루틴들을 처리하지 못해 전체 서버가 느려지기 때문입니다. 이러한 경우에는 별도의 작업자 프로세스나 스레드 풀, 또는 비동기 작업 큐 등을 활용해 부하를 분산해야 합니다. 요컨대, I/O 작업은 비동기로, CPU 작업은 필요하면 병렬로 전략을 취하는 것이 웹 서버 성능에 중요합니다.
'프로그래밍언어' 카테고리의 다른 글
Python 가비지 컬렉션과 인터프리터 동작 원리 (0) | 2025.05.09 |
---|---|
정적 타입 시스템 vs 동적 타입 시스템 (0) | 2025.05.08 |
Python 언어 깊이있게 탐구하기 (5) | 2025.05.08 |