Jieun 9 분 분량 수정
DevOps

CI 대기열부터 Docker 이미지 빌드까지, 배포 병목 개선기

PR 갱신과 dev 머지가 이어지면서, 혼잡한 날의 CI 대기 p90은 51분 13초였습니다. 9월 말 self-hosted 전환 이후 기록으로 공용 Mac 풀 확장과 이미지 빌드 분산의 전후 차이를 확인했습니다.

에버그린 배경의 CI 배포 병목 개선 제목과 실제 CI 장비 사진
목차

들어가며

제가 CI와 빌드·배포 환경을 구성한 WITIM은 Next.js 기반의 모놀리식 웹, Tauri 데스크톱, Flutter 모바일 앱을 함께 개발하는 서비스입니다. 개발자뿐 아니라 디자이너와 기획자도 코드를 수정하며 PR을 올렸고, CI 검사를 통과해야 dev에 머지할 수 있었습니다.

9월 말 웹 CI를 self-hosted로 전환할 때의 기본 러너는 Mac mini 한 대였습니다. 웹 CI와 Flutter 모바일 빌드, macOS 데스크톱 서명·공증이 같은 실행 슬롯을 공유했습니다.

그리고 PR을 수정할 때마다 검사가 다시 요청되고, dev에 머지한 뒤에도 검사가 이어졌습니다. 9월 28일 오전부터 이런 요청 집중이 확인됐고, 가장 혼잡했던 10월 27일에는 하루 전체의 실행 전 대기 중앙값이 약 12분 57초, p90이 51분 13초였습니다. 테스트에도 시간이 소요되는데, 검사를 시작하기까지 한 시간 가까이 기다리는 작업이 있었습니다.

CI 대기와 별개로 웹 Dev 배포의 Docker 이미지 빌드·푸시도 Vultr 서버에 집중돼 있었습니다. 검사를 시작하기까지의 대기와 배포 이미지를 만드는 시간을 서로 다른 병목으로 보고, 공용 Mac 러너 풀 확장과 웹·어드민 이미지 빌드 분산을 차례로 진행했습니다.

1. PR 검사 대기열이 쌓이는 상황을 확인했습니다

9월 28일 10–14시에는 PR 검사 13건과 dev push 검사 2건, 10월 14일에는 약 58분 동안 dev 머지 네 건이 이어졌습니다. PR 검사에는 기존 PR의 코드 갱신도 포함됩니다. 실행 중인 작업이 끝나기 전에 다음 요청이 들어오면서 대기열이 쌓일 수 있는 상황이었습니다.

표는 9월 28일과 확장 전(9/26–10/27) 일별 p90 상위 두 날의 하루 전체 기록입니다. 선행 job이 없는 웹 CI의 생성부터 실행 시작까지를 대기로 계산했습니다. p90은 관측값의 90%가 그 이하인 지점입니다.

날짜 (2026년·KST)루트 job 수하루 대기 중앙값하루 대기 p90
9/28 · 요청 집중573분 48초17분 44초
10/14 · p90 두 번째709분 59초25분 6초
10/27 · p90 최고62약 12분 57초51분 13초

Mac mini의 runner 하나는 동시에 job 하나만 처리했습니다. 다른 PR 검사나 앱 빌드가 실행 중이면 다음 검사는 대기해야 했고, CI 통과가 필요한 PR의 머지도 그만큼 늦어졌습니다.

이 현황을 보고 여러 PR 검사를 병렬로 수행할 수 있다면, 앞선 작업이 끝날 때까지 기다리는 시간을 줄일 수 있겠다고 생각했습니다. 이를 위해 검사를 실행할 수 있는 슬롯부터 늘리기로 했습니다.

2. 웹 CI·Flutter·macOS 빌드를 세 대가 나눠 처리하도록 확장했습니다

2-1. 장비별 전담 대신, 같은 작업을 처리할 수 있는 세 대를 선택했습니다

