본문 바로가기
트러블 슈팅/BE

4코어 4GB 러너 서버에서 Gradle 빌드 시간 86% 줄이기

by Ramos 2026. 3. 19.

벼르고 있었다

시제품화 마감이 코앞이다.

프로젝트 막바지에 접어들면서 코드량과 PR 양이 급격하게 늘었다. AI 코딩 도구를 적극적으로 활용하면서 개발 속도 자체는 나름 빨라졌는데, 그 속도를 인프라가 못 받쳐주고 있었다. PR이 쏟아지는데 빌드/테스트 파이프라인이 병목이 되어 리뷰와 머지가 밀리기 시작했다.

팀원들 사이에서 "PR 확인이 너무 오래 걸린다"는 말이 나왔다. 솔직히 말하면, 그 원인이 전부 CI 속도 때문만은 아니었다. AI 도구를 아직 제대로 활용하지 못하는 경우도 있었고, 코드 리뷰 시 맥락을 빠르게 짚지 못해서 시간이 걸리는 경우도 있었다. 하지만 그건 기술적으로 풀 수 있는 문제가 아니다. 사람의 사고 속도나 도구 숙련도는 강제할 수 없다.

그래서 방향을 잡았다. 기술적으로 풀 수 있는 병목부터 하나씩 구조적으로 개선하자. 협업 파이프라인에서 "기다림"이 발생하는 지점을 찾아서, 내가 손댈 수 있는 영역부터 없애는 거다.

가장 눈에 띄는 병목이 CI였다. PR을 올리면 빌드/테스트 파이프라인이 돌아간다. 결과를 기다리는 동안 컨텍스트가 끊기고, 다른 일을 하다 보면 리뷰 타이밍을 놓친다. 배포할 때도 마찬가지다. Harbor에 이미지를 올리는 CI 파이프라인이 10분 넘게 걸리면, 그 사이에 또 다른 팀원이 트리거를 걸고, 러너가 1대뿐이라 대기열이 쌓인다. 20분 넘게 기다려야 세 모듈의 이미지가 모두 올라가는 상황이 반복됐다.

우리 팀의 CI 환경은 내부망의 self-hosted runner 1대. 스펙은 4코어 CPU, 4GB RAM, 50GB SSD. 넉넉한 환경이 아니다. 그런데 Gradle 빌드 설정을 보면 병렬 빌드가 꺼져 있고(parallel=false, workers.max=1), 빌드 캐시도 없고, 매번 전체 재컴파일을 하고 있었다. 4코어 중 1코어만 쓰고 있었던 거다.

벼르고 있었다. 이걸 한번 제대로 손보자고.

먼저 병목을 찾았다

감으로 최적화하면 안 된다. GitHub Actions 실행 로그를 열어서 스텝별 소요 시간을 직접 측정했다.

KubeManagement 이미지 빌드 워크플로우 기준, 결과는 이랬다:

스텝 소요 시간 비율
Gradle bootJar 7분 03초 75%
Docker 이미지 빌드 34초 6%
JDK 설정 5초 1%
나머지 (checkout, 알림, cleanup 등) ~1분 40초 18%
전체 9분 26초 100%

전체 시간의 75%가 Gradle 빌드 한 스텝에 몰려 있었다. Docker 빌드는 34초. 병목이 아니다. Gradle을 손봐야 했다.

문제는 하나가 아니었다

Gradle 빌드가 느린 것만이 문제가 아니었다. 러너 서버 모니터링을 열어보니 CI 실행 중 메모리가 90% 이상 포화되고, Swap이 80%까지 치솟고, 디스크 I/O가 폭증하는 연쇄 장애가 보였다.

메모리 90%+ 포화 → Swap 80%+ 사용 → Disk I/O 폭증 → Disk Queue 20+, Await 1초

Gradle JVM이 1.5GB, Kotlin daemon이 512MB, 테스트 포크가 1GB. OS까지 합치면 피크 시 약 3.5GB. 4GB 서버에서 Swap 없이는 버틸 수 없는 구조였다.

거기에 .dockerignoredocs/만 제외하는 블랙리스트 방식이었다. fetch-depth: 0으로 전체 Git 히스토리를 받기 때문에 .git/ 디렉토리가 수십~수백 MB에 달하는데, 이게 Docker 빌드 컨텍스트로 그대로 전송되고 있었다. Disk I/O 병목의 직접적 원인이었다.

한 번은 GitHub Actions 워크플로우의 동시 실행 제한 옵션이 일시적으로 풀려 있을 때, 팀원들의 PR이 동시에 몰리면서 서버가 터진 적도 있다. Disk Swap이 방대하게 발생해서 사실상 빌드 불가 상태가 됐었다. 해당 옵션은 바로 원복했지만, 4GB 서버에서 메모리 관리를 제대로 하지 않으면 언제든 재발할 수 있는 구조적 문제였다.

두 가지 방향으로 접근했다

문제가 두 개니 해법도 두 방향이다.

1. 빌드 속도 자체를 올린다

