Puma, 동시성, GVL이 성능에 미치는 영향 이해하기

Understanding Puma, Concurrency, and the Effect of the GVL on Performance

3줄 요약

  • Ruby의 Global VM Lock(GVL)은 한 프로세스 내에서 CPU 바운드 Ruby 코드의 병렬 실행을 제한합니다.
  • Puma는 프로세스(Worker)와 스레드를 활용하여 요청을 처리하며, GVL 때문에 CPU 코어를 효율적으로 사용하려면 `WEB_CONCURRENCY`를 코어 수에 맞춰 여러 프로세스를 사용해야 합니다.
  • I/O 바운드 작업이 많은 애플리케이션은 스레드를 통해 CPU 유휴 시간을 활용하여 동시성을 높일 수 있지만, 과도한 스레드 수는 GVL 경합을 유발할 수 있습니다.

본 글은 Ruby on Rails 애플리케이션의 기본 웹 서버인 Puma의 동작 방식, 동시성(Concurrency)과 병렬성(Parallelism)의 차이, 그리고 Ruby의 Global VM Lock(GVL)이 애플리케이션 성능에 미치는 영향에 대해 깊이 있게 탐구합니다. Rails 8 이상 버전의 기본 Puma 설정 분석을 시작으로, 웹 애플리케이션의 작업 유형(CPU 바운드 vs I/O 바운드)을 이해하고, GVL이 어떻게 단일 프로세스 내에서의 병렬 처리를 제약하는지 설명합니다. 궁극적으로 이러한 개념들이 Puma의 프로세스 및 스레드 구성에 어떤 영향을 미치며, 성능 최적화를 위해 무엇을 고려해야 하는지 그 배경 지식을 제공합니다.

Puma는 TCP 소켓을 통해 들어오는 요청을 수신하며, Reactor 패턴을 구현한 별도의 스레드가 소켓 백로그에서 연결을 읽어들입니다. 요청이 완전히 버퍼링되면 스레드 풀의 @todo 큐에 추가되고, 스레드 풀의 스레드가 요청을 가져와 Rack 애플리케이션(Rails 앱)을 실행하고 응답을 생성합니다. Puma는 크게 Single ModeCluster Mode로 동작합니다. Single Mode는 단일 프로세스만 사용하며 트래픽이 적은 경우에 적합하고, Cluster Mode는 마스터 프로세스가 여러 자식 프로세스(worker)를 생성하여 요청을 처리합니다. Rails 8의 기본 config/puma.rb 설정은 환경 변수가 지정되지 않은 경우 threads 3, 3workers 1로 설정되어, 기본적으로 단일 프로세스에 3개의 스레드가 요청을 동시 처리할 수 있도록 구성됩니다.

웹 애플리케이션의 작업은 크게 CPU 바운드I/O 바운드로 나뉩니다. CPU 바운드 작업은 Ruby 코드 실행, 뷰 렌더링 등 CPU가 직접 계산하는 부분이며, I/O 바운드 작업은 데이터베이스 호출, 네트워크 요청, 파일 시스템 접근 등 CPU가 유휴 상태가 되는 부분입니다. 프로그램의 성능이 CPU 속도에 의해 제한되면 CPU 바운드, I/O 작업 속도에 의해 제한되면 I/O 바운드입니다. I/O 바운드 작업 중 CPU가 유휴 상태가 되는 것을 방지하고 효율성을 높이기 위해 여러 스레드를 사용하여 CPU가 한 스레드의 I/O 대기 시간 동안 다른 스레드의 CPU 작업을 처리하도록 할 수 있습니다.

동시성(Concurrency)은 여러 작업을 처리하는 것처럼 보이게 하는 것이고, 병렬성(Parallelism)은 여러 작업을 동시에 실행하는 것입니다. Ruby의 Global VM Lock (GVL)은 Ruby VM의 내부 상태(예: 메모리 관리)의 스레드 안전성을 보장하기 위해 존재합니다. GVL은 단일 Ruby 프로세스 내에서 CPU 바운드 Ruby 코드동시 실행을 방지합니다. 즉, 아무리 멀티 코어 CPU를 사용하더라도, 한 프로세스 내의 여러 스레드는 GVL 때문에 Ruby 코드를 병렬로 실행할 수 없습니다. 하지만 GVL은 I/O 작업 중에는 해제되므로, I/O 바운드 작업이 많은 경우 여러 스레드가 I/O와 CPU 작업을 번갈아 수행하며 동시성을 높일 수 있습니다. 중요한 점은 GVL이 애플리케이션 코드의 스레드 안전성을 보장하지 않으므로, 공유 자원에 대한 접근은 Mutex 등을 사용하여 명시적으로 동기화해야 한다는 것입니다.

GVL이 프로세스 수준에서 적용되기 때문에, 멀티 코어 CPU를 효율적으로 활용하여 병렬성을 얻으려면 여러 개의 Puma 프로세스를 사용해야 합니다. 각 프로세스는 자체 GVL을 가지므로, 서로 다른 프로세스의 스레드는 다른 CPU 코어에서 Ruby 코드를 병렬로 실행할 수 있습니다. 따라서 CPU 코어 수에 맞춰 WEB_CONCURRENCY 환경 변수를 설정하는 것이 일반적인 권장 사항입니다. 한편, 스레드 스케줄링은 OS 스케줄러와 Ruby VM의 타이머 스레드(기본 100ms 퀀텀)에 의해 관리되며, 스레드는 CPU 작업 완료, I/O 대기, 또는 퀀텀 만료 시 GVL을 해제하고 다른 스레드에 기회를 줍니다. GVL 트레이싱 결과는 CPU 바운드 작업에서는 단일 스레드가 효율적이지만, I/O가 포함된 혼합 워크로드에서는 여러 스레드가 GVL을 공유하며 동시성을 높여 전체 처리량을 개선함을 보여줍니다. 하지만 스레드 수를 과도하게 늘리면 GVL 경합이 심화되어 오히려 성능 저하와 응답 시간 증가를 초래할 수 있습니다.

요약하자면, Puma는 프로세스와 스레드를 통해 웹 요청을 처리합니다. Ruby의 GVL은 단일 프로세스 내에서의 CPU 바운드 코드의 병렬 실행을 제한하므로, 멀티 코어 CPU의 성능을 최대한 활용하기 위해서는 코어 수에 맞춰 Puma 프로세스 수를 늘리는 것이 필수적입니다. 스레드는 주로 I/O 대기 시간을 활용하여 동시성을 높이는 데 기여하지만, CPU 바운드 작업이 많거나 스레드 수가 과도하면 GVL 경합으로 인해 성능이 저하될 수 있습니다. 따라서 애플리케이션의 워크로드 특성(CPU 바운드 vs I/O 바운드 비율)과 서버의 CPU 코어 수를 고려하여 Puma의 프로세스(`WEB_CONCURRENCY`) 및 스레드(`RAILS_MAX_THREADS`) 설정을 신중하게 조정하는 것이 Rails 애플리케이션 성능 최적화의 핵심입니다. 향후 글에서는 `max_threads`의 이상적인 값을 이론적 및 경험적으로 찾는 방법을 다룰 예정입니다.