Search

[JVM & Python] DRF와 FastAPI의 실행과 메모리

카테고리
StudyForCareer
태그
Python
게시일
2026/10/27
수정일
2026/10/27 08:56
시리즈
JVM & Python
1 more property
link iconSpring Boot의 시작과 요청별 메모리Spring Boot의 시작과 요청별 메모리에서 Spring Boot 애플리케이션을 따라가 봤다면 작업 스레드, 호출 프레임, 그리고 힙에 상주하는 싱글톤 서비스 객체의 관계가 머릿속에 그려졌을 것이다. 그런데 Python 백엔드(Django REST framework, FastAPI 등)로 넘어오면 여기에 인터프리터(Interpreter), 워커 프로세스(Worker process), 이벤트 루프(Event loop), 코루틴 태스크(Task) 같은 개념들이 한꺼번에 등장한다. 그러다 보니 “Python은 인터프리터 언어라 원래 싱글 스레드로만 돈다”거나 “FastAPI는 async를 쓰니까 요청마다 스레드가 새로 생긴다”처럼 서로 다른 층위의 개념이 뒤섞인 오해를 접하기 쉽다.
복잡해 보이는 Python 서버의 실행 구조도 각 용어가 맡고 있는 역할부터 분리해 놓으면 훨씬 명쾌해진다. 프로세스는 운영체제로부터 할당받은 독립된 실행 환경이자 메모리 주소 공간의 단위이고, 스레드는 그 프로세스 안에서 코드를 실제로 밀고 나가는 실행 흐름이다. 인터프리터는 Python 코드를 해석하고 실행하는 런타임 상태를 뜻하며, 태스크는 비동기 이벤트 루프가 한 스레드 위에서 번갈아 진행시키는 작업 단위다. 이 네 가지는 서로를 대체하는 동의어가 아니라 각기 다른 층위에서 맞물려 돌아가는 개념이다.
이 글에서는 표준 CPython 런타임을 기준으로 Django + DRF의 동기식 WSGI 구성과 FastAPI의 비동기식 ASGI 구성을 나란히 비교하며, 서버가 켜질 때 만들어지는 자원과 요청마다 생겼다 사라지는 메모리를 추적해 본다. 아울러 최근 큰 변화를 겪고 있는 Python 3.12부터 3.14·3.15까지의 실행 모델 변화(서브인터프리터, Free-threaded 빌드 등)가 웹 서버의 메모리·동시성에 어떤 의미를 갖는지도 함께 짚어본다. (Django 흐름은 5.2 문서의 동기 APIView를 기준으로 설명하며, 모든 서드파티 프레임워크와 C 확장 패키지가 모든 Python 최신 버전 및 Free-threaded 빌드에서 완벽히 검증되었다는 뜻은 아니다. 공식 문서 확인 기준일은 2026-10-27이다.)
코드와 도표는 실행 모델을 설명하기 위한 교육용 예시다. 실제 프레임워크를 통합 구동해 프레임 깊이·참조 카운트·성능을 측정한 실험 결과는 아니다.

1. 개요

인터프리터와 코드 객체

터미널에서 python -m uvicorn app:app 명령을 실행하면 무슨 일이 일어날까? 먼저 운영체제 위에 프로세스가 하나 생기고, 그 안에서 CPython 런타임이 기동되어 Uvicorn 서버 코드를 실행한다. 이어서 우리가 작성한 앱 모듈(app 모듈 파일)을 import하면, 파일 안의 최상단 Python 코드가 실행되면서 함수와 클래스 정의, 라우트 설정, 공유 객체들이 메모리에 준비된다.
여기서 흔히 하는 오해가 “Python은 인터프리터 언어니까 HTTP 요청이 들어올 때마다 소스 코드를 처음부터 한 줄씩 다시 읽어서 번역하겠지?”라고 생각하는 것이다. 실제로는 그렇지 않다. 모듈과 함수 코드는 임포트 시점에 이미 바이트코드와 실행 가능한 코드 객체(Code object)로 컴파일되어 메모리에 올라가 있다. 요청이 들어와 함수를 호출하는 일은 소스 파일을 다시 파싱하는 과정이 아니라, 이미 준비된 코드 객체를 바탕으로 이번 호출만을 위한 새로운 실행 상태(프레임)를 만드는 과정이다. Python 실행 모델

실행 단위 구분

일반적인 Python 웹 서버의 워커 프로세스는 내부에 주 인터프리터(Main interpreter) 하나를 띄워 두고 사용한다. 이 인터프리터가 임포트된 모듈 목록(sys.modules)과 전역 네임스페이스 같은 런타임 상태를 관리한다. 만약 한 워커 프로세스 안에서 여러 스레드를 사용한다면, 그 스레드들은 모두 같은 인터프리터에 속해 있으므로 메모리에 올라간 동일한 모듈과 전역 서비스 객체를 함께 바라보게 된다. 스레드를 하나 늘린다고 해서 Python 프로그램 전체가 메모리에 새로 복제되는 것은 아니다.
본격적인 흐름을 따라가기 전에, 자주 헷갈리는 여섯 가지 실행 단위를 표로 명확히 구분해 두자.
실행 단위
담당 역할
흔한 오해
프로세스 (Process)
독립된 가상 메모리 주소 공간과 OS 자원의 격리 경계
개별 HTTP 요청 하나와 1:1로 대응하는 단위가 아니다.
인터프리터 (Interpreter)
Python 코드 실행에 필요한 모듈·전역 상태·런타임 문맥
OS 프로세스나 스레드와 무조건 1:1 동의어가 아니다(한 프로세스에 여러 인터프리터 가능).
스레드 (Thread)
한 프로세스 안에서 CPU 스케줄링을 받는 OS 실행 흐름
스레드가 나뉘어도 같은 인터프리터의 전역 객체가 알아서 복제·격리되지는 않는다.
이벤트 루프 (Event loop)
대기 중인 비동기 작업과 I/O 완료 이벤트를 조율하는 스케줄러
여러 CPU 코어에 Python 연산을 자동으로 쪼개어 병렬 실행해 주는 마법 장치가 아니다.
태스크·코루틴 (Task / Coroutine)
실행을 잠시 멈췄다(await) 이어갈 수 있는 비동기 작업과 그 상태
태스크를 하나 만든다고 해서 새로운 OS 스레드가 생성되는 것은 아니다.
워커 (Worker)
서버가 부하를 처리하기 위해 띄워 둔 작업 단위
Gunicorn/Uvicorn에서는 대개 '워커 프로세스'를 뜻하지만, 스레드 풀 문맥에서는 '작업 스레드'를 가리키기도 한다.
비유하자면 프로세스는 독립된 벽으로 둘러싸인 '작업실'이고, 인터프리터는 그 작업실 안에 설치된 'Python 전용 작업 설비'이며, 스레드는 그 설비를 움직여 일을 처리하는 '작업자(실행 흐름)'다. 단, 설비 자체가 운영체제 차원의 독립된 메모리 공간을 따로 갖는 것은 아니며, 필요하다면 하나의 작업실(프로세스) 안에 여러 대의 독립된 설비(서브인터프리터)를 들일 수도 있다. 이 차이는 뒤이어 살펴볼 Python 3.12~3.15 버전 비교에서 아주 중요한 열쇠가 된다.

