(2)

Python의 WSGI(Web Server Gateway Interface) - Node와 비교하며 이해하기

서론 Python의 GIL(Global Interpreter Lock) - Node와 비교하며 이해하기서론Node를 처음 접할 때, 가장 먼저 이해해야하는 것들 중에는 아래와 같은 개념들이 있습니다.JS 실행은 기본적으로 싱글 스레드다.대신 이벤트 루프와 비동기 I/O로 동시성을 만든다.CPU를 갈아mag1c.tistory.com 이전글에서 Python의 GIL에 대해 정리했습니다. Node에서 Python으로 전환하면서 동시성 처리의 차이점을 이해하는 것이 중요하다고 생각했기 때문입니다. 이번에는 제가 Python의 레거시 스택을 사용하면서 또 다른 혼동의 원인이었던 WSGI(Web Server Gateway Interface)에 대해 정리해보려 합니다. Node로 웹 서버를 만들 때는 아래처럼 만들..

Python의 GIL(Global Interpreter Lock) - Node와 비교하며 이해하기

서론Node를 처음 접할 때, 가장 먼저 이해해야하는 것들 중에는 아래와 같은 개념들이 있습니다.JS 실행은 기본적으로 싱글 스레드다.대신 이벤트 루프와 비동기 I/O로 동시성을 만든다.CPU를 갈아 넣는 작업은 워커나 별도 프로세스가 담당한다. 저도 Java를 짧게 다루다가 Node로 처음 기술 스택을 전환했을 때 위와 같은 개념을 먼저 접했던 것 같습니다.그리고 이런 개념들은, Node의 JavaScript 실행 방식은 기준점이 되어 프로그래밍을 하면서 항상 생각하고, 녹여내려고 했습니다.기본적인 async/await는 물론이고, 이벤트 루프를 막을 법한 무거운 연산은 워커로 빼는 식의 설계를 자연스럽게 떠올리게 됐습니다. 최근 Python으로 스택 전환을 하면서, Python은 동시성 처리를 어떻..

Python의 WSGI(Web Server Gateway Interface) - Node와 비교하며 이해하기

Tech/Python 2025. 12. 24. 21:43
728x90
728x90

 

 

서론

 

 

Python의 GIL(Global Interpreter Lock) - Node와 비교하며 이해하기

서론Node를 처음 접할 때, 가장 먼저 이해해야하는 것들 중에는 아래와 같은 개념들이 있습니다.JS 실행은 기본적으로 싱글 스레드다.대신 이벤트 루프와 비동기 I/O로 동시성을 만든다.CPU를 갈아

mag1c.tistory.com

 

이전글에서 Python의 GIL에 대해 정리했습니다. Node에서 Python으로 전환하면서 동시성 처리의 차이점을 이해하는 것이 중요하다고 생각했기 때문입니다.

 

이번에는 제가 Python의 레거시 스택을 사용하면서 또 다른 혼동의 원인이었던 WSGI(Web Server Gateway Interface)에 대해 정리해보려 합니다.

 

Node로 웹 서버를 만들 때는 아래처럼 만들죠.

 

const express = require('express');
const app = express();

app.get('/', (req, res) => {
    res.send('Hello World');
});

app.listen(3000);

 

express의 app.listen()은 내부적으로  Node의 http.createServer()을 호출합니다.

런타임에 HTTP 서버가 내장되어 있어서, 별도 서버 없이 바로 띄울 수 있죠.

 

// express/lib/application.js
app.listen = function listen() {
    var server = http.createServer(this)  // Node.js 내장 http 모듈 사용
    return server.listen.apply(server, arguments)
}

 

 

그런데 Django는...

 

python manage.py runserver
# "WARNING: This is a development server. Do not use it in production."
# django 소스코드 일부
# django/core/management/commands/runserver.py
self.stdout.write(
  self.style.WARNING(
      "WARNING: This is a development server. Do not use it in a "
      "production setting. Use a production WSGI or ASGI server "
      "instead.\nFor more information on production servers see: "
      f"https://docs.djangoproject.com/en/{docs_version}/howto/"
      "deployment/"
  )
)

 

 

소스코드를 확인해보니 무슨 SGI를 사용하라고하네요. 서울보증보험인가.. 별도의 서버가 필요하다는 것은 확실해 보였습니다.

 

Java기반의 Spring을 짧게 사용했을 때도 당연히 Tomcat을 별도로 사용했기 때문에 그런가보다 했습니다.

그런데 공부하다보니 2025년을 살아가는 저에게는 꽤나 독특한 녀석이라고 생각했습니다.

 

