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

빌드 시간 86% 줄였는데 서버가 죽었다 - CI 최적화 그 이후

by Ramos 2026. 3. 31.

이전 글에서 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 Kill

4차 핫픽스로 전체 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-stopped

docker 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개 모듈 이미지가 전부 올라간다.

최적화는 한 번에 끝나는 게 아니다. 한 겹 벗기면 다음 병목이 보이고, 그걸 또 벗기면 그 다음이 나온다. 중요한 건 매번 데이터로 병목을 찾고, 할 수 있는 것부터 하나씩 해결하는 거다.