웹 CI뿐 아니라 Flutter 앱과 macOS 데스크톱의 실제 빌드도 여러 장비에 배정할 수 있도록 기존 Mac mini와 MacBook Air M4·16GB 두 대를 공용 풀로 구성했습니다. Flutter의 서명된 IPA·AAB, macOS ARM·Intel 앱의 서명·공증까지 어느 기기에서도 완료할 수 있어야 했습니다.

기기당 동시 job 하나이므로, 한 대가 모바일 빌드를 처리하는 동안 다른 기기는 웹 검사나 데스크톱 빌드를 받을 수 있습니다. 장비별 전담 대신 공용 풀을 택해, 특정 작업이 몰려도 비어 있는 기기를 활용하도록 했습니다.

작업 종류공용 라벨세 장비가 처리할 수 있는 범위
웹 CI·PR 검사witim-builder타입·정적 검사, Vitest, 프로덕션 사전 빌드
macOS 데스크톱witim-builderARM·Intel 빌드, 서명·공증
Flutter 모바일워크플로별 witim-builder 또는 witim-mobile-builder테스트, iOS 시뮬레이터·서명 IPA, Android 서명 AAB

세 기기에 두 공통 라벨을 모두 부여하고, job은 기존 워크플로가 요구하는 라벨로 배정했습니다. Linux DB 테스트·Vultr 배포·Windows 빌드는 이 풀에서 제외했습니다. 확장 전후 구조는 다음과 같습니다.

Mac mini 1개 실행 슬롯을 세 대의 공용 러너 풀로 확장한 구조
Mac mini 1개 실행 슬롯을 세 대의 공용 러너 풀로 확장한 구조

2-2. CI 러너 구성: 빌드 도구부터 서명 환경까지 맞췄습니다

추가한 맥북 두 대는 기존 Mac mini를 기준으로 러너 계정·서비스·빌드 도구·서명 환경을 맞췄습니다.

공용 CI 러너 풀에 추가한 MacBook 두 대
공용 CI 러너 풀에 추가한 MacBook 두 대

사진 1. 공용 CI 맥북 두 대입니다. 뒤에서 다루는 웹 Docker 이미지 빌드 전용 MacBook은 별도 장비입니다.

Xcode·시뮬레이터·CocoaPods처럼 호환성이 중요한 도구는 맞추고, Flutter·Java·Node·Rust 등은 기존 워크플로의 setup 단계로 준비했습니다. 개인 작업과 분리한 계정에 러너를 LaunchAgent로 설치했으며, 서비스에서 빠진 PATH와 잘못된 DEVELOPER_DIR도 보완했습니다. 터미널에서 명령이 실행되는 것과 서비스에서 빌드가 통과하는 것은 달랐습니다.

서명 자격증명은 상시 복제하지 않고, 기존 워크플로처럼 GitHub Secrets에서 임시 키체인·프로파일·Android keystore를 준비한 뒤 정리했습니다. 전용 계정에서 이 흐름을 검증하면서 Apple 약관 미동의로 인한 프로파일 다운로드 실패와 Developer ID 중간 인증서 누락을 해결했습니다.

2-3. 모바일·데스크톱의 서명 빌드까지 검증한 뒤 편입했습니다

새 맥북은 먼저 시험 전용 라벨로 등록했습니다. 앱 소스 변경 없이 모바일·데스크톱·웹 Actions를 실행하되, 스토어 업로드·업데이트 채널 게시·서버 배포는 제외했습니다.

두 맥북에서 iOS IPA·Android AAB 서명, macOS ARM·Intel 서명·공증, 웹 CI를 통과했습니다. 데스크톱은 codesign과 stapler로 결과를 확인했습니다. macOS 버전이 달라도 실제 산출물이 검증된 뒤에만 공용 라벨을 부여했습니다. 슬롯을 늘리는 조건은 도구 설치가 아니라 빌드와 서명 검증의 통과였습니다.

2-4. self-hosted 전환 이후 기록에서 확장 전후를 비교했습니다

self-hosted 실행이 확인되는 9월 26일–10월 31일 기록을 조회했습니다. 이 중 웹 CI의 최초 push·pull_request 실행에서 성공·실패로 완료된 Mac 루트 job의 대기를 집계했습니다. 모바일·데스크톱은 빌드·서명 검증으로 실행 범위 확장을 확인했습니다.

