fos-blog/study
01 / 홈02 / 카테고리03 / 시리즈
01 / 홈02 / 카테고리03 / 시리즈

카테고리

  • AI 페이지로 이동
    • RAG 페이지로 이동
    • agent 페이지로 이동
    • claude-code 페이지로 이동
    • harness 페이지로 이동
    • llm 페이지로 이동
    • ops 페이지로 이동
    • practice 페이지로 이동
  • algorithm 페이지로 이동
    • Alias Method: 가중치 랜덤 선택을 전처리 O(n), 추출 O(1)로 바꾸기
    • Welford's Online Algorithm: 값을 저장하지 않고 평균과 분산 계산하기
  • architecture 페이지로 이동
    • distributed-systems 페이지로 이동
    • domain 페이지로 이동
    • evolution 페이지로 이동
    • patterns 페이지로 이동
  • database 페이지로 이동
    • milvus 페이지로 이동
    • mysql 페이지로 이동
    • opensearch 페이지로 이동
    • postgresql 페이지로 이동
    • qdrant 페이지로 이동
    • redis 페이지로 이동
    • vespa 페이지로 이동
    • DB Connection Pool Saturation과 Thread Pool 격리
    • 커넥션 풀 크기는 얼마나 조정해야 할까?
    • gaps and islands: SQL 창 함수로 이어진 구간 찾기
    • MyBatis 기본기 — XML Mapper, resultMap, 동적 SQL, 운영 패턴 정리
    • MyBatis와 JPA/Hibernate 트레이드오프 — 레거시 백엔드를 다루는 시니어 관점
    • 한국어 형태소 분석기 Nori vs Lindera — OpenSearch에서 Milvus로 갈 때 어휘 검색은 유지되나
    • 정규화와 역정규화: 무결성과 조회 비용 사이의 선택
    • 벡터 DB 5종, 아키텍처는 어떻게 다른가
    • 벡터 DB 5종을 실제로 벤치마크했다 — 같은 recall에서 QPS는 얼마나 갈리나
    • 벡터 DB 어떻게 고를까 — OpenSearch · Milvus · Qdrant · Vespa · pgvector 비교
    • 벡터 DB를 실제로 도입한 사례 — 빅테크 프로덕션
  • devops 페이지로 이동
    • docker 페이지로 이동
    • k8s 페이지로 이동
    • Envoy Proxy
    • Graceful Shutdown
    • 종료 신호는 어디서 멈추는가
  • http 페이지로 이동
    • HTTP Connection Pool
    • HTTPS와 TLS: 핸드셰이크, 인증서, 종료 지점
  • java 페이지로 이동
    • concurrency 페이지로 이동
    • jdbc 페이지로 이동
    • spring 페이지로 이동
    • spring-batch 페이지로 이동
    • testing 페이지로 이동
    • Alpine 이미지의 Java 에서 링크 바꿔치기(TOCTOU)를 막지 못하는 이유
    • Java 동시성 락 정리 — 커머스 메뉴/프로모션 정책 캐시 갱신 관점
    • JVM 튜닝 실전: 메모리 구조부터 Virtual Threads, GC 튜닝, 프로파일링까지
    • 로그에 traceId 남기기 — MDC 부터 OpenTelemetry 까지
    • Java StampedLock — 읽기 폭주에도 쓰기가 밀리지 않는 락
    • Virtual Thread와 Project Loom
  • javascript 페이지로 이동
    • typescript 페이지로 이동
    • AbortController
    • Async Iterator와 제너레이터
    • CommonJS와 ECMAScript Modules
    • 제너레이터(Generator)
    • Http Client
    • Node 백엔드 운영 패턴 — Streams 백프레셔, pipe/pipeline, 멱등성 vs 분산 락
    • Node.js
    • `setImmediate()`
  • kafka 페이지로 이동
    • Kafka 클러스터 아키텍처: Broker, KRaft Controller와 복제
    • Kafka 기본 개념: Topic, Partition, Offset과 Segment
    • Kafka 실전 설계: 파티션 전략, 컨슈머 그룹, 전달 보장, 재시도, 순서 보장 트레이드오프
    • Kafka 파티션·리밸런스·컨슈머 지연 운영
    • Kafka를 로그로 이해하기: 복제, 재처리, 상태와 데이터 통합
    • Spring Kafka 컨슈머 오프셋 커밋과 트랜잭션 정렬: AckMode, manual ack, 멱등 처리
  • linux 페이지로 이동
    • fsync — 리눅스 파일 동기화 시스템 콜
    • SSH forced command 로 배포 키가 실행할 수 있는 명령 제한하기
  • mlops 페이지로 이동
    • llm-serving 페이지로 이동
    • model-router 페이지로 이동
    • Python CUDA 버전 생태계 — nvidia-smi, nvcc, pip, conda가 다 다른 버전을 말하는 이유
    • GPU 컨테이너의 CUDA 버전 호환성 — nvidia-smi부터 이미지 다이어트까지
    • Kubernetes GPU 노드에서 /run tmpfs가 꽉 차서 Pod가 안 뜰 때
    • GPU·CUDA·MPS 기초 — 자바 백엔드 개발자가 처음 만나는 그림
    • Multi-process GPU 워크로드 — 자바 ThreadPool 사용자가 만나는 모델 차이
    • ML 서비스 성능 분석 워크플로 — 자바 백엔드 트러블슈팅과 다른 점
    • 한 GPU 를 여러 프로세스가 나눠 쓰기 — Time-Slicing 과 MPS
  • network 페이지로 이동
    • Connection reset by peer는 누가 보낸 걸까 — 리버스 프록시 홉마다 TCP 연결은 따로 논다
    • L2(스위치)와 L3(라우터)의 역할 차이
    • L4와 VIP(Virtual IP Address)
    • IP Subnet
  • observability 페이지로 이동
    • Observability 입문: 시니어 백엔드가 장애를 탐지하고 대응하는 방식
    • K8s 위 Spring Boot 앱의 메트릭 수집
  • python 페이지로 이동
    • Python async/await — CompletableFuture·Reactor 와 다른 점, 그리고 blocking I/O 함정
    • Python 의존성 관리 — Java Maven/Gradle 사용자가 만나는 첫 충격
    • FastAPI 기초 — Spring Boot 사용자가 빠르게 익히는 법
    • Java 개발자를 위한 Python 심화 — OOP·데코레이터·컨텍스트 매니저
    • PyTorch 기초 — 텐서, 디바이스, 그리고 모델 로딩이 무거운 이유
    • Java 개발자를 위한 Python 문법 핵심
    • ThreadLocal 에서 contextvars 로 — Python 의 요청 컨텍스트 전파
    • OCR 동작 원리 — Layout · Text · Post-process 3단계
    • Python 서버의 RSS가 줄지 않는 이유: CPython, gc.collect(), malloc_trim
  • rabbitmq 페이지로 이동
    • RabbitMQ Basics — 실전 백엔드 관점에서 정리하는 메시지 브로커 기본기
    • RabbitMQ vs Kafka — 백엔드 메시징 선택 기준과 실전 운영 관점
  • resume 페이지로 이동
    • 경험 및 경력기술서
    • 김병태 경력기술서
    • 김병태 포트폴리오
  • task 페이지로 이동
    • ai-service-team 페이지로 이동
    • nsc-slot 페이지로 이동
    • personal-projects 페이지로 이동
    • sb-dev-team 페이지로 이동
    • the-future-company 페이지로 이동
  • thinking 페이지로 이동
    • 자기주도 학습: 질문에서 시작해 글로 검증하기
    • 좋은 일을 넘어 중요한 일을 하는 법
