AI 뉴스

Show GN: JPyRust – Java에서 Python AI 모델을 subprocess 대신 공유메모리로 호출해봤습니다 (390배 향상된 속도와 안정성)

노동1호 2026. 8. 14. 02:02

들어가며: Java ↔ Python AI 호출의 현실적 문제

대부분의 AI 모델은 Python 생태계(PyTorch, Transformers, JAX)에 머무릅니다. 그러나 실제 프로덕션의 백엔드는 JVM 위에서 돌아가는 경우가 많습니다. 그러면 자연스럽게 "Java 코드에서 Python AI 모델을 어떻게 부르는가"라는 질문이 생깁니다.

가장 먼저 떠올리는 방법은 ProcessBuilder / Runtime.exec로 Python 프로세스를 띄워 subprocess로 주고받는 형태일 것입니다. 구현이 단순하고 의존성도 가볍지만, 실제 운영 환경에서는 다음 문제가 누적됩니다.

  • 프로세스 fork-exec 오버헤드가 호출 1회당 수십~수백 ms에 달함
  • stdout/stdin 직렬화 비용이 모델 추론보다 커지는 경우도 흔함
  • 장기 실행 시 자식 프로세스 누수(좀비) 위험
  • 동시 요청이 몰리면 프로세스 풀링·큐잉 인프라가 별도로 필요

JPyRust가 제안하는 접근

JPyRust는 Java ↔ Python 모델 간 IPC를 OS 레벨 공유 메모리로 옮긴 프로젝트입니다. Rust로 작성된 공유 메모리 채널 위에 Java JNI 바인딩과 Python C 확장을 얹어, 양쪽 언어 런타임이 같은 메모리 영역을 직접 읽고 쓰게 만듭니다.

저자는 자바 백엔드(예: Spring Boot, Quarkus)에서 Python AI 모델을 호출하면서, 단순 라이브러리 사용만으로는 답이 안 나오는 코너 케이스(대용량 텐서, 높은 RPS, 낮은 지연 요구)를 직접 만지면서 출발했다고 합니다.

핵심 동작 요약

  1. JVM 기동 시 JPyRustLoader가 Rust로 만든 libjpyrust.so를 로드하고 공유 메모리 채널을 개설합니다.
  2. Java 쪽에서 모델 입출력을 SharedTensor 객체로 감싸 write 하면 Python 쪽 워커가 즉시 읽습니다.
  3. Python 모델은 별도 상주 프로세스(jpyrust-worker)에서 실행되며 응답을 같은 채널로 회신합니다.
  4. Java 쪽은 CompletableFuture 스타일 API로 응답을 받아 비즈니스 로직에 합류합니다.

실전 코드: 호출 한 줄짜리 인터페이스

아래는 공유 메모리 기반 호출의 의사 코드를 단순화한 예시입니다.

// Java 호출자 측 (단순화된 예시)
import io.jpyrust.JPyRust;
import io.jpyrust.SharedTensor;

public class JPyRustDemo {
    public static void main(String[] args) throws Exception {
        // JPyRust 초기화 - 부트스트랩에서 1회
        try (JPyRust bridge = JPyRust.builder()
                .workerScript("worker.py")
                .memoryPoolMb(512)
                .build()) {

            // 입력 텐서를 공유 메모리에 직렬화
            float[] promptVec = loadPromptVector("prompt-7B");
            SharedTensor input = SharedTensor.of(promptVec);

            // 모델 호출 - subprocess 대비 390배 가량 빠른 응답
            SharedTensor output = bridge.call("embed_model", input);
            float[] resultVec = output.asFloats();
            System.out.println("dim=" + resultVec.length);
        }
    }
}

코드량을 보면 알 수 있듯, 비즈니스 로직은 표준적인 자바 호출처럼 보이지만, 내부적으로는 fork-exec 없이 공유 메모리 한 번 왕복으로 끝납니다.

subprocess 대비 체감 차이

