Python/ETC / / 2026. 1. 27. 20:33

[파이썬 기본 다지기] Python의 Event Loop(이벤트 루프)의 작동 방식과 원리, 비동기 프로그래밍, 동기/비동기, 블로킹/논블로킹

반응형

 

1줄 요약

파이썬의 Event Loop(이벤트 루프)는 asyncio의 핵심이며, 비동기 작업과 콜백, 네트워크 I/O 연산 및 자식 프로세스 등을 실행한다.

 

1. 파이썬 그리고 동기/비동기, 블로킹/논블로킹

먼저 파이썬에서 asyncio를 사용하기 전 동기/비동기(sync/async)와 블로킹/논블로킹(blocking/non-blocking)에 대해서 설명하고 가는 것이 이 글에 대한 이해를 상당히 도울 수 있다고 생각한다. 그렇다면 동기와 비동기 그리고 블로킹과 논블로킹의 차이를 집고 넘어가는 것이 중요하다. 일단 큰 틀에서 "동기/비동기"와 "블로킹/논블로킹"의 차이를 짚고 넘어가려고 한다. 먼저 이 동기/비동기와 블로킹/논블로킹은 주체(Caller)가 호출한 함수(Callee)라는 것을 생각해야 한다. 즉 A가 동기적으로 B를 호출한다거나, A가 논블로킹으로 B를 호출한다거나 하는 것이다.

  • 동기/비동기 - 요청한 작업의 결과의 반환 여부에 따라 순차적으로 수행할지 아닌지에 대한 관점
  • 블로킹/논블로킹 - 작업의 주도권(제어권)이 누구에게 있는지 여부에 따라 다른 작업을 수행할 수 있는지에 대한 관점

동기와 비동기는 작업의 결과가 돌아오면 그 다음 차례차례 순차적으로 할지 아니면 결과의 반환 여부에 상관 없이 병렬적으로 이를 실행하는지에 대한 것이고, 블로킹과 논블로킹은 다른 요청의 작업을 처리하기 위해 현재 작업이 대기 혹은 차단되는 것인지에 대한 여부 - 사실 더 자세하게 설명하면, 제어권을 호출자가 가지고 있는지의 여부 - 라고 생각하면 편하다. 즉 실행의 결과에 대한 것은 동기/비동기에 대한 내용이며, 실행의 주도권에 대한 것은 블로킹/논블로킹에 대한 내용이다.

 

예시를 들어 설명해보자면, A가 B를 호출한 상태에서 B의 리턴값이 필요하면 동기, 필요하지 않고 그냥 할거면 비동기다. A가 B를 블로킹으로 호출한 경우 B가 제어권을 가지고 있으니 - 쉽게 예를 들면 옆 개발자에게 본체는 남아있는데 키보드를 넘겨준 경우 - A는 아무것도 안하고 있고, B를 논블로킹으로 호출했으면 제어권은 A가 가지고, B가 뭘 하든 - 마치 옆에 동료 개발자에게 무언가 부탁하고 자기 할 업무를 하는 것이다 - 자기 할 일을 하는 것이다. 간단하게 이들의 조합을 도표로 표현한 그림을 첨부하려고 한다. "동기-블로킹, 동기-논블로킹, 비동기-블로킹, 비동기-논블로킹", 이렇게 조합이 가능하기 때문에 4가지 그림이 나온다. 

 

 

여기서는 자세하게 설명하지 않겠지만, 간단하게 4개를 설명해 보자면 다음과 같다.

  • 동기-블로킹 : Task A가 작업을 진행하다가 Task B를 호출하면 Task B의 작업이 진행되는 동안 자신은 작업을 멈추고(블로킹/Blocking), 제어권을 돌려받을 때 까지 아무것도 하지 않는다. 그리고 제어권을 넘겨 받으면 - 그림 상의 결과값 반환(동기/sync) - 다시 Task A는 작업을 수행한다.
  • 동기-논블로킹 : Task A가 작업을 진행하다가 Task B를 호출하면 Task B의 작업이 진행되어도 Task A는 자기 작업을 하지만(논블로킹/Non-Blocking) Task B의 결과가 필요하기 때문에(동기/sync) 계속해서 A는 B에게 결과를 확인한다.
  • 비동기-블로킹 : Task A 가 작업을 진행하다가 Task B를 호출하면 Call Back함수를 넘기고(비동기/Async) 동시에 제어권도 넘겨 Task B의 작업이 진행되는 동안 자신은 작업을 멈추고(블로킹/Blocking) 아무것도 하지 않는다. 그리고 B가 작업이 끝나면 제어권과 같이 보낸 콜백 함수를 실행한다.
  • 비동기-논블로킹 : Task A가 작업을 진행하다가 Task B를 호출하면 Task B의 작업이 진행되어도 Task A는 자기 작업을 하고(논블로킹/Non-Blocking) 제어권과 함께 콜백함수를 보냈고 결과 값도 신경쓰지 않기에(비동기/Async) B는 작업이 끝나면 A가 넘겨 준 콜백 함수를 실행한다.

