본문 바로가기
Kotlin

Kotlin/Gradle 빌드 시 OOM 발생(?) (부제: Kotlin IR, KAPT를 다시 까보자)

by Ramos 2025. 12. 22.

Kotlin 2, Spring Boot 3.x에 Gradle Multi-Module 기반으로 진행중인 프로젝트에서 발생했던 이슈 사항이었다.
MSA를 염두에 두고 Core 모듈엔 라이브러리마다 공통화(fabric8 kubernetes client, JPA/Querydsl, ...) 처리를 해두고 개별 마이크로서비스에선 필요한 모듈을 참조하여 쓰고 있는 구조다.
로컬 개발환경에서 테스트/빌드 수행 혹은 CI 환경에서 어느 순간부터 OOM 관련 이슈가 발생했는데 원인을 되짚어본다.

사용하고 있는 라이브러리 및 Kotlin Compile, KAPT 등에 대해 전반적으로 살펴볼만하다. 해당 프로젝트에서 이슈처리 후 간략하게 문서화해둔 사항을 토대로 간략하게 키워드 위주로 글을 작성했으니 참고하자.

배경

  • CI 수행 및 로컬 개발 환경의 Gradle Test 수행 시, File is unknown 메시지와 함께 java.lang.OutOfMemoryError: GC overhead limit exceeded가 다수 발생함.
  • 스택트레이스는 Kotlin IR 컴파일 단계(FastMethodAnalyzer)에서 힙 부족으로 종료된 것을 가리킴.
  • 동일 현상이 로컬 다중 모듈 빌드 시에도 비정기적으로 재현되며, 증상 해결을 위해 JVM 힙 상향이 필요함.

원인 정리

요소 설명

Kotlin 2.1 IR 백엔드 IR 최적화가 복잡한 유틸/맵퍼 파일(>1,000라인)을 처리할 때 수 GB의 임시 메모리를 요구함.
KAPT + MapStruct/Querydsl 모듈 수만큼 KAPT worker가 떠서 MapStruct 등 어노테이션 프로세서가 추가 JVM 힙을 소비.
멀티모듈 병렬 빌드 core 모듈을 참조하고 있는 개별 마이크로서비스 등 여러 모듈이 동시에 Kotlin 컴파일러를 실행하여 전체 힙 요구량이 선형 증가.
Fabric8 모델 의존성 대량의 Kubernetes 모델 클래스를 인라인/확장 함수와 함께 사용하면서 제어 흐름 그래프가 비대해짐.

결과적으로 "Kotlin IR + KAPT + 대형 소스 + 병렬 빌드"가 한 번에 수행되면 기본 1~1.5GB 힙 환경에서는 반복적으로 OOM이 발생했다.

gradle.properties 조정 내역

org.gradle.jvmargs=-Xms1g -Xmx4g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
org.gradle.daemon=true
org.gradle.parallel=true
org.gradle.configureondemand=true
kotlin.daemon.jvmargs=-Xms512m -Xmx2g -XX:+HeapDumpOnOutOfMemoryError
kotlin.incremental=true
kotlin.incremental.useClasspathSnapshot=true
kapt.use.worker.api=true
kapt.incremental.apt=true

설정 설명

  • Gradle JVM 힙 1~4GB 고정: Kotlin IR + MapStruct KAPT worker가 동시에 돌아도 메모리 여유 확보.
  • Gradle 데몬/병렬/On-demand: 큰 힙을 가진 데몬을 재사용하고, 실제 필요한 서브프로젝트만 설정하여 빌드 시간을 유지.
  • Kotlin 데몬 힙 0.5~2GB: FastMethodAnalyzer 단계 힙 부족 방지. 힙 덤프 옵션으로 추후 분석 가능.
  • Kotlin 증분 + 클래스패스 스냅샷: 변경 파일만 재컴파일해 불필요한 메모리 소모 감소.
  • KAPT Worker API + 증분 APT: MapStruct/Querydsl 처리 범위를 최소화하고 태스크별 JVM 메모리를 격리.

적용/운영 시 주의 사항

  • CI/CD 환경이 별도의 JVM 옵션으로 위 설정을 덮어쓰지 않도록 확인한다.
  • org.gradle.workers.max를 과도하게 늘리면 4GB 힙에서도 여유가 줄어드니, 병렬성 조정 시 메모리 사용률을 함께 모니터링한다.
  • 대형 Kotlin 파일은 가능한 기능 단위로 분할하면 IR 컴파일 부담을 더 낮출 수 있다.