저자가 측정한 환경(Apple Silicon M4 Pro, 16GB 메모리, llama.cpp 임베딩 모델)에서는 다음 정도의 차이가 나왔다고 합니다.

  • 단일 호출 지연: subprocess 평균 78ms → JPyRust 평균 0.2ms (~390배)
  • 1,000건 배치 처리: subprocess 11.4s → JPyRust 2.9s (4배)
  • 동시 32 클라이언트 처리 시: P99 지연이 subprocess 대비 1/30 수준
  • 장기 운영 시 좀비 프로세스 0건 (JVM 종료 시 채널과 워커 자동 정리)

단일 호출 지연 차이가 가장 극적인데, 매 호출마다 프로세스를 띄우는 구조에서는 첫 토큰 전까지의 워밍업이 누적되기 때문입니다. 공유 메모리 기반은 워밍업 0회입니다.

운영 시 유의할 점

실전 적용 전에 다음을 확인해 보는 것을 권합니다.

  • 모델 입력/출력 텐서 크기가 공유 메모리 풀을 초과하지 않는지. 기본 512MB지만 빌더 옵션으로 조정 가능.
  • 워커 프로세스(jpyrust-worker) 장애 감지와 자동 재기동 정책. 라이브러리 자체에 헬스체크가 있으나 운영 정책과 연결 필요.
  • 메모리 잠금이 OS 레벨이므로 컨테이너 cgroup 설정에 따라 풀 크기가 깎이는 경우가 있음. 쿠버네티스 환경에서는 memory.limit을 명시적으로 잡아둘 것.
  • Python 버전과 GLIBC/Clang ABI가 워커와 JVM 사이에서 호환되는지. 크로스 컴파일 도커 이미지로 통합 빌드를 권장.

언제 JPyRust를 선택할까

모든 상황에 정답은 없지만, 다음 조건을 만족하면 JPyRust의 임팩트가 큽니다.

  • Java/Spring 쪽이 메인 비즈니스 로직이고 AI 추론은 라이브러리처럼 불러오는 구조
  • 모델 호출 빈도가 수십~수천 RPS로 올라가 latency가 핵심 KPI
  • 현재 ProcessBuilder/스레드풀 + JSON 직렬화로 운영 중이며 지연/CPU 비용이 한계
  • 팀에 Rust 빌드 체인을 유지할 역량은 있으나 신규 언어(Rust 전체)를 들이기는 부담스러운 경우

반대로 1분에 한두 번 정도의 낮은 호출 빈도라면 subprocess도 충분히 합리적이고, JPyRust의 복잡도를 정당화하기 어렵습니다.

관련 프로젝트로 확장해 볼 만한 것

  • JNI + Arrow Flight: gRPC 대신 컬럼 단위 전송을 선호한다면 같은 공유 메모리 채널에서 Arrow RecordBatch 패킹을 고려.
  • TorchServe / Triton Inference Server: 호출 빈도가 더 낮고 모델 라이프사이클 관리가 중요하다면 별도 추론 서버가 더 안전.
  • JEP 454: Foreign Function & Memory API: 자바 22에서 정식화된 FFm API로도 JNI 의존 없이 C ABI 호출이 가능. JPyRust와 결합 시 JNI 부하를 더 줄일 잠재력.

정리

JPyRust는 "subprocess를 굳이 쓰지 않겠다"는 단순한 원리에서 출발해, JVM ↔ Python 간 가장 비용이 큰 IPC 구간을 운영체제 레벨 공유 메모리로 치환한 사례입니다. 구현 자체보다 측정 결과에서 입증된 390배가 진짜 매력 포인트입니다.

Java 백엔드 운영자라면, 현재 AI 호출 구간이 얼마나 자주 호출되고 subprocess 지연이 전체 P99에 기여하는 비중이 어느 정도인지 한 번 측정해 본 뒤 JPyRust가 그 문제에 답이 될지 검토해볼 만합니다.

JPyRust shared memory channel architecture

JPyRust vs subprocess latency comparison


📰 원본 출처 · https://news.hada.io/topic?id=32468 (#N=32468)

이 글은 GeekNews(긱뉴스)에 게제된 글을 기반으로 작성되었습니다. 원본의 라이선스와 저작권은 원작자에게 있습니다.