아무튼 이정도로 설명할 수 있을 것 같다. 뭔가 중구난방이 된 것 같지만, 아무튼 추후에 설명할 파이썬의 Event Loop(이벤트 루프)와 다음 글에서 설명할 Coroutine(코루틴)에서 설명할 때 상당히 도움이 될 것 같아 이렇게 내용을 추가했다. 이제 본격적으로 이벤트 루프에 대해 이야기해 보겠다.

 

2. 파이썬에서 Asyncio의 등장

Python에는 GIL(Global Interpretor Lock)이 존재한다. 간략하게 설명하면 여러 스레드가 돌아가도 한 시점에 하나의 스레드가 Python 바이트 코드를 실행하고 있으면 다른 스레드는 실행하지 못하는 것이다. 이는 파이썬의 객체가 생성되고 소멸하는 시점을 관리하는 Reference Count(참조 횟수)와 관련이 있는데, 멀티 스레드 상태에서 GIL이 없는 경우에 여러 스레드가 하나의 객체에 접근하게 되면 Race Condition(경쟁 상태)에 돌입하여 참조 횟수, 카운트가 한 번만 올라야 하는데 추가로 오르거나 사용하고 있는 스레드의 참조 횟수가 다른 스레드의 접근으로 0이 되어버리는 경우가 생길 수 있다. 이를 막으려면 모든 객체에 Lock을 걸어야 하지만 이러면 비용이 너무 비싸진다. 추가적으로 서로 다른 락을 기다리다가 Dead Lock(데드락)에 빠져버릴 수도 있다. 그래서 Guido van Rossum(귀도 반 로섬)은 단순히 인터프리터 자체에 락 하나를 전체적으로 걸어버려서 한 번에 하나의 스레드만 Python 코드를 실행하게 하자는 아주 단순하고 강력한 해결책을 선택했다.

 

물론 Asycnio등장 이전에 I/O 작업에서는 GIL이 적용되어 있는 스레드에서도 유용하다. 왜냐하면 스레드 A가 파일 입출력을 기다리고 있따면 이 A는 응답을 기다리는 동안 GIL을 해제하고 다른 스레드가 이를 활요할 수 있기 때문이다. 물론 이런 멀티 스레드가 수백 수천개가 되면 Context Switching(문맥 전환)비용 이라던지, 동기화 문제 그 외에도 다양한 공유 자원 관리 등의 복잡도가 늘기 때문에 개발 난이도가 올라가고 디버깅도 어려워진다. 이렇듯 가벼운 Cocurrency(동시성) 모델이 필요한데 멀티 스레드는 너무 무겁기에 이를 대체할 용도가 필요했다. Node.js같이 이벤트 루프 기반 비동기 처리를 위해 Python에도 Twisted나 Tornado같이 라이브러리가 있었지만 실질적으로 내장된 것은 없었다. 이런 성원에 힘입어 Python3.4부터 Asyncio가 도입되기 시작했고 지금에 이르렀다.

 

3. Event Loop와 코루틴 그리고 코드

파이썬 비동기 프로그래밍에서 다음 글에서 설명할 Coroutine(코루틴 - 일반적인 함수와 다르게 실행을 정지하거나 재개할 수 있는 특별한 객체)을  실행시키는 방법에 크게 await, asyncio.run(), asyncio.create_task()로 구분할 수 있다. 현재 대부분의 상용 파이썬 버전에서는 asyncio.run()으로 사용하면 되지만, 이 asyncio.run()은 개략적으로 살펴보면 다음과 같이 구분할 수 있다.

 

  • loop = asyncio.get_event_loop()
  • loop.run_until_complete(coroutine_A)
  • loop.close()

asyncio.run()도 비슷하지만, asyncio.get_event_loop()를 통해 스레드에 존재하는 이벤트 루프를 가져오거나 없으면 생성하고, run_until_complete()를 통해 코루틴을 실행한 뒤, 코루틴이 완료되면 close()를 통해 이벤트 루프를 종료한다. 밑의 코드를 통해 자세하게 살펴보자.

 

 

3-1. loop = asyncio.get_event_loop()

 

