
이번 사내 PDF 마스킹 작업을 통해 새벽까지 머리 깨지면서 버그 픽스와 리팩토링을 진행하였습니다.
그러면서 피상적으로 알았던 어플리케이션과 쓰레드, 프로세스와의 관계,
또 힘들게 힘들게 공부했었던 컴퓨터구조와 같은 CS 지식이 실제적이고, 마땅히 개발자로서 알아야하는 것이라는 생각이 들었습니다.
요즘 누구나 AI를 통해 딸깍해서 개발하는 시대에
역으로 가는 것 같지만 깊은 지식으로 개발자로서 차별성을 두기 위해 아래로 깊게 파는 공부를 진행해보려고 합니다.
(물론 저에게는 조금 깊어졌지만 누군가에게는 무릎 밖에 안오는 높이일 수도 있습니다!)
또, 42서울에서 배웠던 이 밑단까지 까보는 이러한 재미가 보통 재밌는게 아닌 것도 같습니다.
물론 긴 고뇌의 시간과 시행착오들이 있겠지만 한번 진행해보려고 합니다.

---
이번 흐름은
Celery에서 CPU 바운드 작업(PDF 읽고 쓰기 등)을 할 때, 멀티스레드가 왜 무력화 되고 이와 같이 python은 왜 느릴까에 대한 흐름으로 진행했습니다.
1. Python의 메모리 구조: 왜 느린가
C에서
int x = 2;
는 컴파일 시점에 int는 4 byte라는 것이 확정됩니다. 컴파일러는 이 정보를 바탕으로 스택에 4바이트만 할당하고, 변수명 x는 컴파일 후 사라져 [rbp-4] 같은 메모리 오프셋으로 대체됩니다. 실행 시에는 mov DWORD [rbp-4], 2라는 기계어 한 줄이면 끝납니다.
반면 Python에서
x = 2
는 사정이 완전히 다릅니다. Python은 dynamic typing 언어이므로, x = 2 다음 줄에 x = "hello"가 올 수 있습니다. 어떤 변수든 실행 중에 타입이 바뀔 수 있기 때문에, 컴파일 시점에 타입과 크기를 확정할 수 없습니다.
그래서 CPython은 모든 값을 PyObject라는 C 구조체로 감싸서 힙(heap)에 저장합니다. PyObject에는 타입 포인터(ob_type), 참조 카운트(ob_refcnt), 실제 값 등이 포함되어 약 28바이트 이상을 차지합니다. C에서 4바이트면 되는 정수가 Python에서는 28바이트짜리 힙 객체가 되는 것입니다.
Python도 실행 전에 전체 코드를 바이트코드(.pyc)로 컴파일하는 단계가 있습니다. 다만 이 컴파일은 문법 검증과 바이트코드 변환만 수행하며, 타입 추론이나 메모리 크기 결정 같은 최적화는 하지 않습니다. 실제 타입과 크기는 바이트코드를 한 줄씩 실행하는 런타임에 결정됩니다.
2. GIL이 필요한 이유: 참조 카운트의 thread safety
Python(CPython)은 메모리 관리를 위해 참조 카운트(reference counting) 방식의 GC를 사용합니다. 모든 PyObject의 ob_refcnt 필드가 몇 명이 참조하는가를 추적하고, 이 값이 0이 되면 메모리를 해제합니다.
문제는 이 ob_refcnt가 힙에 있는 공유 데이터라는 것입니다. 멀티스레드 환경에서 여러 스레드가 동시에 같은 객체의 ob_refcnt를 수정하면 race condition이 발생합니다.
이를 방지하기 위해 CPython은 GIL(Global Interpreter Lock)을 도입했습니다. GIL은 프로세스 전체에 하나만 존재하는 mutex로, 한 순간에 하나의 스레드만 Python 바이트코드를 실행할 수 있도록 강제합니다. 이 방식은 참조 카운트를 안전하게 보호하지만, 대가로 멀티스레드의 병렬 실행이 진행되지 않습니다.
찾아보니 GIL이 도입된 1992년에는 개인 PC가 싱글코어였기 때문에, 어차피 진정한 병렬 실행이 불가능했으므로 GIL은 합리적인 선택이었습니다. 하지만 멀티코어가 보편화된 현재 이 설계는 CPU bound 작업에서 병목이 됩니다.
3. GIL의 탈출구: I/O 대기와 C 확장
GIL이 항상 스레드를 막는 것은 아닙니다. 두 가지 상황에서 GIL이 해제됩니다.
I/O 대기: 네트워크 요청, 파일 읽기, DB 쿼리 등에서 OS에서 오래 기다려야하는 요청을 하는 순간 GIL이 풀립니다. CPU가 할 일이 없는 대기 시간에는 다른 스레드가 GIL을 획득하여 실행할 수 있습니다.
-> 즉, 프로세스 당 GIL은 하나씩 존재하고, I/O 바운드 작업과 같은 것들은 멀티스레드로 사용이 가능한 것 입니다!!!
C 확장: NumPy, Pillow 같은 라이브러리의 내부 C 코드는 ob_refcnt를 건드리지 않으므로, 명시적으로 GIL을 해제하고 자체 연산을 수행할 수 있다고 합니다!!
따라서 I/O bound 작업에서는 멀티스레드가 효과적입니다.
4. Celery에서의 실전 적용
이제 여기가 마지막 종착지입니다!
Celery로 PDF 렌더링(CPU bound) 작업을 처리할 때:
- -pool=threads (pthread 기반)로 실행하면, 모든 스레드가 하나의 GIL을 공유하므로 한 순간에 하나의 스레드만 실행됩니다. CPU bound 작업에서는 사실상 직렬 실행과 다름없습니다.
- -pool=prefork (프로세스 기반)로 변경하면, 각 워커 프로세스가 독립된 Python 인터프리터와 독립된 GIL을 가집니다. 따라서 4개의 워커가 4개의 CPU 코어에서 병렬로 PDF를 동시 처리할 수 있습니다.
- 근데 prefork는 프로세스를 복사하는 것이기 때문에 Stack만 복사하는 쓰레드와 다르게 메모리를 더 먹을 수 밖에 없습니다! 이래서 CPU 바운드 작업을 prefork로 진행하면 금방 memory가 찹니다!
반대로 이메일 발송, 외부 API 호출 같은 I/O bound 작업에서는 --pool=threads가 더 효율적입니다. I/O 대기 중 GIL이 해제되므로 적은 메모리로 높은 동시성을 확보할 수 있기 때문입니다.
요약
Celery로 PDF 마스킹 작업을 할 때, 스레드 값들이 오염되는 것을 방지하고자 계속 집중하다보니
결국 Python과 쓰레드/프로세스 간의 관계를 제대로 이해하지 못해서 병렬처리를 했다고 생각했지만 못하고 있었습니다.
이러한 공부를 기반으로 이제는 진짜 병렬처리를 할 수 있고 실질적으로
동료의 마스킹 작업을 하는 EC2의 리팩토링까지 진행하여
• 마스킹 속도: 120초 → 60초 (2배 ↑)
• PDF 제작 속도: 60초 → 15초 (4배 ↑)
의 성과를 볼 수 있게 되었습니다!
| 구분 | 원인 | 결과 |
| Python이 느린 이유 | 동적 타입 → 모든 값이 힙의 PyObject → 매번 해석 실행 | C 대비 메모리 7배, 속도 수십 배 차이 |
| GIL이 존재하는 이유 | 힙의 ob_refcnt를 race condition 없이 보호하기 위해 | 멀티스레드의 CPU bound 병렬 실행 불가 |
| CPU bound에서의 해결 | prefork로 프로세스를 분리하여 GIL을 각각 가짐 | 진정한 병렬 처리 가능 |
| I/O bound에서의 해결 | thread pool 사용, I/O 대기 시 GIL 해제 | 적은 리소스로 높은 동시성 |
'CS' 카테고리의 다른 글
| Celery 태스크가 50% 확률로 사라지는 미스터리 (18시간의 삽질기) (0) | 2026.03.24 |
|---|