RPC를 언제 쓰는가, 그리고 이 프로젝트는 왜 RPC를 쓰는가
이 글은 두 가지를 정리한다.
- RPC가 무엇이고 어떤 문제를 해결하기 위한 도구인가
detection-mvp/image-extract가 PDFium 호출 방식으로 RPC(go-pdfium의multi_threaded백엔드)를 선택한 정당성
“동료가 왜 이렇게 짰냐고 물었을 때, 막힘 없이 답할 수 있는 근거”를 목표로 한다.
1. RPC란 무엇인가
RPC(Remote Procedure Call)는 “함수 호출처럼 보이지만 실제로는 다른 실행 단위에서 수행되는 호출”이다. “다른 실행 단위”는 다음 중 하나다.
- 같은 머신의 다른 프로세스 (IPC 기반 RPC, 본 프로젝트가 사용하는 형태)
- 네트워크 너머의 다른 머신 (네트워크 RPC, 일반적인 마이크로서비스)
- 같은 프로세스의 다른 언어 런타임 (드물지만 가능)
핵심은 “호출하는 쪽의 코드는 평범한 함수 호출처럼 작성하지만, 실제 실행은 다른 곳에서 일어난다”는 점이다.
go-pdfium의 multi_threaded 백엔드에서 일어나는 흐름:
[메인 프로세스 image-extract] [자식 프로세스 pdfium-worker]
pdfium.RenderToFile(...)
└─ net/rpc 클라이언트
└─ encoding/gob 직렬화
└─ Unix Domain Socket 송신 ──► 수신
└─ encoding/gob 역직렬화
└─ CGO → libpdfium.so
└─ PDF 렌더링 + PNG 인코딩
◄── 응답 직렬화 송신
◄─ 응답 역직렬화
return 값
호출자가 작성하는 코드는 단 한 줄(pdfium.RenderToFile(...))이지만, 그 한 줄 뒤에는 직렬화·소켓·프로세스 분리가 모두 들어있다. 이것이 RPC의 가치이자 비용이다.
2. RPC가 해결하려는 문제 — 언제 RPC를 써야 하는가
RPC는 도구 자체가 목적이 아니다. 다음 중 하나 이상의 문제를 해결하려는 수단이다.
2.1 격리(Isolation)
같은 프로세스 안에서 돌면 한쪽의 segfault, 메모리 손상, 무한 루프가 전체 프로세스를 죽인다. 별도 프로세스에 두면 워커만 죽고 메인은 살아남으며, 감지 즉시 새 워커를 spawn할 수 있다.
판별 질문: “이 라이브러리가 죽으면 내 메인 서비스도 함께 죽어도 되는가?” — 답이 No이면 격리가 필요하다.
2.2 동시성 제약 우회
thread-unsafe한 라이브러리는 한 프로세스 안에서 한 번에 한 호출만 받을 수 있다. 진정한 병렬성을 얻으려면 N개의 독립 프로세스를 띄워야 하고, 그 프로세스들과 통신하려면 RPC가 거의 강제된다.
대표적 예: PDFium, ImageMagick의 일부 연산, MATLAB 엔진, 일부 ML 추론 라이브러리.
2.3 영속 상태(State)
라이브러리 핸들/포인터가 여러 호출에 걸쳐 유효해야 하는 경우, 호출 단위가 아니라 세션 단위로 워커가 살아있어야 한다. 매 호출마다 새 프로세스를 띄우는 Command 패턴은 부적합하다.
2.4 다른 언어/런타임 호출
언어 간 직접 바인딩이 없거나 어려울 때 RPC가 다리 역할을 한다. Python ML 모델을 Go 서버에서 호출, C++ 엔진을 Java에서 호출 등.
2.5 OS 자원 제어와 샌드박싱
별도 프로세스에는 cgroup, ulimit, seccomp 등을 OS 단위로 적용할 수 있다. 같은 프로세스에서는 메인 전체에 적용되므로 부분 격리가 불가능하다.
2.6 독립 배포·확장
“PDF 처리 서버 10대, API 서버 100대”처럼 독립 스케일링이 필요하면 네트워크 RPC(마이크로서비스)가 된다.
3. RPC가 부적합한 경우
- 호출 빈도가 매우 높고 한 번 호출이 매우 가벼운 경우: 호출당 ~수백 μs의 IPC 오버헤드가 처리 시간을 압도한다.
- 신뢰되고 안전한 라이브러리: 죽지 않고 thread-safe하면 격리가 필요 없다.
- 상태가 없는 1회성 작업: Command 패턴(
fork+exec)이 더 단순하다.
4. 통합 방식 비교
| 항목 | 직접 호출 (FFI/CGO) | Command (fork+exec) | 같은 머신 RPC | 네트워크 RPC |
|---|---|---|---|---|
| 호출당 오버헤드 | ~1~10 μs | ~10~150 ms | ~50~500 μs | ~수 ms ~ 수십 ms |
| 격리 | 없음 | 강 (매 호출) | 강 (워커 분리) | 강 (호스트 분리) |
| 영속 상태 | OK | 불가 (매번 초기화) | OK (워커 보유) | OK (서버 보유) |
| 병렬성 (thread-unsafe) | 없음 | 있음 (N프로세스) | 있음 (N워커) | 있음 (N호스트) |
| 배포 단위 | 1 바이너리 | 1 메인 + 외부 도구 | 1 메인 + 1 워커 | N개 독립 서비스 |
| 개발 복잡도 | 낮음 | 중 | 중-상 | 상 |
Command 패턴이 적합한 경우
- 호출 빈도가 매우 낮음 (배치, 정기 작업)
- 호출 사이에 상태가 없어도 됨
- 외부 도구가 CLI 형태로 존재 (예:
ffmpeg,pdftoppm)
직접 호출이 적합한 경우
- 라이브러리가 thread-safe하고 안정적
- 같은 언어 또는 잘 만들어진 바인딩 존재
- 호출 빈도 매우 높음, 호출당 작업량 작음
RPC가 적합한 경우
- 위 조건 중 두 개 이상 해당
- 호출당 작업량이 IPC 오버헤드보다 충분히 큼
- 운영 안정성이 호출 효율보다 중요
5. 본 프로젝트에서 RPC를 선택한 정당성
detection-mvp/image-extract는 go-pdfium의 multi_threaded 백엔드를 사용한다. 격리, 동시성 제약 우회, 영속 상태가 동시에 적용되기 때문이다.
5.1 격리 — PDFium의 크래시 가능성
PDFium은 C++ 코드베이스로 작성된 PDF 파서·렌더러이며, 외부에서 받은 임의의 PDF를 처리한다. 손상된 PDF에 의한 segfault, 악의적 PDF에 의한 메모리 손상, 무한 루프/메모리 폭주가 위협이다.
“처리 중인 PDF 1건이 잘못되어 다른 PDF 99건의 처리를 중단시키지 않는다”는 보장이 격리에서 온다.
5.2 동시성 — PDFium의 thread-safety 부재
PDFium은 공식적으로 thread-safe하지 않다. single_threaded 백엔드는 글로벌 mutex로 호출을 직렬화한다. PDF 4개를 동시에 처리하려면 PDFium 인스턴스 4개, 즉 4개의 독립 프로세스가 필요하다.
5.3 영속 상태 — PDF 핸들의 수명
FPDF_LoadDocument(path) → FPDF_DOCUMENT 핸들
FPDF_GetPageCount(doc) → 핸들 사용
RenderToFile({Page: doc, idx}) → 핸들 사용
FPDF_CloseDocument(doc) → 핸들 해제
Command 패턴이라면 매 호출마다 PDF를 다시 로드해야 한다. 11페이지 PDF에서 파싱이 11번 반복된다. RPC + 영속 워커는 PDF 1번 로드, 핸들 재사용으로 N번 렌더.
5.4 비용 — 무엇을 치르는가
| 항목 | 비용 |
|---|---|
| 호출당 IPC 오버헤드 | ~50~500 μs |
| 워커 시작 비용 | ~50~200 ms (1회) |
| 배포 단위 증가 | image-extract + pdfium-worker 두 바이너리 |
| 데이터 전송 비용 | 큰 비트맵 전송 시 ~50~130 ms (RenderToFile로 회피 가능) |
5.5 트레이드오프 정량
| 작업 단위 | PDFium 처리 시간 | RPC 오버헤드 | 오버헤드 비율 |
|---|---|---|---|
| RenderToFile (페이지 PNG, 300 DPI) | ~200~500 ms | ~1 ms | ~0.3% |
| FPDF_GetPageCount | ~수 ms | ~0.1~0.5 ms | ~5~10% |
| FPDFBitmap_GetWidth (단순 getter) | ~수 μs | ~0.1~0.5 ms | ~수십 배 |
페이지 단위 렌더처럼 호출 1회의 처리량이 무거운 경우 RPC 비용은 묻힌다. 이것이 현재 코드가 RenderToFile을 사용해 워커 안에서 PNG 인코딩과 디스크 쓰기까지 완료시키는 이유다.
5.6 “왜 이렇게 짰냐” — 1줄 답
“PDFium이 thread-unsafe하고 신뢰 못 할 PDF에 죽을 수 있어서, 격리와 병렬성을 동시에 얻으려고 별도 프로세스에 띄우고 RPC로 호출한다. PDF 핸들이 호출 사이에 살아있어야 해서 영속 워커이고, 호출당 ~수백 μs의 IPC 오버헤드는 페이지 렌더 ~수백 ms 앞에서는 무시할 수준이다.”
6. 결정 트리 — 새 라이브러리 통합 시 7가지 질문
- 이 라이브러리가 비정상 입력에 죽을 수 있는가?
→ Yes면 +RPC (격리 필요) - thread-safe한가? 동시성이 필요한가?
→ thread-unsafe + 동시성 필요 → +RPC (병렬성 확보) - 호출 사이에 상태(핸들/세션)가 살아있어야 하는가?
→ Yes면 RPC > Command - 내 언어에서 직접 호출할 안정적 바인딩이 있는가?
→ No면 +RPC 또는 FFI - OS 단위 자원 한도/샌드박싱이 필요한가?
→ Yes면 +RPC - 호출 빈도가 매우 높고 호출당 일이 매우 가벼운가?
→ Yes면 -RPC (직접 호출이 유리) - 독립 배포·확장이 필요한가?
→ Yes면 네트워크 RPC
본 프로젝트 적용: 1=Yes, 2=Yes, 3=Yes, 4=Yes(바인딩 있으나 CGO 필요), 5=No, 6=No(렌더는 무거움), 7=No → 격리 + 병렬성 + 영속 상태가 필요한 단일 머신 IPC RPC가 정답.
7. 의사결정 사후 점검 항목
- 워커 크래시 빈도: 의미 있게 발생하면 격리가 가치를 입증하는 셈
- PDF 동시 처리량 vs 워커 수: 비례하면 병렬성이 정당화됨
- 호출당 RPC 오버헤드 측정: chatty 패턴이 늘어나면 API 설계를 묶는 방향으로 재정비할 신호
- 메모리 누수 누적 여부: 워커 메모리 증가 시 워커 재시작 정책 추가 신호
부록. 사용 중인 통신 스택
| 구성요소 | 사용 기술 |
|---|---|
| RPC 프로토콜 | Go 표준 net/rpc |
| 직렬화 포맷 | encoding/gob (Go 전용 바이너리 포맷) |
| 트랜스포트 | Unix Domain Socket (Linux/macOS) / Named Pipe (Windows) |
| 프로세스 관리 | hashicorp/go-plugin |
.proto 파일이나 *.pb.go 생성 코드는 없다 (Protobuf/gRPC 미사용). 사용자가 직접 작성해야 하는 RPC 코드는 0줄이다.
답글 남기기