들어가며
저번 프로젝트에서 FastAPI를 활용한 백엔드 개발을 수행했다. FastAPI 프레임워크는 ASGI 규약을 따르는 웹 어플리케이션으로, 비동기 처리를 지원하여 높은 성능을 뽑아낼 수 있다. 비동기 관련 개념에 대해 정리하고, Python에서는 어떻게 비동기 처리를 수행하는지 예제 코드와 함께 살펴보고자 한다.
동기/비동기, 블로킹/논블로킹
비동기 처리와 관련해서, 동기/비동기와 블로킹/논블로킹은 늘 함께 등장하는 개념들이다. 프로그래밍의 관점에서, 어떤 함수(작업) 안에서 다른 함수(작업)를 호출하는 일은 빈번하게 일어난다. 관련 개념을 정리하면 다음과 같다.
- 작업을 수행하며 작업을 호출한 쪽의 제어 흐름을 Block하면 블로킹, 그렇지 않으면 논블로킹
- 해당 작업이 수행되어 마무리 될 때까지 기다리는 것이 동기, 기다리지 않는 것이 비동기
- 동기/비동기는 작업을 호출하는 입장에서, 블로킹/논블로킹은 작업을 받아 수행하는 입장에서의 개념
- 일반적으로는 동기 - Blocking, 비동기 - Non-Blocking 짝으로 연결됨
Asyncio와 간단한 사용 예시
Python 3.4부터 asyncio를 공식적인 비동기 라이브러리로 제공한다. asyncio는 이벤트 루프 기반으로 코루틴이라는 비동기 함수들의 실행을 관리한다. 비동기 처리를 통해 여러 작업을 동시에(concurrent, not parallel) 수행할 수 있게 된다.
동시성과 병렬성
동시성은 하나의 주체가 여러 작업을 전환하면서 수행되는 과정이고, 병렬성은 말 그대로 여러 주체가 여러 작업을 수행되는 과정이다. 하나의 이벤트 루프는 하나의 스레드 안에서 동시적으로(concurrent) 작업을 수행한다.
간단한 사용 예시를 확인해보자. say_after 함수는 delay 만큼 대기 후 입력한 문자열을 출력해준다. 동기식으로 실행한다면 delay로 넘겨준 1초 + 2초 = 3초가 소요되지만, 비동기식으로 실행하면 두 작업이 동시에 수행되어 2초가 소요된다.
def say_after(delay, what):
time.sleep(delay)
print(what)
def main():
start = time.time()
say_after(1, 'hello')
say_after(2, 'world')
end = time.time()
print(f"Execution time : {end-start}")
main() # Execution time : 3.031189441680908
import asyncio
import time
async def say_after(delay, what):
await asyncio.sleep(delay)
print(what)
async def main():
start = time.time()
# create_task 하는 순간 작업 등록 후 이벤트 루프에서 실행
task1 = asyncio.create_task(say_after(1, 'hello'))
task2 = asyncio.create_task(say_after(2, 'world'))
# await으로 제어권을 넘긴다.
# 이벤트 루프는 다른 await이 걸린 라인도 동시에 실행하므로, 작업 등록 순서는 상관 없다.
# hello가 1초 뒤에 출력되고, world가 2초 뒤에 출력
await task2
await task1
end = time.time()
print(f"Execution time : {end-start}")
asyncio.run(main()) # Execution time : 2.0171315670013428
코루틴과 async/await
코루틴은 비동기 작업을 수행할 수 있는 함수를 의미한다. 함수 앞에 async 키워드를 붙여 호출 시 코루틴 객체를 반환할 수 있도록 하고, 이러한 코루틴 객체는 해당 코루틴 객체를 호출한 코루틴 내에서 await를 만나면 작업이 완료될 때까지 기다린다. 좀 더 명확하게 이야기하면, await을 하게 되면 실행의 제어권이 해당 코루틴으로 넘어가게 된다는 것이다. 최초의 진입점이 되는 코루틴은 asyncio.run() 함수를 통해 실행이 되고, 코루틴 내부에서 다른 코루틴이 연쇄적으로 호출된다. 이러한 과정을 코루틴 체인이라고 한다.
이벤트 루프
그렇다면 코루틴들은 어떻게 관리되어 실행될까? 먼저, 코루틴은 그 자체로는 코루틴 객체를 반환할 뿐, 스스로 실행되지 않는다. asyncio가 제공하는 이벤트 루프에 Task로 등록됨으로써 작업을 수행하게 된다. 최초 코루틴 실행 시 asyncio.run()을 활용하는데, asyncio.run이 호출되며 현재 스레드에 새로운 이벤트가 생성된다. 이러한 이벤트 루프는 인자로 넘어오는 코루틴을 Task로 예약하여 실행하고(이 과정에서 당연히 코루틴 체인 내의 Task들도 연쇄적으로 등록될 것이다) 코루틴이 포함하는 모든 Task가 완료되면 이벤트 루프가 닫히게 된다. (asyncio.run()이 이벤트 루프를 생성하고 종료하게 됨)
Task 객체와 Future 객체
Task 객체는 생성되면서 이벤트 루프에 해당 코루틴이 실행되도록 예약을 한다. Task 객체와 Future 객체에 대해 이해할 필요가 있다. 먼저 Future 객체는 어떤 작업의 상태(PENDING, CANCELLED, DONE)를 가진다. 또한 작업의 상태가 DONE일 때 호출될 함수를 등록할 수도 있는데, 이를 통해 이벤트 루프가 ‘루프를 돌면서’ 상태를 체크하고 완료되었을 때 그 작업을 실행할 수 있도록 한다. Task 객체는 이러한 Future 객체를 상속하고, 생성 시 이벤트 루프에게 자신이 가지고 있는 코루틴이 실행되도록 요청한다. Task 객체는 Javascript의 Promise와 유사한 개념으로 볼 수도 있다.
Task를 등록한 이벤트 루프의 실행 과정
요약하면 다음과 같다.
- 진입점 코루틴에서, Task 실행 예약을 건다(create_task)
- 각 task가 await를 만나면, 함수에서 주도권을 코루틴 체인(await 연쇄 체인)을 따라 내려준다.
- 그 코루틴 체인에서 sleep 혹은 io 관련 코루틴을 만나게 된다.
- 끝에 만난 코루틴은 퓨쳐 객체를 반환하고, 거기에는 PENDING, CANCELLED, DONE과 같은 상태가 저장되어 있다. 뭐가 됐든 퓨쳐 객체는 자기 자신이 가진 코루틴 실행을 이벤트 루프에 콜백으로 등록해두고, DONE이 될 때까지 기다리게 한 다음 이벤트 루프에 제어권을 넘긴다.
- 그러면 이벤트 루프는 await가 걸린 다음 Task를 우선 실행하고 4번과 동일한 작업을 수행한다.
- DONE이 된 것부터 순차적으로 수행하고, 모든 await이 끝날때까지 기다린다. 이벤트 루프는 모든 task를 concurrent하게 수행하므로, await의 순서는 상관없다.
I/O 성능 향상
이렇게 비동기적으로 처리하게 되면, 메인 작업의 실행을 블로킹하지 않게 되어 성능이 비약적으로 향상되게 된다. 외부 연동, DB I/O, 파일 I/O 와 같은 작업이 많다면 FastAPI의 비동기를 활용하는 게 좋다.
참고 자료
처음 시작하는 파이썬 2판, 15장 프로세스와 동시성
[Python]파이썬 비동기 프로그래밍 동작 원리에 대해서 (feat. 이벤트 루프)
[Python]파이썬 비동기 프로그래밍 동작 원리에 대해서 (feat. 이벤트 루프)
안녕하세요, 오늘은 파이썬 비동기 프로그래밍 동작 원리에 대해서 알아보려고 합니다. 파이썬의 비동기는 이벤트 루프를 통해 동작하고 있다는 정도의 이해만 한 채로 개발을 하다 보니 문득
leffept.tistory.com
Python 비동기 프로그래밍 동작 원리 (asyncio) :: IT 엘도라도
Python 비동기 프로그래밍 동작 원리 (asyncio) :: IT 엘도라도
JavaScript와 달리 Python은 비동기 프로그래밍에 어색하다. 애초에 JavaScript는 비동기 방식으로 동작하도록 설계된 언어인 반면, Python은 동기 방식으로 동작하도록 설계된 언어이기 때문이다. 그래
it-eldorado.com
'Programming Language > Python' 카테고리의 다른 글
| Python ORM : SQLAlchemy (2) | 2025.05.03 |
|---|---|
| Getter/Setter를 파이써닉하게 구현하기(@property) (0) | 2025.04.19 |