Introduction

부트캠프에서 모델 추론 서버를 다루다 만난 질문에서 출발한 글이다. 왜 파이썬은 멀티스레드로 짜도 CPU 중심 작업에서 빨라지지 않을까? 그리고 FastAPI는 def로 선언한 함수와 async def로 선언한 함수를 왜 다르게 취급할까? 이 글은 동시성과 병렬성의 구분에서 출발해, 그 두 질문의 공통 원흉인 GIL의 정체와 존재 이유, 그리고 FastAPI의 실행 모델까지를 따라간다. 이 원리가 모델 추론 API에서 왜 결정적인지는 후속 글인 모델 추론 API와 비동기에서 다룬다.

컴퓨팅 자원과 비동기

컴퓨터는 한 번에 하나의 일밖에 못한다. 지금도 마찬가지다. 오늘날 CPU 안에는 여러 개의 코어/프로세서가 있는데, 정확히는 이 코어/프로세서가 한 번에 하나의 일만 할 수 있다. 그런데 CPU는 빠르고 I/O 장치는 느리다. I/O는 흔히 말하는 파일 입출력뿐만 아니라 네트워크를 통해 다른 서버와 데이터를 주고받거나 콘솔에 출력하는 것 등, 어떤 디바이스를 통해 입력과 출력이 이루어지는 모든 작업을 통칭하는 개념이다. 아무래도 I/O 장치는 CPU에 비해 느릴 수밖에 없고, 그래서 CPU가 기다려 줘야 한다. I/O 작업이 CPU가 다른 일을 못하도록 CPU를 붙잡아 두고 있는 것이다.

그림 1. 동기 방식과 비동기 방식의 비교.

비동기(asynchronous)는 이러한 맥락에서 등장한 개념으로, I/O 작업이 이루어지는 동안에는 CPU가 관여하지 않으니 그 시간에 다른 작업을 하자는 것이다. 동기(synchronous) 방식은 요청과 결과가 붙어 있어야 한다. 이전 요청의 응답이 도착하기 전까지는 다음 요청을 보낼 수 없고, 상대적으로 설계하기 쉽지만 컴퓨팅 자원을 비효율적으로 사용하게 된다. 반면 비동기 방식으로는 컴퓨팅 자원을 효율적으로 사용할 수 있지만, 대신 상대적으로 설계하기 어렵다.

동시성(Concurrency) vs 병렬성(Parallelism)

동시성

그림 2. 단일 코어 위에서 이루어지는 동시성.

그런데 이 개념이 등장했던 시대에는 지금에 비해 코어/프로세서가 굉장히 귀하고 비싼 자원이어서 CPU에 코어를 두 개 이상 넣기도 어려웠다. 하지만 그 시절의 개발자들은 동시성이라는 방법을 찾아냈다.

그림 3. 시간대가 겹치기만 하면 성립하는 동시성.

동시성은 작업의 시작과 종료에 대한 개념이다. 두 작업 A와 B에 대하여 A의 시작과 끝, 그리고 B의 시작과 끝이 겹치면 두 작업은 동시적이다. 중요한 것은 이 정의가 실제로 두 작업이 같은 순간에 실행되는 구간이 있는가와는 무관하다는 것이다. 그저 시간대가 겹치기만 해도 된다. 즉 위 그림처럼 A를 실행했다가 B를 실행했다가 C를 실행했다가 다시 A로 돌아와도, 시간대가 겹치기 때문에 세 작업 A, B, C는 동시적이다.

병렬성

또 다른 아이디어인 병렬성은 어떤 시점에서 두 작업이 동시에 이루어지고 있는 상황, 그러니까 실제로 두 가지 일을 같은 순간에 하고 있는 것이다.

그림 4. 여러 코어가 실제로 같은 순간에 일하는 병렬성.

웹 애플리케이션에서의 동시성과 병렬성

그렇다면 웹 애플리케이션에서 동시성과 병렬성이 이루어지는 시나리오를 한번 보자. 서버에 요청을 보내면 받기까지 시간이 걸린다. 그때 서버와 관계가 없는 다른 작업, 예를 들어 로딩 애니메이션을 불러올 수 있다. 그러다 서버에서 응답이 오면 페이지를 이동하면 되는 것이다.

