7.11 Python 환경에서의 성능 최적화 전략

7.11 Python 환경에서의 성능 최적화 전략

“Zenoh는 빠르다. 하지만 당신의 파이썬 코드가 그 속도를 다 갉아먹고 있다.”

초당 1만 건의 패킷이 도착할 때, 파이썬 기반 서버의 CPU 점유율이 100%를 찍으며 기절하는 현상을 겪어봤는가? 이는 하부의 Rust 코어가 파이썬의 PyObject로 데이터를 변환해서 올려주는 과정, 그리고 파이썬 특유의 가비지 컬렉터(GC) 폭주가 빚어낸 참사다.

이 챕터에서는 멀티 프로세싱(Multiprocessing)을 통해 GIL(Global Interpreter Lock)의 목을 자르고 물리적 CPU 코어 전체를 데이터 수신에 투입하는 법, 불필요한 캐스팅 연산을 막아 파이썬 객체 생성 부하를 10분의 1로 깎아내는 리팩토링 전술, 나아가 궁극의 확장(Extension) 아키텍처까지 샅샅이 파헤친다. 이 장을 넘기고 나면 당신의 파이썬 코드는 C++ 에 버금가는 처리량을 뿜어낼 것이다.

1. Python 객체 변환 오버헤드 분석 및 최소화 기법

Zenoh 네트워크에서 아무리 제로 카피(Zero-Copy)로 데이터가 날아와도, 그것을 파이썬 스크립트가 json.loadsint() 로 캐스팅하는 순간 무거운 PyObject 가 메모리 힙(Heap)에 튀어나온다. 이 행위가 초당 10만 번 일어난다면 파이썬 인터프리터는 문자열 파싱만 하다가 압살당한다.

1.0.1 [Runbook] 데이터 변환 딜레이 타파 전술

안티 패턴: 무지성 형 변환

## 10만번 반복되는 루프 안
def bad_callback(sample: zenoh.Sample):
    # 1. 바이트를 파이썬 String 클래스로 복사 변환
    raw_str = sample.payload.decode('utf-8')
    # 2. String 클래스를 파싱해 거대한 파이썬 Dict 객체 트리 생성
    data = json.loads(raw_str) 
    # 3. 객체 안에서 Key 검색
    value = data["sensor_val"] 

이 3단계 변환은 초당 TPS(Transactions Per Second)를 1,000 이하로 수직 하락시키는 주범이다.

전술 1. 바이너리 뷰(Binary View) 구조체 캐스팅
데이터를 JSON 대신 정적 구조체(struct) 패킹으로 바꾸고, 수신부에서는 파이썬 객체를 “생성“하는 대신 C 메모리 주소값만 “읽어(View)“낸다.

import struct

def good_callback(sample: zenoh.Sample):
    # payload 자체를 복사(decode) 하지 않고 메모리 번지에 struct 칼을 넣고 자름
    # "i f" = 정수(4byte) 1개, 실수(4byte) 1개 등 총 8바이트 메모리 공간으로 매핑
    sensor_id, sensor_val = struct.unpack("i f", sample.payload)
    
    # 단 한 번의 바이트->객체 변환만 일어남. (JSON 대비 약 40배 빠름)
    print(sensor_val)

전술 2. 식별자(KeyExpr) 캐싱 처리
앞서 7.4.1장에서도 강조했듯, 루프 안에서 파이썬 String sensor/temperature 을 반복적으로 Zenoh API 에 넣으면 매번 파싱 검증이 일어난다. 반드시 부팅 시점에 만들어 둔 zenoh.KeyExpr("sensor/temperature") 객체를 재활용하라.

2. Multiprocessing 모듈을 활용한 멀티 코어 병렬 처리

아무리 코드를 깎아도 GIL 때문에 1개 CPU 코어 이상을 사용하지 못하는 한, 파이썬 노드의 데이터 처리량은 한계상태에 부딪힌다. 이럴 때는 파이썬의 threading 이 아니라 아예 독립적인 OS 프로세스를 여러 개 복제하는 multiprocessing 을 써야만 진정한 병렬 가동(Parallelism)을 얻어낸다.

2.0.1 [Runbook] 데이터 분쇄기 팩토리(Factory) 구축 작전