확장 전에는 개인 Mac이 처리한 작업도 포함했습니다. 시험·편입 기간인 10월 28–29일을 제외하고 공용 풀 구성 전후를 비교했습니다.

관측 시기 (2026년·KST)구성루트 job 수대기 중앙값대기 p90
9/26–10/27기존 Mac 운영 · 일부 개인 Mac 포함1,2402분 30초13분 34초
10/30–10/31세 대의 공용 풀 구성 이후13610.5초5분 52초

관측된 중앙값은 93.0%, p90은 56.8% 감소했습니다. 10분 초과 대기 비중은 15.6%에서 1.5%로 줄었습니다.

3. ARM 맥북으로 amd64 웹 이미지를 빌드할 수 있을까?

CI 대기를 줄여도 Vultr에 집중된 웹·어드민 이미지 빌드는 남았습니다. 이를 나눠 처리할 전용 MacBook Air M4·16GB 한 대를 추가했습니다. 앞 절의 공용 CI 맥북 두 대와 별도 장비이며, 먼저 ARM Mac에서 Linux amd64 서버용 이미지를 만들 수 있는지 검증했습니다.

3-1. Next.js는 ARM에서 빌드하고 실행 이미지는 amd64로 조립했습니다

현재 WITIM의 웹 코드는 Next.js 기반의 모놀리식 구조입니다.

전체 빌드를 amd64로 에뮬레이션하면 무거운 Next.js 컴파일에도 변환 비용이 들어갑니다. 대신 OrbStack의 Linux ARM64 환경에서 Buildx를 사용해 Next.js는 ARM으로 빌드하고, 산출물을 amd64 실행 이미지에 조립했습니다. builder는 FROM --platform=$BUILDPLATFORM node:22-slim으로 실행하고, runner·migrator에는 amd64 Node와 네이티브 의존성을 넣었습니다.

JavaScript·HTML·CSS와 달리 sharp·Prisma 같은 네이티브 파일은 아키텍처에 맞아야 했습니다. 실제로 두 문제가 발생했습니다.

문제원인과 대응
웹 빌드 OOMOrbStack 메모리를 약 8GiB→10GiB로 늘리고 BuildKit을 max-parallelism=1로 제한했습니다.
amd64 이미지에서 sharp 로딩 실패ARM용 sharp·libvips가 포함돼 있었습니다. 최종 단계에 같은 버전의 x64 패키지를 넣었습니다.

3-2. 이미지 생성부터 실제 Dev 배포까지 검증했습니다

이미지의 amd64 표기만으로 호환성을 판단하지 않았습니다. --platform linux/amd64 --network none으로 실행해 sharp 이미지 변환과 Prisma 엔진 로딩을 확인했습니다. 이 단계에서는 마이그레이션을 우회했으므로 실제 DB 쿼리 검증과는 구분했습니다.

Registry 업로드까지 확인한 뒤 전용 Mac을 Actions 러너로 등록했습니다. 웹·어드민 모두 Mac 빌드·런타임 검사 → Vultr Dev 배포·스모크·트래픽 전환을 완료했고, 성능은 로컬 캐시 재빌드가 아닌 실제 Actions 기록으로 비교했습니다.

4. 빌드는 분산하고 배포는 직렬화한 뒤 검증했습니다

4-1. 유휴 Mac으로 우선 배정하되 중복 예약은 막았습니다

Mac이 이미 빌드 중이거나 오프라인이면 Vultr를 사용하도록 했습니다. 하나의 빌드를 두 장비로 쪼개는 것이 아니라, 서로 다른 빌드 요청을 Mac 또는 Vultr에 배정하는 구조입니다.

Hosted 배정에서 Mac 또는 Vultr의 이미지 빌드를 거쳐 공통 Vultr 배포로 합류하는 흐름
Hosted 배정에서 Mac 또는 Vultr의 이미지 빌드를 거쳐 공통 Vultr 배포로 합류하는 흐름

