세 언어가 동시성을 다루는 방식이 완전히 다르다. 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 의 깊은 동작은 별도 글에 정리해 두었다.

세 모델 비교

세 동시성 모델이 같은 백엔드 요청 처리에서 어디로 갈리는지 정리하면 다음과 같다.

항목PythonJavaGo
기본 단위스레드 (GIL 직렬화)Platform thread / Virtual threadGoroutine
단위당 비용OS 스레드 (MB)Platform: MB / Virtual: KBKB (초기 2KB)
멀티코어 CPU boundmultiprocessing 필요Platform: 가능 / Virtual: 가능 (단, pin 주의)기본 가능
멀티코어 I/O boundasyncio 또는 thread (GIL 해제)Virtual thread 친화Goroutine 친화
동기화 모델Lock, asyncio primitivessynchronized, java.util.concurrentChannel (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 중 어디로 갈지가 갈린다.

참고