2. 워커 프로세스와 전역 객체

모듈 캐시와 서비스 객체

Python 백엔드에서도 서비스 객체를 하나만 만들어 두고 재사용하는 패턴을 자주 쓴다. 다음 예시 코드를 보자.
# services.py — 모듈 단위 객체 수명을 설명하기 위한 예시 class CouponService: def read(self, coupon_id, user_id): return {"coupon_id": coupon_id, "owner_id": user_id} service = CouponService()
Python
복사
Python에서 어떤 모듈을 처음 import하면, 인터프리터는 그 모듈 파일을 실행해 모듈 객체를 만든 뒤 sys.modules라는 내부 캐시 사전에 저장해 둔다. 이후 다른 파일에서 똑같이 import services를 호출하면 파일을 다시 실행하지 않고 sys.modules에 보관된 바로 그 모듈 객체를 꺼내 준다. 따라서 하나의 인터프리터 안에서 services.service를 가져다 쓰는 모든 코드는 자연스럽게 동일한 CouponService 인스턴스를 공유하게 된다. (물론 누군가 코드에서 CouponService() 생성자를 직접 다시 호출해 새 인스턴스를 만드는 것까지 언어 차원에서 막는 엄격한 싱글톤 강제 문법은 아니다. Python 모듈 캐시)

다중 워커와 메모리 격리

하지만 운영 환경에서 멀티코어 CPU를 활용하기 위해 워커 프로세스를 4개 띄우면 이야기가 완전히 달라진다. 프로세스는 메모리 공간이 분리되어 있으므로, 4개의 워커 프로세스마다 각자의 인터프리터와 각자의 services.service 객체가 따로 존재한다. 즉, 워커 1번의 메모리에서 파이썬 딕셔너리나 리스트에 값을 추가해도 워커 2, 3, 4번에는 전혀 반영되지 않는다. 마스터 프로세스가 앱을 미리 로드한 뒤 자식 워커를 fork하는(Pre-fork) 구성을 쓰더라도, 초기의 읽기 전용 메모리 페이지를 운영체제의 Copy-on-Write(COW)로 잠시 공유할 뿐, 일반적인 파이썬 가변 객체가 프로세스 사이의 실시간 공용 저장소로 동작하는 것은 아니다(프로세스 간 상태 공유가 필요하다면 Redis 같은 외부 저장소나 별도의 IPC 설계를 써야 한다). FastAPI의 프로세스와 메모리
따라서 실무에서 서버 워커 수를 늘릴 때는 단순히 “동시 처리량이 몇 배 늘어나겠구나”만 계산해서는 안 된다. 각 워커가 메모리에 들고 있는 앱 객체, 로컬 인메모리 캐시, 그리고 데이터베이스 커넥션 풀(Connection pool)까지 워커 수에 비례해 늘어난다는 점을 함께 따져야 한다. 예를 들어 커넥션 풀 크기를 20으로 잡고 워커 프로세스를 4개 띄우면, 그 서버 한 대가 DB에 맺을 수 있는 최대 연결 수는 20개가 아니라 80개가 된다. “전역 객체는 서버 전체에 하나뿐이다”라고 착각할 때 가장 흔히 겪는 운영 함정이다.

3. Django와 DRF 실행 흐름

WSGI 서버와 앱 초기화

먼저 동기식 웹 표준 인터페이스인 WSGI(Web Server Gateway Interface) 기반의 Django + DRF 구성을 따라가 보자. 여기서 기억할 점은 Django 프레임워크 자체가 혼자서 프로세스나 스레드 개수를 결정하지 않는다는 사실이다. 앞단에서 애플리케이션을 구동하는 Gunicorn이나 uWSGI 같은 WSGI 서버가 워커 프로세스를 몇 개 띄울지, 그리고 각 워커 안에서 요청을 단일 스레드로 처리할지(sync 워커) 아니면 여러 스레드로 처리할지(gthread 워커)를 결정한다. 로컬에서 테스트할 때 쓰는 manage.py runserver 개발 서버의 동작을 실제 운영 환경의 실행 모델과 동일시해서는 안 된다.
WSGI 서버 워커가 기동되면서 Django 애플리케이션을 로드하면, settings 모듈의 설정을 읽고 설치된 앱 레지스트리(apps)를 초기화하며 요청이 통과할 미들웨어 체인(Middleware chain)을 조립한다.
(아래 시퀀스 다이어그램의 Module creates service는 이번 예시에서 서비스 모듈을 초기화 과정에 명시적으로 임포트해 전역 객체를 준비한 구성을 뜻한다. Django가 스프링처럼 @Service 클래스를 찾아 자동으로 싱글톤 빈을 만들어 준다는 뜻도 아니고, AppConfig.ready() 메서드 안에서 무거운 DB 쿼리를 실행하라는 권장도 아니다.)

시작과 요청 시퀀스

