Gradle 공식 로고
이미지: gradle/gradle 공식 GitHub 저장소

안드로이드와 iOS 타겟을 같이 빌드하는 CI 파이프라인에서, 가끔 빌드가 특별한 이유 없이 실패했습니다. 로그를 끝까지 읽어봐도 컴파일 에러가 없고 테스트도 실패한 게 없습니다. compileKotlinIosArm64 같은 태스크가 한참 돌다가 로그가 그냥 뚝 끊기고, 몇 초 뒤 러너 자체가 종료됩니다. 로컬 맥북에서는 같은 커밋으로 몇 번을 돌려도 재현이 안 됐습니다.

증상: 로그가 중간에 끊긴다

실패한 러닝의 마지막 로그는 대략 이런 모습이었습니다.

> Task :shared:compileKotlinIosArm64
> Task :shared:compileKotlinIosSimulatorArm64
> Task :androidApp:compileDebugKotlin

그 다음 줄이 없습니다. BUILD FAILED도, 스택트레이스도, exit code 설명도 없이 Job 자체가 "This step has timed out" 비슷한 러너 레벨 메시지만 남기고 끝났습니다. 처음엔 네트워크 문제나 Gradle 캐시 서버 쪽 타임아웃을 의심했는데, 재시도해보면 또 잘 되는 경우가 있어서 더 헷갈렸습니다.

원인: OOM 킬러는 자바 예외를 남기지 않는다

Java 힙이 부족해서 죽는 경우라면 보통 OutOfMemoryError: Java heap space 스택트레이스가 로그에 남습니다. 그런데 이번처럼 스택트레이스 자체가 없이 프로세스가 통째로 사라지는 경우는 JVM이 자기 힙 한도에 걸린 게 아니라, 리눅스 커널의 OOM 킬러가 머신 전체 메모리가 바닥났을 때 프로세스를 강제로 죽인 상황일 가능성이 큽니다. JVM 입장에서는 예외를 던질 기회조차 없이 SIGKILL을 맞는 셈입니다.

러너에 직접 SSH로 들어갈 수 없는 GitHub Actions 환경에서는 이 가설을 확인하기가 번거롭습니다. 대신 실패하는 스텝 바로 앞에 디버깅용 스텝을 하나 끼워 넣었습니다.

- name: 메모리 상태 기록 (디버깅용)
  if: always()
  run: |
    free -h
    ps aux --sort=-%mem | head -n 15

이렇게 몇 번 더 실패를 재현시켜서 확인해보니, 빌드가 죽기 직전 free -h의 available 값이 수백 MB까지 떨어져 있었고, ps aux 상위 목록에 java 프로세스가 세 개 넘게 떠 있었습니다. 하나가 아니라 여러 개입니다.

데몬이 몇 개나 떠 있었는지가 핵심

KMP 프로젝트를 빌드할 때 메모리를 쓰는 JVM 프로세스는 생각보다 여러 개입니다.

  • Gradle 데몬 — org.gradle.jvmargs로 힙을 지정하는 그 프로세스
  • Kotlin 컴파일 데몬 — Gradle 데몬과는 별개 프로세스로, 기본적으로는 Gradle 데몬의 힙 설정을 상속하지만 kotlin.daemon.jvmargs로 따로 지정할 수 있습니다
  • Kotlin/Native 백엔드(konanc) 프로세스 — iosArm64, iosSimulatorArm64처럼 네이티브 타겟을 컴파일할 때마다 뜨는 별도 프로세스로, LLVM 기반이라 타겟 하나당 체감상 가장 무겁습니다

로컬 맥북은 메모리가 넉넉해서 이 세 종류가 동시에 떠도 여유가 있었지만, GitHub Actions의 프라이빗 저장소용 기본 러너는 2 vCPU / 8GB RAM입니다. 안드로이드 컴파일용 Kotlin 데몬이 뜬 상태에서 iOS 타겟 두 개를 병렬로 컴파일하면, 각 konanc 프로세스가 수백 MB에서 1GB 이상을 먹는 경우가 있어 금방 8GB에 가까워집니다. Gradle은 기본적으로 태스크를 최대한 병렬로 돌리려고 하기 때문에, 메모리가 넉넉한 로컬에서는 장점이던 병렬 실행이 러너에서는 오히려 동시에 여러 무거운 프로세스를 띄우는 원인이 됩니다.

해결: 늘리는 게 아니라 총합을 맞추는 것

처음엔 단순하게 힙을 키우는 쪽으로 접근했습니다.

# gradle.properties - 첫 시도, 이것만으론 부족했음
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g

그런데 이건 Gradle 데몬 자체의 힙만 키울 뿐, Kotlin 데몬과 konanc 프로세스는 그대로 Gradle 데몬 설정을 상속하거나 자체 기본값으로 뜨기 때문에 총합은 오히려 더 커졌습니다. 효과가 없었던 이유를 알고 나서는, 반대로 러너 전체 메모리 안에서 각 프로세스가 쓸 수 있는 몫을 나눠주는 방향으로 바꿨습니다.

# gradle.properties - CI용
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m
kotlin.daemon.jvmargs=-Xmx1536m
org.gradle.workers.max=2

org.gradle.workers.max를 2로 제한한 게 체감상 가장 효과가 컸습니다. 기본값은 CPU 코어 수를 따라가는데, 2 vCPU 러너에서는 어차피 병렬로 돌려봐야 실질 이득이 크지 않으면서 동시에 뜨는 프로세스 수만 늘어납니다. 여기에 iOS 타겟 빌드 스텝과 안드로이드 빌드 스텝을 같은 Job 안에서 동시에 돌리지 않고 순서대로 나눠서, 적어도 konanc 두 개가 동시에 뜨는 상황 자체를 피했습니다.

로컬 환경과 CI용 설정을 다르게 가져가야 해서, gradle.properties를 그대로 공유하는 대신 CI 워크플로우에서 환경변수로 오버라이드하는 방식도 같이 썼습니다.

- name: KMP 빌드
  env:
    GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m"
  run: ./gradlew :androidApp:assembleDebug :shared:compileKotlinIosArm64 --no-parallel

그 후

같은 조건으로 스무 번 넘게 돌려봐도 재발하지 않았습니다. 다만 이게 "완전히 해결됐다"는 보장은 아니고, 러너 리소스가 가끔 더 타이트하게 할당되는 날에는 또 걸릴 여지가 있다고 생각합니다. 그래서 위에 적은 메모리 상태 기록 스텝은 지우지 않고 그대로 남겨뒀습니다. 다음에 또 로그가 뚝 끊기면, 이번처럼 원인을 처음부터 다시 추측하는 대신 바로 free -h 기록을 확인할 수 있게요.