왜 Python 웹 생태계는 GIL 뿐 아니라 WSGI 같은 녀석도 표준이 되어 지금까지도 사용되고 있을까요?

 

 

 

 

애플리케이션과 서버의 분리

 

 

 

다시 말하지만 Node는 개발자가 웹 서버를 별도 구성할 필요가 없습니다.

런타임에 HTTP 서버가 내장되어 있어 별도 서버를 구성하지 않고도 바로 웹 서버를 띄울 수 있죠.

 

 

 

 

왜 이렇게 분리되었을까요?

 

2000년대 초반에는 Python 웹 생태계에는 Zope, Quixote, Webware 등의 다양한 프레임워크가 있었다고 합니다.

문제는 프레임워크 선택이 서버 선택이 되어, Zope를 쓰려면 Zope 서버를, Quixote를 쓰려면 또 다른 서버를 써야 했다고 해요.

 

Java에서는 Servlet API가 이 문제를 해결했어요.

어떤 서블릿 컨테이너(Tomcat, Jetty 등)에서든 서블릿 스펙을 따르는 웹 앱을 실행할 수 있습니다.

 

Python도 비슷한 표준이 필요했고, 그렇게 WSGI가 탄생했다고 합니다.

(이와 관련된 자세한 내용은 PEP-333에 나와있습니다.)

 

 

 

 

WSGI

WSGI(Web Server Gateway Interface)는 웹 서버 게이트웨이의 표준 인터페이스입니다.

웹 서버와 Python Application 사이의 표준 인터페이스 인 셈이죠.

 

Python이 그러한 것 처럼, WSGI 또한 단순하고 간결한 것이 원칙이었다고 합니다.

 

Thus, simplicity of implementation on both the server and framework sides of the interface is absolutely critical to the utility of the WSGI interface, and is therefore the principal criterion for any design decisions.

the goal of WSGI is to facilitate easy interconnection of existing servers and applications or frameworks, not to create a new web framework