그렇다면 워커가 기동될 때 한 번 준비해 두고 계속 재사용하는 자원과, 매 요청이 들어올 때마다 새로 만들었다가 버리는 View 인스턴스는 어느 지점에서 갈라질까?
단계별 설명을 포함한 Django WSGI 시작과 DRF 요청별 View·응답 처리
앱 초기화와 공유 객체 준비
1~4단계에서는 WSGI 워커가 앱을 로드하면서 Django 설정·미들웨어를 준비하고, 임포트된 모듈에 정의된 공유 서비스 객체를 메모리에 유지한다(구성에 따라 일부 모듈이나 URLconf는 첫 요청 시점에 지연 로드될 수도 있으며, 그림은 이해를 돕기 위한 논리적 준비 순서를 보여준다).
요청별 View 인스턴스와 전처리 정책
5~9단계에서는 워커가 HTTP 요청을 받아 미들웨어와 URL 라우팅을 통과시키면, 클래스 기반 뷰(CBV)의 진입점인 as_view() 내부 콜백이 실행되면서 이번 요청만을 위한 새로운 View 인스턴스를 생성하고 dispatch()를 호출한다. 이어서 DRF의 APIView는 기본 HttpRequest를 확장된 DRF Request로 감싸고, 콘텐츠 협상(Content negotiation)과 인증(Authentication)·권한(Permission)·스로틀링(Throttling) 검사를 차례로 수행한다. 여기서 핵심은 요청마다 View 인스턴스는 새로 만들어지지만, 모듈 전역에 떠 있는 서비스 객체나 View 클래스 자체에 붙어 있는 클래스 속성(Class attribute)까지 새로 복제되지는 않는다는 점이다.
직렬화와 바이트 렌더링
10~17단계에서는 검사를 통과한 핸들러(get 메서드 등)가 공유 서비스와 ORM을 호출해 데이터를 가져오면, Serializer가 모델 인스턴스를 파이썬 기본 자료형(딕셔너리·리스트 등)으로 변환한다. 이후 핸들러가 DRF Response 객체를 반환하면 finalize_response()에서 렌더러 문맥을 잡고, 최종 응답 단계에서 Renderer(예: JSONRenderer)가 파이썬 딕셔너리를 실제 전송할 JSON 바이트열(bytes)로 렌더링한 뒤 응답 미들웨어를 거쳐 클라이언트로 내보낸다.
즉, Serializer가 만드는 파이썬 딕셔너리(serializer.data)와 네트워크로 나가는 최종 JSON 바이트는 생성 시점과 담당 주체가 서로 다르다. 동기식 호출이므로 DB 응답을 기다리는 동안 현재 실행 스레드는 블로킹되지만, 다른 워커 프로세스나 gthread 풀의 다른 스레드는 각자의 요청을 계속 처리할 수 있다.
(위 도표는 정상적인 GET 조회 경로를 기준으로 그린 것이며, 중간에 예외나 권한 거절, 입력 검증 실패가 발생하면 핸들러를 건너뛰고 예외 응답 경로로 빠진다. 또한 Django를 ASGI 서버 위에 올려 실행하더라도, 기존에 동기 함수(def)로 작성한 DRF APIView나 동기 미들웨어가 저절로 논블로킹 비동기 코드로 변신하는 것은 아니다. Django는 동기와 비동기 경계 사이에서 스레드를 전환하는 어댑터를 두어 안전하게 동작시키므로, 비동기의 이점을 제대로 얻으려면 미들웨어부터 뷰, ORM 호출까지 실제 실행 경로가 어떻게 연결되는지 확인해야 한다.)

4. Python 프레임과 객체 참조

이름 바인딩과 런타임 힙

서비스의 read(123, 77) 함수를 호출하면, 이번 호출에 넘어온 인자(coupon_id, user_id)와 지역 변수, 그리고 현재 어느 줄을 실행 중인지 기록할 공간이 필요하다. Python 역시 이를 실행 프레임(Frame)이라는 개념으로 다룬다. 하지만 앞서 본 JVM의 메모리 모델을 떠올리며 “기본형 숫자인 123은 OS 스레드 스택에 값으로 바로 박히고, 무거운 객체만 힙에 생기겠지”라고 생각하면 CPython의 실제 구조와 어긋나게 된다.
Python에서는 정수(123)를 포함한 모든 값이 객체이며, 변수는 그 객체를 가리키는 '이름(Name binding)'이다. coupon_id = 123이라고 쓰면 스택에 8바이트 정수 값이 직접 저장되는 것이 아니라, 런타임 힙에 존재하는 int 객체 123을 가리키는 참조가 현재 프레임의 지역 이름(coupon_id)과 연결(Binding)된다. 마찬가지로 result라는 지역 변수가 결과 딕셔너리를 가리킨다면, 함수 안에서만 유효한 '지역 이름 result의 수명'과 실제 힙에 놓인 '딕셔너리 객체의 수명'을 분리해서 봐야 한다(a = b 같은 대입문 역시 객체를 복사하는 것이 아니라 같은 객체에 이름표를 하나 더 붙이는 일이다). CPython에서 모든 파이썬 객체의 메모리는 런타임 전용 메모리 할당자가 관리하는 힙 영역에 놓인다. Python 데이터 모델, CPython 메모리 관리

내부 프레임 배치와 클래스 속성

그렇다면 함수 호출 프레임 자체는 메모리의 어디에 생길까? CPython 3.14 내부 구현 문서를 보면, 일반적인 동기 함수 호출의 프레임 데이터는 성능을 위해 스레드별 내부 프레임 스택 영역에 연속적으로 빠르게 배치되고, 반대로 멈췄다 재개해야 하는 코루틴(async def)이나 제너레이터의 프레임은 해당 코루틴·제너레이터 객체 내부에 내장(Embedded)된다. 이는 운영체제 수준에서 C 함수가 호출될 때 쓰는 네이티브 C 스택과는 별개의 구조다. 또한 디버거나 sys._getframe()처럼 외부에서 프레임 객체를 직접 들여다봐야 할 때만 온전한 PyFrameObject가 힙에 구체화되므로, “파이썬 함수를 한 번 호출할 때마다 무조건 무거운 프레임 객체가 힙에 새로 하나씩 할당된다”고 단정해서도 안 된다.
이를 DRF의 동기 요청 처리에 대입해 보자. 멀티스레드 워커(gthread)에서 요청 A와 요청 B가 동시에 들어오면:
•
as_view()가 요청마다 새 인스턴스를 만드므로, 뷰 메서드의 self는 요청 A와 B에서 서로 다른 View 객체를 가리킨다.
•
반면 두 뷰가 모듈 전역에 있는 동일한 service 객체를 호출한다면, 서비스 메서드의 self는 같은 CouponService 객체를 가리킨다.
•
설령 서비스의 self가 같더라도, 각 함수 호출 프레임의 지역 이름(coupon_id, user_id)은 스레드·호출마다 독립되어 있으므로 서로 섞이지 않는다.
•
단, 매번 새로 만들어지는 View라 할지라도 클래스 속성(예: class CouponView(APIView): cache = [])에 가변 리스트를 선언해 두고 요청마다 요소를 추가한다면, 모든 View 인스턴스가 동일한 클래스 속성 리스트를 공유하므로 요청별 데이터가 섞일 수 있다.
그렇다면 Django + DRF 워커와 FastAPI 워커에서 각각 요청마다 새로 생성되어 격리되는 인스턴스와 워커 프로세스 안에서 여러 요청이 함께 바라보는 공유 객체의 경계는 어디서 갈릴까?
Django DRF의 as_view 요청별 인스턴스 및 공유 클래스 속성과 FastAPI Depends 공급자의 객체 수명 경계 비교
도표 좌측(DJANGO + DRF WORKER PROCESS)에서 보듯, as_view()가 요청마다 새로 만드는 View A와 View B는 각자의 인스턴스 속성(id = 123, 456)을 안전하게 분리해 가진다. 하지만 두 뷰 인스턴스가 참조하는 모듈 전역 서비스(CouponService S), 시작 시점에 한 번 생성된 미들웨어 인스턴스, 그리고 클래스 자체에 매달린 가변 속성(View.cache = [], 빨간색 CLASS ATTR)은 같은 워커 안의 모든 요청이 함께 공유한다.
우측(FASTAPI WORKER PROCESS)의 Depends 역시 공급자 함수를 어떻게 작성하느냐에 따라 수명이 결정된다. 호출할 때마다 return RequestCtx()처럼 새 인스턴스를 만들어 반환하는 공급자는 요청 태스크마다 독립된 객체(Ctx A, Ctx B)를 제공하지만, return app.state.svc처럼 이미 생성된 객체를 반환하는 공급자는 모든 요청 태스크에 동일한 Service S 참조를 건넨다. (Depends의 기본 캐시는 한 요청 안에서 같은 의존성이 여러 번 호출될 때 결과를 재사용할 뿐, 공유 서비스 객체를 요청마다 깊은 복사해 주지는 않는다.)

