
OOM 났다고 -Xmx 를 올리면 빌드가 오히려 더 잘 죽습니다. CI 러너에선 힙을 줄여야 살아나는 경우가 훨씬 많더라고요.
지난 봄에 사이드 프로젝트를 깃허브 액션(GitHub Actions) 으로 옮기면서 제대로 한 번 데인 적이 있어요. 로컬 32GB 머신에선 멀쩡히 도는 안드로이드 멀티 모듈 빌드가, 깃허브 액션 기본 러너만 올라가면 어김없이 죽더라고요. 로그엔 "Daemon disappeared unexpectedly" 한 줄, 그리고 exit code 137. 처음엔 당연히 메모리가 부족한가 싶어서 org.gradle.jvmargs=-Xmx5g 로 올렸는데, 그게 사고의 시작이었습니다.
Gradle OOM 진단, exit code 137 과 JVM OOM 은 다른 문제
이게 가장 중요한 분기점이에요. 같은 "메모리 부족" 처럼 보여도 처방이 정반대로 갈리거든요.
OutOfMemoryError: Java heap space 가 로그에 찍혀 있으면 JVM 힙 자체가 모자란 상황입니다. 이때는 -Xmx 를 올리는 게 맞아요. 힙 덤프(-XX:+HeapDumpOnOutOfMemoryError) 까두면 어떤 객체가 메모리를 먹었는지 사후 분석도 가능합니다.
반대로 Process completed with exit code 137 만 덩그러니 남고 OutOfMemoryError 가 안 보이면, 이건 JVM 이 죽은 게 아니라 OS 의 OOM Killer 가 SIGKILL 로 프로세스를 강제 종료한 자리입니다. 128 + 9 = 137 이지요. 시스템 전체 메모리가 모자라서 커널이 가장 덩치 큰 프로세스를 잘라낸 셈입니다. 이 경우 -Xmx 를 더 올리면 다른 데몬·테스트 JVM 이 굶어 죽으면서 빌드가 더 자주 터지더라고요.
깃허브 액션 리눅스 기본 러너(ubuntu-latest)는 2 vCPU / 7GB RAM 으로 알려져 있어요. 7GB 가 어느 정도냐면, 그래들 데몬 하나, 코틀린 데몬 하나, 테스트 JVM 하나, 거기에 OS 와 액션 러너 에이전트까지 다 올리고 나면 사실 여유 공간이 1~2GB 도 안 남는 빠듯한 사이즈인 셈입니다.

org.gradle.jvmargs 의 함정, Metaspace 같이 잡아야 합니다
-Xmx 만 깔끔하게 올리면 끝일 것 같은데, 여기에 숨겨진 함정이 하나 있어요. org.gradle.jvmargs 를 한 번이라도 건드리면 Gradle 이 들고 있던 JVM 기본값(-Xmx512m, -XX:MaxMetaspaceSize=384m) 이 통째로 사라진다는 점입니다.
즉 -Xmx4g 만 적어두면 힙은 4GB 로 늘었는데 Metaspace 상한이 없어진 상태가 됩니다. 멀티 모듈 + KSP + Hilt 같은 조합이 클래스를 폭증시키면, Metaspace 가 시스템 메모리를 야금야금 갉아먹다가 결국 OOM Killer 한테 잡혀가는 패턴이 나오는 것이지요.
7GB 러너 기준 제가 안정화시킨 값은 이렇습니다. (아래 -Xmx3g … 줄은 \ 로 끊어 적었지만 한 줄로 붙여 적어도 동일하게 동작해요.)
# 힙과 Metaspace 를 명시적으로 함께 묶기
org.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=1g \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=build/oom
# CI 에서도 데몬은 켜는 게 빠르다
org.gradle.daemon=true
# vCPU 수에 맞춰 워커 제한 — 메모리 폭증 방지
org.gradle.parallel=true
org.gradle.workers.max=2
# Kotlin 데몬 힙 별도 제어
kotlin.daemon.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m
-Xmx 가 결국 4g 가 아니라 3g 로 내려간 게 보이실 거예요. 힙을 줄여서 빌드가 안정화된다는 게 처음엔 직관과 반대라 헷갈리는데, 시스템 전체 메모리 예산 안에서 모든 JVM 의 -Xmx 합계를 가둬야 한다는 게 핵심입니다.

