python의 병렬 프로그래밍
병렬 작업이란 무엇일까? 넓은 의미에서 한 번에 여러 가지 일을 하는 것 을 의미한다. 기술적으로는 멀티프로세싱이나 멀티스레딩을 통해 구현되며, I/O 대기 시간을 활용하는 것 부터 여러 CPU 코어를 동시에 활용하는 진정한 의미의 병렬성까지 다양한 스펙트럼을 포함한다. 결국 OS 스케줄러의 관점에서 보면, 고속으로 처리되는 수많은 작업을 사용자가 병렬로 인식하도록 분배하는 것이다. 현재 나는 전자책 DB 구축을 위해 대규모 웹 스크래핑 작업을 계획하고 있다. 그 분량이 너무 많아, 물리적인 시간으로 링크 하나씩 순차적으로 스크래핑을 진행하면 자료 수집에만 몇 개월이 걸릴 수 있다. 이는 현실적으로 불가능한 일정이다. 이때 병렬 처리가 해법이 될 수 있다! 만약 1개의 스크래퍼가 아닌 n개의 스크래퍼를 동시에 구동한다면, 이론적으로 예상 기간은 n분의 1로 줄어들 것이다.
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 텍스트, 이미지 등)를 주고받을 때 심각한 성능 저하를 유발할 수 있다. 또한, 프로세스 생성 자체가 스레드 생성보다 훨씬 무거운 작업이라 리소스 소모가 크다.
멀티스레딩과 멀티프로세싱은 각각 I/O-bound와 CPU-bound라는 나름의 영역이 있지만, 내 웹 스크래핑 작업은 대량의 I/O 작업과 수집된 데이터를 처리(parsing, saving)하는 CPU 작업이 혼재되어 있으며, 무엇보다 데이터 전달 비용이 큰 문제가 될 것이라 예상했다. 그렇기 때문에 다른 방법을 찾아보다가 Ray 를 접하게 되었고, 이번 기회에 사용해보자고 마음 먹었다.
이번 스크래핑 작업은 그 규모가 방대하다. Ray를 사용하면, 객체 저장소를 통해 데이터 전달 비용을 최소화하면서 멀티프로세싱의 장점(GIL 우회 및 멀티 코어 활용)을 누릴 수 있다. 무엇보다 중요한 것은 확장성이다. 지금 당장은 내 로컬 머신에서 작업을 시작하겠지만..! 이 작업량이 로컬 머신으로 감당이 안 될 경우, Ray는 코드 변경 거의 없이 클라우드 환경의 거대한 클러스터로 작업을 즉시 확장할 수 있는 유연성을 제공한다. 이것은 Ray만의 독보적인 장점이다. 결론적으로 Ray는 멀티프로세싱의 성능 한계를 극복하고, 향후 시스템 확장을 고려한 가장 합리적인 선택지인 것이다. 이제 Ray에 대해서 조금 자세히 알아보자.
Ray는 기존 멀티프로세싱 방법처럼 GIL을 우회하기 위해 멀티 프로세스를 사용하지만, 근본적으로는 분산 컴퓨팅 프레임워크 이다. 즉, 단일 머신(노트북, 서버)의 멀티 코어를 활용하는 것에서 나아가, 여러 머신(클러스터)으로 작업을 확장하는 것을 전제로 설계되었다.
멀티프로세싱 방법이 직렬화/역직렬화에 의존하는 것과 달리, Ray는 특수한 객체 저장소를 사용한다. 작업자(Worker) 프로세스들은 이 공유 메모리 공간을 통해 데이터를 제로-카피(zero-copy)에 가깝게 읽어들일 수 있다. 이는 대규모 데이터를 프로세스 간에 전달할 때 발생하는 오버헤드를 줄여준다.
Ray의 API는 매우 직관적이다. 기존 함수에 @ray.remote 데코레이터 하나만 붙이면 해당 함수를 비동기 병렬 작업으로 만들 수 있다. 또한, 상태를 가지는 병렬 객체인 ‘액터(Actor)’ 모델도 지원하여, 스크래퍼 세션이나 DB 커넥션 풀을 관리할 수 있다.
Ray 아키텍처는 Task, Actor, Object 로 구성된다.
Task는 Ray에서 병렬 처리를 수행하는 가장 기본적인 단위로, 간단히 원격 비동기 함수 로 이해할 수 있다. 일반적인 Python 함수에 @ray.remote 데코레이터를 붙여 정의하며, 호출 시에는 my_func.remote(arg)와 같이 .remote() 메서드를 사용한다. Task는 상태를 가지지 않는(Stateless) 것을 전제로 한다. .remote()로 호출하면 작업이 즉시 실행되는 것이 아니라, Ray의 백그라운드 작업자(Worker)에 예약되며, 호출자에게는 객체 참조(ObjectRef)라는 일종의 영수증을 즉시 반환한다. 이것이 Ray의 비동기 실행 방식이며, 여러 Task를 동시에 호출하면 가용한 CPU 코어에서 병렬로 처리된다.
Task가 상태 없는 일회성 함수라면, Actor는 상태를 가지는 원격 객체 이다. Python 클래스에 @ray.remote 데코레이터를 붙여 정의하며, MyClass.remote()와 같이 .remote()로 인스턴스화한다. 이 Actor의 메서드를 호출할 때도 my_actor.my_method.remote()처럼 .remote()를 사용한다. Actor의 핵심은 내부 상태(Stateful)를 가질 수 있다는 점이다. 웹 스크래핑 시나리오에서 DB 커넥션 풀을 관리하거나, 여러 스크래퍼의 작업 완료 건수를 집계하는 중앙 카운터로 활용하기에 적합하다. Ray는 여러 Task가 하나의 Actor에 동시에 접근하더라도 순서를 보장하여, 개발자가 복잡한 Lock 없이도 상태를 안전하게 관리할 수 있도록 지원한다.
Object는 Task나 Actor가 실행된 후 반환하는 결과 데이터를 의미한다. 이 데이터는 Plasma라고 불리는 Ray의 In-memory 객체 저장소에 저장된다. Task가 .remote()로 호출되어 실행을 마치면, 그 결과는 Plasma에 저장되고, 호출자에게는 이 데이터의 실제 위치를 가리키는 객체 참조(ObjectRef)가 반환된다. Ray의 핵심 성능 비결이 바로 여기에 있다. 만약 이 ObjectRef를 다른 Task의 인자로 전달하면, 데이터의 ‘복사’나 ‘직렬화’가 발생하지 않는다. 대신, 새로운 Task는 ObjectRef를 통해 공유 메모리(Plasma)에 있는 원본 데이터에 직접 접근한다. 이를 제로-카피라 부르며, 멀티프로세싱 방식의 고비용 직렬화 문제를 근본적으로 해결한다. 메인 프로세스에서 이 결과 데이터의 실제 값이 필요할 때는 ray.get(object_ref)를 호출하며, 이 함수는 작업이 완료되어 데이터를 가져올 때까지 대기한다.
이렇게 Ray 에 대해서 공부하다 보니 아래 의문이 들었다.
- Ray는 어떻게 이렇게 작동할 수 있는가?
- Ray는 어떻게 서로 다른 프로세스를 어떻게 관리할 수 있는거지?
- 부모관계도 아니고, 내부에서 스레드로 관리하는 것도 아님…
- 여기에 공통 저장소를 가지고 Actor들이 공유할 수 있기도 함…
궁금증을 해결하기 위해 Ray 공식문서와 논문을 살펴보자!
정리하면 아래와 같다.
우리가 작성한 코드와 실제 작업자(Actor)는 OS 레벨에서 남남 즉 독립된 프로세스 이다. 부모-자식 관계가 아닌데도 한 몸처럼 움직이는 비결은 Ray가 구현한 분산 시스템 덕분이다. Ray는 아래 3가지 기술을 사용해 Actor를 관리한다.
우리 눈에는 안 보이지만, Ray를 시작(ray.init)하면 각 컴퓨터 백그라운드에 Raylet 이라는 프로세스가 실행된다.
우리가 코드에서 Worker.remote()를 호출하면, 직접 Actor를 만드는 게 아니다.
우리는 단순히 로컬에 있는 Raylet 에게 “Actor 하나 필요해” 라고 요청을 보내는 것이다.
즉, Actor의 실제 부모는 Raylet이며, 그것이 바로 OS에게 요청하여 파이썬 워커 프로세스를 생성하고 감시한다.
서로 다른 프로세스가 데이터를 주고받으려면 보통은 복사가 일어나며 오버헤드가 생긴다. Ray는 Plasma 라는 객체 저장소를 통해 이를 해결한다.
Ray는 RAM의 일부를 공유 구역으로 지정한다.
Actor A가 데이터를 이 구역에 씁니다.
Actor B는 데이터를 복사해가는 게 아니라, 이 공유 구역의 메모리 주소만 건네받는다.
Actor B는 mmap? 기술을 통해 남의 메모리를 마치 자기 메모리인 것처럼 직접 읽습니다.
데이터는 공유 메모리에 두더라도, “작업 시작해”, “이거 처리해” 같은 제어 신호는 어떻게 주고받을까?
Ray의 모든 컴포넌트(Driver, Raylet, Actor)는 내부적으로 gRPC 라는 통신 프로토콜로 연결되어 있다.
TCP 소켓 위에서 Protocol Buffers라는 효율적인 이진 포맷으로 메시지를 주고받는다고 한다.
이 덕분에 서로 다른 언어(Python, Java, C++)로 짜인 프로세스끼리도 대화가 가능하다..!
위에서 공유 저장소 내용을 읽다가 흠칫한다면 정상이다. 음? 분명히 기존의 파이썬 병렬프로그래밍 방식에서 지적되었던 내용이 바로 이 데이터 공유와 락부분도 있지 않았나? Ray에서 공유저장소를 사용한다면 이걸 각 액터들이 접근할때 락을 걸어야만할텐데? 보통 공유 메모리는 여러 명이 동시에 쓰면 데이터가 깨지기 때문에 복잡한 락(Mutex, Semaphore)을 걸어야만 하니까.. 그럼 가장 오버헤드가 많이 일어나는 부분에서 기존 방법과 별 차이 없는거 아닌가?
놀랍게도 Ray는 “Lock을 아예 안 쓰는 방식"으로 이 문제를 해결했다. 그 비결은 바로 Immutability (불변성) 과 Sealing (봉인) 이다!
Ray의 객체 저장소(Plasma)에 데이터가 저장되는 과정을 상세히 살펴보자.
나 데이터 쓸 거야 라고 요청하면, Ray는 공유 메모리에 빈 공간을 할당한다.다 썼음! 을 외치며 도장을 꽝 찍는다. (ray.put() 완료 시점)정리하면, 쓰는 동안은 남들이 못 보니까 Lock 필요 없다. 다 쓴 후에는 아무도 수정 못 하니까(읽기만 하니까) Lock 필요 없다. 만약 데이터를 수정해야 한다면? 그냥 새로운 메모리 공간에 새 데이터를 쓰고, 새로운 공간을 발급받는다. 기존 데이터는 건드리지 않는다.