5. FastAPI 실행 흐름과 태스크

ASGI 이벤트 루프와 Lifespan

이번에는 비동기 웹 표준인 ASGI(Asynchronous Server Gateway Interface) 위에서 동작하는 FastAPI와 Uvicorn 구성을 살펴보자. 일반적인 단일 워커 구성을 기준으로 하면, 하나의 OS 스레드 위에서 돌아가는 이벤트 루프(Event loop)가 수십·수백 개의 요청 태스크(Task)를 번갈아 가며 진행시킨다(워커 프로세스를 여러 개 띄우면 이러한 '이벤트 루프 스레드 + 태스크들' 세트가 프로세스마다 하나씩 생긴다). 즉, 동시에 처리 중인 HTTP 요청 태스크의 개수와 OS 스레드의 개수는 전혀 같지 않다.
서버가 켜질 때 앱 모듈을 임포트하면 FastAPI() 앱 인스턴스와 라우팅 테이블이 먼저 만들어진다. 이어서 ASGI 서버가 Lifespan(수명 주기) 시작 이벤트를 발생시키면 개발자가 정의한 초기화 코드가 실행되는데, 보통 이 구간에서 DB 커넥션 풀이나 외부 API 클라이언트, 공유 서비스 객체를 생성해 app.state 등에 달아 둔다.
(이번 예시는 그렇게 Lifespan에서 만든 서비스를 여러 요청이 공유하도록 작성한 경우다. FastAPI 프레임워크 자체가 모든 의존성(Depends)을 스프링처럼 자동으로 전역 싱글톤으로 만들어 주는 것은 아니다.)

시작과 요청 시퀀스

그렇다면 이벤트 루프 스레드는 단 하나뿐이고 서비스 객체도 하나뿐인데, 어떻게 요청 A가 DB 응답을 기다리는 동안 요청 B가 막히지 않고 실행될 수 있을까?
단계별 설명을 포함한 FastAPI lifespan 초기화와 ASGI 요청 태스크·응답 처리
앱 로드와 Lifespan 초기화
1~4단계에서는 Uvicorn이 앱과 라우트를 불러온 뒤 Lifespan 시작 구간을 실행해 공유 서비스와 리소스를 준비하고, 초기화 완료 신호를 받으면 요청 수신을 시작한다.
ASGI 요청 태스크와 의존성 해결
5~8단계에서는 HTTP 요청이 들어오면 이벤트 루프 위에서 해당 요청을 처리할 비동기 작업 흐름이 시작된다. FastAPI는 URL에 맞는 라우트를 찾고, Pydantic을 통한 입력값 검증과 Depends 의존성 해결을 거친 뒤 엔드포인트 함수를 호출한다(도표의 Route / validate / DI는 이 전처리 과정을 하나로 묶어 표현한 것이며, 모든 입력 검증이 모든 의존성 실행보다 무조건 앞서 실행된다는 고정 순서를 뜻하지는 않는다).
코루틴 중단·재개와 응답 직렬화
9~15단계에서는 엔드포인트가 비동기 서비스와 비동기 DB 드라이버를 차례로 await한다. 이때 DB 소켓에서 아직 응답 데이터가 도착하지 않았다면, 현재 코루틴은 자신의 실행 상태(지역 변수와 코드 진행 위치)를 고스란히 간직한 채 일시 중단(Suspend)되고 제어권을 이벤트 루프에 반납한다. 그 틈에 이벤트 루프는 대기열에 있는 다른 요청 B의 태스크를 실행시킨다(단, 코드에 await 키워드가 적혀 있다고 해서 무조건 실행이 멈추는 것은 아니며, 대상 작업이 이미 완료된 상태라면 멈추지 않고 즉시 다음 줄로 넘어간다). 이후 DB I/O가 완료되어 코루틴이 다시 깨어나면(Resume) 결과를 받아 응답 모델로 반환하고, FastAPI가 이를 직렬화해 ASGI 응답 메시지로 전송한다.

동기·비동기 함수와 스레드 위임

여기서 실행 단위를 정확히 구분해야 한다. 엔드포인트 안에서 await service.read(...)를 호출하는 것은 현재 요청 태스크의 흐름 안에서 하위 코루틴을 호출해 이어가는 것이지, await를 만날 때마다 이벤트 루프에 새로운 독립 Task가 하나씩 생겨나는 것이 아니다(asyncio.create_task()로 백그라운드 작업을 명시적으로 분리해 스케줄링하는 경우와 혼동하면 안 된다).
또한 FastAPI에서 함수를 어떻게 선언하느냐에 따라 실행되는 스레드가 달라진다:
•
엔드포인트나 Depends 함수를 일반 동기 함수(def)로 선언하면, FastAPI가 이벤트 루프 블로킹을 막기 위해 내부적으로 외부 스레드 풀(Threadpool)에 작업을 위임해 실행한다.
•
반면 비동기 함수(async def)로 선언하면 이벤트 루프 스레드에서 직접 실행된다. 만약 async def 엔드포인트 안에서 일반 동기 함수나 동기식 DB 드라이버(time.sleep, 동기 requests, 동기 ORM 등)를 직접 호출하면, FastAPI가 이를 알아서 스레드 풀로 옮겨 주지 않는다. 결국 그 동기 작업이 끝날 때까지 이벤트 루프 스레드 전체가 멈춰 서서 다른 모든 요청의 처리까지 함께 마비되므로 각별히 주의해야 한다. FastAPI의 def와 async def

6. 객체 회수와 자원 정리

참조 카운팅·순환 GC·RSS

