세 언어가 동시성을 다루는 방식이 완전히 다르다. CPython 은 전역 인터프리터 락(GIL, Global Interpreter Lock)으로 멀티스레드 실행을 직렬화한다. Java 는 OS 스레드 1:1 매핑이 기본이었다가 Java 21 부터 가상 스레드(Virtual Thread)로 다중화 모델을 추가했다. Go 는 처음부터 M:N 스케줄러 위의 goroutine 으로 시작했다. 같은 백엔드 요청 처리에서 모델 선택이 thread-per-request, reactive, multiprocessing 중 어디로 갈지를 결정한다.
Python GIL
CPython 의 GIL 은 인터프리터 자체를 보호하는 락이다. 어떤 시점에든 GIL 을 잡은 스레드 한 개만 파이썬 바이트코드를 실행할 수 있다.
sequenceDiagram
participant T1 as Thread 1
participant GIL as GIL
participant T2 as Thread 2
T1->>GIL: acquire
Note over T1: 바이트코드 실행
T1->>GIL: release (interval 도달)
T2->>GIL: acquire
Note over T2: 바이트코드 실행
T2->>GIL: release
T1->>GIL: acquire
결과는 명확하다. 순수 파이썬 코드로 CPU bound 작업을 멀티스레드로 돌리면 코어 하나만 쓴다. 단, I/O 호출(소켓 read, DB 조회, 파일 I/O)에 들어가는 동안 GIL 이 해제된다. 그래서 I/O bound 워크로드에서는 멀티스레드가 효과를 낸다. NumPy 나 일부 C extension 도 CPU 작업 중 GIL 을 풀고 동작하도록 작성된 경우가 많다.
CPU bound 를 진짜로 병렬화하려면 우회법이 필요하다.
- multiprocessing — 프로세스를 여러 개 띄워 GIL 을 우회. IPC(Inter-Process Communication) 비용이 크지만 독립적 메모리 공간을 갖는다.
- asyncio — 단일 스레드 이벤트 루프. 협조적 스케줄링으로 수많은 I/O 작업을 동시에 다룬다. GIL 은 의미 없음.
- C extension 으로 GIL 해제 — CPU heavy 부분을 C 로 작성하고 그 안에서
Py_BEGIN_ALLOW_THREADS로 해제.
Python 3.13 에서 free-threaded build 가 실험적으로 도입됐다(PEP 703). --disable-gil 옵션으로 GIL 없는 인터프리터를 빌드할 수 있다. PEP 779 (2025) 로 supported 단계로 진입했지만 기본 빌드는 여전히 GIL 이다. 라이브러리 생태계가 free-threaded 환경에서 안전하게 동작하는지 검증되는 데 시간이 더 필요하다.
Java Virtual Thread
전통적인 Java 스레드는 OS 스레드와 1:1 매핑 된다. JVM 의 Thread 객체 하나가 OS 의 kernel 스레드 하나를 점유한다. 스택은 기본 1MB (-Xss 로 조정), 컨텍스트 스위칭은 kernel 영역. 수천 개를 동시에 띄우려 하면 메모리와 스케줄링 비용이 빠르게 한계에 도달한다.
이 제약 때문에 thread-per-request 모델은 한동안 reactive 프레임워크(Spring WebFlux, Vert.x)에 자리를 내줬다. 한 요청에 한 스레드를 묶는 단순한 모델이 트래픽이 늘면 무너진다.
Java 21 에서 가상 스레드 가 GA 됐다(JEP 444). 가상 스레드는 JVM 이 관리하는 경량 스레드로, 여러 가상 스레드가 소수의 운반 스레드(Carrier Thread, OS 스레드) 위에서 다중화된다.
block-beta
columns 4
v1["VT 1"]
v2["VT 2"]
v3["VT 3"]
vn["VT N (수만)"]
space:4
c1["Carrier
(OS thread)"]
c2["Carrier"]
c3["Carrier"]
c4["Carrier
(보통 CPU 수)"]
style v1 fill:#C8E6C9
style v2 fill:#C8E6C9
style v3 fill:#C8E6C9
style vn fill:#C8E6C9
style c1 fill:#90CAF9
style c2 fill:#90CAF9
style c3 fill:#90CAF9
style c4 fill:#90CAF9
핵심은 blocking I/O 처리다. 가상 스레드가 Socket.read 같은 blocking 호출에 들어가면 JVM 이 그 가상 스레드를 운반 스레드에서 떼어내(unmount) 다른 가상 스레드가 운반 스레드를 쓰게 한다. I/O 가 완료되면 다시 어느 운반 스레드든 가용한 곳에 mount 되어 실행을 이어간다. 이 메커니즘이 continuation 이다.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 10_000).forEach(i -> {
executor.submit(() -> {
// blocking I/O 호출 — carrier 를 점유하지 않음
response = httpClient.send(request, BodyHandlers.ofString());
return response.body();
});
});
}
수만 개의 가상 스레드가 자연스럽게 동작한다. 코드 모델은 익숙한 thread-per-request 그대로지만 비용 구조는 다르다. 단, CPU heavy 작업이나 synchronized 블록 안에서는 운반 스레드가 핀(pin)되어 가상 스레드가 떨어지지 않으므로 다른 가상 스레드가 그 운반 스레드를 못 쓴다. 이 한계는 Java 24 (JEP 491) 에서 일부 해소될 예정이다.
Go Goroutine
Go 는 처음부터 M:N 스케줄링을 전제로 설계됐다. goroutine 은 Go 런타임이 관리하는 경량 스레드로, 초기 스택이 2KB 이고 필요에 따라 늘어난다. go 키워드 하나로 생성되고, 수십만 개를 같은 주소 공간에서 동시에 띄울 수 있다.
for i := 0; i < 100_000; i++ {
go func(id int) {
// I/O 또는 CPU 작업
}(i)
}
Go 런타임은 GMP 스케줄러 로 goroutine 을 적은 수의 OS 스레드에 다중화한다. G 는 goroutine, M 은 OS 스레드, P 는 논리적 프로세서다 (GOMAXPROCS 로 P 의 수 조정, 기본은 CPU 코어 수). goroutine 이 시스템 콜로 blocking 되면 런타임이 같은 P 의 다른 goroutine 을 다른 M 에 옮겨 실행을 계속한다.
flowchart TB
subgraph runtime["Go Runtime"]
P1["P
(local queue)"]
P2["P
(local queue)"]
GRQ["Global queue"]
end
subgraph os["OS"]
M1["M (OS thread)"]
M2["M (OS thread)"]
end
P1 --> M1
P2 --> M2
GRQ -.-> P1
GRQ -.-> P2
Go 동시성의 또 다른 핵심은 CSP(Communicating Sequential Processes)다. “메모리를 공유해서 통신하지 말고, 통신해서 메모리를 공유하라.” 락 대신 채널로 데이터를 넘기는 모델이 기본 권장이다. 락도 sync 패키지로 제공되지만 채널 우선이 관례다.
goroutine 의 경량성과 스케줄러의 자동 다중화 덕분에 thread-per-request 와 유사한 단순한 모델로 수십만 동시 요청을 처리한다. 운반 스레드의 핀 같은 함정도 적어서 의사결정이 단순하다. Go 의 동시성 모델과 GMP 의 깊은 동작은 별도 글에 정리해 두었다.
세 모델 비교
세 동시성 모델이 같은 백엔드 요청 처리에서 어디로 갈리는지 정리하면 다음과 같다.
| 항목 | Python | Java | Go |
|---|---|---|---|
| 기본 단위 | 스레드 (GIL 직렬화) | Platform thread / Virtual thread | Goroutine |
| 단위당 비용 | OS 스레드 (MB) | Platform: MB / Virtual: KB | KB (초기 2KB) |
| 멀티코어 CPU bound | multiprocessing 필요 | Platform: 가능 / Virtual: 가능 (단, pin 주의) | 기본 가능 |
| 멀티코어 I/O bound | asyncio 또는 thread (GIL 해제) | Virtual thread 친화 | Goroutine 친화 |
| 동기화 모델 | Lock, asyncio primitives | synchronized, java.util.concurrent | Channel (CSP) + sync |
| 운영 관행 | asyncio + multiprocessing 혼용 | Java 21+ virtual thread 마이그레이션 | thread-per-request 자연스럽게 |
Python 동시성이 적합한 경우:
- I/O bound 워크로드 (asyncio 단일 스레드 이벤트 루프)
- CPU bound 가 명확히 분리되어 multiprocessing 또는 C extension 으로 격리 가능
- 단순한 스크립트성 워크로드
Java Virtual Thread 가 적합한 경우:
- 익숙한 thread-per-request 모델로 수만 동시 요청 처리
- 기존 JVM 생태계 (JDBC, HTTP client) 활용
synchronized블록과 CPU heavy 작업의 비중이 작음
Go Goroutine 이 적합한 경우:
- 처음부터 동시성을 전제로 한 새 서비스
- 단일 알고리즘으로 단순한 운영 의사결정
- channel 기반 통신이 도메인에 잘 맞음
세 모델이 같은 멀티코어 환경에서 다른 결정 위에 서 있다. Python 은 GIL 의 단순함을 유지한 채 우회법을 표준화했고, Java 는 OS 스레드의 비용을 가상 스레드로 늦게나마 우회했으며, Go 는 처음부터 경량 goroutine 으로 시작했다. 같은 요청 처리 패턴도 모델에 따라 thread-per-request, reactive, multiprocessing 중 어디로 갈지가 갈린다.
참고
- 모든 것은 값 복사다 — Go, Java, Python 의 메모리 전달 — 함수 인자 전달의 공통 메모리 모델. 동시성 단위 사이에 데이터를 어떻게 넘기느냐의 기반.
- 세 가지 가비지 컬렉터 — GC 알고리즘의 차이가 STW 와 멀티스레드 워크로드의 지연 특성을 가른다.
- Go Concurrency 모델 — Goroutine, GMP 스케줄러, channel 의 깊은 동작.