파이썬의 비동기 프로그래밍: async / await의 작동 방식

파이썬에서 비동기 프로그래밍을 가능하게 해 주는 문법인 async / await가 어떻게 가능한지 알아보자. 그 핵심에는 멀티스레딩으로 파이썬 프로그램을 설계해도 I/O 중심 작업이 아닌 CPU 중심 작업에서는 별 효과가 없는 원흉이 숨겨져 있다.

구현체

컴퓨터는 0과 1밖에 모르기 때문에 소스 코드를 기계어로 번역하는 도구가 필요한데, 이것을 컴파일러라 부른다. 하지만 파이썬은 인터프리터 언어다. 파이썬은 코드를 한 줄씩 읽으며 이것을 중간 단계의 코드인 바이트코드(bytecode)로 변환한다. 이 바이트코드는 소스 코드와 파이썬 버전만 같다면 어디서나 동일하게 만들어진다. 하지만 실제로 프로그램을 실행하는 환경은 제각각인데, 이 문제를 해결해 주는 것이 바로 구현체다. 파이썬의 구현체는 파이썬 코드를 입력으로 받아 바이트코드로 변환한 다음, 이를 실행 환경에 맞게 해석하고 실행한다.

파이썬의 구현체는 여러 가지가 있지만, 그중 표준이며 가장 광범위하게 사용되는 구현체는 C 언어로 구현된 CPython이다. CPython은 메모리 관리를 단순화하고, 어떤 함수나 변수 혹은 객체에 여러 스레드가 동시에 접근해도 각 스레드에서 수행한 결과가 올바르게 나오도록 하기 위해, 오직 하나의 스레드만이 파이썬 바이트코드를 실행할 수 있도록 제한한다. 이게 무엇을 의미하는지 알기 위해서는 스레드와 프로세스의 관계를 알 필요가 있다.

스레드와 프로세스

프로세스는 메모리 위에서 실행 중인 프로그램을 뜻하며, 스레드는 어떤 프로세스의 실행 흐름 하나를 일컫는다. 두 개념은 아래 그림처럼 얽혀 있다.

그림 5. 프로세스와 스레드의 관계.

스레드는 프로세스 내부에 존재하며, 같은 프로세스에 존재하는 다른 스레드와 일부 데이터를 공유하지만, 스택 메모리와 같이 독자적인 메모리 공간을 가지기도 하며, 기본적으로 다른 스레드와 상관없이 주어진 작업을 수행한다. 멀티스레딩(multithreading) 프로그램이란 실행되었을 때 하나의 프로세스 안에 여러 개의 스레드가 실행되는 프로그램을 가리키는 말이다.

Global Interpreter Lock

이때 오직 하나의 스레드만이 파이썬 바이트코드를 실행할 수 있도록 제한하는 것이 바로 GIL(Global Interpreter Lock, 전역 인터프리터 락)이다. GIL은 파이썬 코드가 멀티스레드로 작성되어 있어도 한 번에 하나의 스레드만 실행하도록 제어하는 역할을 수행한다. 왜 그 좋다는 멀티스레딩을 막는 걸까?

GIL이 존재하는 이유

파이썬은 참조 카운팅(reference counting)이라는 메모리 관리 기법을 사용한다. 모든 객체에는 참조 개수(reference count)라는 것이 있어서, 그 객체가 몇 번 참조되는지가 계속 추적된다. 만약 이 숫자가 0이 된다면, 즉 그 객체를 아무도 참조하지 않는다면, 메모리에 남아 있을 이유가 없기 때문에 객체는 메모리에서 자동으로 사라진다.

import threading
counter = 0

def increment():
  global counter
  for _ in range(100000):
    counter += 1

t1 = threading.Thread(target=increment)
t2 = threading.Thread(target=increment)

t1.start()
t2.start()

t1.join()
t2.join()

print(counter)

