← 염승준
01

빌드 파이프라인 측정과 캐시 최적화

캐시를 더 얹는 대신 이미지를 줄이고, 있는 캐시부터 확인했습니다.

문제
  • 단일 Docker 이미지 빌드·배포에 15~20분. 긴급 수정이든 작은 변경이든 반영할 때마다 팀 전체가 대기
  • 이미지가 2GB로, 실행에 필요 없는 빌드 의존성까지 모두 담겨 있었음
해결
  • 멀티 스테이지 빌드로 빌드·실행 환경을 분리하고, Next standalone으로 실제 쓰는 의존성만 복사
  • GCS 캐시 가설은 A/B 비교(미사용 6분 32초, 사용 8분)로 기각. 빌드 컨텍스트가 50MB → 1.5GB로 커진 전송 비용이 원인
  • 새 인프라 대신 기존 레이어 캐싱이 제대로 동작하는지 먼저 확인
  • 이후 Sentry 릴리스 ENV로 생긴 캐시 회귀는 구간 계측으로 원인을 찾아 설치 순서를 바꾸고, 같은 PR에서 검사를 CI로 분리
결과
  • 배포 15~20분 → 9분 안팎, Docker 이미지 2GB → 115.9MB
  • 이후 15.4분까지 회귀한 배포를 복구해 현재 중앙값 8.1분
  • 릴리스당 불필요한 의존성 재설치 90~108초 제거
프로덕션 배포 · 이미지 경량화
15~20분
9분 안팎
Docker 이미지
2GB
115.9MB
프로덕션 배포 · 이후 회귀 복구
15.4분
8.1분
릴리스당 불필요 재설치
90~108초 제거

경량화 이후 배포 시간은 기록이 남아 있는 2025년 8~11월 GitHub Actions 성공 실행의 중앙값(9.4분)입니다. 회귀 복구 before는 회귀 구간(2025-12-17 ~ 2026-01-19, 50건), after는 2026-09-01 이후(33건)의 중앙값입니다. 본문의 빌드 스텝 단위 수치는 Cloud Build 로그 기준입니다.

문제

단일 Docker 이미지를 빌드해 배포하는 데 15~20분이 걸렸습니다. 긴급한 버그 수정이든 작은 변경이든, 반영할 때마다 팀 전체가 같은 시간을 기다렸습니다. 하루에도 몇 번씩 배포하는 팀이라 이 대기가 그대로 개발 사이클의 길이가 됐습니다.

이미지를 줄이다

단일 이미지가 2GB였습니다. 멀티 스테이지 빌드로 빌드 환경과 실행 환경을 분리하고, 실제로 쓰는 node_modules만 추적해 복사하는 Next standalone 모드로 실행 이미지에 필요한 것만 담아 115.9MB로 줄였습니다.

Artifact Registry 이미지 상세. 가상 크기 2GB, 2025년 4월 30일 빌드.
개선 전 (2GB, 2025-04-30 빌드)
Artifact Registry 이미지 상세. 가상 크기 115.9MB, 2025년 5월 13일 빌드.
개선 후 (115.9MB, 2025-05-13 빌드)

캐시를 더 얹는다는 가설을 기각하다

다음 후보는 캐시였습니다. GCS 캐시를 도입하면 빌드를 더 줄일 수 있다는 가설을 세우고 A/B로 비교했습니다.

소요 시간
캐시 미사용6분 32초
캐시 사용8분 00초

오히려 느려졌습니다. 캐시를 주고받느라 Docker 빌드 컨텍스트가 50MB에서 1.5GB로 커졌고, 그 전송 비용이 캐시로 얻는 이득을 넘어섰습니다. "캐시가 많을수록 빌드가 빠르다"가 항상 참은 아니었습니다. 가설을 기각하고 되돌렸습니다.

새로 들이기 전에, 있는 것부터

새 솔루션을 들이기 전에 기존 Docker 레이어 캐싱이 제대로 동작하는지부터 확인했습니다. 이미 제 역할을 하고 있었습니다. 빌드 단계별 병목을 따져 보니 새 인프라 없이 기존 구성 안에서 줄일 수 있는 부분이 대부분이었습니다.

그 결과 배포는 15~20분에서 7~9분대로 내려왔습니다. 기록이 남아 있는 2025년 8~11월 프로덕션 배포 중앙값은 9.4분입니다.

그 뒤 — 회귀와 복구

이 수치는 그대로 유지되지 않았습니다.

2025년 12월, 릴리스마다 Sentry 릴리스 버전을 기록하도록 Dockerfile에 ARG/ENV가 추가됐습니다. 그날부터 프로덕션 배포 중앙값이 9.4분에서 15.4분으로 뛰었습니다. 한 달 뒤 BuildKit 캐시 마운트가 도입되며 12분대로 내려왔지만 원인은 그대로 남아 있었습니다. 캐시 마운트는 무효화된 의존성 설치를 더 싸게 만들었을 뿐, 무효화 자체를 막지는 않았습니다. 이후 일곱 달 넘게 배포는 12분대에 머물렀습니다.