Kotlin 데몬과 워커 옵션, 숨은 메모리 소비자
코틀린(Kotlin) 프로젝트에서 진짜 범인은 그래들 데몬이 아니라 옆에서 조용히 돌고 있는 코틀린 데몬일 때가 많아요. 그래들 데몬과 별개 프로세스로 떠서 컴파일 캐시를 들고 있는데, kotlin.daemon.jvmargs 를 따로 안 잡아두면 그래들 데몬과 같은 옵션을 그대로 물려받으면서 메모리 풋프린트가 두 배로 뛰는 일이 생깁니다.
급하게 안정성부터 잡고 싶으면 kotlin.compiler.execution.strategy=in-process 로 데몬 자체를 끄는 선택지도 있어요. 빌드 시간은 한 자릿수 퍼센트 정도 늘어나는 대신 메모리 피크가 확실히 낮아지더라고요.
워커도 같은 결입니다. org.gradle.parallel=true 만 켜두면 워커가 코어 수만큼 자유롭게 떠요. 2 vCPU 러너인데 워커가 4개 뜨면 각자 자기 JVM 을 들고 다니면서 메모리를 배수로 잡아먹어요. org.gradle.workers.max 를 vCPU 수와 맞춰두면 137 에러 빈도가 눈에 띄게 떨어집니다.
암묵적 규칙 하나만 외워두시면 좋겠어요. (그래들 데몬 힙) + (코틀린 데몬 힙) + (테스트 JVM 힙) < (러너 RAM − OS 여유 1.5GB). 이 부등식이 깨지면 워커 수부터 줄이는 게 정공법입니다. -Xmx 는 힙만 막을 뿐, 실제 OS 가 보는 RSS 는 거기에 Metaspace 와 네이티브 메모리까지 얹혀서 보통 Xmx 보다 1GB 안팎 더 크게 잡히는 편으로 알려져 있어요.

CI 빌드 OOM 재발 방지, 기준선 문서화와 힙 덤프 아카이빙
설정 한 번 맞췄다고 끝나는 일이 아니더라고요. 모듈이 늘거나 KSP 가 새로 들어오면 메모리 예산이 또 흔들립니다. 그래서 저는 PR 템플릿에 세 줄을 박아뒀어요.
- 러너 RAM: 7GB (
ubuntu-latest) - 모든 JVM 의
-Xmx합계: 5GB 상한 org.gradle.workers.max: vCPU 수와 동일
힙 덤프 경로도 꼭 잡아두시는 게 좋겠어요. -XX:HeapDumpPath=build/oom 으로 빼두고, 액션 워크플로에서 actions/upload-artifact 로 build/oom/** 를 아티팩트로 끌어 올리게 해뒀어요. 다음에 OOM 이 다시 터지면 그 자리에서 덤프가 남으니까, 인텔리J 프로파일러나 이클립스 MAT 으로 열어서 범인을 바로 짚을 수 있게 됩니다.
러너를 4 vCPU / 16GB 짜리 larger runner 로 올리는 선택지도 물론 있는데, 분당 단가가 두 배 가까이 뛰니까 최후의 카드로 남겨두는 게 낫다고 봅니다. 모듈 분리·불필요 태스크 제거로 빌드를 가볍게 만드는 쪽이 장기적으로 훨씬 남는 장사예요.
결국 CI 빌드 OOM 은 "내 JVM 에 메모리를 더 주자" 가 아니라 "한정된 7GB 안에서 누가 얼마씩 쓸지 분배표를 짜자" 의 문제였습니다. -Xmx 를 5g 에서 3g 로 내린 그 2GB 의 양보가, 빌드를 살린 자리지요.
'Android 개발 > Gradle・빌드' 카테고리의 다른 글
| 카톡으로 .apk 보내던 시절 끝 — Firebase App Distribution으로 QA 배포 자동화한 기록 (0) | 2026.07.19 |
|---|---|
| KMP 새 기본 프로젝트 구조로 옮겨보니 모듈 책임이 또렷해졌습니다 (0) | 2026.06.29 |
| Claude Code Opus 4.8 dynamic workflows로 본인 안드 멀티 모듈 마이그레이션 돌려본 닷새 (0) | 2026.06.20 |
| Aluminium OS 가을 출시 D-150, 안드로이드 앱이 챙길 첫 PR 5가지 (0) | 2026.05.29 |
| Gradle 빌드가 갑자기 느려졌을 때 가장 먼저 보는 곳 (0) | 2026.05.05 |