그렇다면 요청이 들어와 엔드포인트(또는 APIView)부터 서비스·리포지토리까지 실행 프레임이 쌓였다가(Push) 반환되면서 빠질 때(Pop), 프레임의 지역 이름 바인딩(Name bindings)과 CPython 전용 힙 객체(Service S, int(123), Model M-A, Result R-A)의 참조 카운트는 어떤 흐름으로 변할까?
파이썬 실행 프레임의 Push 및 Pop에 따라 힙 객체의 이름 바인딩과 참조 카운트가 유지되거나 0이 되어 해제되는 단계별 흐름
그림의 ref = 1은 실제 참조 카운트 측정값이 아니라, 설명에 필요한 참조만 남긴 모형이다. 일반 GIL 빌드에서 순환 참조와 추가 보관자가 없는 객체를 가정한다. 특히 작은 정수 123은 캐시·불멸 객체 최적화의 대상이 될 수 있어 요청이 끝날 때마다 새로 생성·해제된다고 읽으면 안 된다. endpoint / APIView & Serializer도 실제 하나의 프레임이 아니라 요청·응답 처리 구간을 묶은 표기다. 핸들러가 반환된 뒤에는 프레임 대신 프레임워크나 응답 객체가 결과를 붙들 수 있다.
1.
프레임 적재와 이름 바인딩 (STEP 1 ~ 2): 요청이 시작되면 1. endpoint / APIView → 2. 서비스의 read → 3. repo.find 순으로 실행 프레임이 활성화된다(PUSH). 이때 정수 123을 포함한 모든 값은 우측 CPYTHON PRIVATE HEAP의 객체로 존재하며, 각 프레임의 지역 이름(coupon_id, self)은 그 힙 객체를 가리키는 참조를 바인딩한다. 만약 비동기 코루틴이 await에서 잠시 멈추더라도(STEP 2), 코루틴 프레임이 사라지지 않고 힙 객체들에 대한 이름 바인딩을 그대로 유지한다.
2.
하위 프레임 해제와 결과 객체 생존 (STEP 3 ~ 4): DB 조회 결과(Model M-A)를 바탕으로 응답용 데이터(Result dict / Pydantic Model R-A)를 만든 뒤 3. repo.find와 2. 서비스의 read 프레임이 차례로 반환(POP)되면, 더 이상 아무 이름도 가리키지 않는 중간 객체 M-A는 참조 카운트가 0이 되어 먼저 해제된다. 반면 STEP 4에서 2. 서비스의 read 프레임이 사라졌더라도, 상위 호출자 또는 프레임워크의 응답 처리 경로가 여전히 R-A 참조를 유지하며 JSON 바이트 직렬화를 수행하므로 Result R-A는 힙에 그대로 살아남는다(LIVE, ref = 1).
3.
최종 프레임 해제와 참조 카운트 0 (STEP 5 ~ COMPLETE): 엔드포인트 프레임은 반환 시 끝나고, 응답 처리가 이후에도 결과를 참조할 수 있다. 이 예시처럼 응답 처리와 함께 마지막 참조까지 사라지면 Result R-A의 참조 카운트가 0이 되어 해제된다. 반면 sys.modules나 app.state가 계속 참조를 쥐고 있는 Service S는 요청 중 임시 참조 수가 오르내리더라도 마지막 참조가 남으므로 워커 힙에 그대로 유지(Pinned)된다.
이처럼 실행 프레임이 끝났다는 사실과 그 안에서 만든 응답 객체가 메모리에서 사라진다는 사실은 동의어가 아니다. 만약 백그라운드 태스크, 예외가 남긴 트레이스백(traceback) 객체, 혹은 모듈 전역 캐시가 그 객체를 참조하고 있다면 요청이 완전히 끝난 뒤에도 메모리에 계속 살아남는다.
CPython은 기본적으로 객체를 가리키는 참조 개수를 세는 참조 카운팅(Reference counting)과, 자기들끼리 꼬리를 물고 참조하는 객체들을 찾아내는 순환 가비지 컬렉터(Cyclic GC)를 함께 사용한다. 일반적인 CPython 빌드에서는 마지막 참조가 사라지는 순간 참조 카운트가 0이 되면서 객체가 즉시 해제되는 경우가 많지만, 이 역시 파이썬 언어 명세가 모든 구현체에서 무조건 보장하는 규칙은 아니다. 게다가 파이썬 객체가 해제되었더라도, CPython의 메모리 할당자(pymalloc 등)는 다음 객체 할당을 빠르게 처리하기 위해 확보해 둔 메모리 블록(pymalloc arena)을 운영체제에 곧바로 돌려주지 않고 프로세스 내부 풀에 쥐고 있는 경우가 흔하다. 따라서 “요청 처리가 끝났는데 왜 모니터링 도구의 프로세스 메모리 사용량(RSS)이 즉시 줄어들지 않지?”라고 당황할 필요가 없다(후술할 Free-threaded 빌드에서는 스레드 간 성능을 위해 참조 회수 지연 방식이 더해지기도 한다).

외부 자원과 명시적 정리

결국 데이터베이스 세션, 파일 핸들, 외부 HTTP 클라이언트 연결 같은 자원은 객체가 메모리에서 언제 소멸하느냐(del)에 기대지 말고, with / async with 컨텍스트 매니저, FastAPI의 Depends(..., yield) 정리 구간, 혹은 Lifespan 종료 훅처럼 자원의 소유자가 명시적으로 닫아 주는(close) 경로를 통해 관리해야 한다. 특히 비동기 환경에서는 클라이언트가 연결을 끊어 태스크가 취소(CancelledError)되거나 예외가 발생했을 때도 정리 코드가 빠짐없이 실행되는지 반드시 확인해야 한다.

7. GIL과 경쟁 상태

GIL과 I/O 동시성

전통적인(GIL이 활성화된) CPython에서는 한 인터프리터 안에 여러 OS 스레드가 떠 있더라도, 특정 순간에 파이썬 바이트코드를 실행할 수 있는 스레드는 단 하나로 제한된다. 이 전역 자물쇠가 바로 GIL(Global Interpreter Lock)이다.
그렇다면 GIL이 있으니 Python에서 멀티스레드는 아무 쓸모가 없는 걸까? 그렇지 않다. 한 스레드가 네트워크 응답이나 DB 쿼리, 디스크 읽기/쓰기 같은 I/O 작업을 기다리는 동안에는 GIL을 잠시 내려놓기 때문이다(일부 연산 집약적인 C 확장 라이브러리도 내부 계산 중에 GIL을 해제한다). 그 사이에 다른 스레드가 GIL을 넘겨받아 파이썬 코드를 실행할 수 있으므로, I/O 대기 비중이 높은 일반적인 웹 백엔드에서는 멀티스레드 워커(gthread 등)가 여전히 유의미한 동시성 처리 효과를 낸다.

비동기 교차 실행과 경쟁 상태