Phillip J. Eby (PEP 333 - https://peps.python.org/pep-0333)

 

 

 

WSGI Application 구조

단순하고 간결한 원칙 때문이었을까요? WSGI 애플리케이션은 단순합니다.

 

def application(environ, start_response):
    """단순한 WSGI Application"""
    status = '200 OK'
    response_headers = [('Content-Type', 'text/plain')]
    start_response(status, response_headers)
    return [b'Hello World']

 

두 개의 파라미터를 받는 callable* 객체면 충분합니다. 이게 전부에요.

callable은 말 그대로 호출할 수 있는 객체를 뜻해요. application() 처럼요.

 

참고로 응답이 리스트(iterable)인 이유가 있어요.
대용량 파일을 한 번에 메모리에 올리지 않고 chunk 단위로 스트리밍할 수 있게 하려는 설계입니다.

 

environ

이름만 봐도 감이 오죠? .env를 생각하면 될 것 같아요.

environ은 CGI 스타일의 환경 변수 딕셔너리에요. CGI(Common Gateway Interface)는 1990년대 웹 서버가 외부 프로그램을 실행하던 방식이에요. WSGI가 이 변수 컨벤션을 그대로 사용한 이유는, 당시 Python 프레임워크들이 이미 CGI 방식을 구현해뒀기 때문입니다.

아래의 값들을 통해 웹 서버를 설정해야해요.

 

 

 

 

start_response

start_response는 응답 상태와 헤더를 설정하는 callable 이에요.

 

start_response(status, response_headers, exc_info=None)
status = '200 OK'  # HTTP 상태코드 + 메시지 (문자열)
response_headers = [
  ('Content-Type', 'text/plain'),
  ('Content-Length', '12')
]
start_response(status, response_headers)  # 서버에게 알림
return [b'Hello World!']  # 그 다음 본문 반환

 

 

 

WSGI 실제 구현 예시

제가 사용하는 Django를 예로 들어볼게요. 개발자는, Response안에 응답 데이터와 상태를 넣어줘요.

 

return Response(data=serializer.data, status=status.HTTP_200_OK)

 

 

위에서 언급한 WSGI Application에 들어가는 환경 변수나 응답 헤더 등의 설정은 하지 않았는데요.

Django 내부에서는 이 저수준의 WSGI를 래핑한 고수준의 API를 제공합니다. 아래는 간략화한 예시입니다.

 

class WSGIHandler:
  def __call__(self, environ, start_response):
      # 1. environ을 Django Request로 변환
      request = self.request_class(environ)

      # 2. View 실행 → Response 객체 반환
      response = self.get_response(request)

      # 3. Response를 WSGI 형식으로 변환
      status = '%d %s' % (response.status_code, response.reason_phrase)

      start_response(status, response_headers)
      return response

 

 

 

 

 

 

WSGI를 사용한 HTTP Request LifeCycle

 

 

위 시퀀스 다이어그램은 WSGI를 사용한 HTTP 요청이 처리되기 까지를 요약한 다이어그램입니다.

 

실제 프로덕션 환경에서는

  • 리버스 프록시: HTTPS, static serving, LB, Buffering 등의 역할
  • WSGI 서버: HTTP 요청을 WSGI 프로토콜(environ, start_response)로 변환하여 애플리케이션에 전달, 응답을 클라이언트에 반환, 동시 요청 처리
  • WSGI 애플리케이션: WSGI 스펙을 따르는 callable(wsgi.py)을 통해 요청을 받아 비즈니스 로직 수행

 

위와 같은 플로우로 동작하게 됩니다.

 

 

 

 

WSGI의 한계

WSGI는 2003년에 만들어졌다고 해요. 틀딱인거죠

GIL이 멀티스레드 CPU 연산을 제한하는 것처럼, WSGI도 요청-응답 모델에서 제약이 있습니다. 다만 문제의 성격은 달라요. GIL은 스레드 병렬성의 문제이고, WSGI는 연결 유지와 양방향 통신이 불가능한 설계의 문제입니다

 

def application(environ, start_response):
    result = do_something_slow()  # 블로킹!
    start_response('200 OK', [('Content-Type', 'text/plain')])
    return [result.encode()]

 

요청이 들어오면 워커 하나가 요청을 받고, 처리가 끝날 때까지 해당 워커는 점유되며 응답을 반환하고 나서야 다음 요청 처리가 가능해요. (sync worker 기준)

물론 Gunicorn도 gevent나 eventlet 같은 async worker를 사용하면 Green thread 기반으로 수백 개의 동시 연결을 처리할 수 있어요. 하지만 이건 WSGI 표준 위에서의 우회 방식이고, WebSocket 같은 양방향 통신은 여전히 구조적으로 불가능합니다.

 

물론 Django를 사용하더라도 멀티프로세싱이나 스케일 아웃으로 많은 워커를 구성하거나
적절한 캐싱과 인프라 구조의 최적화를 통해 개선할 수도 있겠죠...?

실제로 인스타그램은 2012년 Django + Gunicorn 스택으로 1400만 유저까지 스케일했고,
현재도 Django를 핵심 스택으로 사용하며 수십억 사용자를 처리하고 있어요. 대단하죠.. (인스타 기술 블로그)

 

 

 

 

 

ASGI

ASGI(Asynchronous Server Gateway Interface)는 비동기 기능을 갖춘 파이썬 웹 서버 인터페이스입니다.

(ASGI 스펙 문서에서는 WSGI의 정신적 후계자(spiritual successor)라고 소개되어 있어요)

 

async를 통해 비동기 처리를 지원하는 ASGI는 Django 기준 3.0부터 공식 지원한다고해요.

 

# WSGI
def application(environ, start_response):
    start_response('200 OK', headers)
    return [b'Hello World']

# ASGI
async def application(scope, receive, send):
    await send({
        'type': 'http.response.start',
        'status': 200,
        'headers': [(b'content-type', b'text/plain')],
    })

 

 

ASGI의 세 파라미터를 간단히 설명하자면

  • scope: 연결의 메타데이터 (HTTP인지 WebSocket인지, 경로, 헤더 등)
  • receive: 클라이언트로부터 메시지를 비동기로 수신
  • send: 클라이언트로 메시지를 비동기로 전송

WSGI가 요청을 받아 응답을 반환하는 단방향이었다면, ASGI는 receive/send로 언제든 양방향 통신이 가능한 구조입니다.

 

# asgi.py
import os
from django.core.asgi import get_asgi_application

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
application = get_asgi_application()



Django에서는 ASGI를 지원한다고 해서 모든 코드가 비동기로 동작을 지원하지는 않습니다.

대표적으로 Django ORM은 기본적으로 동기 드라이버(psycopg2 등)를 사용하기 때문에, DB 쿼리 시 해당 스레드가 블로킹됩니다.

 

def my_view(request):
    result = SomeModel.objects.all()  # 동기 ORM
    return HttpResponse(result)

 

하지만, Django 4.1 버전 이후부터는 async ORM을 점진적으로 지원하기 시작했고, ORM 뿐 아니라 Django와 Python에서 비동기를 점진적으로 지원하기 위한 노력은 지금도 꾸준히 진행되고 있는 것으로 보여요. (이전 글에서 다룬 GIL Free-threading도 그 일환이죠)

 

 

 

 

정리

Node에서는 런타임에 내장되어있었기 떄문에, 그리고 기본적으로 비동기를 지원했기 떄문에 다소 많은 차이가 느껴졌습니다.

 

이전 GIL 포스팅과는 다르게 마냥 부정적으로만 보이지는 않았는데요, 이는 GIL이라는 언어 자체의 레거시와는 느낌이 달랐기 떄문입니다.

GIL은 언어(CPython) 레벨의 문제이고, WSGI는 프레임워크와 웹 서버 생태계의 문제입니다. 흥미롭게도 생태계 전환이 오히려 더 빠르게 진행 중이에요. GIL 제거는 수십 년간 시도 끝에 Python 3.13에서야 실험적으로 도입된 반면, ASGI로의 전환은 FastAPI의 부상, Django 3.0+의 공식 지원 등 이미 활발히 이루어지고 있죠.

 

특히 Django는 ORM, Admin, Auth 등 많은 기능이 내장되어 있고, 이 모든 것들이 동기 기반으로 설계되어 있잖아요. 이걸 비동기로 전환하려면 프레임워크 전체가 바뀌어야 하는 거니까요. 그래도 Django 4.1부터 async ORM이 점진적으로 지원되고 있고, Python 생태계 전체가 비동기를 향해 나아가고 있으니 긍정적으로 보고 있어요. 

 

그리고..... 개발자의 역량에 따라 동기적인 웹 서버로도 충분히 10M+의 트래픽이 제어 가능하고 인스타라는 선진 사례도 있기 떄문에, 이 모든게 저의 역량에 달린 일이 아닐까..(?????????) 하는 생각도 들었습니다.

 

다음 Python 관련 스터디는 딱히 정해지진 않았지만, 무언가 정리할 만한 주제를 찾아 돌아오도록 하겠습니다.

 

 

 

 

 

 

 

 

 

References

https://peps.python.org/pep-0333

https://instagram-engineering.com/what-powers-instagram-hundreds-of-instances-dozens-of-technologies-adf2e22da2ad

https://read.engineerscodex.com/p/how-instagram-scaled-to-14-million

https://asgi.readthedocs.io/en/latest/

https://gunicorn.org/

https://www.uvicorn.org/

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

Python의 GIL(Global Interpreter Lock) - Node와 비교하며 이해하기

Tech/Python 2025. 12. 9. 21:35
728x90
728x90

 

서론

Node를 처음 접할 때, 가장 먼저 이해해야하는 것들 중에는 아래와 같은 개념들이 있습니다.

JS 실행은 기본적으로 싱글 스레드다.
대신 이벤트 루프와 비동기 I/O로 동시성을 만든다.
CPU를 갈아 넣는 작업은 워커나 별도 프로세스가 담당한다.

 

저도 Java를 짧게 다루다가 Node로 처음 기술 스택을 전환했을 때 위와 같은 개념을 먼저 접했던 것 같습니다.

그리고 이런 개념들은, Node의 JavaScript 실행 방식은 기준점이 되어 프로그래밍을 하면서 항상 생각하고, 녹여내려고 했습니다.

기본적인 async/await는 물론이고, 이벤트 루프를 막을 법한 무거운 연산은 워커로 빼는 식의 설계를 자연스럽게 떠올리게 됐습니다.

 

 

최근 Python으로 스택 전환을 하면서, Python은 동시성 처리를 어떻게 해야할까? 라는 생각에 조금씩 학습을 하고 있습니다.

제가 Node에서 체화했던 동시성 처리 부분이 Python에서 혼동이 생겨 동시성 처리의 핵심이 되는 GIL(Global Interpreter Lock)에 관련된 내용을 정리하고자 합니다. 미리 요약하면 다음과 같습니다.

 

  • GIL이란? CPython에서 GIL이 생긴 이유
  • 멀티스레딩이 실제로 어떻게 제한되는지
  • Node.js와 Python의 동시성 모델 비교
  • 그래서 어떤 설계를 선택할지
  • GIL과 관련해서 2025년 기준의 방향성

에 대해 정리해보겠습니다.

 

 

 

 

GIL(Global Interpreter Lock)

The global interpreter lock, or GIL, is a mutex that protects access to Python objects, preventing multiple threads from executing Python bytecodes at once. - Python Wiki

 

 

GIL(Global Interpreter Lock)은 한 번에 하나의 스레드만 Python 바이트코드를 실행할 수 있도록 보장하는 뮤텍스이며 CPython의 특성입니다. GIL 덕분에 thread-safe를 보장하지만, 같은 프로세스 안에서 스레드가 여러 개 있어도 한 번에 하나의 인터프리터만 실행시키는 제약이 생깁니다.

 

 

1. CPython

  JavaScript는 V8, SpiderMonkey, NodeJS, Deno, Bun 등 여러 런타임이 존재합니다.

  Python도 실행하는 인터프리터의 종류가 다양하며, 그 중 가장 널리 쓰이는 공식 구현체가 C로 작성된 CPython입니다.

 

2. Mutex

  Mutex(Mutual Exclusion)는 공유 자원에 대한 동시 접근을 막는 동기화 메커니즘입니다.

  GIL은 일종의 열쇠입니다. 이 GIL을 통해 하나의 스레드에서 작업을 수행하고 반납하면, 다음 스레드에서 GIL을 얻어 작업을 수행합니다.

 

 

동작 방식을 시각화해보면 다음과 같습니다.

 

 

 

Python 3.2 기준으로 CPython은 기본적으로 5ms 간격으로 GIL을 해제하여 다른 스레드에게 실행 기회를 줍니다.

이 간격은 sys.getswitchinterval() 로 확인해볼 수 있습니다.

 

 

 

GIL은 왜 존재할까?

GIL 때문에 멀티스레드가 제한된다는 건 알겠습니다. 근데 왜 굳이 이런 제약을 만들었을까요? 동시성에 제약이 생긴다는 것은 많은 부분에서 성능 이슈들이 발생할 잠재적인 원인이 될 수 있는데 말이에요.

 

이해를 돕기 위해 CPython의 메모리 관리 방식을 조금 뜯어보았습니다.

 

 

CPython의 메모리 관리

CPython은 참조 카운팅(Reference Counting) 기반의 GC를 사용합니다.

 

import sys

a = []          # 리스트 객체 생성, refcount = 1
b = a           # 같은 객체 참조, refcount = 2
print(sys.getrefcount(a))  # 3 (함수 인자로 전달되면서 +1)

del b           # refcount = 2
del a           # refcount = 1 → 스코프 종료 시 0 → 메모리 해제

 

모든 Pyhthon 객체는 내부적으로 ob_refcnt 라는 참조 카운터를 가지고 있어요.

 

typedef struct _object {
    Py_ssize_t ob_refcnt;    // 참조 카운트
    PyTypeObject *ob_type;    // 타입 정보
} PyObject;

 

객체를 참조할 때마다 이 카운터가 증가하고, 참조가 해제되면 감소하는 구조입니다. 카운터가 0이 되면 메모리에서 해제되는거죠.

 

 

GIL이 없다면?

만약 GIL이 없어 여러 스레드가 동시에 같은 객체를 참조한다면, 예상하시다시피 Race Condition이 발생하게 되죠.

이 현상은 참조 카운터에도 동일하게 적용됩니다.

 

 

현재 참조 카운트가 1인 객체를 스레드 1과 스레드 2가 동시에 참조했습니다.

두 번의 참조가 추가되었기 때문에 당연히 3일 줄 알았지만 결과는 2가 될 수 있어요.

 

이런 상황이 반복되면 실제로 참조중이지만 GC에 의해 객체가 메모리에서 해제되어 참조에 실패하게되고

반대의 경우에는 참조가 끝났지만 메모리에 남아있어 메모리 누수가 발생하게 됩니다.

 

 

 

왜 하필 GIL인가?

여기까지 이해한 내용을 바탕으로 곱씹어보니, 참조 카운트마다 개별 락을 걸어도 될 것 같다는 생각이 들었습니다.

물론 당연히 구현 복잡도는 올라가겠지만 현대의 프로그래밍에서 이 정도의 복잡성을 해결하지 못할 리가 없으니까요.

 

하지만, Python이 만들어졌을 때는 1991년으로 싱글 코어 CPU가 일반적이었다고 해요.

GIL은 그 당시 시대성을 반영한 단일 스레드 성능의 최적화 라는 관점에서의 합리적인 선택이었다고 합니다.

 

 

 

 

Node와의 동시성 모델 비교

저를 포함한 Node 개발자 입장에서 동시성 처리에 혼동이 오는 이유는, Node의 동시성과 병렬 처리 방식과 Python의 방식이 다르기 때문이라고 생각합니다.

 

 

Node와 JavaScript의 철학은 다음과 같죠

 

  • JavaScript 코드는 싱글 스레드에서 실행
  • I/O 작업은 libuv의 스레드 풀에서, 또는 OS 비동기 API로 위임
  • I/O 완료를 기다리지 않고 다음 작업을 진행하는 Non-Blocking 모델
  • 콜백과 Promise로 결과 처리

 

const fs = require('fs').promises;

async function readFiles() {
    // 두 파일 읽기가 "동시에" 진행
    const [file1, file2] = await Promise.all([
        fs.readFile('a.txt'),
        fs.readFile('b.txt')
    ]);
    return [file1, file2];
}

 

 

 

Node가 싱글 스레드 + 이벤트 루프인데 반해 CPython은 멀티스레드 + GIL 조합을 사용합니다.

 

 

 

여러 스레드를 생성할 수 있지만, GIL 때문에 Python 코드를 실행하는 스레드는 하나일 수밖에 없습니다.

데이터베이스의 락처럼, 해제를 기다리게 되죠. (단, I/O 작업에는 GIL이 해제되어 다른 스레드가 실행될 수 있습니다.)


NodeJS에서 Promise.all로 동시에 파일을 읽었다면, Python에서는 스레드를 직접 생성해서 처리합니다.

 

import threading

def read_file(filename):
    with open(filename) as f:
        return f.read()

# 스레드 생성
t1 = threading.Thread(target=read_file, args=('a.txt',))
t2 = threading.Thread(target=read_file, args=('b.txt',))

t1.start()
t2.start()
t1.join()
t2.join()

 

 

 

차이점 정리

 

 

 

 

 

CPU bound 와 I/O bound

GIL의 영향이 작업 유형에 따라 달라지는데요.

CPU bound 작업과 I/O bound 작업을 비교해보겠습니다.

 

CPU bound

CPU bound 작업에서는 멀티스레드를 활용하더라도 작업 속도 개선에 도움되지 않는데요. 바로 코드로 확인해보겠습니다.

 

import threading
import multiprocessing
import time

def count_primenum(n):
    """2부터 n-1까지 소수 개수 세기"""
    count = 0
    for i in range(2, n):
        if all(i % j != 0 for j in range(2, int(i**0.5) + 1)):
            count += 1
    return count

def main():
    N = 1000000

    # 순차 실행
    start = time.time()
    count_primenum(N)
    count_primenum(N)
    print(f"순차: {time.time() - start:.2f}초")

    # 멀티스레드 실행
    start = time.time()
    t1 = threading.Thread(target=count_primenum, args=(N,))
    t2 = threading.Thread(target=count_primenum, args=(N,))
    t1.start()
    t2.start()
    t1.join()
    t2.join()
    print(f"멀티스레드: {time.time() - start:.2f}초")

    # 멀티프로세싱 실행
    start = time.time()
    with multiprocessing.Pool(2) as p:
        p.map(count_primenum, [N, N])
    print(f"멀티프로세싱: {time.time() - start:.2f}초")

if __name__ == '__main__':
    main()

 

 

 

순차 실행과 멀티 스레드의 실행 속도가 거의 동일합니다.

GIL 때문에 두 스레드가 번갈아 실행되지만, 결국 한 번에 하나의 스레드만 Python 코드를 실행하기 때문에 총 소요 시간은 순차 실행과 다를 바가 없습니다. 별개로 위 예제에서는 멀티프로세싱은 프로세스를 여러 대 활용하는 것이기 때문에, 영향을 받지 않습니다.

 

공부하면서 코드로 실제로 확인해보고나니 오히려 스레드가 많아지면 GIL 획득과 해제 오버헤드가 추가되어 더 느려질 수도 있겠다는 생각이 드네요. GIL의 간격마다 해제되고 새로 GIL을 획득하는 과정을 반복하게 되기 때문이겠죠.

 

 

I/O bound

위에서 언급했다시피 I/O 작업에서는 조금 다른데요. 블로킹 작업에서는 GIL이 해제됩니다.

 

import threading
import time
import requests

URL = "https://example.com"

def io_work():
    requests.get(URL)

def run_sequential(num_requests=20):
    start = time.time()
    for _ in range(num_requests):
        io_work()
    return time.time() - start

def run_threads(num_threads=20):
    threads = []
    start = time.time()
    for _ in range(num_threads):
        t = threading.Thread(target=io_work)
        t.start()
        threads.append(t)
    for t in threads:
        t.join()
    return time.time() - start

if __name__ == "__main__":
    print(f"순차 (20회): {run_sequential(20):.2f}초")
    print(f"멀티스레드 (20개): {run_threads(20):.2f}초")

 

 

CPU 작업과는 달리 20개 요청이 거의 단일 요청 시간과 비슷하게 완료되는데요.

스레드에서 I/O 대기중에는 GIL이 해제되기 때문에, 다른 스레드에서 GIL을 획득하여 그 시간을 활용할 수 있습니다.

 

 

20개의 스레드는 너무 많기에, 3개만 압축해서 플로우 차트를 그려봤어요.

세 개의 스레드로도 복잡한데요. 요약하자면 Python 코드, 즉 바이트 코드를 실행하기 위해서 GIL이 필요합니다.

 

하지만 I/O bound는 커널 혹은 OS 레벨의 작업이 필요하기 때문에 GIL을 반환하게 돼요. 이 때 다른 스레드에서 GIL을 획득해요.

 

백그라운드 작업이 끝난 뒤에도 마찬가지입니다. 그 뒤에 실행 로직들이 있다면 다시 GIL을 획득해야만 작업할 수 있어요.

 

 

 

 

 

다시 정리하겠습니다.

 

I/O 대기중에는 GIL이 풀리므로 다른 스레드가 그 시간을 활용할 수 있어요.

반면 CPU 작업에서는 GIL을 번갈아 잡기 때문에 스레드가 많을수록 오버헤드가 생깁니다.

 

 

 

 

동시성과 최적화 모두 잡기

GIL에 대해 알아봤어요.

그렇다면 극단적으로 보이는 GIL 위에서, 개발자인 저는 상황에 맞게 동시성을 제한하거나, 동시성을 극대화하는 등 다양한 방향으로 구현을 해야할텐데요. 실제로 어떻게 구현을 해야할까요? 무엇을 어떻게 써야할까요?

 

 

멀티프로세싱

위에서 보여드린 예제처럼, 멀티프로세싱을 활용하는 방법이 있습니다.

 

위 내용들에서 눈치채셨겠지만, GIL은 프로세스 단위로 존재해요.

스레드는 같은 프로세스 내에서 메모리를 공유하기 때문에 GIL로 동기화가 필요하지만, 프로세스는 완전히 독립된 메모리 공간을 가지기 때문에 독립적인 Python 인터프리터와 GIL을 갖게 됩니다. 즉 4개의 프로세스를 띄우면 4개의 GIL이 독립적으로 동작하고, 각 프로세스는 서로의 GIL에 영향을 받지 않아 병렬 실행이 가능해지죠.

 

 

 

아래의 상황에서 고려해볼 수 있을 것 같아요.

 

  1. CPU bound 작업이 명확한 이미지 처리나 연산 처리 등
  2. 작업 단위가 독립적이고 데이터/상태 공유가 적음
  3. 작업 하나의 실행 시간이 프로세스 생성 오버헤드보다 클 때

 

하지만 IPC 오버헤드가 우려되거나, 비동기 처리가 더 효율적일 때는 사용을 피하는 게 좋습니다.

 

 

 

비동기처리

NodeJS의 async/await와 유사한 모델인 asyncio를 사용할 수도 있어요.

 

asyncio는 코루틴 기반의 비동기처리 모델로 싱글 스레드에서 이벤트 루프를 통해 여러 I/O 작업을 동시에 처리합니다.

스레드를 여러 개 만들지 않고도 I/O 대기 시간을 효율적으로 활용할 수 있어요.

 

Node 개발자라면 익숙한 패턴이죠

 

import asyncio
import aiohttp

async def fetch_url(session, url):
    async with session.get(url) as response:
        return await response.text()

async def main():
    urls = ['https://example.com'] * 10

    async with aiohttp.ClientSession() as session:
        tasks = [fetch_url(session, url) for url in urls]
        results = await asyncio.gather(*tasks)

    return results

asyncio.run(main())

 

 

threading(멀티스레딩)과 asyncio는 뭐가 다를까요? 저는 위에서 threading 방식도 I/O bound 작업에 효과적이라고 언급했습니다.

 

핵심 차이는 동시성을 만드는 방식에 있어요.

 

  • threading: OS가 스레드를 관리하고, OS가 컨텍스트 스위칭 결정
  • asyncio: 이벤트 루프가 코루틴을 관리하고, await 지점에서 능동적으로 제어권을 넘김

 

이런 방식의 차이 때문에, asyncio는 스레드를 만들지 않기 때문에 컨텍스트 스위칭 오버헤드가 적고 메모리 사용량도 낮습니다.

동시 요청이 수백 ~ 수천 개로 늘어나도 threading처럼 리소스가 폭발적으로 사용되진 않아요.

 

다만 제약도 있습니다.

 

  • 사용하는 라이브러리가 async를 지원해야함
  • CPU bound 작업에는 여전히 적합하지 않음 (싱글 스레드니까)

 

일반적인 서버 애플리케이션은 네트워크, DB, 파일 등 I/O 작업 비중이 높기 때문에 async 지원 라이브러리를 쓰고 있다면 asyncio가 자연스러운 선택이 될거에요.

 

 

 

GIL을 해제하기

NumPy, Pandas 같은 라이브러리는 C로 작성된 부분에서 GIL을 해제한다고 합니다.

 

import numpy as np

# NumPy 연산은 C 레벨에서 GIL 해제 후 병렬 처리
a = np.random.rand(10000, 10000)
b = np.random.rand(10000, 10000)
c = np.dot(a, b)

 

또, Cython에서는 명시적으로 GIL을 해제할 수 있어요. 마치 free 처럼요

 

# example.pyx
from cython.parallel import prange

def parallel_sum(double[:] arr):
    cdef double total = 0
    cdef int i

    with nogil:  # GIL 해제
        for i in prange(arr.shape[0]):
            total += arr[i]

    return total

 

 

 

 

 

Python ^3.13: Free Threaded Python

Python 3.13부터 Cpython에서는 GIL을 비활성화한 빌드인 free threading을 실험적으로 지원합니다.

자세한 내용은 PEP 703에서 제안한 Making the Global Interpreter Lock Optional in CPython을 확인해보시면 좋습니다.

 

저는 pyenv를 사용해서 한 번 적용해보겠습니다.

 

# free-threaded 버전 확인
pyenv install --list | grep 3.13t

# free-threaded 버전 설치 (3.13t가 있으면)
pyenv install 3.13t-dev  # 또는 3.13.0t 같은 형식

# 해당 디렉토리에서 사용
pyenv local 3.13t-dev

 

import sys

print(sys._is_gil_enabled()) # False = Free-threaded

 

세팅을 마무리하고, 위의 CPU bound의 예제 코드인 primenum 코드를 다시 실행시켜볼게요.

 

 

이전 결과와는 많이 다른 모습을 볼 수 있어요. GIL이 없기 때문에 두 스레드가 각자의 CPU 코어에서 진짜 동시에 실행 된거죠.

 

 

주의사항: 명시적 동기화 필수

Free threaded가 적용된 Python에서는 GIL이 암묵적으로 보장하던 안전성이 사라집니다.

락이 걸리지 않고 동시에 같은 자원을 공유할 수 밖에 없기 때문에, Race Condition이 발생한다고 이해하면 쉬워요.

 

한 번 확인해보겠습니다.

 

import threading

shared_iter = iter(range(100000))
results = []

def consume():
    for item in shared_iter:
        results.append(item)

threads = []
for _ in range(10):
    t = threading.Thread(target=consume)
    t.start()
    threads.append(t)

for t in threads:
    t.join()

print(f"예상: 100000개, 실제: {len(results)}개")
print(f"중복 있음: {len(results) != len(set(results))}")

 

 

간단하게, 여러 스레드에서 공유하는 하나의 이터레이터를 카운팅하는 로직을 만들어봤습니다.

결과는 보시다시피 서로 공유된 자원을 마구마구 침범하는(?) 결과를 보실 수 있어요.

 

이런 문제가 발생하지 않게 하기 위해서는, 공유 자원을 적절하게 관리하는 추가적인 방법을 생각해야합니다.

 

 

 

GIL 제거 로드맵

PEP 703에서 정리한 GIL 제거에 대한 내용을 로드맵 형태로 정리해봤습니다.

 

 

아마 이 GIL이 구시대에 적합한 유물(?)이다보니 제거하는 방향으로 나아가고 있는 것 같아요.

 

 

 

 

정리

제가 GIL을 바라보는 시각은 여전히 부정적이에요.

하지만 저는 개발을 2022년, 매우 현대적인 환경에서 접했고 Python이 태어난 년도와는 근본적으로 여러 환경의 차이가 있습니다.

그 당시의 CPython을 개발할 때, 당시 시대상을 반영한 동시성/성능의 타협점이 아니었을까 생각합니다.

 

Python 또한 이런 문제점들을 개선하기 위해 GIL을 제거하려고 준비하고 있으니 제가 현재 속해있는 레거시 환경에서도 변화를 적용할 준비를 해야겠습니다. (아직 3.9버전대를 사용중이에요)

 

Node에서 Python으로 스택 전환을 하면서, 해당 기술의 컨셉들을 하나씩 파보는 것을 목표로 하고 있어요.

다음 포스팅은, 또 다른 레거시의 산물인 WSGI에 대해 조금 깊게 들여다보려고 합니다.

 

 

 

 

 

References

https://wiki.python.org/moin/GlobalInterpreterLock

https://docs.python.org/3/library/threading.html

https://docs.python.org/3/c-api/init.html

https://peps.python.org/pep-0703

https://peps.python.org/pep-0779

https://docs.python.org/3/howto/free-threading-python.html

https://docs.python.org/3/whatsnew/3.13.html

https://realpython.com/python313-free-threading-jit

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

방명록