예상대로라면 t1 스레드와 t2 스레드가 각각 100,000번씩 더해 결과가 200,000이 되어야 하지만, 실제로는 그렇지 않다. 여기서 문제가 되는 것은 두 스레드가 공유하는 counter라는 변수다. 두 스레드가 동시에 변수에 개입하면서 데이터의 무결성이 깨진 것이다(경쟁 조건). 그리고 이렇게 깨지는 데이터에는 객체의 참조 횟수도 포함된다. 어떤 보호 장치가 없다면 프로그램이 실행 중간에 멈추거나 메모리를 잘못 사용하는 문제가 생기게 된다.

그림 6. 보호 장치 없이 여러 스레드가 같은 데이터에 개입할 때 생기는 일.

GIL은 바로 이 문제를 해결하기 위해 등장한 보호 장치다. 한 번에 하나의 스레드만 파이썬 바이트코드를 실행하도록 강제함으로써 이 문제를 해결한 대신, 멀티스레드 프로그램의 퍼포먼스를 희생한 것이다. 스레드가 파이썬 바이트코드를 실행하기 위해서는 GIL을 요청한 다음 획득해야 한다.

그림 7. 여러 스레드가 번갈아 가며 GIL을 주고받는 과정.

스레드 A가 GIL을 요청해서 획득하면 파이썬 바이트코드를 실행할 권한을 얻게 된다. 그렇게 스레드 A는 한 번에 하나의 스레드에게만 주어지는 영광을 혼자 누리다가, 일정 실행 구간이 지나거나 I/O 연산이 발생하면 그 권한을 내려놓아야 한다. 그렇게 GIL은 스레드 A의 손에서 떠나 다른 스레드의 손으로 넘어간다. 이 과정이 반복되면서 여러 스레드가 번갈아 가며 GIL을 획득해 자신의 파이썬 바이트코드를 실행하지만, 그 어떤 시점에서도 두 개 이상의 스레드가 GIL을 공유하는 일은 없다.

GIL이 멀티스레드 프로그래밍에 끼치는 영향력은 단순하지 않다. I/O 중심의 작업에서는 대부분의 시간을 I/O 결과가 나오기를 기다리는 경우가 많다. 이때 GIL이 임시로 풀리면서 다른 스레드가 파이썬 바이트코드를 실행할 수 있게 된다. 덕분에 스레드를 사용했을 때 유의미한 성능 향상을 이끌어낼 수 있다. 하지만 CPU 중심의 작업에서는 GIL이 풀리지 않는다. 그래서 스레드를 사용해도 한 번에 하나의 스레드만 실행되기 때문에, 성능이 엇비슷하거나 오히려 나빠지는 결과로 이어진다.

꼼수: 멀티프로세싱

물론 이 문제를 우회하는 방법이 없을 리가 없다. 여러 개의 스레드가 아니라 여러 개의 프로세스를 만드는 멀티프로세싱(multiprocessing) 기법이다.

from multiprocessing import Process
def compute():
  total = 0
  for _ in range(10**7):
    total += 1

p1 = Process(target=compute)
p2 = Process(target=compute)

p1.start()
p2.start()

p1.join()
p2.join()

스레드와 달리 프로세스는 자신만의 메모리 공간이 있기 때문에 GIL을 각자 하나씩 가지게 되고, 따라서 진정한 병렬 프로그래밍을 실현할 수 있게 된다. 물론 이 방법에도 문제가 있다. 메모리를 훨씬 더 많이 잡아먹는다는 것이다.

FastAPI: def vs async def

이제 FastAPI에서 동기성과 비동기성이 어떻게 처리되는지 이야기할 수 있다. FastAPI는 ASGI(Asynchronous Server Gateway Interface, 비동기 서버 게이트웨이 인터페이스)를 기반으로 구축되었는데, 일반적으로 ASGI 서버는 비동기 작업을 예약하고 실행하는 이벤트 루프(event loop) 덕분에 비동기 작업을 지원한다 (브라우저의 JavaScript가 쓰는 것과 같은 원리의 실행 모델이다 — 브라우저의 내부 구조 참고). 이를 구현한 것이 바로 Uvicorn이며, 이 Uvicorn을 사용하기 편하게 도와주는 프레임워크가 바로 FastAPI다.