4GB RAM이라는 제약 안에서 안전하게 적용할 수 있는 최적화를 골랐다.

Gradle 병렬 빌드 활성화

GRADLE_OPTS: >
  -Dorg.gradle.parallel=true
  -Dorg.gradle.workers.max=2
  -Dorg.gradle.caching=true

parallel=true는 모듈 간 병렬 컴파일을 활성화한다. 여기서 중요한 건, Gradle의 병렬 빌드는 같은 JVM 내 멀티스레드라는 점이다. 별도 프로세스를 띄우는 게 아니라서 메모리 추가가 거의 없다. 4코어 중 2코어를 활용하면서도 메모리 안전성을 확보할 수 있었다.

반면 maxParallelForks(테스트 병렬 실행)는 1을 유지했다. 이건 별도 JVM을 포크하는 방식이라 포크 하나당 768MB~1GB가 추가된다. 현재 PR 테스트 시 이미 메모리가 약 4GB 한계치인 상황에서 포크를 추가하면 OOM이 뻔하다.

Gradle 빌드 캐시 + Configuration Cache

./gradlew :member:bootJar --configuration-cache --no-daemon

caching=true는 변경되지 않은 태스크를 재실행하지 않는다. 디스크 기반이라 메모리 증가 없이 빌드 시간을 줄인다. --configuration-cache는 빌드 스크립트 파싱 단계를 캐싱한다. 전 모듈에서 로컬 검증 후 적용했다.

캐시 키 전략 변경

- name: Restore Gradle cache
  uses: actions/cache@v4
  with:
    key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts', '**/gradle-wrapper.properties', 'gradle/libs.versions.toml') }}

기존에는 setup-java의 내장 cache: gradle을 사용했는데, 이 방식은 매 실행마다 Post step에서 캐시를 재저장한다. 실측해보니 이 단계만 1분 30초. actions/cache@v4로 바꾸면 exact key가 히트할 때 재저장을 스킵하기 때문에 이 시간을 통째로 절약할 수 있다.

system-core PR 시 불필요한 빌드 스킵

system-core 모듈이 변경되면 downstream의 common 모듈과 BC 모듈을 전부 재빌드하는 구조였다. 그런데 테스트 파일이나 문서만 수정한 PR에서도 전체 빌드가 돌았다. git diffsystem-core/src/main/ 변경 여부를 감지하여, 소스 변경이 없으면 downstream 빌드를 스킵하도록 했다.

2. 러너 서버 리소스를 안정화한다

빌드가 아무리 빨라져도 서버가 Swap thrashing에 빠지면 소용없다.

JVM 메모리 축소

항목 Before After
Gradle JVM -Xmx 1536m 1024m
Gradle MaxMetaspaceSize 512m 384m
테스트 JVM -Xmx 1024m 768m
피크 합계 ~3.5GB ~2.7GB

4GB 서버에서 여유 메모리가 500MB에서 1.3GB로 늘어났다. Swap 진입 자체를 회피할 수 있게 됐다.

.dockerignore 화이트리스트 전환

# Before: docs/만 제외 (나머지 전부 Docker 컨텍스트에 포함)
docs/

# After: 필요한 파일만 허용
*
!*/build/libs/app.jar
!docker/docker-entrypoint.sh

Dockerfile에서 실제로 필요한 건 jar 파일과 entrypoint 스크립트뿐이다. 나머지는 다 불필요하다. 특히 .git/ 디렉토리가 수백 MB에 달하는데, 이걸 매번 Docker 데몬에 전송하고 있었다.

Pact 테스트 순차 실행

Consumer 3개 모듈 + Provider 3개 모듈의 Pact 테스트가 max-parallel 미설정으로 동시에 돌 수 있었다. 4GB 서버에서 Gradle JVM이 여러 개 뜨면 당연히 터진다. max-parallel: 1로 순차 실행하게 변경했다.

결과

Harbor 이미지 빌드 (배포 CI)

가장 극적인 변화는 Gradle bootJar 빌드에서 나왔다.

모듈 Before 최악 After 최선 개선율
KubeManagement 9분 38초 2분 02초 79%
Member 1분 54초 1분 02초 45%
PortalManagement 2분 52초 24초 86%

대기 시간을 제외한 순수 작업 시간(Job Active Time) 기준:

모듈 Before After 개선율
KubeManagement 13분 27초 3분 55초 71%
Member 3분 06초 1분 59초 36%
PortalManagement 4분 28초 1분 26초 68%

KubeManagement는 13분 27초에서 3분 55초로. 프로젝트에서 가장 큰 모듈이라 개선 체감이 크다.

PR 빌드/테스트

PR open이나 추가 커밋 시 돌아가는 build-test 워크플로우도 개선됐다.

모듈 Before After (캐시 안정 시) 개선율
member 6분 43초 1분 43초 74%
portalManagement 5분 43초 2분 32초 56%
kubeManagement 3분 31초 ~3분 50초 유사