Mac이 online·idle이라고 조회돼도, 동시 요청이 같은 장비를 선택할 수 있었습니다. 전용 브랜치의 예약 파일을 GitHub Contents API의 기존 SHA 조건부 갱신으로 수정해 한 요청만 예약하도록 했습니다. 예약에는 run·attempt·앱 식별자와 75분 만료를 두고 완료·취소된 실행의 예약을 회수했습니다. 빌드 제한 시간은 60분으로 뒀습니다.

4-2. 이미지 준비는 분산하고 서버 변경은 직렬화했습니다

배정과 해제는 짧은 GitHub-hosted job으로 분리했습니다. Vultr가 빌드 중이어도 뒤따르는 요청이 유휴 Mac을 선택할 수 있어야 했기 때문입니다.

이미지 업로드 이후에는 웹·어드민 공통 배포 잠금으로 Vultr의 마이그레이션·컨테이너 교체·스모크·트래픽 전환을 직렬화했습니다. 커밋 SHA를 이미지 태그로 쓰고, 늦게 끝난 과거 빌드가 새 배포를 덮지 않도록 순서를 검사했습니다.

기존 Registry 정리는 태그를 문자순으로 삭제해 배포 차례를 기다리는 이미지까지 지울 수 있었습니다. 이 정리를 제거하고 서버 변경을 앱별 하나의 job으로 묶었습니다. 빌드를 병렬로 준비할수록 배포 순서와 이미지 보존을 함께 관리해야 했습니다.

4-3. 중앙값은 줄었지만, Vultr 배포 대기는 남았습니다

성공한 최초 dev push를 비교했습니다. 도입 전은 10/29 16:31–11/04 18:55의 Vultr 빌드 53건, 초기 분산은 11/04 18:55–11/08 03:17의 13건입니다(KST). 후속 파이프라인 변경 이후는 제외했습니다.

지표도입 전초기 Mac·Vultr 분산변화
빌드·푸시 중앙값16분 58초6분 27초62.0% 감소
전체 워크플로 중앙값33분 5초26분 34초19.7% 감소
빌드·푸시 p9018분 12초22분 3초3분 51초 증가
전체 워크플로 p9056분 5초60분 35초4분 30초 증가

빌드 시간은 Build and Push (pnpm cache) step이며, 전체 시간에는 CI·대기·배포까지 포함됩니다. 분산 후 Mac 11건은 빌드·푸시 중앙값이 6분 17초였지만, Vultr 2건은 22분 3초·23분 12초로 느린 경로가 남았습니다. 표본 13건의 p90은 두 번째로 긴 값이므로 이를 일반적인 성능 저하율로 해석하지는 않았습니다.

빌드는 동시에 준비할 수 있어도, 배포는 여전히 Vultr 한 대에 모였습니다. 11월 6일에는 Mac과 Vultr의 빌드·푸시가 1분 41초 겹쳤지만, Mac에서 이미지 준비를 마친 뒤 배포 job은 Vultr의 빌드가 끝나기를 18분 29초 기다렸습니다. 중앙값 개선과 별개로, 배포 서버가 빌드까지 맡는 자원 경합은 남았습니다.

마무리하며

출발점은 PR 갱신과 dev 머지가 이어질 때 검사가 실행 슬롯을 기다리는 문제였습니다. 공용 Mac 풀을 구성한 뒤 관측 구간의 대기 중앙값은 2분 30초에서 10.5초로, p90은 13분 34초에서 5분 52초로 줄었습니다. 어느 장비에서도 CI 테스트 및 모바일·데스크톱 빌드와 서명·공증을 수행할 수 있도록 검증한 것이 전제였습니다.

웹 빌드에서는 ARM Mac에서 amd64 이미지를 만드는 경로를 검증하고 Mac·Vultr에 빌드를 분산했습니다. 서로 다른 dev 요청의 이미지를 동시에 만드는 실행을 확인했고, 빌드·푸시 중앙값은 62.0%, 전체 워크플로 중앙값은 19.7% 줄었습니다. 하지만 Mac에서 이미지가 준비돼도 Vultr의 빌드 작업 때문에 배포가 기다렸습니다. 빌드 중앙값은 줄였지만, 배포 서버의 자원 경합은 남았습니다.

댓글