FOS-BLOG · FOOTERall systems normal·v0.1 · 2026.04.27·seoul, kr
Ffos-blog/study

개발 학습 기록을 정리하는 블로그입니다. 공부하면서 기록하고, 기록하면서 다시 배웁니다.

visitors
01site
  • Home↗
  • Posts↗
  • Categories↗
  • Glossary↗
  • About↗
02policy
  • 소개/about
  • 개인정보처리방침/privacy
  • 연락처/contact
03categories
  • AI↗
  • Algorithm↗
  • DB↗
  • DevOps↗
  • Java/Spring↗
  • JS/TS↗
  • React↗
  • Next.js↗
  • System↗
04connect
  • GitHub@jon890↗
  • Source repositoryjon890/fos-study↗
  • RSS feed/rss.xml↗
  • Newsletter매주 1 회 · 한 편의 글→
© 2026 FOS Study. All posts MIT-licensed.
built with·Next.js·Tailwind v4·Geist·Pretendard·oklch
fos-blog/mlops/Multi-process GPU 워크로드 —…
mlops

Multi-process GPU 워크로드 — 자바 ThreadPool 사용자가 만나는 모델 차이

자바 백엔드에서 ThreadPoolExecutor 는 거의 만능이었다. CPU bound 든 I/O bound 든 스레드 풀 크기만 잘 잡으면 동시성을 챙길 수 있었다. JVM 안에서 메모리를 공유하니 작업 간 데이터 전달도 가볍다. Python ML 서비스는 그림이 다르다. ThreadPoolExecutor 가 있지만 CPU/GPU 작업에서는 거의 안 쓰...

