[Python] 절대 족쇄 GIL을 넘어 빛의 속도로: Cython과 JIT 분석
파이썬의 속도 한계인 GIL의 원인과 이를 극복하기 위한 Cython, Numba(JIT) 기술을 알아본다.
취업 서류 제출란에 Python 활용 능력을 상·중·하 세 가지 중 선택하는 것을 보고, 과연 나는 Python에 대해 얼마나 이해하고 있는지 정리하면서 알아보기로 했다.
파이썬은 쉽고 강력하다. 하지만 “느리다”는 꼬리표는 파이썬 개발자들의 오랜 콤플렉스다. 단순히 알고리즘을 개선하는 것을 넘어, 언어의 내부 구조를 이해하고 한계를 돌파하는 방법들을 정리해본다.
이 글은 파이썬의 속도를 제어하는 거대한 족쇄 GIL에서 시작해, C언어의 속도를 빌려오는 Cython, 그리고 최신 컴파일 기술인 Numba(JIT)까지 이어지는 최적화 여정을 담았다.
1. 첫 번째 관문: Python을 느리게하는 족쇄 GIL
자바(Java)와 같은 언어는 스레드를 여러 개 만들면 CPU 코어를 모두 사용하여 병렬 처리를 수행한다. 파이썬도 threading 라이브러리가 존재한다. 그렇다면 여기서 첫 번째 의문이 든다.
파이썬에서도 스레드를 4개, 8개 만들면 당연히 4배, 8배 빨라져야 하는 것 아닐까?
안타깝게도 답은 “아니오”다. 오히려 더 느려질 수도 있다. 그 범인은 바로 GIL (Global Interpreter Lock)이다.
파이썬(CPython)은 메모리 관리(Reference Counting)의 안전성을 위해 “한 번에 단 하나의 스레드만 파이썬 코드를 실행할 수 있다”는 룰을 걸어놨다. 비유하자면, 요리사(스레드)는 8명인데 칼(GIL)이 단 한 자루뿐인 상황이다. 요리사들은 칼을 차지하기 위해 서로 눈치 게임(Context Switching)을 해야 하고, 이 과정에서 오버헤드가 발생해 오히려 싱글 스레드보다 성능이 떨어지기도 한다.
해결책은 무엇인가?
- CPU 연산이 필요하다면 스레드 대신 멀티프로세싱(Multiprocessing)을 써서 아예 ‘주방’을 여러 개 지어야 한다.
- I/O 작업(네트워크, 파일) 위주라면 GIL은 큰 문제가 되지 않는다.
2. 두 번째 관문: 파이썬에 C의 옷을 입히다, Cython
GIL 때문에 멀티스레딩이 막혔고, 파이썬의 동적 타이핑 때문에 연산 자체가 느리다면 어떻게 해야 할까? 아예 파이썬 코드를 C언어로 바꿔버리면 어떨까?
파이썬의 문법은 유지하면서 C언어의 성능을 낼 수는 없을까?
이 질문에 대한 대답이 바로 Cython이다. Cython은 파이썬 코드에 정적 타입(Type)을 명시하여 C 코드로 변환(Transpile)하고 컴파일하는 도구다.
1
2
3
4
5
6
7
8
9
10
11
12
13
# 일반 파이썬: 변수 타입 모름 (느림)
def sum(n):
total = 0
for i in range(n): total += i
return total
# Cython: 변수 타입 확정 (빠름)
def sum_c(int n):
cdef int i
cdef long long total = 0
for i in range(n): total += i
return total
Cython을 사용하면 파이썬의 객체 오버헤드를 없애고 순수 C 연산으로 돌아가게 만들 수 있다. 심지어 with nogil: 구문을 사용해 합법적으로 GIL을 해제하고 멀티코어를 풀가동할 수도 있다. 속도는 10배에서 100배까지 빨라진다. 단점이라면, 별도의 빌드 과정(setup.py)이 필요하고 코드를 수정해야 한다는 점이다.
3. 세 번째 관문: 알아서 해주는 마법사, Numba (JIT)
Cython은 확실히 빠르지만, 코드를 수정하고 빌드하는 과정이 번거롭다. 개발자들은 더 게으르고 스마트한 방법을 원했다.
내가 일일이 타입을 지정하지 않아도, 코드가 실행될 때 알아서 최적화해주는 건 없을까?
여기서 Numba가 등장한다. 함수 위에 @jit 데코레이터 하나만 붙이면 끝이다. Numba는 LLVM이라는 강력한 컴파일러 엔진을 이용해, 실행 시점(Runtime)에 파이썬 코드를 분석하고 기계어(Machine Code)로 번역한다. 이를 JIT(Just-In-Time) 컴파일이라고 한다.
LLVM이란?
컴파일러를 만들기 위한 도구 모음으로, Numba가 파이썬 코드를 분석할 때 사용하는 핵심 엔진
1
2
3
4
5
6
from numba import jit
@jit(nopython=True) # 이 한 줄이면 끝난다.
def sum_array(n):
...
위 예시에서 Numba는 함수가 호출될 때 들어오는 인자의 타입을 보고 덧셈인 것을 파악한 뒤, 최적화된 기계어를 생성해 캐싱해둔다.
4. 심층 분석: JIT는 왜 빠른가? (Overhead vs Payoff)
여기서 굉장히 날카로운 의문이 들 수 있다.
LLVM(Numba)도 결국 실행할 때 타입을 파악해야 하지 않나? 파이썬 인터프리터도 매번 타입을 파악하는데, 왜 LLVM 방식이 더 빠른 것인가?
이것은 “매번 검사하느냐” vs “한 번 검사하고 기억하느냐”의 차이다.
- 파이썬(인터프리터): 반복문을 1억 번 돌면, 변수의 타입을 1억 번 검사한다. 파이썬은 유연성을 위해 “변수가 언제든 변할 수 있다”고 가정하기 때문이다.
- Numba(JIT): 진입 시점에 딱 한 번 비싼 비용을 들여 타입을 검사하고 기계어를 만든다. 그리고 반복문 1억 번을 돌 때는 검사 없이 프리패스한다.
사실, 타입을 파악하고 기계어로 바꾸는 ‘초기 비용’ 자체는 Numba가 파이썬의 단순 검사보다 훨씬 느리다. 하지만 그 비용을 한 번 지불함으로써, 뒤에 이어지는 수억 번의 연산에서 압도적인 이득을 챙기는 것이다. (마치 놀이공원에서 줄 서서 자유이용권을 끊는 시간은 걸리지만, 이후엔 줄을 안 서고 타는 것과 같다.)
5. 근원적 질문: 파이썬은 왜 처음부터 JIT를 안 썼나?
Numba나 JIT 기술이 이렇게 강력하다면, 마지막으로 이런 의문이 남는다.
그렇게 좋다면, 파이썬(CPython)은 왜 애초에 JIT 방식으로 만들어지지 않았을까? 왜 이렇게 비효율적인 방식을 고수하나?
이것은 기술력의 문제가 아니라 ‘철학’과 ‘생태계’의 선택이었다.
- 동적 타이핑의 유연성: 파이썬은 루프 중간에 변수 타입이 숫자가 됐다가 문자열이 되어도 돌아가는 언어다. JIT는 “타입이 고정된다”는 전제가 깨지면 최적화가 풀리거나 에러가 난다.
- 시동 속도와 가벼움: LLVM 같은 컴파일러 엔진은 무겁다. 파이썬은 가볍고 빠르게 켜지는 스크립트 언어를 지향했다.
- C 라이브러리(NumPy)와의 호환성: 이것이 결정적이다. 파이썬의 느린 객체 구조(PyObject)를 기반으로 만들어진 수만 개의 C 확장 라이브러리들이 생태계를 지탱하고 있다. 내부 구조를 뜯어고치면 이 생태계가 붕괴될 위험이 있다.
마치며: 파이썬 개발자의 무기
우리는 파이썬이 느리다는 사실을 부정할 필요가 없다. 대신 그 느림을 극복할 다양한 무기가 있음을 알게 되었다.
- 일반적인 로직은 Python의 간결함으로 승부한다.
- 병렬 처리가 필요하면 Multiprocessing이나 Asyncio를 쓴다.
- C 라이브러리를 엮거나 정교한 튜닝이 필요하면 Cython을 쓴다.
- 수학 연산과 반복문을 손쉽게 가속하고 싶다면 Numba를 쓴다.
“파이썬은 느리다”는 말은 반은 맞고 반은 틀리다. “파이썬을 모르는 사람에게는 느리지만, 파이썬을 깊게 아는 사람에게는 무엇보다 빠르다.” 이것이 최적화의 결론이다.