1. 소개


python의 병렬 프로그래밍

병렬 작업이란 무엇일까? 넓은 의미에서 한 번에 여러 가지 일을 하는 것 을 의미한다. 기술적으로는 멀티프로세싱이나 멀티스레딩을 통해 구현되며, I/O 대기 시간을 활용하는 것 부터 여러 CPU 코어를 동시에 활용하는 진정한 의미의 병렬성까지 다양한 스펙트럼을 포함한다. 결국 OS 스케줄러의 관점에서 보면, 고속으로 처리되는 수많은 작업을 사용자가 병렬로 인식하도록 분배하는 것이다. 현재 나는 전자책 DB 구축을 위해 대규모 웹 스크래핑 작업을 계획하고 있다. 그 분량이 너무 많아, 물리적인 시간으로 링크 하나씩 순차적으로 스크래핑을 진행하면 자료 수집에만 몇 개월이 걸릴 수 있다. 이는 현실적으로 불가능한 일정이다. 이때 병렬 처리가 해법이 될 수 있다! 만약 1개의 스크래퍼가 아닌 n개의 스크래퍼를 동시에 구동한다면, 이론적으로 예상 기간은 n분의 1로 줄어들 것이다.



2. Python에서의 병렬 작업


Python에서 병렬 작업을 구현하는 전통적인 방법으로는 멀티스레딩과 멀티프로세싱이 있다. 그러나 이 두 가지 방법은 각각 명확한 한계가 존재하며, 이것이 내가 Ray 를 선택하게 된 이유이다.

멀티스레딩

하나의 프로세스 내에서 여러 개의 스레드를 생성하여 작업을 분배하는 방식이다. 스레드들은 메모리를 공유하기 때문에, 데이터 공유가 비교적 간단하고 프로세스를 새로 생성하는 것보다 훨씬 가볍다는 장점이 있다. 웹 스크래핑과 같이 네트워크 응답을 기다리는 I/O-bound 작업에 적합하다. 하나의 스레드가 서버의 응답을 기다리며 대기 상태에 있을 때, 다른 스레드가 CPU를 점유하여 다음 요청을 보내는 식으로 동시성을 확보할 수 있다.

그러나 Python의 멀티스레딩은 GIL (Global Interpreter Lock) 이라는 치명적인 한계를 갖는다. GIL은 Python 인터프리터가 한 번에 단 하나의 스레드만이 Python 바이트코드를 실행할 수 있도록 강제하는 잠금장치이다. 이로 인해, 멀티 코어 CPU를 사용하더라도 여러 스레드가 동시에 CPU 연산을 수행할 수 없다. 즉, I/O 대기 시간을 활용하는 동시성은 달성할 수 있지만, CPU 연산을 병렬로 처리하는 병렬성은 달성할 수 없다.

멀티프로세싱

GIL의 한계를 극복하기 위해, 아예 여러 개의 프로세스를 생성하는 방식이다. 각 프로세스는 독립적인 메모리 공간과 자신만의 Python 인터프리터를 갖는다. 따라서 GIL의 제약을 받지 않으며, 멀티 코어 CPU를 온전히 활용하는 진정한 의미의 병렬성을 달성할 수 있다. 데이터 분석이나 복잡한 연산 등 CPU bound 작업에 절대적으로 유리하다.

그러나 가장 큰 한계는 데이터 공유와 통신 비용이다. 프로세스들은 메모리를 공유하지 않기 때문에, 부모-자식 프로세스 간에 데이터를 주고받으려면 직렬화 및 역직렬화 과정이 필수적이다. 이 과정에서 발생하는 오버헤드가 상당하며, 특히 스크래핑한 대용량 데이터(HTML 텍스트, 이미지 등)를 주고받을 때 심각한 성능 저하를 유발할 수 있다. 또한, 프로세스 생성 자체가 스레드 생성보다 훨씬 무거운 작업이라 리소스 소모가 크다.