2026.06.16·7 min read·141 views·SERIES · AI 서빙 인프라: GPU부터 문서 파싱까지 · 5/11

자바 백엔드에서 ThreadPoolExecutor 는 거의 만능이었다. CPU bound 든 I/O bound 든 스레드 풀 크기만 잘 잡으면 동시성을 챙길 수 있었다. JVM 안에서 메모리를 공유하니 작업 간 데이터 전달도 가볍다.

Python ML 서비스는 그림이 다르다. ThreadPoolExecutor 가 있지만 CPU/GPU 작업에서는 거의 안 쓰고, 대신 ProcessPoolExecutor (실제 OS 프로세스 풀) 를 쓴다. 우리 프로젝트도 KR 워커 2개 + JA 워커 1개를 모두 별도 프로세스로 띄운다. 자바 시각에서는 "왜 굳이 무거운 프로세스를?" 라는 의문이 자연스럽게 생긴다.

이 글은 그 의문에 답하고, multi-process GPU 워크로드의 핵심 패턴 4개를 자바 ThreadPool 과 비교해 정리한다.

  • worker 라이프사이클
  • MAX_TASKS_PER_WORKER
  • NVIDIA MPS
  • worker death monitor

왜 thread 가 아니라 process 인가 — GIL 의 결정

이전 async/await 글 에서 짚었듯 CPython 은 GIL (Global Interpreter Lock) 로 인터프리터에 큰 락을 박았다. 한 프로세스 안에서는 한 번에 하나의 스레드만 Python 바이트코드를 실행한다.

자바에서 Runtime.getRuntime().availableProcessors() 만큼 스레드를 띄워 CPU 작업을 병렬화하는 패턴은 Python 에서는 거의 동작하지 않는다. CPU 시간이 단일 스레드로 직렬화되기 때문.

GPU 작업도 비슷한 영향을 받는다. PyTorch 가 CUDA kernel 을 호출하는 잠깐 동안 GIL 을 놓지만, 그 외 파이썬 코드 (전처리·후처리·dispatch) 가 직렬화된다. 모델 추론 자체가 빠르더라도 주변 코드가 병목이 되면 throughput 이 올라가지 않는다.

해결책은 프로세스 단위 격리. 프로세스마다 독립 인터프리터·독립 GIL 을 가지므로 진짜 병렬 실행이 된다.

비교자바 ThreadPoolPython ProcessPool
단위OS 스레드OS 프로세스
메모리JVM 안에서 공유격리 (IPC 필요)
생성 비용가벼움 (수십 μs)무거움 (Python 인터프리터 + 모델 로드, 수 초)
데이터 전달객체 참조 직접pickle 직렬화 + IPC
GIL 영향없음없음 (프로세스 분리)
OOM 영향 범위JVM 전체해당 프로세스만
모니터링 단위jstackps, nvidia-smi

가장 큰 차이는 메모리 격리와 생성 비용. 자바라면 1ms 만에 새 스레드를 만들지만, Python ProcessPool 의 worker spawn 은 우리 실측으로 5.74초 (MAX_TASKS_PER_WORKER 도달 시 매번). PyTorch 모델 로딩 글 에서 분해한 다섯 단계가 매 spawn 마다 반복된다.

ProcessPoolExecutor 의 동작

자바의 ExecutorService 자리에 concurrent.futures.ProcessPoolExecutor 가 들어간다.

python
from concurrent.futures import ProcessPoolExecutor
 
def init_worker():
    # 워커 프로세스가 시작될 때 한 번만 실행
    global converter
    converter = build_converter()
 
def process_document(path: str) -> str:
    return converter.convert(path)
 
executor = ProcessPoolExecutor(
    max_workers=2,
    initializer=init_worker,
    max_tasks_per_child=50,
)
 
future = executor.submit(process_document, "/tmp/file.pdf")
result = future.result(timeout=1200)

initializer 는 자바 ThreadFactory 에서 beforeExecute 자리. 각 워커 프로세스가 시작될 때 한 번 호출되어 무거운 모델을 미리 로드한다. 우리 코드의 init_worker_process 함수가 이 자리에서 PaddleOCR + Docling converter 를 워커마다 캐싱한다.