반대로 더 위험한 오해는 “GIL이 파이썬 코드를 한 번에 한 스레드만 실행해 주니까, 우리 비즈니스 로직에는 동시성 버그가 절대 안 생기겠지?”라고 믿는 것이다. GIL은 인터프리터 내부의 메모리 관리 구조가 망가지지 않게 보호할 뿐, 우리가 작성한 여러 줄의 업무 규칙을 하나의 원자적 트랜잭션으로 묶어 주지 않는다. 심지어 멀티스레드가 아니라 단일 스레드 이벤트 루프로 돌아가는 FastAPI에서도 다음과 같이 공유 서비스 필드에 요청값을 저장하면 문제가 터진다.
# 의도적으로 잘못 작성한 예: 공유 서비스의 인스턴스 속성을 요청 임시 저장소로 사용한다. async def read(self, coupon_id, user_id): self.current_coupon_id = coupon_id await self.wait_for_backend() # 여기서 I/O 대기로 멈추면 이벤트 루프가 다른 요청을 실행한다! return await self.repository.find(self.current_coupon_id, user_id)
Python
복사
단일 이벤트 루프 스레드에서 Task A가 await 지점에서 멈추고 Task B가 실행될 때, 각 코루틴 프레임의 지역 변수(local coupon_id)와 공유 서비스의 속성(self.current_coupon_id)에는 각각 어떤 변화가 일어날까?
단일 이벤트 루프에서 Task A가 await로 중단된 사이 Task B가 공유 서비스의 current_coupon_id를 456으로 덮어쓰는 과정
도표의 단계별 변화를 보면 왜 코루틴 지역 변수는 안전하고 공유 서비스 속성은 위험한지가 극명하게 대비된다:
•
STEP 1: 요청 A(Task A Coroutine)가 실행되면서 자신의 코루틴 프레임에는 local coupon_id = 123을 바인딩하고, 공유 객체인 Shared Service S의 self.current_coupon_id에도 123을 적어 넣은 뒤 await 지점에서 I/O 대기로 일시 중단(SUSPENDED AT AWAIT)된다.
•
STEP 2: Task A가 멈춰 있는 바로 그 틈에, 같은 이벤트 루프 스레드가 대기 중이던 요청 B(Task B Coroutine)를 실행시킨다. Task B는 자신의 코루틴 프레임에 local coupon_id = 456을 가지고 있으면서, 공유 서비스의 self.current_coupon_id를 456으로 덮어써 버린다.
•
STEP 3: 잠시 후 I/O가 끝나 Task A가 다시 깨어나(RESUMED AFTER AWAIT) 다음 줄을 실행한다. 이때 Task A의 코루틴 프레임 안에 있던 지역 변수 local coupon_id = 123은 조금도 손상되지 않고 그대로 남아 있지만, 코드가 공유 객체의 self.current_coupon_id를 읽어 리포지토리를 호출하는 바람에 엉뚱하게도 456번 쿠폰을 조회하게 된다.
이처럼 경쟁 상태(Race condition)는 꼭 여러 CPU 코어에서 여러 OS 스레드가 물리적으로 동시에 질주해야만 일어나는 현상이 아니다. 하나의 스레드 안에서도 작업이 멈추고 끼어드는 순서(Interleaving)에 따라 결과가 오염될 수 있다. 결론은 명확하다. GIL이 켜져 있든 꺼져 있든, 요청마다 달라지는 작업 데이터는 공유 객체의 속성에 두지 말고 함수 인자와 지역 변수로만 전달해야 한다.

8. Python 버전별 실행 모델 변화

버전별 주요 변화

최근 Python 생태계에서 가장 뜨거운 주제는 단연 GIL의 변화와 다중 인터프리터다. 하지만 단순히 “Python 최신 버전에서는 GIL이 사라졌다더라”라는 소문만 듣고 서버를 업그레이드하면 큰 혼란을 겪을 수 있다. 파이썬 버전 번호와 실제 런타임의 실행 모드는 구분해서 확인해야 하기 때문이다. 이 글의 주제인 웹 서버의 메모리·실행 모델과 직결되는 핵심 변화만 버전별로 짚어보자.
버전 및 빌드 구성
핵심 실행 모델 변화
웹 백엔드 서버 관점에서의 실제 의미
Python 3.12 (일반 빌드)
기본 인터프리터는 기존처럼 GIL 사용. 단, 격리된 서브인터프리터마다 별도의 GIL을 갖게 하는 C API(PEP 684) 도입
일반적인 DRF·FastAPI 워커가 3.12로 올린다고 해서 자동으로 여러 인터프리터를 쓰거나 GIL이 사라지는 것은 아니다.
Python 3.13
GIL 없이 동작하는 Free-threaded 빌드(--disable-gil)가 실험적(Experimental) 선택 사양으로 처음 도입
본격적인 전환의 시작점이다. 일반 기본 설치판은 여전히 GIL을 사용한다.
Python 3.14 (일반 빌드)
기본 빌드는 여전히 GIL 유지. 파이썬 표준 라이브러리에 concurrent.interpreters와 InterpreterPoolExecutor 추가
C API를 직접 건드리지 않고도 파이썬 코드에서 격리된 다중 인터프리터 풀을 명시적으로 활용할 수 있는 길이 열렸다.
Python 3.14 (Free-threaded 빌드)
Free-threaded 빌드가 실험 단계를 벗어나 공식 지원(Supported, PEP 779) 단계로 격상(단, 여전히 선택형 빌드)
이 빌드로 실행하고 GIL이 비활성화된 상태라면, 한 인터프리터 안의 여러 스레드가 여러 CPU 코어에서 파이썬 코드를 진짜 병렬로 실행할 수 있다.
Python 3.15
Free-threaded C 확장을 위한 Stable ABI 지원 추가, 명시적 지연 임포트(Lazy import) 문법 도입 등
서드파티 C 확장 패키지의 호환성과 모듈 초기화 시점을 점검해야 한다. 버전이 올라갔다고 요청 간 상태 격리가 저절로 보장되지는 않는다.

다중 인터프리터와 Free-threading