3. Ray


멀티스레딩과 멀티프로세싱은 각각 I/O-bound와 CPU-bound라는 나름의 영역이 있지만, 내 웹 스크래핑 작업은 대량의 I/O 작업과 수집된 데이터를 처리(parsing, saving)하는 CPU 작업이 혼재되어 있으며, 무엇보다 데이터 전달 비용이 큰 문제가 될 것이라 예상했다. 그렇기 때문에 다른 방법을 찾아보다가 Ray 를 접하게 되었고, 이번 기회에 사용해보자고 마음 먹었다.

이번 스크래핑 작업은 그 규모가 방대하다. Ray를 사용하면, 객체 저장소를 통해 데이터 전달 비용을 최소화하면서 멀티프로세싱의 장점(GIL 우회 및 멀티 코어 활용)을 누릴 수 있다. 무엇보다 중요한 것은 확장성이다. 지금 당장은 내 로컬 머신에서 작업을 시작하겠지만..! 이 작업량이 로컬 머신으로 감당이 안 될 경우, Ray는 코드 변경 거의 없이 클라우드 환경의 거대한 클러스터로 작업을 즉시 확장할 수 있는 유연성을 제공한다. 이것은 Ray만의 독보적인 장점이다. 결론적으로 Ray는 멀티프로세싱의 성능 한계를 극복하고, 향후 시스템 확장을 고려한 가장 합리적인 선택지인 것이다. 이제 Ray에 대해서 조금 자세히 알아보자.


3-1. Ray의 특징


3-2. Ray의 핵심 요소

Ray 아키텍처는 Task, Actor, Object 로 구성된다.


3-3. Ray 내부 동작

이렇게 Ray 에 대해서 공부하다 보니 아래 의문이 들었다.

궁금증을 해결하기 위해 Ray 공식문서와 논문을 살펴보자!

정리하면 아래와 같다.

우리가 작성한 코드와 실제 작업자(Actor)는 OS 레벨에서 남남독립된 프로세스 이다. 부모-자식 관계가 아닌데도 한 몸처럼 움직이는 비결은 Ray가 구현한 분산 시스템 덕분이다. Ray는 아래 3가지 기술을 사용해 Actor를 관리한다.


3-4. 공유 저장소

위에서 공유 저장소 내용을 읽다가 흠칫한다면 정상이다. 음? 분명히 기존의 파이썬 병렬프로그래밍 방식에서 지적되었던 내용이 바로 이 데이터 공유와 락부분도 있지 않았나? Ray에서 공유저장소를 사용한다면 이걸 각 액터들이 접근할때 락을 걸어야만할텐데? 보통 공유 메모리는 여러 명이 동시에 쓰면 데이터가 깨지기 때문에 복잡한 락(Mutex, Semaphore)을 걸어야만 하니까.. 그럼 가장 오버헤드가 많이 일어나는 부분에서 기존 방법과 별 차이 없는거 아닌가?

Lock? 그런거 안 씁니다..

놀랍게도 Ray는 “Lock을 아예 안 쓰는 방식"으로 이 문제를 해결했다. 그 비결은 바로 Immutability (불변성) 과 Sealing (봉인) 이다! Ray의 객체 저장소(Plasma)에 데이터가 저장되는 과정을 상세히 살펴보자.

정리하면, 쓰는 동안은 남들이 못 보니까 Lock 필요 없다. 다 쓴 후에는 아무도 수정 못 하니까(읽기만 하니까) Lock 필요 없다. 만약 데이터를 수정해야 한다면? 그냥 새로운 메모리 공간에 새 데이터를 쓰고, 새로운 공간을 발급받는다. 기존 데이터는 건드리지 않는다.



4. Reference


아래 블로그와 공식문서에 더 상세하고 정갈한 설명이 적혀있으니 읽어보면 이해에 더 도움이 될 듯 하다.