member 모듈이 6분 43초에서 1분 43초로 줄었다. PR 올리고 1분 반이면 빌드/테스트 결과를 볼 수 있다.

kubeManagement는 Build 스텝 자체는 30~35% 빨라졌지만, 캐시 복원/저장 오버헤드가 단축분을 상쇄해서 Total Job 시간은 비슷하다. 모듈 크기가 커서 캐시 자체도 무겁기 때문인데, 이건 후속으로 캐시 키를 더 정밀하게 나누는 것으로 개선할 여지가 있다.

부가 효과

항목 Before After 원인
Docker 빌드 ~33초 ~25초 .dockerignore 화이트리스트
JDK Setup 최대 3분 47초 0~1초 actions/cache@v4 캐시 히트
Queue Wait 10~17분 4~9분 선행 빌드가 빨라져 후행 대기 감소

JDK Setup이 간헐적으로 3분 47초까지 걸리던 게 0~1초로 안정화된 건 의외의 수확이었다. 캐시 전략을 바꾸면서 JDK 설치 자체를 스킵할 수 있게 된 것이다.

최적화는 코드만의 영역이 아니다

이번 작업을 하면서 느낀 게 있다.

"최적화"라고 하면 보통 코드 레벨을 떠올린다. 쿼리 튜닝, 알고리즘 개선, API 응답 속도 단축. 물론 중요하다. 하지만 최적화의 범위는 코드 내부에 한정되지 않는다.

  • Gradle 빌드 옵션을 바꿔서 CI 시간을 줄이는 것도 최적화다
  • .dockerignore를 화이트리스트로 바꿔서 Docker 빌드 컨텍스트를 줄이는 것도 최적화다
  • JVM 메모리를 서버 스펙에 맞게 조정해서 Swap thrashing을 방지하는 것도 최적화다
  • 불필요한 downstream 빌드를 스킵하는 것도 최적화다
  • Thread Pool 사이즈를 조정하는 것도, 애플리케이션 부팅 시점을 줄이는 것도, 전부 최적화다

팀의 개발 속도가 빨라지면 병목은 코드 바깥에서 드러난다. AI 도구 덕분에 코드 작성 속도는 빨라졌는데, 정작 PR을 올리고 빌드 결과를 10분 넘게 기다려야 한다면 그 속도가 무슨 의미가 있나. 배포 CI가 20분 걸려서 대기열이 쌓이면 하루에 배포할 수 있는 횟수 자체가 줄어든다. 개발 속도를 높이는 도구를 쥐어줘도, 그걸 받쳐주는 인프라와 파이프라인이 느리면 결국 팀 전체가 느려진다.

협업에서 발생하는 병목의 원인은 다양하다. 기술적으로 풀 수 있는 것도 있고, 그렇지 않은 것도 있다. 사람의 맥락 파악 속도나 도구 숙련도는 하루아침에 바뀌지 않는다. 하지만 CI가 느린 건? 그건 고칠 수 있다. 내가 손댈 수 있는 영역에서, 기다림을 줄일 수 있는 곳부터 하나씩 구조를 뜯어고치는 거다.

오죽 병목이 심했으면 GitHub Actions 워크플로우 파일을 전부 뜯어고쳤을까. 코드 레벨에서 풀 수 있는 방안을 찾아서 실제로 풀었고, 결과는 수치로 증명했다.

남은 과제

현재 가장 큰 병목은 Runner 대기(Queue Wait) 다. 러너가 1대뿐이라 3개 모듈의 이미지를 빌드하면 순차 대기가 발생한다. 빌드 자체가 빨라지면서 대기 시간도 간접적으로 줄었지만, 근본적으로는 러너 증설이 필요하다. 현재 전체 소요 시간의 40~60%가 대기 시간이다.

그 외에도:

  • Runner 메모리 증설 (4GB → 8GB): maxParallelForks=2, workers.max=4 적용 가능. 추가 30~40% 단축 기대
  • Docker base image JRE 전환: 이미지 크기 ~40% 감소 (운영 디버깅 도구 부재 트레이드오프)
  • Docker 빌드 캐시 (--cache-from): Docker Build 20~30초 → 5~10초

인프라 투자가 필요한 부분이라 팀/조직 차원의 판단이 필요하지만, 소프트웨어 레벨에서 할 수 있는 건 이번에 대부분 해치웠다.

정리

항목 Before After
Gradle bootJar (KubeManagement) 9분 38초 2분 02초 (79% 단축)
Gradle bootJar (PortalManagement) 2분 52초 24초 (86% 단축)
Job Active Time (KubeManagement) 13분 27초 3분 55초 (71% 단축)
PR build-test (member) 6분 43초 1분 43초 (74% 단축)
피크 메모리 사용량 ~3.5GB (Swap 진입) ~2.7GB (Swap 회피)
JDK Setup 최대 3분 47초 0~1초

4코어 4GB라는 제약 안에서, 추가 인프라 투자 없이, 소프트웨어 설정만으로 이 정도 개선을 이뤄냈다. 최적화는 어디서든 할 수 있다.