GitHub Actions 프로덕션 배포 워크플로우 실행 목록. 2026년 8월 3~6일 실행 다섯 건이 12분에서 20분 25초 사이.
수정 직전 프로덕션 배포 (2026년 8월 3~6일, 12분 ~ 20분 25초)

원인을 찾은 방법 — 구간 계측

어디가 느린지부터 알 수 없었습니다. Cloud Build의 build 스텝이 669.6초로 찍히는데, 그 안에서 시간이 기록되는 구간은 2차 도커 빌드 11초뿐이었습니다. 나머지 650여 초가 pull·빌더 빌드·의존성 설치·webpack 컴파일로 뭉쳐 있었습니다. 추정으로 최적화를 시작하면 틀린 곳을 고칩니다.

구간별 시각을 기록하게 하자 이상한 게 보였습니다. package.json은 몇 주째 바뀐 적이 없는데도 프로덕션 릴리스에서 yarn install이 90~108초씩 걸리는 경우가 있었습니다.

단서는 환경 간 차이였습니다. dev에서는 이 현상이 없었습니다. 두 환경의 차이를 좁혀 가니 하나가 남았습니다. 프로덕션은 릴리스마다 Sentry 릴리스 버전을 환경변수로 주입하고, dev는 그 값이 고정이었습니다.

원인이 여기 있었습니다. Docker는 ENV 값을 레이어 캐시 키에 포함합니다. 그런데 60여 개의 ARG/ENV 선언이 COPY package.json + yarn install보다 앞에 있어, 릴리스 버전이 바뀌면 그 아래 모든 레이어가 무효화되고 의존성 설치가 통째로 다시 돌았습니다.

ENV NEXT_PUBLIC_SENTRY_RELEASE=$NEXT_PUBLIC_SENTRY_RELEASE  # 릴리스마다 바뀜
COPY package.json yarn.lock ./   # 원래 캐시 적중 → 매번 미스
RUN yarn install                 # 원래 캐시 적중 → 매번 미스
COPY . .                         # 코드가 바뀌므로 원래도 매번 미스
RUN yarn run build

의존성 설치를 선언보다 앞으로 옮겼습니다. 레이어 순서만 바꾸는 것이라 빌드 산출물은 동일합니다. 로컬 대조 실험에서 수정 전에는 두 번째 빌드가 캐시 미스, 수정 후에는 CACHED였고 실제 릴리스에서도 확인했습니다.

BuildKit 이전에 회귀 폭이 6분이나 됐던 것도 이걸로 설명되는 것으로 봅니다. 캐시 마운트가 없을 때는 레이어가 무효화되면 패키지를 처음부터 전부 다시 받아야 했고, 마운트가 생긴 뒤에는 다운로드는 캐시를 쓰고 설치 단계의 90~108초만 남았습니다.

검사를 배포 경로에서 떼어내기

같은 PR에서 검사도 옮겼습니다. next build는 타입 검사와 ESLint를 함께 실행합니다. 즉 PR에서 이미 할 수 있는 검사가 배포의 크리티컬 패스에 있었습니다.

검사를 GitHub Actions 별도 워크플로우로 옮기되, 그냥 떼면 검사가 빠질 수 있어 세 가지를 챙겼습니다. PR에서 실행하고, 배포 워크플로우에서도 병렬로 돌려 PR을 우회한 push를 커버하되 needs로 걸지 않아 배포를 막지 않고, 같은 브랜치의 이전 실행은 자동 취소.

옮기는 김에 lint 범위도 확인했습니다. 기존 설정은 세 디렉토리만 검사하고 있었고, 전체로 넓히자 에러 2건이 나왔습니다. 그중 하나는 lint가 "쓰이지 않는 import"라고 지적했지만 지우면 타입 에러가 19건 터지는 케이스였습니다. 그 import가 파일을 모듈로 만들어 타입 병합을 성립시키고 있었습니다. 규칙을 그대로 따랐다면 타입 시스템을 깨뜨릴 뻔했습니다.

두 수정의 몫 나누기

캐시 복구와 검사 분리가 한 PR에 들어가서, 배포 시간을 전후로 비교하는 것만으로는 각각의 효과를 나눌 수 없습니다. 여기서 dev가 비교군이 됩니다. dev는 Sentry 릴리스 값이 고정이라 캐시 문제가 애초에 없었으므로, dev에서 줄어든 만큼은 캐시 복구와 무관한 몫입니다.

배포 시간 중앙값수정 직전 2주수정 후차이
프로덕션12.4분8.1분-4.3분
dev11.5분8.2분-3.3분