이벤트 루프는 단일 스레드 내에서 동작하며 등록된 작업들을 순차적으로 실행하지만, 만약 작업이 I/O를 대기하게 되면 제어권을 다시 가져온다. 이를 통해 이벤트 루프는 블로킹(blocking) 없이 대기 중인 다른 작업을 처리할 수 있다. 이를 이해하기 위해서는 FastAPI에서 동기 함수와 비동기 함수가 실행되는 방식을 이해할 필요가 있다.

def (동기 함수)

FastAPI에서는 개발자가 엔드포인트를 정의하는 방식에 따라 요청을 처리하는 방식이 크게 달라진다. 일반적으로 def만 사용하여 정의된 함수는 동기 함수로 간주된다. 동기 함수를 실행하다 이벤트 루프가 차단되는 것을 방지하기 위해, FastAPI는 이러한 함수들을 별도의 스레드 풀(thread pool)에서 실행한다.

Thread Pool

스레드는 작업 흐름이다. 프로세스에 속한 각 스레드는 프로세스의 컨텍스트를 공유하지만, 프로세스가 할당받은 CPU 자원을 나눠 가져 각자 자신의 작업을 수행한다.

그림 8. 스레드 풀의 구조.

그런데 만약 스레드를 만들 일이 많다면 어떨까? 스레드를 만들고 버리고 만들고 버리는 일을 반복하는 것은 시스템 호출을 많이 요구하기 때문에 속도가 저하될 수 있다. 그래서 몇 개의 스레드를 미리 만들어 놓고 필요할 때 몇 개씩 가져와서 쓰자는 아이디어가 바로 스레드 풀이다.

Blocking I/O vs Non-Blocking I/O

이제 블로킹을 설명할 차례다. I/O에는 크게 두 가지 종류가 존재한다.

  • Blocking I/O는 I/O 작업이 진행되는 동안 자신의 작업을 중단하고 I/O가 끝날 때까지 대기하는 방식이다. 실행의 제어권을 I/O 함수가 가져간다는 것이며, CPU가 I/O 함수의 결과를 받아야 다음 단계로 움직일 수 있기 때문에 그때까지 애플리케이션이 멈춘다.
  • Non-Blocking I/O는 I/O 함수의 결과가 즉시 리턴되고, I/O가 끝날 때까지 대기하지 않고 다른 일을 수행하는 방식이다. 실행의 제어권은 여전히 애플리케이션에 있으며, 따라서 애플리케이션이 계속 동작한다. 필요하다면 폴링으로 I/O가 완료되었는지 계속 물어볼 수 있다.

Blocking/Non-Blocking은 동기성/비동기성과는 다른 개념이다. Blocking/Non-Blocking I/O는 I/O 호출이 발생했을 때 결과가 나올 때까지 기다리느냐 아니냐의 차이이며, 동기성과 비동기성은 요청과 응답이 일련의 순서를 따르느냐 아니냐의 차이다.

동기적 프로그래밍에서는 모든 요청과 응답이 일련의 순서를 따른다. 그래서 I/O 함수를 호출하면 호출한 쪽이 I/O 함수가 종료된 후 그 결과를 직접 처리하며, I/O가 완료되었는지 계속 커널에 물어봐야 한다. 콜백 함수(callback function, 함수를 호출할 때 다른 함수를 인자로 넘겨 그 함수에서 실행하게 하는 것)를 넘기지 않는 방식인 것이다. 반면 비동기적 프로그래밍에서는 요청을 보냈을 때 응답 상태와 상관없이 다음 동작을 수행하기 때문에 작업의 순서가 보장되지 않는다. 호출된 I/O 함수가 종료되면 콜백 함수를 통해 결과가 처리되기 때문에 호출한 쪽은 작업 완료 여부를 신경 쓰지 않고, I/O가 완료되면 커널이 유저 프로세스에게 알려 준다.

async def (비동기 함수)

FastAPI에서 비동기 함수는 이벤트 루프에서 직접 실행한다. 동기 함수와는 어떤 차이가 있는지 직접 코드를 보며 비교해 보자.

