JVM-Python 경계가 만들어낸 예상치 못한 병목
PyFlink 파이프라인은 Kafka 이벤트를 소비하고, Protobuf로 직렬화한 뒤 다운스트림 시스템으로 전달하는 비교적 단순한 구조였다. 그러나 프로덕션 부하 상황에서 p99 지연이 지속적으로 3~5초에 달하며 SLO를 위반했다. 문제 원인을 찾기 위해 프로파일링을 수행한 결과, 비즈니스 로직은 전혀 문제가 없었다. 병목은 JVM과 Python 프로세스 사이의 경계에 있었다.
Flink 런타임은 JVM 기반으로 동작한다. 그런데 Protobuf 역직렬화를 Python UDF에서 처리하도록 구성되어 있었기 때문에, 모든 레코드가 JVM → Python 프로세스 경계를 왕복해야 했다. 즉, 데이터가 들어올 때마다 직렬화된 바이트를 Python 프로세스로 넘기고, Python UDF가 파싱한 뒤, 결과를 다시 JVM으로 반환하는 과정이 반복됐다. 비유하자면, 로직 자체는 빠른데 방 사이의 문을 통과하는 데 시간이 걸리는 구조였다.
언어 경계를 JVM 쪽으로 옮기는 방식으로 해결
해결 방향은 명확했다. Python UDF에서 처리하던 Protobuf 역직렬화 로직을 JVM 레이어로 이동시켜 프로세스 경계 왕복을 최소화하는 것이었다. Flink에서는 Java/Scala로 작성한 DeserializationSchema를 통해 Kafka 레코드를 JVM 내부에서 직접 파싱할 수 있다.
public class ProtobufDeserializationSchema
implements DeserializationSchema<MyEvent> {
@Override
public MyEvent deserialize(byte[] message) throws IOException {
return MyEvent.parseFrom(message); // JVM 내부에서 파싱
}
@Override
public boolean isEndOfStream(MyEvent nextElement) {
return false;
}
@Override
public TypeInformation<MyEvent> getProducedType() {
return TypeInformation.of(MyEvent.class);
}
}
이렇게 역직렬화를 JVM에서 완료한 뒤, 꼭 필요한 경우에만 Python UDF로 데이터를 넘기는 구조로 변경하면 프로세스 경계 횟수 자체가 줄어든다. 결과적으로 p99 지연은 약 500ms 수준으로 크게 개선됐다.
실무에서 얻을 수 있는 교훈
이 사례는 분산 스트림 처리 시스템에서 흔히 간과되는 문제를 잘 보여준다. 성능 최적화를 논할 때 대부분 알고리즘 복잡도나 쿼리 튜닝에 집중하지만, 언어 경계(Language Boundary) 오버헤드는 레코드 처리량이 증가할수록 누적 비용이 선형 이상으로 커진다.
Java 백엔드 개발자 관점에서 중요한 점은 다음과 같다.
- PyFlink나 Apache Beam 같은 하이브리드 런타임 환경에서는, 어느 언어 레이어에서 어떤 작업을 처리하는지가 아키텍처 수준의 결정이다.
- Python UDF는 ML 추론이나 Python 생태계 전용 라이브러리가 필요한 경우에만 선택적으로 사용하고, 범용 파싱·변환 로직은 JVM 레이어에서 처리하는 것이 원칙이다.
- 프로파일링 없이 "비즈니스 로직이 느리다"고 가정하면 진짜 병목을 놓친다. 프로세스 간 통신(IPC) 비용은 마이크로벤치마크로는 잘 보이지 않고, 실제 프로덕션 트래픽에서 두드러진다.
정리
- PyFlink처럼 JVM과 Python이 혼재하는 환경에서는 프로세스 경계 왕복 횟수 자체가 핵심 성능 지표다.
- Protobuf 역직렬화 등 범용 변환 로직은 JVM의
DeserializationSchema로 옮겨 언어 경계 오버헤드를 제거해야 한다. - 스트림 처리 파이프라인의 지연 문제는 비즈니스 로직보다 런타임 아키텍처 경계에서 발생하는 경우가 많으므로, 프로파일링을 통해 실제 병목 위치를 먼저 특정해야 한다.