여기서 두 갈래의 진화 방향을 명확히 구분해야 한다.
첫 번째 갈래는 다중 인터프리터(Per-interpreter GIL) 모델이다. Python 3.12의 PEP 684와 3.14의 InterpreterPoolExecutor가 여기에 해당한다. 이는 하나의 인터프리터 안에서 GIL을 없앤 것이 아니라, 한 프로세스 안에 서로 격리된 인터프리터를 여러 개 만들고 각 인터프리터가 자기만의 GIL을 따로 갖게 한 구조다. 인터프리터마다 모듈 상태(sys.modules)와 전역 객체가 분리되어 있으므로, 한 인터프리터에서 만든 일반 가변 파이썬 객체를 다른 인터프리터 스레드에 참조 그대로 넘겨 함께 수정할 수 없고 직렬화나 전용 공유 채널을 거쳐야 한다. 무거운 CPU 계산 작업을 별도 인터프리터 풀로 격리해 돌리는 용도로는 유용하지만, 파이썬 버전을 3.14로 올렸다고 해서 기존 FastAPI나 DRF의 워커가 자동으로 InterpreterPoolExecutor로 바뀌지는 않는다(또한 한 OS 프로세스 안에서의 논리적 격리이므로, 네이티브 C 크래시나 OS 자원 한계를 분리하는 멀티 프로세스 수준의 완전한 격리와도 다르다).
•
interpreterPoolExecutor: link iconLifespan Events - FastAPI
두 번째 갈래는 자유 스레딩(Free-threaded Python, PEP 703 / PEP 779) 모델이다. 이는 우리가 알던 JVM처럼 하나의 인터프리터 안에 있는 동일한 힙 객체들을 여러 OS 스레드가 공유하면서, GIL 없이 여러 코어에서 동시에 실행할 수 있게 만든 빌드다. 런타임 차원에서 기본 dict나 list 같은 내장 컨테이너가 내부적으로 깨지지 않도록 자체 락을 걸어 보호해 주지만, 앞서 Spring Boot에서 봤던 것처럼 여러 단계의 연산을 묶은 비즈니스 불변식(예: 조회 후 차감)이나 사용자 정의 클래스의 공유 필드 오염까지 알아서 막아주지는 않는다. 오히려 기존에는 GIL 덕분에 우연히 가려져 있던 미세한 스레드 데이터 레이스가 수면 위로 드러날 수 있으므로, 공유 상태와 서드파티 C 확장 모듈의 스레드 안전성을 훨씬 더 엄격하게 점검해야 한다(또한 단일 이벤트 루프 스레드에서 도는 asyncio 태스크들이 Free-threaded 빌드를 썼다고 해서 자동으로 여러 코어 스레드에 쪼개져 병렬 실행되는 것도 아니다).
메모리 회수 메커니즘도 함께 변한다. 여러 스레드가 동시에 같은 객체의 참조 카운트를 올리고 내리면 CPU 캐시 경합으로 성능이 급격히 떨어지기 때문에, Free-threaded CPython은 객체를 주로 다루는 스레드에 가중치를 두는 편향 참조 카운팅(Biased reference counting)과 일부 객체의 참조 카운트 갱신을 나중으로 미루는 지연 참조 카운팅(Deferred reference counting) 기법을 도입했다. 그만큼 “지역 변수가 스코프를 벗어나면 그 즉시 객체가 파괴된다”는 식의 구현 의존적 가정에 파일이나 커넥션 정리를 맡겨서는 더더욱 안 된다.

빌드 구성과 GIL 활성 확인

주의할 점이 하나 더 있다. Free-threaded 빌드를 설치했더라도 실행할 때 환경 변수나 명령줄 옵션(-X gil=1)으로 GIL을 다시 켤 수 있고, 무엇보다 아직 Free-threading을 지원하지 않는 구형 C 확장 패키지를 import하는 순간 런타임이 안전을 위해 GIL을 자동으로 다시 활성화할 수도 있다. 따라서 파이썬 버전 번호만 보지 말고, 애플리케이션의 모든 의존성을 임포트한 뒤 아래 진단 코드로 실제 상태를 확인해 보는 것이 가장 정확하다.
import sys import sysconfig print(sys.version) print("free-threaded build:", sysconfig.get_config_var("Py_GIL_DISABLED") == 1) probe = getattr(sys, "_is_gil_enabled", None) print("GIL enabled:", probe() if probe else "API unavailable on this version")
Python
복사
(Python 3.12에는 sys._is_gil_enabled API가 없으므로 위 코드는 API 부재를 안전하게 출력한다. 즉, “Free-threaded로 빌드된 파이썬인가?”와 “지금 이 프로세스에서 실제로 GIL이 꺼져 있는가?”는 별개의 질문이다.)

프로세스 시작 방식과 지연 초기화

실무 배포 환경과 관련해 3.14와 3.15에서 눈여겨볼 변화가 세 가지 더 있다.
1.
multiprocessing 기본 시작 방식 변경 (3.14): 멀티스레드 프로세스를 그대로 fork할 때 발생하는 고질적인 교착 상태(Deadlock) 문제를 줄이기 위해, Python 3.14부터는 표준 라이브러리 multiprocessing의 기본 프로세스 생성 방식이 Linux를 포함한 어떤 플랫폼에서도 fork가 아니게 바뀌었다(지원되는 POSIX 환경에서는 기본적으로 forkserver를, macOS와 Windows에서는 spawn을 사용한다). 단, 이는 파이썬 표준 multiprocessing 모듈의 기본 정책이 바뀐 것이지, Gunicorn의 Pre-fork 워커 모델처럼 자체적으로 os.fork()를 제어하는 웹 서버들의 아키텍처가 일괄 변경되었다는 뜻은 아니다. 만약 앱 내부에서 별도의 프로세스 풀을 띄운다면 get_start_method()와 객체 직렬화(Pickling) 조건을 꼭 확인해야 한다. multiprocessing 시작 방식
2.
타입 어노테이션의 지연 평가 (3.14): 함수나 클래스의 타입 힌트(Annotation)를 정의 시점에 바로 계산하지 않고 필요할 때 평가하도록 기본 동작이 바뀌었다(PEP 649 / PEP 749). FastAPI나 Pydantic처럼 런타임에 타입 어노테이션을 적극적으로 읽어들여 검증 모델을 만드는 프레임워크를 쓴다면, 파이썬 메이저 업그레이드 시 프레임워크의 해당 버전 지원 여부를 반드시 확인해야 한다. 3.14 annotation 변화
3.
명시적 지연 임포트와 Stable ABI (3.15): Python 3.15에서 도입된 명시적 지연 임포트(Lazy import)를 사용하면, 모듈 최상단 코드가 서버 시작 시점이 아니라 그 모듈의 속성을 실제로 처음 건드리는 요청 시점에 실행될 수 있다. 따라서 서비스 모듈에 지연 임포트를 적용한다면 “모든 전역 서비스 객체는 서버가 켜질 때 미리 생성된다”는 가정이 달라질 수 있다. 또한 Free-threaded 확장용 Stable ABI가 도입되었더라도, 내가 설치한 모든 서드파티 패키지가 자동으로 스레드 안전해지는 것은 아니므로 배포 조합을 직접 검증해야 한다. 3.15 변경 사항

9. 상태 관리와 동기화 도구

설계 원칙과 동기화 수단

