동시성 마스터하기
여러 사용자가 동시에 앱을 사용하게 되고, 우리는 최대한 빠른 응답 속도를 제공하고 싶습니다. 그러려면 동시성(concurrency)을 처리할 방법이 필요합니다. 걱정하지 마세요! 대부분의 웹 서버는 기본적으로 이를 지원합니다. 하지만 규모를 확장해야 하는 시점이 오면, 동시성을 가장 효율적인 방식으로 활용하는 것이 중요해집니다.
다양한 종류의 동시성 모델
동시성을 처리하는 방법은 크게 세 가지입니다: 멀티 프로세스(multi-process), 멀티 스레딩(multi-threading), 그리고 이벤트 드리븐(event-driven). 각 방식은 고유한 용도와 장단점을 가지고 있습니다. 이 글에서는 세 가지 방식이 어떻게 다른지, 그리고 언제 어떤 방식을 선택해야 하는지 알아보겠습니다.
멀티 프로세스 (Unicorn)
가장 구현하기 쉬운 방식입니다. 마스터 프로세스(master process)가 자기 자신을 여러 개의 워커 프로세스(worker process)로 포크(fork)합니다. 워커 프로세스가 실제 요청을 처리하고, 마스터 프로세스는 워커들을 관리하는 역할을 담당합니다.
각 워커 프로세스는 전체 코드베이스를 메모리에 로드합니다. 이 때문에 메모리 소모가 상당히 크며, 대규모 인프라로 확장하기도 어렵습니다.
| 멀티 프로세스 요약 | |
|---|---|
| 활용 사례 | Ruby 외의 친숙한 예로는 Chrome 브라우저가 있습니다. Chrome은 멀티 프로세스 동시성을 활용해 각 탭에 별도의 프로세스를 할당합니다. 덕분에 하나의 탭이 충돌해도 전체 애플리케이션은 멈추지 않으며, 보안 취약점(exploit)도 하나의 탭에만 격리됩니다. |
| 장점 | 구현이 가장 간단하다. 스레드 안전성(thread safety) 문제를 신경 쓸 필요가 없다. 각 워커가 충돌해도 시스템의 나머지 부분에는 영향을 주지 않는다. |
| 단점 | 각 프로세스마다 전체 코드베이스를 메모리에 로드하므로 메모리 소모가 크다. 따라서 대량의 동시 연결 처리에는 적합하지 않다. |
멀티 스레딩 (Puma)
이 스레딩 모델은 하나의 프로세스가 여러 요청을 동시에 처리할 수 있게 해줍니다. 단일 프로세스 내부에서 여러 스레드를 실행하는 방식으로 작동합니다.
멀티 프로세스 방식과 달리 모든 스레드가 같은 프로세스 안에서 실행됩니다. 즉, 전역 변수와 같은 데이터를 서로 공유합니다. 따라서 스레드 하나당 추가되는 메모리는 아주 적습니다.
글로벌 인터프리터 락(GIL)
여기서 MRI(Ruby 표준 구현체)의 GIL(Global Interpreter Lock) 이야기를 빼놓을 수 없습니다. GIL은 모든 Ruby 코드 실행을 감싸는 잠금 장치입니다. 스레드들이 병렬로 실행되는 것처럼 보여도, 실제로는 한 번에 하나의 스레드만 활성화됩니다.
IO 작업은 GIL의 영향 밖에서 수행됩니다. 데이터베이스 쿼리를 실행하고 결과를 기다리는 동안에는 잠금이 걸리지 않기 때문에, 그 사이 다른 스레드가 작업을 진행할 수 있습니다. 반면 스레드 안에서 수학 연산이나 해시·배열 조작을 많이 수행한다면, MRI에서는 단일 코어만 활용하게 됩니다. 대부분의 경우 머신을 최대한 활용하려면 여러 프로세스가 여전히 필요합니다. 아니면 GIL이 없는 Rubinius나 jRuby를 사용하는 방법도 있습니다.
스레드 안전성
여러 스레드를 사용한다면, 공유 데이터를 다루는 모든 코드를 스레드 안전(thread-safe)하게 작성해야 합니다. 예를 들어 Mutex를 사용해 공유 데이터 구조를 조작하기 전에 잠그는 방식이 있습니다. 이렇게 하면 데이터를 변경하는 도중에 다른 스레드가 낡은(stale) 데이터를 기반으로 작업하는 일을 방지할 수 있습니다.
| 멀티 스레딩 요약 | |
|---|---|
| 활용 사례 | "무난한 중간 선택지"에 해당합니다. 짧은 요청을 대량으로 처리해야 하는 일반적인 웹 애플리케이션(바쁜 웹 서비스 등)에 널리 사용됩니다. |
| 장점 | 멀티 프로세스보다 메모리를 적게 사용한다. |
| 단점 | 코드가 스레드 안전한지 반드시 확인해야 한다. 한 스레드가 충돌을 일으키면 프로세스 전체가 다운될 수 있다. GIL이 I/O를 제외한 모든 작업을 잠근다. |
이벤트 루프 (Thin)
이벤트 루프는 대량의 동시 I/O 작업을 처리해야 할 때 사용됩니다. 이 모델 자체가 여러 요청을 강제로 동시에 실행하지는 않지만, 수많은 동시 접속자를 효율적으로 처리할 수 있는 방법입니다.
아래는 Ruby로 작성된 아주 단순한 이벤트 루프입니다. 루프는 event_queue에서 이벤트를 꺼내 처리합니다. 이벤트가 없으면 잠시 대기한 후, 큐에 새 이벤트가 있는지 다시 확인합니다.
loop do
if event_queue.any?
handle_event(event_queue.pop)
else
sleep 0.1
end
end동작 원리
조금 더 깊이 들어가 보겠습니다. 이벤트 루프는 운영체제(OS), 큐, 그리고 일부 메모리와 마치 아름다운 춤을 추듯 긴밀하게 협력합니다.
단계별 동작 과정
- OS가 네트워크 및 디스크의 준비 상태를 추적합니다.
- OS가 I/O가 준비되었음을 감지하면 큐에 이벤트를 전송합니다.
- 큐는 이벤트 목록이며, 이벤트 루프는 그중 맨 위의 항목을 꺼냅니다.
- 이벤트 루프가 해당 이벤트를 처리합니다.
- 연결에 대한 메타데이터를 저장하기 위해 일부 메모리를 사용합니다.
- 새 이벤트를 직접 이벤트 큐에 다시 넣을 수도 있습니다. 예를 들어 이벤트 내용에 따라 큐를 종료하라는 메시지를 추가하는 경우입니다.
- I/O 작업이 필요하면 OS에게 특정 I/O 작업에 관심이 있다고 알립니다. OS는 네트워크와 디스크 상태(1번 참조)를 계속 추적하다가 I/O가 준비되면 다시 이벤트를 큐에 추가합니다.
| 이벤트 루프 요약 | |
|---|---|
| 활용 사례 | 사용자와의 동시 연결이 매우 많을 때 적합합니다. Slack이나 Chrome 알림 같은 서비스가 대표적인 예입니다. |
| 장점 | 연결당 메모리 오버헤드가 거의 없다. 수많은 병렬 연결로 확장할 수 있다. |
| 단점 | 개념적 모델이 다소 이해하기 어렵다. 큐가 쌓이는 것을 방지하려면 배치 크기가 작고 예측 가능해야 한다. |
어떤 방식을 사용해야 할까?
이 글이 다양한 동시성 모델을 이해하는 데 도움이 되었기를 바랍니다. 개발자에게 다소 어려운 주제이지만, 이를 제대로 이해하면 자신의 앱에 맞는 올바른 설정을 실험하고 선택할 수 있는 도구를 갖추게 됩니다.
핵심 정리
- 대부분의 앱에는 스레딩이 적합하며, Ruby/Rails 생태계 역시 (천천히) 이 방향으로 이동하고 있습니다.
- 장기간 유지되는 스트림을 사용하는 고동시성 앱이라면, 이벤트 루프가 확장성을 제공합니다.
- 트래픽이 많지 않거나 워커가 자주 문제를 일으킬 것으로 예상된다면, 검증된 멀티 프로세스 방식을 선택하세요.
참고로, 멀티 프로세스 안의 스레드, 그리고 그 스레드 안의 이벤트 루프를 함께 구성하는 것도 가능합니다. 즉, 스트루프와펠(stroopwafel)을 가지면서 동시에 맛볼 수도 있는 셈입니다!
이 동시성 모델들에 대해 더 자세히 알고 싶다면, 멀티 프로세스, 멀티 스레딩, 그리고 이벤트 루프를 다룬 심화 아티클도 확인해 보세요.