MQ 비동기 도입 전/후 테스트를 진행하며, 파이썬의 I/O 작업 처리 방식에 궁금증이 생겼다. 1초가 걸리는 요청을 초당 100개씩 보냈는데도 1초마다 100개를 전부 응답했기 때문이다. 파이썬은 GIL 때문에 한번에 하나의 작업만 처리하는 걸로 알고 있었는데다가, 내가 비동기 처리를 하지 않았는데도 비동기처럼 동작하길래 무슨 원리인지 궁금해서 파 보았다.

GIL(Global Interpreter Lock)
GIL은 Global Interpreter Lock의 약자로서, 간단히 말하면 파이썬에서 하나의 프로세스(워커)가 동시에 하나의 CPU 작업만 실행하게 하는 제약조건이다. 이 제약 때문에 파이썬 코드는 언제나 동시에 단 한개만 실행된다.
그러나 이것이 싱글스레드로 동작한다는 말은 아니다. 필요에 따라 스레드를 추가로 띄워 멀티 스레드로 동작하지만, 동시에 실행되는 스레드가 하나일 뿐이다. 즉, 몇 개의 스레드를 활용 중이든 현재 CPU를 점유한 단 하나의 스레드만 동작하고 나머지는 블로킹(Blocking) 되어 있다.
GIL을 우회하는 방법은 하나뿐이다. 프로세스(워커)를 여러 개 띄우는 것이다. GIL의 영향범위는 프로세스 내부이기 때문에 다른 프로세스에 간섭할 수 없다. 그러나 이 방식도 한계가 있는게, 프로세스는 CPU 코어 수 만큼만 확장할 수 있다. 하나의 스레드는 동시에 하나의 코어만 점유할 수 있기 때문이다. 게다가 프로세스를 여러 개 띄우면 메모리 낭비로 이어지기도 한다. 즉 이것은 제약을 어느 정도 완화하는 거지, 해결 방법은 아니다.
I/O Bound
I/O Bound 작업에서의 Python 동작방식
비동기 처리를 하지 않은 상태에서, I/O 작업이 동시에 100개가 들어왔다고 해 보자(예: 외부 API 호출). 이 때 스레드와 요청은 1:1로 묶여 블로킹 되지만, I/O 대기시간엔 CPU를 점유하지 않기 때문에 자발적으로 GIL을 놓아 다른 스레드를 추가하고 실행될 기회를 준다.
즉 스레드 생성 -> 블로킹 -> GIL 놓음 -> 다음 스레드 생성 -> 블로킹 ... 이런 순서다.
그러나 OS 스레드는 무거운데다, 문맥 전환 비용이 커서 이것은 절대 효율적인 방식이 아니다. 요청이 만약 1000개, 10000개 들어온다면? 스레드를 그만큼 많이 띄우게 되어 메모리가 터져버릴 것이다.
I/O Bound 작업에서의 Go 동작방식
우선 Go는 GIL 제약이 없기 때문에 단 하나의 프로세스로 멀티스레드를 활용할 수 있다. 즉, 코어를 전부 활용할 수 있다. Go는 특이하게도 Goroutine 이라는 스레드보다 더 작은 자체 작업 단위를 사용하는데, Goroutine의 오버헤드는 스레드 생성/삭제/문맥전환 비용보다 훨씬 적기 때문에 수십만 개를 띄워도 문제가 없다. 따라서 하나의 요청 당 하나의 Goroutine을 띄워서, 이것을 멀티스레드에 적절히 분배한다.
하나의 프로세스 내에서 멀티스레드가 멀티코어를 효율적으로 활용하기 때문에, 이것만으로도 충분히 빠른 성능을 낸다. 그러나 가장 큰 차이를 내는 건 역시 파이썬의 OS 스레드와 Go의 Goroutine 효율 차이이다.
CPU Bound
CPU Bound 작업에서의 Python 동작방식
I/O 작업에서는 OS스레드를 무리하게 띄워 어떻게든 처리가 가능했으나, CPU Bound로 넘어오면 다르다. CPU Bound 작업은 연산하는 동안 GIL을 놓지 않기 때문에, 멀티 코어의 병렬 처리 이점을 얻을 수 없으며 정확히 프로세스(워커) 수 만큼만 동시에 처리할 수 있다. 만약 프로세스가 4개이고 동시 요청 100개가 들어온다면, 4개의 요청만 동시에 처리하고 나머지 96개는 뒤늦게 처리되거나 전부 드랍될 것이다.
CPU Bound 작업에서의 Go 동작방식
GIL 제약이 없기 때문에 병렬 처리가 가능하다. 요청 수 만큼 Goroutine을 띄우고 연산 작업을 맡기며, 이 Goroutine들을 멀티스레드에 적절하게 할당하여 멀티스레드 + 멀티코어의 성능을 최대한 활용한 병렬 처리를 실현한다(M:N 스케줄링).
여러 개 프로세스에서 코어를 하나씩 쓰는 거나(Python), 하나의 프로세스에서 코어를 여러개 쓰는 거나(Go) 결국 코어를 전부 사용한다는 건 같지 않나?
그럴 것 같지만, 자원 공유 및 문맥 전환 비용에서 큰 차이가 있다. 각 프로세스는 독립적인 공간을 가지기 때문에 메모리 복사 및 초기화 비용이 크고, 메모리 사용량이 많아진다. 또한 IPC(프로세스간 통신 메커니즘)가 필요한데, 이는 커널 개입이 필요하여 무겁고 느리다. 또한 문맥 전환 시 매우 큰 프로세스 정보를 저장하고 복원해야 하므로, 비용이 높다.
하나의 프로세스 내에서 동작하는 스레드/Goroutine은 하나의 메모리 공간을 공유하며, 자원 공유나 전환 비용이 낮다. 또한 Goroutine 전환은 Go 런타임에 자체적으로 이루어지고, OS 스레드 문맥 전환보다 훨씬 가볍기 때문에 효율적이다.
-> 즉, 멀티프로세스(Python)에서는 자원 공유/문맥 전환 오버헤드가 Go에 비해 훨씬 높기 때문에 비효율적이다.
Go의 M:N 스케줄링
| 구분 | M (Goroutine) | N (OS 스레드) |
| 역할 | 작업 단위 (가벼움, 수많은 요청) | 실행 단위 (무거움, CPU 코어 수와 비슷한 수준으로 관리) |
| 특징 | Go 런타임이 관리 (생성/전환 비용 극히 낮음) | OS 커널이 관리 (생성/전환 비용 및 메모리 사용량 높음) |
M개의 Goroutine을 N개의 OS스레드에 유연하게 매핑하여 실행하는, Go 런타임의 동시성 관리 기법이다.
Go는 CPU 코어 수와 같거나 약간 많은 수의 OS 스레드를 N개 생성한다. 이 스레드 위에서 수십만 개의 Goroutine M개를, Go 런타임이 자체적으로 관리하며 실행한다. Goroutine 간 전환이 OS 커널 개입 없이 Go 런타임 내에서 이루어지기 때문에, Python처럼 무거운 OS 스레드를 전환하는 오버헤드를 원천적으로 제거한다. 또한 Goroutine이 I/O 대기 상태에 들어가면, Go는 해당 스레드를 묶어두지 않고 대기 중인 Goroutine을 제거 후 다른 Goroutine을 즉시 투입한다(논블로킹).
-> 결론적으로, Go는 무거운 OS 스레드(N)은 CPU 활용에만 전념하게 하고, 수많은 요청(M)은 가벼운 Goroutine으로 처리하여 자원 효율성과 병렬성을 극대화한다.
'Development > [msa-perf-lab] Flask & Go MSA 성능 실험 프로젝트' 카테고리의 다른 글
| [msa-perf-lab] 메시지 큐(RabbitMQ) 비동기 처리 전후 I/O 작업 성능 테스트 (1) | 2025.11.11 |
|---|---|
| [msa-perf-lab] REST vs gRPC 성능 비교 및 InfluxDB + Grafana 시각화 (0) | 2025.11.07 |
| [msa-perf-lab] Flask <-> Go(Gin) gRPC 환경 세팅 및 내부 gRPC 통신 테스트 (0) | 2025.11.03 |
| [msa-perf-lab] Flask & PostgreSQL 개발환경 세팅 및 컨테이너화, docker-compose 그룹화 (0) | 2025.10.31 |
| [msa-perf-lab] MSA 성능 실험 프로젝트 - Flask & Go(Gin) (0) | 2025.10.31 |