지금까지 살펴본 것처럼, 런타임이 세그멘테이션 오류를 막아주는 메모리 안전성과 동시 요청 사이에서 비즈니스 데이터 정합성을 지켜내는 스레드·동시성 안전성은 전혀 다른 문제다. 공유 서비스 객체를 사용할 때는 서버 시작(또는 Lifespan) 단계에서 초기화를 완전히 마친 뒤 요청 처리에 노출하고, 이후 요청을 처리할 때는 실행 모델에 맞는 명확한 도구를 선택해야 한다. (JVM의 happens-before 명세를 Python에 그대로 끌어다 쓰기보다는, Python의 각 동기화 도구가 보장하는 범위를 기준으로 판단하자.)
설계 원칙 및 도구
Python 웹 백엔드에서의 적용 기준과 한계
무상태 서비스 (Stateless service)
공유되는 CouponService의 속성(인스턴스 속성)에 현재 요청의 사용자 ID나 쿠폰 ID를 저장하지 않고, 항상 함수 인자로 넘겨 처리한다.
요청·태스크 한정 (Request / Task confinement)
요청마다 만든 딕셔너리, 모델, 리스트는 해당 요청의 함수·태스크 흐름 안에서만 사용한다. 만약 한 요청 안에서 asyncio.gather 등으로 하위 태스크 여러 개를 동시에 띄우고 같은 가변 객체를 넘긴다면, 그 태스크들 사이의 공유도 다시 점검해야 한다.
불변성 (Immutability)
여러 요청이 함께 참조하는 설정이나 정책 데이터는 frozenset, tuple, frozen=True 데이터클래스 등 변경 불가능한 구조로 유지한다. 단, 바깥 컨테이너가 튜플이라도 그 안에 든 요소가 가변 딕셔너리라면 내부 값은 바뀔 수 있음에 유의한다.
스레드 한정 (Thread confinement)
스레드에 안전하지 않은 외부 클라이언트나 자원을 특정 스레드 하나에서만 접근하도록 격리한다. 단, 단일 스레드 이벤트 루프에서는 한 스레드 위에서 여러 요청 태스크가 번갈아 실행되므로, “스레드가 하나니까 요청 데이터도 격리되겠지”라고 착각해서는 안 된다.
threading.Lock
여러 OS 스레드가 같은 인터프리터의 가변 상태를 동시에 읽고 쓸 때 임계 구역(Critical section)을 보호한다. 관련된 모든 읽기·쓰기 경로가 동일한 락을 거쳐야 한다.
asyncio.Lock
같은 이벤트 루프 안에서 돌아가는 비동기 태스크들 사이의 임계 구역을 보호한다. 다른 OS 스레드나 다른 워커 프로세스의 접근은 전혀 막아주지 못한다.
ContextVar (contextvars)
비동기 태스크 호출 체인을 따라 요청 ID, 추적 문맥(Trace context) 등을 안전하게 전달할 때 사용한다(threading.local의 비동기 대응판). 단, ContextVar에 가변 객체를 넣었다고 해서 그 객체가 자동으로 깊은 복사되는 것은 아니며, DB 세션 닫기 같은 자원 정리를 대신해 주지도 않는다.
원자성과 불변식 (Atomicity / Invariant)
dict[key] = val 같은 단일 연산이 안전하다고 해서 “쿠폰 잔여 수량을 조회한 뒤 0보다 크면 차감한다”는 복합 업무 규칙까지 원자적으로 보호되지는 않는다.

락 범위와 외부 저장소

특히 비동기 코드에서 흔히 범하는 치명적인 실수는 블로킹 스레드 락(threading.Lock)을 잡은 채로 await를 호출하는 것이다. 이는 이벤트 루프 전체를 멈춰 세우거나 손쉽게 교착 상태(Deadlock)를 유발한다. 애초에 요청별 데이터를 공유 객체에 쓰지 않도록 설계하면 락 자체가 필요 없는 경우가 대부분이다.
그리고 마지막으로, 서버를 여러 워커 프로세스나 여러 컨테이너로 띄웠다면 파이썬 내부의 threading.Lock이든 asyncio.Lock이든 프로세스 경계 밖의 다른 워커는 전혀 통제할 수 없다. 여러 서버와 워커가 동시에 달려드는 선착순 쿠폰 발급·차감 같은 비즈니스 규칙은 애플리케이션 메모리 락이 아니라, 데이터베이스의 조건부 UPDATE나 트랜잭션 격리, 비관적/낙관적 락, 혹은 Redis 분산 락 같은 외부 공통 저장소 수준의 동시성 제어로 풀어야 한다.

10. 전체 요약과 판단 기준

DRF·FastAPI 실행과 메모리 요약

지금까지 따라온 DRF(WSGI)와 FastAPI(ASGI)의 서버 시작부터 응답, 그리고 메모리 회수까지의 흐름을 한 화면에서 비교해 보자. 한 워커 프로세스 안에서 여러 요청이 공유하는 영역과, 요청마다 독립적으로 생겼다 사라지는 영역은 어떻게 맞물려 있을까?
DRF와 FastAPI의 워커·인터프리터·실행 상태·공유 서비스·응답 수명 비교
왼쪽의 DRF / WSGI 경로에서는 웹 서버 설정이 정한 스레드가 동기 호출 프레임을 쌓으며 요청을 처리하고, 요청마다 새로운 View 인스턴스를 만들되 모듈에 준비된 Shared service object를 함께 사용한다. 오른쪽의 FastAPI / ASGI 경로에서는 이벤트 루프 스레드가 여러 요청 태스크를 스케줄링하며, await로 중단되더라도 각 코루틴의 지역 상태(Task / coroutine state)는 안전하게 보존된 채 Lifespan 등에서 준비한 Shared service object를 공유한다. 두 프레임워크 모두 응답 바이트 전송이 끝나고 요청 관련 참조가 해제되면 참조 카운팅과 순환 GC를 통해 객체를 정리하며, 워커 프로세스를 늘리면 이 전체 박스가 프로세스마다 독립적으로 복제된다.

점검 기준

앞으로 Python 버전이 3.14, 3.15, 그리고 그 이후로 계속 진화하더라도, 백엔드 엔지니어가 코드 앞에서 던져야 할 핵심 질문은 변하지 않는다.
1.
이 코드는 몇 개의 프로세스와 몇 개의 인터프리터 위에서 돌고 있는가?
2.
요청을 실제로 밀고 나가는 실행 주체는 OS 스레드인가, 아니면 이벤트 루프 위의 코루틴 태스크인가?
3.
지금 내가 건드리는 이 객체는 이번 호출만의 지역 상태인가, 아니면 다른 스레드·태스크가 함께 바라보는 공유 가변 상태인가?
이 세 가지 질문에 답할 수 있다면, GIL, async, singleton 같은 단어 하나에 막연하게 안전성을 기대는 대신 내 서버의 실제 요청 경로와 메모리 수명을 정확하게 통제할 수 있을 것이다.