콜드 스타트 신화: 이벤트를 위한 JVM 최적화
SchemaBridge Team · 2026-01-20 · Serverless, Performance, JVM, Java
Java의 콜드 스타트 악명은 JVM이 아니라 FaaS의 문제입니다. 스레드 풀, JIT 워밍업, 그리고 장기 실행 워커 모델.
현대 클라우드의 지연 시간 세금
'서버리스'는 우리를 구원할 것이었습니다. 무한한 확장과 관리 부담 제로를 약속했죠. 하지만 사용자가 체감하는 지연 시간에 관해서는, 범용 서버리스 함수(FaaS)에 감추고 싶은 비밀이 있습니다. 바로 콜드 스타트입니다.
한동안 실행되지 않은 Lambda에 요청이 도착하면, 제공자는 컨테이너를 띄우고, 런타임을 부팅하고, 코드를 로드해야 합니다. 복잡한 애플리케이션에서는 이 과정이 몇 초씩 걸릴 수 있습니다. 현대 웹 애플리케이션의 세계에서 2초는 영원과 같습니다. '빠릿빠릿한' 경험과 '고장 난' 경험을 가르는 차이입니다.
수년간 업계의 통념은 "Java는 서버리스에 너무 무겁다. Go나 Node.js를 써라"였습니다.
SchemaBridge는 이 통념에 이의를 제기합니다. 우리는 JVM(Java Virtual Machine)의 견고함에 의존하며, Java를 버리는 것이 아니라 애초에 Java를 수명이 짧은 함수로 실행하지 않음으로써 콜드 스타트 문제를 피해 갑니다.
JVM: 단거리 선수가 아닌 짐 나르는 일꾼
JVM은 임시 함수가 아니라 장기 실행 서버 프로세스를 위해 설계되었습니다. 초기 순간은 '워밍업'에 쓰입니다. 클래스를 로드하고, 바이트코드를 인터프리트하고, 핫 경로를 최적화(JIT 컴파일)합니다.
Java 애플리케이션을 100ms 후에 죽는 '함수'로 다룬다면, 런타임의 물리 법칙과 싸우는 셈입니다. JIT 최적화의 이점은 누리지 못한 채 요청마다 시작 비용을 치르게 됩니다.
실제 적용: 장기 실행 애플리케이션 컨텍스트
SchemaBridge는 워크플로 단계를 격리된 임시 함수로 실행하지 않습니다. 대신 단계들은 Kubernetes에 배포된 장기 실행 JVM 워커에서 실행됩니다.
1. 스레드 풀링: 각 워커는 Valkey Streams에서 소비하는, 따뜻하게 유지되는 워커 스레드의 고정 풀을 보유합니다. 이벤트가 도착하면 기존 스레드가 이를 처리합니다. 이벤트마다 OS 프로세스를 생성하거나 JVM을 부팅할 필요가 없습니다.
2. JIT 최적화: 워커는 장기 실행 프로세스이므로, JVM의 C2 컴파일러가 핫 경로를 네이티브 기계어로 최적화할 시간이 있습니다. 워커는 워밍업될수록 빨라지며, 비교를 공정하게 하자면 새로 배포된 워커는 콜드 상태로 시작해 그 워밍업 비용을 이벤트마다가 아니라 한 번만 치릅니다.
3. 커넥션 풀링: 데이터베이스 연결 관리는 비용이 큽니다. FaaS 모델에서 연결은 웜 실행 환경이 살아 있는 동안만 유지되며, 스케일업이 일어나거나 환경이 재활용될 때마다 다시 수립해야 합니다. 우리의 장기 실행 워커는 PostgreSQL과 Valkey에 대한 풀링된 연결을 유지하고 외부 서비스용 HTTP 클라이언트를 재사용하므로, 어떤 단계도 연결을 처음부터 수립하는 비용을 치르지 않습니다.
따뜻한 워커도 없애지 못하는 것은 내구성 있는(durable) 워크플로 엔진이라는 데 따르는 비용입니다. 각 단계는 여러분의 로직이 실행되기 전에 스트림 홉을 거치고, PostgreSQL에서 자신의 상태를 점유(claim)하고, 입력을 가져옵니다. 이것이 단계 도중의 크래시에서 살아남기 위한 대가이며, 단계 지연 시간을 마이크로초가 아니라 네트워크 및 데이터베이스 왕복 수준에 놓이게 합니다.
GraalVM의 위치
GraalVM은 이미 플랫폼의 일부이지만, Java를 더 빨리 시작하기 위한 수단으로서가 아닙니다. 작성자가 워크플로 안에 작성하는 JavaScript 및 Python 스니펫을 실행하는 폴리글랏 엔진이며, 프로세스 생성과 파일·네트워크 I/O를 금지하는 샌드박스 안에서 동작합니다.
GraalVM Native Image — Java 서비스를 사전(ahead-of-time) 컴파일하여 독립 실행형 바이너리로 만드는 것 — 는 별개의 기능이며, 우리는 이를 사용하지 않습니다. 시작 시간과 메모리를 크게 줄여 준다고 널리 알려져 있지만, 빌드 복잡성과 리플렉션 및 동적 클래스 로딩에 대한 제약이라는 대가가 따릅니다. 장기 실행 워커 플릿에서는 시작 시간 개선이 수명이 짧은 함수만큼 중요하지 않으며, 바로 그 때문에 우리는 대신 장기 실행 워커를 선택했습니다.
예시 시나리오: 지연 시간에 민감한 파이프라인
FaaS 위의 Java 함수로 꾸준한 이벤트 스트림을 처리하는 가상의 분석 팀을 생각해 봅시다.
문제
플랫폼이 스케일 업하거나 유휴 함수를 재활용할 때마다 주기적인 콜드 스타트가 초 단위의 지연 시간 급증을 일으키고, 테일 지연 시간은 팀 자체의 코드가 아니라 부팅에 의해 좌우됩니다.
장기 실행 워커 접근 방식
이 처리를 따뜻한 장기 실행 워커로 옮기면 지연 시간 곡선의 형태가 바뀔 것입니다.
1. 따뜻한 스레드: 이벤트는 JIT로 워밍업된 프로세스 안에서 이미 존재하는 스레드 위에서 실행됩니다.
2. 더 안정적인 테일: 이벤트별 컨테이너 프로비저닝이 없으므로, 부팅으로 인한 급증이 사라집니다. 남은 지연 시간은 작업 자체와 각 단계가 거치는 내구성 있는 홉에 의해 좌우됩니다.
3. 트레이드오프: 이벤트가 도착하든 안 하든 실행 중인 워커에 비용을 지불하게 되며, 한 자릿수 밀리초로 측정되는 엄격한 실시간 예산은 여전히 내구성 있는 워크플로 엔진에 맞지 않는 작업입니다. 그것은 여러분 애플리케이션 자체의 요청 경로에 속합니다.
비교: 임시 FaaS vs. 장기 실행 워커
| 기능 | 표준 FaaS(Lambda) | SchemaBridge 따뜻한 워커 |
| :--- | :--- | :--- |
| 콜드 스타트 | 스케일 업 시 프로세스/런타임 부팅 | 이벤트별 프로세스 부팅 없음 |
| 런타임 | 스케일업 시 / 유휴 후 부팅 | 장기 실행 프로세스 |
| 최적화 | 제한적(환경이 재활용됨) | 완전한 JIT 컴파일 |
| 연결 | 환경이 재활용될 때 사라짐 | 풀링 및 재사용 |
| 단계별 오버헤드 | 함수 호출 | 스트림 홉 + 내구성 있는 상태 점유 |
Java 성능을 위한 전문가 체크리스트
1. 리소스 재사용: 핸들러 함수 안에서 데이터베이스 클라이언트를 생성하지 마세요. 한 번(static/전역으로) 생성해 재사용하세요.
2. 힙 튜닝: 공격적인 가비지 컬렉션을 피하려면 JVM 힙이 컨테이너에 맞게 적절한 크기로 설정되어 있는지 확인하세요.
3. GC를 CPU 제한에 맞추기: CPU 제한이 1코어 미만인 컨테이너는 병렬 컬렉터의 이점을 얻지 못할 수 있습니다. 여러분의 제한에서 JVM이 실제로 어떤 GC를 선택하는지 확인하세요.
4. 리플렉션 피하기: 리플렉션을 많이 사용하는 라이브러리(일부 오래된 JSON 파서 등)는 워밍업이 느립니다. 가능하면 컴파일 타임 생성을 선호하세요.
5. GC 일시 정지 모니터링: 도구를 사용해 가비지 컬렉션이 워크플로에 지연 시간 급증을 일으키지 않는지 확인하세요.
// 비용이 큰 클라이언트는 이벤트마다가 아니라 프로세스당 한 번만 생성한다.
private static final HttpClient CLIENT = HttpClient.newHttpClient();
결론: 런타임은 중요하다
"Java는 콜드 스타트가 느리다"는 말은 Java 자체가 아니라 Java를 배포하는 방식에 관한 이야기입니다. 런타임의 근본적인 물리 법칙을 이해하고, 그 법칙을 존중하는 아키텍처를 선택하면, 이벤트마다 부팅 비용을 치르지 않고도 Java 생태계의 생산성을 유지할 수 있습니다. 다만 남는 것에 대해서는 정직해야 합니다. 내구성 있는 엔진은 단계가 크래시에서 살아남는다는 보장을 얻는 대가로 약간의 지연 시간을 내줍니다.
Building the Bridge 시리즈의 일부입니다. 함께 보기: 컴플라이언스와 감사: 부수 효과로서의 증거, 범용 커넥터 구축하기.