from fastapi import APIRouter

router = APIRouter()

@router.get("/items")
async def get_items():
    return {"message": "아이템 목록입니다."}

get_items 함수는 GET /items 요청을 처리하는 경로 작업 함수다. 이 함수가 async def로 선언되어 있기 때문에 FastAPI는 이 함수를 직접 실행한다. 함수 내부에는 블로킹 코드가 없어야 하고, 있다면 await를 통해 반드시 비동기적으로 처리되어야 한다.

이 함수가 def로 선언되어 있다면 어떨까? 그렇다면 이 함수는 동기적으로 동작한다고 간주되며, 서버가 응답 불능 상태에 빠질 위험이 있기 때문에 FastAPI는 이러한 함수들을 별도의 스레드 풀에서 실행한다. 그래서 이런 함수들에 대한 요청이 들어오면 일단 스레드 풀로 던진 다음, 스레드 풀에서 결과가 오면 응답을 보내게 된다.

직접 비교하기

그렇다면 도대체 동기와 블로킹에는 어떤 차이가 있는 걸까? 동기/비동기와 블로킹/논블로킹의 조합으로 가능한 4가지 입출력 방식을 직접 비교해 보자.

  1. sync + blocking: I/O가 실행되는 동안 애플리케이션이 다른 일을 하지 못하고 읽기/쓰기만 하다가, I/O가 끝나고 나서야 작업을 이어서 처리한다. file.read()가 실행되면 읽기만 하고 다른 건 못하다가, 읽기가 끝나면 file.write()를 실행하고 이때도 쓰기만 하고 다른 건 못한다. 멀티스레드를 쓰더라도 시스템 호출마다 스레드를 생성하고 블록될 동안 I/O 함수의 응답만 기다리기 때문에, 자원을 효율적으로 활용하지 못한다.

    def blocking_sync():
        """
        Blocking + Sync: 각 작업이 완료될 때까지 기다리고, 순차적으로 실행
        - 가장 직관적이지만 느림
        """
        print("\n[1] Blocking I/O + Synchronous")
        print("-" * 50)
        start = time.time()
    
        results = []
        for i in range(3):
            result = simulate_io_task(i, 1)  # 각 작업이 완료될 때까지 block
            results.append(result)
    
        elapsed = time.time() - start
        print(f"총 소요 시간: {elapsed:.2f}초")
        print(f"결과: {results}\n")
        return results
  2. async + blocking: I/O 호출이 발생하면 콜백을 같이 넘겨서 I/O 작업이 종료되었을 때 애플리케이션의 해당 콜백 함수가 호출되는 방식이지만, 시스템 호출에 대한 커널의 응답이 블록되기 때문에 실질적으로는 I/O 작업이 끝날 때까지 애플리케이션이 블록된다.

    def blocking_async_threading():
        """
        Blocking + Async: 각 작업은 blocking이지만, 여러 스레드에서 동시 실행
        - 스레드가 I/O를 기다리는 동안 다른 스레드가 실행됨
        """
        print("\n[2] Blocking I/O + Asynchronous (Threading)")
        print("-" * 50)
        start = time.time()
    
        results = []
        threads = []
    
        def worker(task_id):
            result = simulate_io_task(task_id, 1)
            results.append(result)
    
        # 3개의 스레드를 동시에 시작
        for i in range(3):
            thread = threading.Thread(target=worker, args=(i,))
            thread.start()
            threads.append(thread)
    
        # 모든 스레드가 완료될 때까지 대기
        for thread in threads:
            thread.join()
    
        elapsed = time.time() - start
        print(f"총 소요 시간: {elapsed:.2f}초")
        print(f"결과: {results}\n")
        return results
  3. sync + non-blocking: I/O 호출이 발생하면 실제로 I/O 작업이 완료되었는지와 관계없이 즉시 함수가 어떤 결과값을 반환하기 때문에, 애플리케이션은 I/O가 완료되기 전에 다른 작업을 할 수는 있다. 하지만 sync이기 때문에 중간중간 I/O 작업이 완료되었는지 지속적으로 시스템에 물어봐야 하고, I/O 작업의 결과는 호출한 함수에서 처리한다.

    def nonblocking_sync_file():
        """
        Non-blocking + Sync: I/O는 non-blocking이지만 순차적으로 체크
        - 파일 서술자를 non-blocking 모드로 설정
        - select로 준비된 파일만 처리
        """
        print("\n[3] Non-blocking I/O + Synchronous (File I/O + Select)")
        print("-" * 50)
        start = time.time()
    
        import os
        import select
        import tempfile
    
        # 임시 파일 생성
        files = []
        for i in range(3):
            fd = os.open(tempfile.mktemp(), os.O_CREAT | os.O_RDWR | os.O_NONBLOCK)
            files.append(fd)
    
        # 각 파일에 순차적으로 쓰기 시도 (non-blocking)
        for i, fd in enumerate(files):
            print(f"  파일 {i} 쓰기 시도... (non-blocking)")
            os.write(fd, f"Data {i}\n".encode())
            time.sleep(0.3)  # 약간의 지연
    
        # select로 읽기 가능한 파일 체크
        print("  select로 읽기 가능한 파일 체크...")
        readable, _, _ = select.select(files, [], [], 0)
        print(f"  읽기 가능한 파일: {len(readable)}개")
    
        # 정리
        for fd in files:
            os.close(fd)
    
        elapsed = time.time() - start
        print(f"총 소요 시간: {elapsed:.2f}\n")
  4. async + non-blocking: 애플리케이션은 I/O 작업을 위한 시스템 호출 이후 I/O 처리에 신경 쓰지 않고 있다가, 작업이 완료되면 커널로부터 결과를 마치 이벤트처럼 전달받는다. 그래서 응답이 오기 전까지 I/O와 독립적인 다른 작업을 수행할 수 있다.

    async def async_io_task(task_id, duration=1):
        """비동기 I/O 작업"""
        print(f"  Async Task {task_id} 시작...")
        await asyncio.sleep(duration)  # Non-blocking sleep
        print(f"  Async Task {task_id} 완료!")
        return f"Result {task_id}"
    
    async def nonblocking_async():
        """
        Non-blocking + Async: 가장 효율적인 방식
        - asyncio를 사용한 협력적 멀티태스킹
        - 단일 스레드에서 여러 작업을 동시에 처리
        - I/O 대기 중 다른 작업으로 전환
        """
        print("\n[4] Non-blocking I/O + Asynchronous (asyncio)")
        print("-" * 50)
        start = time.time()
    
        # 3개의 작업을 동시에 시작
        tasks = [async_io_task(i, 1) for i in range(3)]
        results = await asyncio.gather(*tasks)
    
        elapsed = time.time() - start
        print(f"총 소요 시간: {elapsed:.2f}초")
        print(f"결과: {results}\n")
        return results

이를 통해 제일 효율적인 입출력 방식이 바로 블로킹이 없는 동시에 비동기적인 방식이라는 것을 확인할 수 있다.

결론

파이썬의 비동기를 이해하는 열쇠는 결국 GIL이다. CPython은 참조 카운팅의 무결성을 지키기 위해 한 번에 하나의 스레드만 바이트코드를 실행하게 했고, 그 대가로 멀티스레딩은 CPU 중심 작업에서 무력해졌다. I/O 중심 작업에서는 GIL이 대기 중에 풀리므로 스레드가 여전히 유효하고, 진짜 병렬이 필요하면 멀티프로세싱으로 우회하며, 단일 스레드 안에서 I/O 대기를 겹쳐 쓰는 세련된 해법이 이벤트 루프 위의 async/await다. FastAPI가 def를 스레드 풀로 보내고 async def를 이벤트 루프에서 직접 실행하는 것은 바로 이 지형도 위에 세워진 설계다. 그리고 이 지형도가 GPU가 끼어드는 순간 어떻게 달라지는지 — 그것이 모델 추론 API와 비동기의 주제다.


출처

*[GIL]: Global Interpreter Lock *[ASGI]: Asynchronous Server Gateway Interface