이전 글에서 4코어 4GB 러너 서버에서 Gradle 빌드 시간을 최대 86% 줄인 이야기를 썼다. 병렬 빌드, 캐시 전략, JVM 메모리 튜닝. 소프트웨어 설정만으로 꽤 의미 있는 개선을 이뤄냈다고 생각했다.
그런데 최적화를 적용한 다음 날, 서버가 죽었다.
최적화 다음 날, OOM
03/20 오전. Harbor 이미지 빌드를 위해 3개 모듈(KubeManagement, Member, PortalManagement)의 워크플로우를 동시에 트리거했다. 러너가 1대니까 순차 실행될 거라 생각했고, 실제로 그렇게 동작해야 맞다. 그런데 세 번째 워크플로우가 시작되면서 서버가 응답을 멈췄다. SSH 타임아웃. 모니터링 메트릭 수집도 중단. 서버가 완전히 죽은 거다.
원인을 추적해보니 세 가지가 겹쳤다.
첫째, JVM 메모리 오버라이드 누락. Harbor 이미지 빌드 워크플로우에서 Gradle JVM 옵션을 오버라이드하지 않고 있었다. 프로젝트 gradle.properties에는 로컬 개발용으로 -Xmx4g가 설정되어 있다. Kotlin 2.1과 fabric8 K8s/Tekton 클라이언트를 쓰는 프로젝트라 IR 최적화 단계에서 2GB 이상의 힙을 먹기 때문에 로컬에서는 이 설정이 필요하다. 문제는 CI에서도 이 값이 그대로 적용됐다는 거다. 4GB 서버에서 Gradle JVM에 4GB를 할당하면 OS와 Docker를 위한 메모리가 없다.
둘째, 캐시 중복 다운로드. actions/cache@v4가 3개 워크플로우에서 각각 동일한 캐시를 다운로드하고 압축 해제했다. 첫 번째 워크플로우가 캐시를 로컬에 풀어놓더라도, 두 번째·세 번째 워크플로우는 그 사실을 모른다. GitHub Cache에서 또 받고, 또 받는다. 순차 실행이라 동시에 메모리를 먹진 않지만, 디스크 I/O가 반복되면서 이전 워크플로우의 Docker 데몬 메모리가 해제되지 않은 상태에서 메모리가 누적됐다.
셋째, Docker 메모리 누적. 워크플로우가 끝나도 Docker 데몬이 빌드 캐시와 레이어를 메모리에 들고 있었다. 3개 워크플로우가 순차로 돌면서 이 메모리가 쌓이고, 결국 4GB를 넘어서 OOM이 발생했다.
세 가지 원인이 동시에 맞물린 거다. 어느 하나만 없었어도 서버는 안 죽었을 텐데.
긴급 핫픽스 — 근본 원인 3건 제거
서버를 재기동하고 곧바로 핫픽스를 작성했다.
1. Harbor 워크플로우에 JVM 오버라이드 명시
GRADLE_OPTS: >
-Dorg.gradle.jvmargs="-Xms256m -Xmx1024m -XX:MaxMetaspaceSize=384m"gradle.properties의 -Xmx4g가 CI에서 적용되지 않도록, 워크플로우 레벨에서 명시적으로 덮어쓴다. 이게 없으면 Gradle은 gradle.properties의 값을 사용한다. 사실 이전 글에서 build-test 워크플로우에는 이미 적용해뒀는데, Harbor 이미지 빌드 워크플로우에는 빠져 있었다. 내 실수다.
2. 워크플로우 시작 시 Docker 메모리 정리
- name: Free runner memory
run: |
docker system prune -af --volumes
free -h각 워크플로우 첫 스텝에서 Docker의 미사용 이미지, 빌드 캐시, 볼륨을 전부 정리한다. 이전 워크플로우가 남긴 메모리를 깨끗하게 비우고 시작하는 거다.
3. 2단계 캐시 전략 — 로컬 우선, GitHub 폴백
- name: Check local Gradle cache
id: local-cache
run: |
if [ -d ~/.gradle/caches ] && [ "$(ls -A ~/.gradle/caches)" ]; then
echo "hit=true" >> $GITHUB_OUTPUT
du -sh ~/.gradle/caches ~/.gradle/wrapper
else
echo "hit=false" >> $GITHUB_OUTPUT
fi
- name: Download Gradle cache
if: steps.local-cache.outputs.hit != 'true'
uses: actions/cache/restore@v4
with:
key: gradle-${{ runner.os }}-${{ hashFiles('...') }}Self-hosted runner는 로컬 디스크에 캐시가 남아있다. 매번 GitHub Cache에서 다운로드하는 건 낭비다. 로컬 캐시가 있으면 다운로드를 완전히 스킵하고, 없을 때(서버 재기동 직후 등)만 GitHub Cache에서 복원한다.
이 세 가지를 적용하고 Harbor 이미지 빌드를 다시 돌렸다. 메모리 피크가 ~4GB+에서 ~2GB로 떨어졌다. 안전 마진 2GB.
닷새 뒤, 또 서버가 죽었다
03/25. 이번엔 공용 러너 서버 자산 자체에 장애가 발생해서 물리서버가 재기동됐다. CI 최적화와는 관계없는, 인프라 레벨의 장애였다.
서버가 올라오고 나서 첫 워크플로우를 돌렸더니 Swap이 50%까지 치솟았다. "또 터지나?" 싶었는데, 이후 빌드는 정상이었다. 서버 재기동으로 Gradle 캐시, Kotlin daemon, OS 페이지 캐시가 전부 날아간 Cold Start 상태에서 모든 걸 처음부터 빌드하느라 메모리가 순간적으로 높아진 거였다. 두 번째 빌드부터는 캐시가 Warm 상태라 Swap 없이 정상 동작했다.
Cold Start swap 자체는 문제가 아니다. 하지만 같은 날 또 다른 문제가 터졌다.
PR build-test 워크플로우에서 kubeManagement 모듈을 빌드하다가 Gradle Daemon이 OOM Kill됐다. 3차 핫픽스는 Harbor 이미지 빌드 워크플로우만 대상으로 했기 때문에, build-test 워크플로우는 여전히 메모리가 빡빡했다.
테스트 실행 시점에 3개의 JVM 프로세스가 동시에 돈다:
Gradle Daemon (1024m) + Kotlin Daemon (512m) + Test JVM fork (768m)
= Heap만 2,304MB
+ Metaspace(384m) + Native Memory + OS
≈ 3.5~4.0GB → OOM Kill4차 핫픽스로 전체 Heap을 2,304MB에서 1,664MB로 640MB 줄였다.
| 대상 | Before | After |
|---|---|---|
| Gradle JVM | -Xmx1024m |
-Xmx768m |
| MaxMetaspaceSize | 384m |
256m |
| Kotlin Daemon | -Xmx512m |
-Xmx384m |
| Test JVM | -Xmx768m |
-Xmx512m |
적용 후 동일 워크플로우를 다시 돌리니 빌드+테스트 성공. 메모리 피크 ~3GB, Swap ~768MB(일시적), OOM Kill 없음. 4GB 러너에서 할 수 있는 메모리 조정은 이게 한계에 가깝다.
4GB의 벽
여기까지가 소프트웨어 레벨에서 할 수 있는 전부였다.
정리하면 이렇다. 1, 2차 최적화로 빌드 시간을 79~86% 줄였고, 3, 4차 핫픽스로 OOM을 잡았다. 빌드는 빨라졌고 서버는 안정됐다. 하지만 4GB라는 물리적 한계는 여전히 남아있었다.
- Queue Wait: 러너 1대에 3개 모듈을 순차 빌드하면 4~9분 대기. 전체 배포 시간의 40-60%가 대기 시간
- Cold Start Swap: 서버 재기동 후 첫 빌드에서 불가피하게 Swap 발생
- SPOF: 러너가 1대뿐. 서버가 죽으면 CI/CD 전체가 멈춘다
- 메모리 여유 부족: 빌드 + 테스트 시 피크 ~3GB. 4GB 대비 여유 1GB. Swap 진입 경계선
소프트웨어로 최대한 쥐어짰지만, 하드웨어의 벽은 하드웨어로만 넘을 수 있다. 다만 이 시점에서 서버 증설을 요청할 때, "빌드가 느려요"라는 모호한 근거가 아니라 실측 데이터로 한계를 증명할 수 있었다. 1~4차 최적화를 거치면서 쌓인 수치들 — 메모리 프로파일, 빌드 시간 측정, OOM 발생 로그, Swap 패턴 분석 — 이 모든 게 "4GB로는 더 이상 안 된다"는 걸 보여주는 근거였다.
16코어 32GB, 그리고 로컬 Docker Runner
03/26. 추가 서버 자산이 투입됐다. 16코어 CPU, 32GB RAM.
기존 4GB 서버와 추가 32GB 서버, 2대 체제로 전환됐다. 둘 다 동일한 러너 라벨([self-hosted, Linux, X64])로 등록되어 있어서 GitHub Actions가 가용한 러너에 자동으로 Job을 배정한다.
그리고 PR build-test 워크플로우에는 별도의 해법이 있었다. 팀원들이 개인 로컬 환경에서 Docker Runner를 띄우는 구조다.
프로젝트 .github/github-runner/에 Docker Compose 파일이 있다:
services:
runner1:
image: gh-runner
container_name: runner-1
deploy:
resources:
limits:
memory: 4g
environment:
RUNNER_BASE_NAME: ${COMPUTER_NAME:-unknown}-runner-1
restart: unless-stopped
runner2:
image: gh-runner
container_name: runner-2
deploy:
resources:
limits:
memory: 4g
environment:
RUNNER_BASE_NAME: ${COMPUTER_NAME:-unknown}-runner-2
restart: unless-stoppeddocker compose up -d 한 방이면 Runner 2개가 뜬다. --ephemeral 모드라 작업이 끝나면 자동으로 등록 해제되고, 컨테이너가 재시작되면 새 Runner로 다시 등록된다. 오프라인 Runner가 쌓이는 문제가 없다.
개발자 1명이 Runner 2개를 띄우면, 같은 PR에서 트리거된 여러 모듈의 build-test가 동시에 돌 수 있다. 팀원 N명이 각자 띄우면 N×2개의 Runner가 PR 빌드를 병렬로 처리한다. 서버 자산을 추가하지 않고도 PR 빌드 병목을 해소한 셈이다.
결과적으로 CI 러너 구조가 이렇게 됐다:
| 구분 | 역할 | 스펙 |
|---|---|---|
| 기존 서버 (4C/4GB) | Harbor 이미지 CI + CD (보조) | 상시 운영 |
| 추가 서버 (16C/32GB) | Harbor 이미지 CI + CD (메인) | 상시 운영 |
| 로컬 Docker Runner | PR build-test 전담 | 개발자가 필요 시 기동 |
Harbor 이미지 빌드(배포 CI)는 서버 자산이 담당하고, PR 빌드/테스트는 개발자 로컬 Runner가 분담하는 구조. 워크로드가 자연스럽게 분리됐다.
결과 — Queue Wait 0분
서버 증설 효과는 즉각적이었다.
03/26 첫 Harbor 배포. KubeManagement, Member, PortalManagement 3개 모듈을 동시에 트리거했다.
05:07:57 ~ 05:11:15 Build KubeManagement ✓ (3분 18초)
05:08:02 ~ 05:09:30 Build Member ✓ (1분 28초)
05:08:07 ~ 05:11:14 Build PortalManagement ✓ (3분 7초)3개가 동시에 시작해서 동시에 끝났다. 이전에는 순차 실행으로 Queue Wait만 10~17분이었다. 이제 전체 배포가 3분이면 끝난다.
3단계 비교
| 지표 | 최적화 전 (03/18) | SW 최적화 (03/19~25) | 서버 증설 (03/26~) |
|---|---|---|---|
| KubeMgmt bootJar | 9분 38초 | 2분 02초 (79%↓) | ~1분 06초 (89%↓) |
| Member bootJar | 1분 54초 | 1분 02초 (45%↓) | ~51초 (55%↓) |
| PortalMgmt bootJar | 2분 52초 | 24초 (86%↓) | ~20초 (88%↓) |
| Harbor CI (3개 모듈 전체) | 20~30분 | 10~15분 | ~2분 |
| Queue Wait | 10~17분 | 4~9분 | 0분 |
| 메모리 피크 | ~3.5GB (Swap) | ~2.0GB | 여유 충분 |
Harbor CI 3개 모듈 전체 배포 시간이 20~30분에서 2분으로 줄었다. 93% 단축. Queue Wait가 0이 된 건 순전히 서버 증설 덕이지만, 개별 빌드 시간이 줄어든 건 1, 2차 SW 최적화의 효과다. 둘 다 필요했다.
안정성
| 날짜 | 전체 실행 | 성공 | 실패 | 비고 |
|---|---|---|---|---|
| 03/20 | 34 | 20 | 7 | OOM 장애일 |
| 03/25 | 21 | 17 | 3 | 서버 재기동 + 4차 핫픽스 |
| 03/26 | 45 | 39 | 0 | 서버 증설 첫날 |
| 03/30 | 35 | 34 | 0 | 안정 운영 |
03/26 이후 워크플로우 실패 건수가 사실상 0이다. 45건이 돌아도 실패가 없다. 이전에는 하루에 7건씩 실패하던 것과 비교하면 완전히 다른 세상이다.
돌아보면
12일이었다. 03/18에 병목을 인식하고, 03/30에 안정 운영을 확인하기까지.
03/18 병목 인식 — bootJar 9분 38초
03/19 1~2차 최적화 — 빌드 79~86% 단축
03/20 OOM 장애 → 3차 핫픽스
03/25 서버 재기동 → 4차 핫픽스
03/26 서버 증설 (16C/32GB) — Queue Wait 해소
03/30 Failure 0건 안정 운영이번에 느낀 건 최적화와 인프라 투자는 대립하는 게 아니라 순서의 문제라는 거다.
서버 증설만 했다면 어떨까? 32GB 서버를 투입하면 메모리 문제는 해결되지만, Gradle 빌드 자체가 9분 38초 걸리는 건 변함없다. 3모듈 동시 빌드해도 10분은 걸린다. 반대로 SW 최적화만 했다면? 빌드는 2분으로 줄었지만 Queue Wait 10분은 그대로다.
소프트웨어 최적화가 먼저다. 제한된 환경에서 할 수 있는 걸 다 한 뒤에, 그래도 넘지 못하는 벽이 있을 때 인프라를 투자하는 거다. 그래야 투자의 효과가 극대화된다. 빌드 시간이 2분인 상태에서 서버를 늘리니까 전체 배포가 2분이 된 거지, 빌드가 10분인 상태에서 서버를 늘렸으면 10분이다.
그리고 소프트웨어 최적화 과정에서 쌓인 데이터가 인프라 투자의 근거가 된다. "빌드가 느려요"가 아니라 "메모리 피크 3GB, Swap 768MB, Cold Start 시 50% swap 진입, 4GB로는 구조적으로 불가"라고 말할 수 있다. 숫자로 한계를 보여주면 의사결정이 빨라진다.
남은 과제
안정화됐지만 끝은 아니다.
- Runner 라벨 분리: 현재 모든 러너가 동일 라벨. Harbor CI는 서버 전용, PR build-test는 범용으로 분리하면 워크로드 격리가 더 깔끔해진다
- 개발자 로컬 Runner 가용성: 퇴근 후나 주말에는 로컬 Runner가 없다. 이 시간대 PR 빌드는 서버로 큐잉되는데, 현재로선 큰 문제가 아니지만 팀 규모가 커지면 고려해야 한다
- Healthcheck 가이드 현행화: 서버 점검 가이드가 1대 체제 기준으로 작성되어 있다. 2대 + 로컬 Runner 구조를 반영해야 한다
- Spring Boot Startup 최적화: CI 다음 병목은 Pod 부팅 시간이다. 빌드가 2분인데 Pod가 올라오는 데 30초 걸리면 그것도 줄여야 한다
정리
| 항목 | 최적화 전 (03/18) | 최종 (03/30) | 개선율 |
|---|---|---|---|
| KubeMgmt bootJar | 9분 38초 | ~1분 06초 | 89% |
| PortalMgmt bootJar | 2분 52초 | ~20초 | 88% |
| Harbor 3모듈 전체 배포 | 20~30분 | ~2분 | 93% |
| Queue Wait | 10~17분 | 0분 | 100% |
| 일일 Failure | 7건/일 (최대) | 0건 | — |
| 피크 메모리 | ~3.5GB (Swap) | 여유 충분 | — |
이전 글에서 "소프트웨어 레벨에서 할 수 있는 건 대부분 해치웠다"고 썼다. 그게 진짜였고, 그래서 그 다음 스텝인 인프라 투자로 자연스럽게 넘어갈 수 있었다.
4코어 4GB에서 시작해서 최적화로 한계까지 밀어붙이고, 서버 증설로 그 한계를 넘었다. 30분 걸리던 배포가 2분이 됐다. 매일 7건씩 실패하던 CI가 0건이 됐다. 개발자가 PR 올리고 1분이면 결과를 본다. 배포 버튼 누르고 2분이면 3개 모듈 이미지가 전부 올라간다.
최적화는 한 번에 끝나는 게 아니다. 한 겹 벗기면 다음 병목이 보이고, 그걸 또 벗기면 그 다음이 나온다. 중요한 건 매번 데이터로 병목을 찾고, 할 수 있는 것부터 하나씩 해결하는 거다.
'트러블 슈팅 > BE' 카테고리의 다른 글
| 4코어 4GB 러너 서버에서 Gradle 빌드 시간 86% 줄이기 (3) | 2026.03.19 |
|---|---|
| RestTemplate으로 외부 API 호출 이후 예외 응답시 body가 유실되는 현상에 대해 (0) | 2025.04.11 |
| Redis 분산락 컴포넌트 리팩토링 적용기 (2) | 2025.01.04 |