들어가며
평균 6~8분, 길게는 17분까지 걸리던 배포를 3분대로 줄였어요.
처음엔 "배포가 좀 느리네" 정도로만 생각했어요. 그런데 저희 팀이 운영하는 Next.js 15 서비스는 하루에도 수십 번 배포가 일어나거든요. 한 번에 6~8분씩 걸리니까, 막상 일하다 보면 이게 꽤 큰 비용이었습니다.
QA는 수정한 걸 확인하려고 매번 그 시간을 기다려야 했고, 개발자도 배포를 누른 뒤 다른 일로 넘어갔다가 다시 돌아오면 흐름이 끊겼어요. 배포 한 번이 느린 게, 알고 보니 팀 전체의 속도를 잡고 있었던 거죠.
그래서 이 시간을 한번 제대로 줄여보기로 했습니다. 이 글은 그 시간이 어디서 새고 있었는지 찾고, 어떻게 3분대까지 줄였는지, 그리고 줄이는 과정에서 한 번 크게 데인 이야기까지 담은 기록이에요.
문제 — 배포 대기가 팀의 병목이 된다
가장 먼저 한 건 "느리다"는 느낌을 숫자로 바꾸는 일이었어요.
자주 도는 배포 한 번이 평균 6~8분, 어떤 날은 17분까지 걸렸습니다. 배포 흐름 자체는 특별할 게 없었어요.
개발자 → GitHub Push → GitHub Actions → Docker Build (ECR) → Kubernetes 배포그래서 단계별로 시간을 쪼개서 재봤더니, 시간 대부분이 Docker 빌드 단계에 몰려 있더라고요. 어디가 느린지 보이고 나서야, 비로소 뭘 손봐야 할지 감이 잡혔습니다.
분석 — 시간은 '빌드'에서 샌다
빌드 단계를 들여다보니, 시간을 잡아먹는 지점이 네 군데였어요.
① 안 쓰는 아키텍처까지 빌드하고 있었어요
이미지를 amd64랑 arm64, 두 아키텍처로 빌드하고 있었어요. 그런데 막상 dev·stage 클러스터 노드를 보니 전부 amd64더라고요. 절대 실행될 일 없는 arm64 이미지를 매번 같이 만드느라, 빌드 시간의 절반 가까이가 그냥 버려지고 있었습니다.
② 이미지가 너무 무거웠어요
최종 이미지에 node_modules를 통째로 담고 있었어요. 이미지가 무거우니 레지스트리에 올리고 노드에서 받아오는 시간이 길 수밖에 없었죠.
③ 빌드 캐시가 비대했어요
캐시를 mode=max로 내보내고 있었는데, 이 옵션은 최종 이미지뿐 아니라 중간 단계 레이어까지 전부 캐시에 저장해요. 캐시가 무거워지니까, 캐시를 올리고 받는 데 드는 시간이 오히려 더 늘어나 있었습니다.
④ 같은 걸 또 빌드하고 있었어요
같은 커밋을 다시 배포해도 처음부터 새로 빌드했고, 연속으로 push하면 이미 의미 없어진 이전 빌드까지 끝까지 돌고 있었어요.
여기서 중요한 건, "느리다"를 "어디가 왜 느리다"로 좁혔다는 점이에요. 막연히 빠르게 만들겠다고 덤비는 대신, 측정으로 확인한 이 네 군데만 정확히 건드리기로 했습니다.
해결 — 꼭 필요한 것만, 꼭 필요한 환경에서
① 환경별로 빌드 아키텍처를 나눴어요
효과가 가장 컸던 결정이에요. 자주 도는 dev·stage는 amd64 하나만 빌드하도록 나눴습니다.
develop / stage → amd64 단일 빌드 (불필요한 arm64 빌드 제거)
production (tag) → amd64 + arm64 멀티 빌드dev·stage 클러스터는 전부 amd64니까 arm64를 만들 이유가 없어요. 반면 production은 arm64 노드도 쓰기 때문에 멀티 아키텍처를 그대로 뒀습니다. "환경마다 필요한 게 다르다"를 빌드에 반영한 것뿐인데, 이것만으로 빌드 시간이 눈에 띄게 줄었어요.
② 멀티스테이지 빌드와 Standalone으로 이미지를 가볍게
Next.js의 output: 'standalone'을 켜면, 실행에 꼭 필요한 것만 추려서 내보내줘요. 그래서 최종 이미지엔 이 결과물이랑 static 파일만 복사하면 되고, node_modules를 통째로 넣을 필요가 없어집니다.
# runner — standalone 출력물만 복사 (node_modules 불필요)
FROM base AS runner
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
USER nextjs # 비root 실행빌드할 때 필요한 거랑 실행할 때 필요한 걸 나눠줬더니, 이미지가 가벼워지고 올리고 받는 시간도 같이 줄었어요.
③ 빌드 캐시 모드를 max에서 min으로
이건 좋고 나쁨의 문제가 아니라 트레이드오프의 문제였어요.
mode=max는 중간 단계까지 캐시해두니까 재빌드할 때 적중률이 높다는 장점이 있어요. 그래서 무조건 나쁜 설정은 아니에요. 근데 저희 팀 빌드 패턴에선, 캐시가 맞아서 아끼는 시간보다 그 무거운 캐시를 매번 주고받는 시간이 더 컸습니다.
# Before — 중간 단계까지 전부 캐시 (적중률↑, 전송 비용↑)
cache-to: type=registry,ref=...,mode=max
# After — 최종 이미지에 필요한 것만 (전송 비용↓)
cache-to: type=registry,ref=...,mode=min그래서 min으로 바꿨어요. 캐시 전송량이 확 줄면서, 캐시 비용이 두 배로 들던 멀티 아키텍처 production 배포까지 dev·stage랑 똑같이 3분대로 들어왔습니다. 설정값 하나지만, "좋은 설정"이 아니라 "저희 팀에 맞는 설정"을 고른 셈이에요.
④ 같은 빌드는 다시 하지 않게
레지스트리에 같은 태그가 이미 있으면 빌드를 건너뛰고, 같은 브랜치에 연속으로 push가 들어오면 이전 빌드를 자동으로 취소했어요.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true같은 결과물은 두 번 만들지 않고, 커밋이 연달아 들어오면 마지막 것만 빌드하게 됩니다.
성과 — 3분대로
| 항목 | 개선 전 | 개선 후 |
|---|---|---|
| 배포 시간 | 평균 6~8분 (최대 17분) | 3분대 |
| dev/stage 빌드 | amd64 + arm64 멀티 | amd64 단일 (불필요 빌드 제거) |
| 최종 이미지 | node_modules 포함 | standalone만 (경량) |
| 빌드 캐시 | mode=max (비대) | mode=min (경량) |
| 같은 커밋 재배포 | 매번 풀 빌드 | 빌드 스킵 |
배포를 누르고 다른 일을 시작하기도 전에 끝나게 됐어요.
덕분에 QA는 수정한 걸 바로바로 확인할 수 있게 됐고, 개발자한테는 "배포 기다리는 시간"이라는 게 사실상 사라졌습니다. 배포가 빨라지니까 피드백도 그만큼 빨라지더라고요.
보완 — 빌드와 런타임 아키텍처는 맞춰야 해요
근데 빌드를 환경별로 나누고 나서, 생각 못 한 데서 한 번 데었어요. 어느 날 stage 배포가 5분 만에 타임아웃으로 실패한 거예요.
07:32:00 Helm 배포 시작 (--wait --timeout 5m)
07:37:01 ❌ UPGRADE FAILED: context deadline exceeded
09:48:04 자동 롤백 → 이전 버전 복원 (서비스는 정상 유지)원인은 빌드한 아키텍처랑 실제로 돌아갈 노드의 아키텍처가 안 맞아서였어요.
빌드: linux/amd64 전용
Helm values: nodeSelector 없음 ← 노드 제한 없음
K8s 스케줄러: → arm64 노드에 배치 → exec format error → Pod 기동 실패amd64로만 빌드한 이미지인데 nodeSelector가 없으니까, 스케줄러가 amd64랑 arm64가 섞인 stage 클러스터에서 arm64 노드에 Pod를 올려버린 거죠. 그럼 당연히 컨테이너가 못 떠요.
해결은 간단했어요. 단일 아키텍처로만 빌드하는 환경엔 노드를 딱 못 박았습니다.
nodeSelector:
kubernetes.io/arch: amd64빌드를 최적화하면서 "어디서든 돌던" 이미지를 "amd64에서만 도는" 이미지로 바꿔놓고, 정작 실행하는 쪽은 그대로 둔 게 문제였어요. 최적화로 전제를 좁혔으면, 그 전제를 시스템 곳곳에 분명히 못 박아둬야 한다는 걸 이때 배웠습니다.
마무리
돌아보면 결국 한 가지였어요. 꼭 필요한 것만, 꼭 필요한 환경에서 하게 만드는 것.
안 쓰는 아키텍처는 빌드하지 않고, 실행에 안 쓰는 의존성은 이미지에 담지 않고, 저희 팀에 안 맞는 캐시는 무겁게 들고 다니지 않고, 같은 결과물은 두 번 만들지 않고요.
말로 풀면 다 당연한 얘기예요. 근데 막연히 "빠르게"가 아니라, 어디가 느린지 직접 재보고 설정 하나까지 저희 상황에 맞는지 따져본 게 6~8분을 3분대로 만들었습니다. 그리고 중간에 데인 장애 덕분에, 최적화란 결국 전제를 좁히는 일이고 그 전제는 끝까지 챙겨야 한다는 것도 배웠고요.
혹시 배포 속도 때문에 비슷한 고민을 하고 계신 분이 있다면, 거창한 도구부터 찾기보다 "우리 빌드는 대체 어디서 시간이 새고 있을까"부터 한번 재보시길 권해요. 저희 팀의 3분도 거기서 시작했거든요.