JVM-Python 경계가 만드는 숨겨진 병목
PyFlink는 Flink의 강력한 분산 스트림 처리 능력을 Python 생태계에서 활용할 수 있게 해주는 매력적인 선택지다. 하지만 내부 구조를 이해하지 않으면 예상치 못한 곳에서 성능 함정에 빠질 수 있다. 실제로 Kafka 이벤트를 소비해 Protobuf로 직렬화 후 다운스트림으로 전달하는 단순한 파이프라인에서 p99 지연이 3~5초에 달하는 SLO 위반이 발생했다. 파이프라인 자체의 비즈니스 로직은 문제가 없었다. 병목은 JVM과 Python 프로세스 사이의 경계, 즉 "문(doorway)" 자체에 있었다.
Flink 런타임은 JVM 위에서 동작한다. PyFlink에서 Python UDF를 사용하면 Flink는 별도의 Python 워커 프로세스를 띄우고, 레코드를 직렬화해 IPC(Inter-Process Communication)로 전달한 뒤 Python 측에서 처리한 결과를 다시 JVM으로 가져온다. 이 왕복 비용은 레코드 한 건에 수십 밀리초에 불과해 보여도, 프로덕션 트래픽 아래에서 수천 건이 누적되면 p99 지점에서 수 초로 증폭된다.
원인 분석: Protobuf 역직렬화를 어디서 하는가
프로파일링 결과 핵심 원인은 Protobuf 역직렬화를 Python UDF 내부에서 수행하고 있었다는 점이다. 구조를 단순화하면 아래와 같다.
[Kafka Source, JVM]
→ (IPC 경계 통과)
→ [Python UDF: Protobuf 역직렬화 + 변환]
→ (IPC 경계 통과)
→ [Downstream Sink, JVM]
모든 레코드가 JVM → Python → JVM 경로를 왕복하고 있었고, Protobuf 역직렬화 자체보다 경계 통과 비용이 훨씬 컸다. 이는 Java 백엔드 개발자에게도 중요한 시사점을 준다. gRPC나 Kafka 기반 시스템에서 역직렬화 로직을 어느 레이어에 배치하느냐가 처리량과 지연에 직접적인 영향을 미친다.
해결 전략: 언어 경계를 최소화하라
개선의 핵심은 JVM-Python 경계를 넘는 횟수와 데이터 크기를 줄이는 것이다. 구체적으로는 Protobuf 역직렬화를 Java/Scala 기반의 Flink 오퍼레이터로 이동시켜, Python UDF가 받는 시점에는 이미 파싱된 필드만 전달되도록 구조를 바꿨다.
// JVM 측에서 Protobuf 역직렬화 처리
public class ProtobufDeserializationSchema implements DeserializationSchema<MyEvent> {
@Override
public MyEvent deserialize(byte[] message) throws IOException {
return MyEvent.parseFrom(message); // JVM에서 처리
}
}
Python UDF는 이미 파싱된 단순 필드만 받아 변환 로직에만 집중하게 된다. 경계를 넘는 페이로드 크기가 줄어들고, 파싱 연산 자체도 JVM에서 처리되므로 IPC 왕복 비용이 대폭 절감된다. 결과적으로 p99 지연은 3~5초에서 약 500ms 수준으로 감소했다.
이 접근은 PyFlink에만 국한된 이야기가 아니다. Java 서비스에서 외부 프로세스(Python 스크립트, 사이드카 등)를 호출하는 구조라면 동일한 원칙이 적용된다. 무거운 파싱과 변환은 호출 전에 JVM 쪽에서 처리하고, 경계를 넘기는 데이터는 최소화해야 한다.
정리
- PyFlink에서 Python UDF는 JVM과 별도 프로세스로 동작하며, 레코드마다 IPC 왕복 비용이 발생한다.
- Protobuf 역직렬화 등 무거운 파싱 로직은 JVM 레이어에서 처리하고, Python UDF에는 가공된 데이터만 전달해 경계 통과 비용을 줄여야 한다.
- 분산 스트림 처리에서 성능 병목은 비즈니스 로직보다 언어·프로세스 경계에 있을 수 있으므로, 아키텍처 설계 단계에서 데이터 흐름의 경계를 의식적으로 설계해야 한다.