오직 1개의 Zenoh 마스터 수신기가 데이터를 받은 뒤, 8개의 비워커(Worker) 프로세스에게 조각을 나누어주는 분산 파이프라인.

import zenoh
import multiprocessing as mp
import time

def ai_worker_process(worker_id, rx_queue):
    """이 함수는 완전히 별개의 OS 프로세스(램 점유율 별도)로 돌아간다!"""
    print(f"-> [워커 {worker_id}] AI 연산 라인 가동 개시")
    while True:
        # 워커들은 블로킹 큐 앞에서 무한정 대기하다 먹이를 채간다.
        job = rx_queue.get()
        if job == "QUIT":
            break
        
        # [무거운 작업] 여기서 OpenCV나 딥러닝을 돌려도 메인 Zenoh는 1초도 안 쉰다.
        # time.sleep(0.1)
        # print(f"워커 {worker_id} 처리 완료: {job}")

def main_zenoh_distributor():
    # 프로세스 간의 통신(IPC)을 위한 강력한 락프리 큐
    # 파이썬 queue.Queue() 와 다르다. OS 레벨 파이프(Pipe)다!
    job_queue = mp.Queue()

    # 서버의 코어 개수만큼 비워커 군단 배양
    cpu_count = 4 
    workers = []
    for i in range(cpu_count):
        p = mp.Process(target=ai_worker_process, args=(i, job_queue))
        p.start()
        workers.append(p)

    def distributor_callback(sample: zenoh.Sample):
        # 마스터(수신부)는 Zenoh로부터 데이터를 뽑자마자 즉시 IPC 큐에 투척.
        # 가장 한가한 워커 프로세스 중 하나가 경쟁적으로 뺏어간다 (Auto Load Balancing)
        val = sample.payload.decode('utf-8')
        job_queue.put(val)

    with zenoh.open() as session:
        sub = session.declare_subscriber("heavy/data/stream", distributor_callback)
        print("마스터 라우터 노드 전개 완료. 분배 시작.")
        
        try:
            while True:
                time.sleep(1)
        except KeyboardInterrupt:
            for _ in workers:
                job_queue.put("QUIT")
            for w in workers:
                w.join()

if __name__ == "__main__":
    main_zenoh_distributor()

이 “Master-Worker 토폴로지“는 자율주행 차량의 프레임 인식기나 챗봇 LLM 서버에서 무조건 세워야 하는 기초 인프라다.
다만, 이 방식의 유일한 오버헤드는 마스터 프로세스와 워커 프로세스 간에 큐 메모리가 복제(Copy)된다는 것이다. 따라서 100MB 크기의 단일 파일보다, 100KB짜리 파일 1만 개를 쏘아댈 때 진가를 발휘한다.

3. C/Rust 모듈 확장(CFFI, PyO3)과 Zenoh의 혼합 사용 아키텍처

파이썬 내부에서 아무리 쥐어짜도 객체를 struct.unpack() 하는 시간조차 부족한 텐 텐-밀리초(10ms) 단위의 고주파수(HFT, High Frequency Trading) 도메인이라면, 아예 “내가 쓸 커스텀 로직을 Rust 나 C++ 로 짜서 파이썬 모듈(.so)로 임포트” 해버리는 최후의 수단이 남았다.

3.0.1 [Runbook] 데이터 오프로딩(Off-loading) 파이프라인

1. 아키텍처 분리 전략

  1. 파이썬 스크립트에서는 zenoh.open() 과 껍데기 서버 루프만 유지한다 (유지보수 용이).
  2. 무거운 수식 계산이나 이미지 포맷 변환 로직은 Rust (PyO3) 로 작성해 my_c_algo.so 로 컴파일한다.
  3. Zenoh 리스너에 바이트 배열(Payload)이 떨어지는 즉시, 파이썬 객체로 분해하지 말고 통째로 my_c_algo.compute(sample.payload) 로 다시 넘겨버린다!
import zenoh
## 내 손으로 직접 짠 C/Rust 가속 모듈
import fast_algo_extension 