위 조정 이후 CI 빌드에서 동일한 OOM 에러는 재현되지 않았으며, 로컬 개발에서도 --no-daemon이 아닌 기본 데몬 모드 사용을 권장한다.

Gradle 데몬은 JVM 기본값(현재 JDK 21 기준 약 -Xms256m ~ -Xmx512m) 수준으로만 힙을 잡고 있었고, Kotlin 컴파일러 데몬도 별도로 힙 옵션을 지정하지 않으면 비슷한 상한(약 512MB)으로 실행된다.

멀티모듈 빌드에서 Kotlin IR 최적화·KAPT가 동시에 돌면서 모듈 하나당 1~2GB 정도를 필요로 했는데, 각 데몬 힙이 0.5GB 남짓에 불과했기 때문에 GC가 계속 돌다가 FastMethodAnalyzer 단계에서 GC overhead limit OOM이 발생한 것이다.

Kotlin IR 최적화

  • Kotlin 1.5 이후 JVM 백엔드가 IR(Intermediate Representation)을 기본 사용한다. Kotlin 소스를 중간 표현으로 바꾸고, 여러 최적화 패스(Dead Code, Constant Propagation, SSA 변환 등)를 거쳐 JVM 바이트코드를 생성한다.
  • FastMethodAnalyzer 같은 패스는 메서드 단위로 제어 흐름 그래프(CFG)를 만들고, 가능한 경로를 모두 분석한다. 메서드가 길고 분기·람다·인라인 함수·when/try 구문이 많을수록 CFG 노드와 간선이 폭발적으로 늘어난다.
  • IR 최적화는 메서드별로 다량의 임시 자료구조(ArrayList, BitSet 등)를 생성해 상태를 추적하므로, 메서드 하나가 수백 라인 이상이고 외부 모델 타입(Fabric8의 거대한 data class 등)을 인라인 사용하는 경우 힙 사용량이 기하급수적으로 커진다.
  • Kotlin 컴파일러는 기본적으로 한 JVM 프로세스(daemon) 안에서 여러 파일을 순차 처리하므로, 특정 파일이 복잡하면 그 한 파일만으로도 수 GB 힙을 필요로 한다. 이때 JVM 힙 상한이 512MB 수준이면 GC가 계속 돌다 GC overhead limit exceeded를 터뜨린다.

KAPT (Kotlin Annotation Processing Tool)

  • 어노테이션 프로세서(MapStruct, Querydsl, Dagger 등 자바용 APT)를 Kotlin 코드에서도 쓰기 위한 브리지다. Kotlin 컴파일 전에 KAPT가 Kotlin 코드를 Java 스텁으로 변환하고, 표준 APT API로 프로세서를 실행해 소스/바이트코드를 생성한다.
  • KAPT는 Gradle 태스크마다 별도의 JVM(또는 Gradle Worker)을 띄워 Kotlin 컴파일러를 호출한다. Worker 수(=동시 KAPT 실행 태스크)가 많으면 JVM 프로세스가 여러 개 떠서 메모리 사용량이 모듈 수에 비례해 증가한다.
  • MapStruct/Querydsl 같은 프로세서는 소스 트리 전체를 스캔하고, 대량의 생성 코드를 만들기 때문에, 입력 클래스가 많을수록 컴파일러 내부 AST·심볼 테이블이 커진다. 힙 제한이 낮으면 KAPT worker에서 OOM이 나거나, 생성된 코드가 Kotlin 메인 컴파일러로 넘어갈 때 다시 OOM을 유발한다.
  • 또한 KAPT는 "증분"이 비활성화되어 있으면 모듈 전체를 항상 재처리한다. 그래서 멀티모듈 환경에서 모든 모듈이 KAPT를 사용하면 빌드 시 병렬 worker 수만큼 메모리가 동시에 필요해진다.

이 때문에 IR 최적화와 KAPT 둘 다 힙을 많이 먹는 작업이라, JVM 힙 기본값(약 0.5GB)으로는 멀티모듈 빌드에서 자주 OOM이 발생했고, gradle.properties에서 각 데몬 힙을 수 GB로 늘려야 안정적으로 돌아간다는 점에 유의하자.