max_tasks_per_child 는 자바에 없는 옵션. 워커 한 명이 N 개 task 를 처리하면 자동으로 죽고 새 워커가 spawn 된다. 메모리 누수 방어용으로 도입된 패턴인데, 우리 분석에서는 이 값이 너무 작게 (3) 설정되어 throughput 손해가 큰 것으로 드러났다.

워커 라이프사이클

자바 ThreadPool 의 스레드는 보통 영원히 살아 있다. 작업 큐에서 작업을 꺼내 실행하고 다음 작업을 기다리는 것을 반복.

Python ProcessPool 의 워커는 라이프사이클이 더 명시적이다.

  1. Spawn — os.fork() 또는 spawn 방식으로 새 프로세스 생성 (Linux 기본 fork, macOS·Windows 기본 spawn)
  2. Initialize — initializer 호출, 모델 로딩, GPU 컨텍스트 생성. 5-30초 소요.
  3. Process tasks — 작업 큐에서 task pickle 을 받아 실행, 결과 pickle 로 반환
  4. Recycle(선택) — max_tasks_per_child 도달 시 종료, 새 워커가 자리에 spawn
  5. Death — 예외·OOM·외부 시그널로 비정상 종료

자바와 결정적으로 다른 점은 3번에서 4번 사이의 비용. 자바 스레드는 그냥 다음 작업을 받지만, Python 프로세스는 종료 후 새 프로세스가 모델을 다시 로드한다. 워밍업 비용 5.74초가 이 사이클의 핵심 부담이 된다.

MAX_TASKS_PER_WORKER 의 트레이드오프

자바 ThreadPool 에는 거의 없는 개념이지만 Python 에서는 표준 패턴이 됐다. 이유는 두 가지.

메모리 누수 방어: PyTorch / PaddleOCR 같은 native 라이브러리가 가끔 메모리를 명확히 회수하지 않는 케이스가 있다. JVM GC 처럼 자동 회수가 잘 되는 환경이 아니라서, 일정 작업마다 프로세스를 죽이고 다시 만들어 메모리를 초기화하는 게 안전.

predictable shutdown: 자바 GC tuning 처럼 메모리 추세를 예측하기 어려운 워크로드에서 "N 건마다 무조건 reset" 이라는 단순 규칙이 운영 안정성을 준다.

비용은 명확하다. 매번의 워밍업 시간이 누적된다. 우리 운영의 MAX_TASKS=3 은 12시간 동안 1,565회 워밍업 = 약 150분의 누적 다운타임을 만든다. 적정값을 찾으려면 메모리 추세를 실측한 뒤 점진적 상향을 권한다 (우리 케이스는 50 수준이 안전한 것으로 판단).

자바의 Tomcat 가 worker 스레드를 자동 재활용하지 않는 것과 대비된다. 자바는 그 자리에 heap dump + GC tuning 으로 대응. Python ML 서비스는 더 국소적인 "주기적 reset" 방식.

NVIDIA MPS — multi-process GPU 효율화

GPU·CUDA 글 에서 NVIDIA MPS 를 짚었다. multi-process GPU 워크로드의 핵심 최적화.

기본 모드에서는 각 프로세스가 GPU 에 접근할 때 자체 CUDA context 를 만든다. 우리 워커 3개라면 CUDA context 3개 × 300-600MB = ~1.5GB VRAM 이 그냥 컨텍스트로 소비. T4 15GB 중에 큰 비중.

NVIDIA MPS daemon 을 띄우면 여러 프로세스가 같은 컨텍스트를 공유한다. VRAM 절약 + 컨텍스트 전환 비용 감소. 우리 운영은 현재 MPS=OFF 인데, multi-process 워커 수를 늘릴 때 MPS 를 켜는 게 ROI 있는 방향.

자바에는 비유할 게 없는 영역. JVM 자체가 프로세스 격리를 받지 않고 OS 의 GPU 직접 접근도 거의 없기 때문.

Round-Robin GPU 스케줄링

multi-GPU 환경에서는 작업을 어느 GPU 에 보낼지 결정해야 한다. 우리 프로젝트 코드는 KR / JA / Priority 풀별로 Round-Robin index 를 락으로 보호하면서 GPU 를 순회한다.

python
_kr_gpu_round_robin_idx = 0
_kr_gpu_round_robin_lock = threading.Lock()
 
def get_kr_executor() -> ProcessPoolExecutor:
    global _kr_gpu_round_robin_idx
    with _kr_gpu_round_robin_lock:
        idx = _kr_gpu_round_robin_idx % len(available_gpus)
        _kr_gpu_round_robin_idx += 1
    return _kr_executors[idx]