def hybrid_listener(sample: zenoh.Sample):
    # 파이썬은 이 데이터를 파싱할 능력도 의지도 없다.
    # 그저 C 확장 모듈의 입구로 바이트 스트림을 던져버린다(Offload)
    
    # C/Rust 확장은 이 버퍼를 읽고 초정밀 연산을 한 후
    # 결과 요약본(Int, Boolean 등)만 다시 파이썬으로 튕겨 넘겨준다!
    result = fast_algo_extension.process_raw_bytes(sample.payload)
    
    if result.is_danger:
        print("위험 경보! 즉각 브레이크 가동")

with zenoh.open() as session:
    sub = session.declare_subscriber("camera/60fps/raw", hybrid_listener)
    ...

이 패턴이 바로 AI 생태계의 패왕인 PyTorchOpenCV가 파이썬에서 가장 날렵하게 도는 비밀이다.
파이썬 Zenoh 는 오직 “교환원“의 역할만 하고, 연산의 진짜 심장은 C/Rust 확장 팩에 맡긴다. 이 경계(Boundary) 설계야말로 파이썬 아키텍트가 가져야 할 궁극의 미학이다.

4. 메모리 누수 방지 및 가비지 컬렉션(GC) 튜닝

Zenoh + Python 환경을 3달씩 켜둬야 하는 장기 서버를 굴릴 때, 가장 흔하게 마주하는 장애는 OOM(Out of Memory)이다. “C 래퍼에서 샌 건가?” 하고 범인을 잡다 보면 99%는 파이썬 콜백 안에서 쌓인(Append) 무법 리스트(List) 캐시거나, Circular Reference(순환 참조)로 인해 파이썬 가비지 컬렉터(GC)가 지우지 못한 메모리 조각들이다.

4.0.1 [Runbook] 불사의 파이썬 메모리 방어 전술

1. 수신 버퍼 사이즈 강제 제어 (링 버퍼)
콜백에서 데이터를 받아서 메인 로직으로 넘길 때 끝없이 커지는 큐(Queue)를 쓰면 언젠가는 서버가 터진다.

from collections import deque

## 파이썬의 deque 에 최대 길이를 강제(maxlen)하면, 
## 10,001번째 데이터가 들어오는 순간 1번째 데이터(가장 오래된 것)가 자동으로 파괴(GC)된다.
## 이로써 메모리 사용량의 고점(Ceiling)이 100% 한정되어 OOM이 영원히 발생하지 않는다.
g_packet_ring = deque(maxlen=10000)

def memory_safe_listener(sample: zenoh.Sample):
    # [주의] sample.payload 를 큐에 저장할 때는 반드시 분리 복제(tobytes) 해준다.
    # 참조 원본을 살려두면 Rust 계층의 메모리 프레임이 회수되지 않고 발목이 잡힐 수 있다.
    g_packet_ring.append(sample.payload.tobytes())

2. [하드코어] GC 세대별 임계치(Threshold) 강경 튜닝
파이썬의 자동 쓰레기차(GC)는 너무 자주 돌면 CPU 프레임을 끊어먹고 제어가 멈칫(Jitter)거리는 랙 현상을 낳는다.
실시간 통신 로봇에서는 이 쓰레기 수거 시점을 내가 강제로 늦추거나 덩치를 키워버려야 한다.

import gc

## 파이썬 부팅 시점
def tune_gc():
    # 기본값: (700, 10, 10)
    # 튜닝값: 1세대 객체가 10배인 7000개가 될 때까지 쓰레기차는 절대 출동하지 마라!
    # 이로서 Zenoh 패킷 처리 도중 마이크로 인터럽트가 생기는 현상을 틀어막는다.
    gc.set_threshold(7000, 50, 50)

    # 극단적인 경우(수 밀리초 단위 통제가 필요한 경우) 
    # AI 텐서 연산 직전에는 아예 GC를 수동으로 정지(Disable)하기도 한다.
    # gc.disable()
    # do_very_critical_control()
    # gc.enable()

tune_gc()

이런 지독한 메모리 보수 공사 없이는 “파이썬으로 짠 코드는 1주일 뒤에 리붓시켜야 한다“라는 C++ 프로그래머들의 멸시를 버틸 수 없을 것이다. Zenoh의 강인한 C/Rust 심장 위에 얹은 파이썬 코드가 오염되지 않도록 끝까지 방역(Sanitize)에 책임을 져야 한다.