현재 스레드에 설정된 이벤트 루프를 가져오거나 새로 만들어서 가져온다. 이때 상단의 그림처럼 이벤트 루프는 무한 루프를 돌면서 Task(태스크)를 하나씩 실행시키는 Logic을 의미한다. 이때 태스크는 하나의 Coroutine에서 출발하는 실행흐름이다.

 

3-2. loop.run_until_complete(coroutine_A)

 

인자로 받아온 코루틴을 이용해 Task 객체를 생성하고 이벤트 루프에 의해 Task의 실행이 즉시 예약된다. 이 때 코드 내에 asyncio.sleep또는 파일 입출력 혹은 네트워크와 같은 I/O와 관련된 코루틴을 await하는 코드를 마주친다. 만약 이 코루틴 B가 다른 역할 - 파일 입출력 같은 I/O 혹은 sleep이 아닌 경우 - 코루틴 B로 넘어가게 된다. 이를 코루틴 체인이 이루어진다고 보는데, 즉 B로 넘어가서 코루틴 B가 똑같은 상황을 맞이하면 다른 코루틴으로 연쇄(Chain)적으로 호출하는 것이다.

 

이런 코루틴 체인은 이 때 코드 내에 asyncio.sleep또는 파일 입출력 혹은 네트워크와 같은 I/O와 관련된 코루틴을 await하는 코드를 마주치면 이제 일시 정지가 되고 이를 연쇄적으로 물고 있던 - 위의 그림 상에서는 A, B, C - 코루틴들이 완료된지 않은 Pending상태가 되며 제어권이 이벤트 루프로 돌아간다. 이 때 이벤트 루프는 실행 준비가 된 Task를 선택하거나 콜백 및 Future 객체를 완료처리하는 등의 일을 한다. 스레드 스케줄러 처럼 움직이는 것이다.

 

그리고 I/O가 끝나거나 sleep이 끝나 일시 정지가 해제 되고 코루틴이 재개되면 이벤트 루프가 해당 작업이 끝났음을 감지하고 중단된 지점부터 다시 실행을 재개한다. 위의 예시에서는 코루틴 C가 다시 실행되고 코루틴 C가 값을 반환하면 그 다음은 B, A 순으로 결과가 전달 - 이 때 이를 감싸고 있떤 Task의 Future 객체(간단히 설명해 비동기 결과의 보관처)가 완료 상태가 된다 - 되며 체인이 풀린다.  

 

3-3. loop.close()

 

언젠가 Task가 실행한 최초의 코루틴이 반환되며 최종적으로 실행될 코루틴이 완료되면 실행을 종료한다.

 

4. 그렇다면 멀티 스레딩과 Asyncio, 뭐가 더 나을까?

 

가장 큰 차이는 바로 멀티 스레드인가 단일 스레드인가의 차이이다. 멀티 스레딩은 여러개의 스레드를 사용하지만, Asyncio는 단일 스레드에서 구현된다. 이는 다르게 보면 여러 작업을 할 때 멀티 스레딩은 여러개의 스레드를 사용함에 따른 Context Switching(문맥 전환) 비용이 들지만, Asyncio는 그렇지 않다는 것이다. 또한 멀티 스레딩은 GIL로 인해 제약이 생기지만, Asyncio가 유리하다. 그렇다면 무조건 멀티 스레딩 보다 Asyncio가 더 좋은 것일까? 그렇지 않다. asyncio의 경우 코드가 복잡하고 Threading의 경우가 파이썬에서 더 고수준에서 간편하게 작업할 수 있다. 그래서 간단한 경우에는 스레딩을 사용하는 것이 생산성이 높을 수 있다. 여기에 더해 비동기/논블로킹이 주된 목적인 Asycnio이기에 만약 코드가 블로킹이 대부분이면 멀티스레딩이 더 편할 수 있다.

 

5. 아니면 멀티 스레드와 같이 쓰자

 

비동기 서버나 클라이언트 로직은 Asyncio 로 작성하면서, 내부에서 꼭 필요한 블로킹 코드나 무거운 CPU 작업은  loop.run_in_executor(ThreadPoolExecutor)를 통해 스레드 풀에 넘기는 것이다. 이렇게 하면 이벤트 루프는 I/O 코루틴을 계속 돌리고, 무거운 일은 별도 워커 스레드/프로세스가 처리하게 된다. 결국 무조건 한 쪽만 써야한다는 정답은 없는 것이다. 둘 다 쓰면서 적절하게 섞어 쓰는 것이 좋은 것 같다.

 

다음 글에서는 이 글에서 설명된 Coroutine(코루틴)과 Task, Future 및 그 외의 객체들에 대해 이야기해 보겠다.

728x90
반응형
  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유