Gradle 병렬빌드·GC 최적화로 빌드 속도 개선
목차
멀티모듈 프로젝트를 운영하다 보면 어느 시점부터 빌드 시간이 슬금슬금 늘어난다. 처음엔 "뭐, 좀 느리지" 하고 넘기다가, 어느 날 클린 빌드 한 번에 커피 한 잔 시간이 통째로 사라지는 걸 경험하고서야 진지하게 들여다보게 됐다. 이번에 gradle.properties 설정을 정비하면서 병렬 빌드·GC·캐싱까지 한꺼번에 손봤고, 그 과정에서 생각보다 고려할 지점이 많았다.
뭘 바꿨고 왜 바꿨는가
결론부터 보면 이렇다.
# gradle.properties
org.gradle.jvmargs=-Xmx2g -XX:+UseG1GC -XX:MaxMetaspaceSize=512m
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configureondemand=true
각 옵션이 실제로 어떤 역할을 하는지, 그리고 어떤 상황에서 역효과가 나는지 정리하면 아래와 같다.
| 옵션 | 역할 | 주의사항 |
|---|---|---|
-Xmx2g |
JVM 힙 상한 설정 | RAM의 50-70% 이내 권장 |
G1GC |
저지연 GC | Java 9+ 기본값이지만 명시 권장 |
MaxMetaspaceSize |
클래스 로딩 메모리 제한 | 미설정 시 무한 증가 가능 |
parallel=true |
모듈 병렬 빌드 | 모듈 간 의존성 없을 때 효과적 |
caching=true |
태스크 캐시 재사용 | 변경 없는 모듈 스킵 |
configureondemand=true |
필요한 서브프로젝트만 설정 단계 실행 | 의존 관계 복잡하면 오히려 불안정 |
-Xmx2g 수치는 기계마다 달라져야 한다. 개발자 맥북이 16GB면 -Xmx4g까지 올려볼 수 있고, CI 컨테이너가 4GB 메모리 제한으로 돌아가면 -Xmx1g가 안전하다. 힙을 너무 크게 잡으면 GC 일시정지 시간이 길어지고, 너무 작으면 OutOfMemoryError나 빈번한 GC로 오히려 느려진다. RAM 절반 정도에서 출발해서 --profile 결과 보면서 조정하는 게 현실적인 방법이다.
G1GC는 Java 9부터 기본값이라 명시 안 해도 되긴 하지만, 팀 내 빌드 환경에 Java 8 섞인 케이스가 아직 있으면 명시적으로 적어두는 게 낫다. 나중에 누가 환경 변경했을 때 "이 설정 왜 있지?" 보다 "아 이래서 있구나"가 훨씬 낫고.
MaxMetaspaceSize 미설정이 생각보다 자주 문제가 된다. Metaspace는 기본적으로 네이티브 메모리를 무제한 사용하게 돼 있어서, 클래스 언로딩이 제대로 안 되는 빌드 플러그인이 있으면 조용히 메모리를 갉아먹는다. 512m으로 상한을 박아두면 적어도 OOM kill이 명확하게 터지고, 원인 추적이 쉬워진다.
병렬 빌드, 어떤 구조일 때 효과가 나오는가
org.gradle.parallel=true는 서브프로젝트를 병렬로 빌드하는 옵션인데, 모듈 구조가 선형 의존성으로 엮여 있으면 효과가 거의 없다. A → B → C처럼 순차 의존이면 어차피 직렬로 기다려야 하기 때문이다.
반면 아래처럼 공통 모듈에서 여러 서비스 모듈이 독립적으로 분기되는 구조면 체감 차이가 크게 난다.
:common
/ | \
:api :batch :worker
:api, :batch, :worker가 서로를 의존하지 않으면 세 모듈이 동시에 컴파일된다. CPU 코어가 놀지 않고 일을 나눠 갖게 되는 거다. configureondemand=true까지 켜두면 실제로 빌드에 필요한 서브프로젝트만 설정 단계를 거쳐서, 사용하지 않는 모듈의 build.gradle 파싱 비용도 줄어든다. 단, 동적으로 의존성이 엮이는 모듈 구조에서는 이 옵션이 예상치 못한 빌드 실패를 만들기도 한다. 처음엔 켜두고 빌드 결과를 며칠 지켜보는 게 안전하다.
캐싱은 :common처럼 자주 바뀌지 않는 모듈에서 효과가 도드라진다. 코드가 바뀌지 않으면 이전 빌드 아티팩트를 재사용하고 태스크 자체를 스킵한다. 로컬에서 작업하면 ~/.gradle/caches에 쌓이고, CI 환경에서 캐시 디렉토리를 파이프라인 캐시에 올려두면 빌드 사이에도 재사용된다. 단, 태스크 결과가 외부 상태(환경변수, 시스템 시간 등)에 의존하면 캐시 히트율이 뚝 떨어지니 @Input/@Output 어노테이션을 커스텀 태스크에 빠짐없이 달아줘야 한다.
Gradle Daemon과 메모리 트레이드오프
Gradle Daemon은 기본 활성화 상태다. JVM 기동 비용을 아끼는 게 핵심인데, Daemon이 없으면 ./gradlew build 할 때마다 JVM 콜드스타트를 기다려야 한다. 이게 프로젝트 작을 때는 0.5-1초 차이지만, 플러그인 많고 모듈 많은 프로젝트에서는 기동만 몇 초씩 잡아먹는다.
메모리 여유가 없는 환경, 특히 저사양 CI 에이전트나 공유 빌드 서버에서는 Daemon이 오히려 짐이 된다. Daemon은 종료되지 않고 메모리를 붙잡고 있기 때문에, 동시에 여러 빌드가 돌면 힙이 중첩해서 쌓인다. 이런 환경에서는 org.gradle.daemon=false로 꺼두는 게 낫다. 로컬 개발 환경은 켜고, CI는 끄는 식으로 환경별로 분리하는 게 현실적인 구성이다.
CI에는 gradle.properties 말고 GRADLE_OPTS 환경변수로 오버라이드하거나, CI 전용 gradle-ci.properties 파일을 별도로 관리하는 방법도 있다.
이번 작업에서 다시 확인한 건, 설정 한 줄이 아니라 설정들의 조합이 맞물려야 효과가 난다는 거다. 병렬 빌드만 켜도 모듈 구조가 선형이면 의미없고, 캐싱만 켜도 태스크에 @Input이 없으면 캐시 버스팅이 안 된다. GC 설정도 힙 크기랑 세트로 봐야 한다. 결국 빌드 환경 튜닝은 --profile 켜서 어느 태스크가 병목인지 먼저 보고, 거기에 맞는 옵션을 조합하는 순서로 가야 한다. 설정 먼저 바꾸고 효과 기대하면 엉뚱한 데서 헤매게 된다.
댓글 0
첫 댓글 달아줘.