자바 AtomicInteger 의 incrementAndGet() 자리. 단 single GPU 환경에서는 idx % 1 = 0 으로 항상 같은 결과라 락만 잡고 효과가 없다. 우리 분석에서 이슈로 잡힌 부분. multi-GPU 클러스터로 확장될 가능성이 있는 코드라 일반화는 합리적이지만, 단일 GPU 케이스의 단락 처리를 추가하는 게 좋다.

Worker death monitor — 자바엔 없는 패턴

자바 ThreadPool 의 스레드가 예외로 죽으면 풀이 새 스레드를 spawn 한다. Python ProcessPool 도 비슷하지만, 모니터링이 더 명시적이다.

우리 코드의 worker_death_monitor 는 별도 thread 가 1초 간격으로 executor 의 워커 상태를 확인한다. 죽은 워커가 발견되면 즉시 새 worker 를 spawn 한다.

python
def worker_death_monitor():
    while True:
        for executor in [_kr_executor, _ja_executor]:
            if has_dead_worker(executor):
                respawn(executor)
        time.sleep(1)

자바라면 Thread.setUncaughtExceptionHandler + 풀 내장 동작으로 해결되는 일을, Python 에서는 별도 watchdog 스레드로 풀어야 한다. 프로세스 간 통신·죽음 감지가 일반적으로 더 깨지기 쉬워서다.

restart-all-at-once 패턴의 함정

우리 분석에서 가장 큰 운영 이슈로 잡힌 것이 restart_all_executors_if_needed 패턴. RAM threshold 도달 또는 수동 /restart 호출 시 KR/JA/Priority 풀을 동시에 teardown 한 뒤 _is_restarting=True 동안 모든 incoming 요청을 503 으로 거부한다.

실측 결과 12시간 동안 503 응답이 225,803회. 거부율 96.7%. 같은 시점에 다른 풀이 살아 있도록 staggered restart 패턴이 필요하다는 결론.

자바 진영의 rolling restart (Kubernetes Deployment 의 maxUnavailable) 와 같은 개념인데, 단일 프로세스 안의 worker pool 단위에서 같은 패턴을 적용해야 한다. JVM 내부에서는 거의 마주치지 않던 문제.

정리

자바 ThreadPool 사용자가 Python multi-process worker pool 로 옮길 때 외워둘 한 줄.

Python ML 워크로드는 GIL 때문에 thread 가 아닌 process 단위로 격리한다. process 는 비싸므로 worker 라이프사이클 (spawn, initialize, recycle) 이 핵심 비용이고, 모든 운영 문제 (503 폭풍, 메모리 누수, 워밍업 누적) 가 그 라이프사이클의 변형으로 환원된다.

다음 글은 이 위에서 OCR 파이프라인이 어떻게 동작하는지 (layout detection → text recognition → post-processing) 정리한다. ML 모델 추론의 실제 흐름을 보여주는 단계.

참고

  • concurrent.futures — ProcessPoolExecutor
  • Python multiprocessing — Contexts and start methods
  • NVIDIA Multi-Process Service (MPS)
  • PyTorch — Distributed and parallel training
  • Real Python — Speed Up Your Python Program with Concurrency
on this page
  • 01왜 thread 가 아니라 process 인가 — GIL 의 결정
  • 02ProcessPoolExecutor 의 동작
  • 03워커 라이프사이클
  • 04MAX_TASKS_PER_WORKER 의 트레이드오프
  • 05NVIDIA MPS — multi-process GPU 효율화
  • 06Round-Robin GPU 스케줄링
  • 07Worker death monitor — 자바엔 없는 패턴
  • 08restart-all-at-once 패턴의 함정
  • 09정리
  • 10참고
tags
#study📚 AI 서빙 인프라: GPU부터 문서 파싱까지
← PREVIOUS한 GPU 를 여러 프로세스가 나눠 쓰기 — Time-Slicing 과 MPSNEXT →Kubernetes GPU 노드에서 /run tmpfs가 꽉 차서 Pod가 안 뜰 때

이런 글도

  • mlops

    추측 디코딩과 vLLM 적응형 검증

    2026.11.14
  • mlops

    같은 문제를 회사마다 다른 층에서 푼다

    2026.09.08
  • mlops

    캐시를 보고 인스턴스를 고른다

    2026.09.08
  • mlops

    vLLM 지표를 라우팅 판단에 쓰는 방법

    2026.09.08

댓글 (0)