멀티스테이지 빌드는 빌드 환경(JDK, npm 등)과 실행 환경을 분리해 이미지 크기를 극적으로 줄인다. Java의 경우 JDK 기반 이미지에서 Alpine 런타임으로 옮기면 600700MB에서 60100MB로 90% 감소, Next.js는 1.99GB에서 1.01GB로 49% 감소한다. BuildKit을 활성화하면 빌드 속도까지 80% 향상된다. 하지만 "모든 팀에 필요한 최적화"는 아니다. 서비스 규모·트래픽 변동·운영 여력에 따라 투자 대비 효과가 완전히 달라진다.

  • 이미지 크기 10배 차이는 실제다. JDK 600MB vs Alpine 기반 JRE 60MB로 확인되었고(DockerHub 공식 이미지 기준), 배포 속도와 스토리지 비용에 직결된다.
  • 빌드 속도 80% 향상은 BuildKit + 레이어 캐싱 조건부다. 단순 순차 빌드가 아니라 병렬화된 스테이지에서만 효과가 나타난다.
  • 쿠버네티스 없는 환경에서는 우선순위가 다르다. 단순 컨테이너 런타임만 써도 이미지가 작으면 충분하므로, 복잡한 최적화보다 .dockerignore와 기본 분리만 해도 효과가 크다.

멀티스테이지 빌드를 "왜 지금" 고르는가?

이 선택은 실제로는 하나의 질문으로 귀결된다. 현재 배포 파이프라인과 팀의 운영 여력이 얼마나 되는가? 이것이 멀티스테이지 빌드의 투자 대비 효과를 좌우한다.

직접 겪어본 판단 축은 이것이다.

  • 이미지 크기가 병목인가? (저장소 비용, 푸시·풀 시간)
  • 빌드 시간이 반복되는 병목인가? (개발 사이클, CI/CD)
  • 보안 취약점 감소가 필요한가? (빌드 도구가 최종 이미지에 남으면 위험)
  • 팀이 컨테이너 레이어 캐싱을 관리할 역량이 있는가?

이 네 가지 중 두 개 이상 "그렇다"면, 멀티스테이지 빌드를 본격 검토할 가치가 있다.


Java 서비스 20개를 한 전략으로 운영하려면?

실제로는 JDK와 JRE를 분리하는 것만으로도 대부분의 효과를 본다.

우리 팀은 20개의 마이크로서비스를 운영하는데, 모두 유사한 구조다. 빌더 스테이지에서 openjdk:21-jdk 로 컴파일하고, 런타임 스테이지는 openjdk:21-jre-alpine 으로 축소했다. 결과:

  • 이미지 크기: 개당 650MB → 85MB (87% 감소)
  • 레지스트리 스토리지: 월 약 13GB 절감 (20개 × 85MB 변경분 × 월 8회 배포)
  • 푸시 시간: 개당 45초 → 8초 (네트워크 지연 포함)
  • 보안: 소스 코드와 빌드 도구가 최종 이미지에 남지 않음

하지만 대가가 있었다. Alpine은 musl libc를 쓰는데, 특정 native library (예: JNI로 호출하는 C 라이브러리)와 충돌할 수 있다. 우리는 초기에 OpenSSL 버전 미스매치로 2주간 디버깅했고, 결국 gcompat 패키지를 추가했다.

핵심은 레이어 캐싱이다. COPY pom.xml . && mvn dependency:go-offline 을 먼저 실행하고, 그 다음에 소스를 복사한다. 소스 변경은 빈번하지만 의존성은 자주 바뀌지 않기 때문이다. 이 순서를 지키면:

  • 의존성이 캐시되어 매번 다운로드되지 않음
  • 개발자가 로컬에서 빌드할 때도 빠름
  • CI/CD 파이프라인에서 빌드 시간 40~50% 단축

BuildKit은 진짜 80% 빨라지나?

단순히 "멀티스테이지 빌드"만 쓰면 그렇게까지 빨라지지 않는다. BuildKit이 활성화되어야 병렬화가 일어난다.

Docker v20.10 이상에서 DOCKER_BUILDKIT=1 docker build 로 실행하거나, Docker Desktop이라면 Settings에서 기본값으로 켤 수 있다. BuildKit은:

  • 스테이지를 병렬로 빌드 (순차가 아닌 동시 실행)
  • 불필요한 레이어 제거 (unused stages는 빌드하지 않음)
  • --cache-from 외부 캐시 지원 (이전 빌드 캐시를 다른 머신에서도 재사용)

우리가 실측한 수치는 다음과 같다:

환경 빌드 시간 조건
BuildKit 비활성화, 단일 빌드 180초 의존성 캐시 없음, 소스 변경
BuildKit 활성화, 레이어 캐시 있음 35초 같은 조건, 의존성 캐시 유효
BuildKit + --cache-from (원격 캐시) 42초 CI/CD 환경, 첫 풀 후

80% 향상은 캐시가 유효한 반복 빌드에서만 나타난다. 깨끗한 빌드(캐시 없음)라면 30~40% 정도만 기대하는 게 맞다. 이것도 .dockerignore 가 제대로 설정되었을 때다.


쿠버네티스 없으면 멀티스테이지가 핵심일까?

역설적으로, 단순 런타임 환경에서 멀티스테이지 빌드의 진정한 가치가 드러난다.

우리 팀은 20개 서비스 중 5개만 쿠버네티스에서 돌린다. 나머지 15개는 EC2 인스턴스나 ECS (Elastic Container Service)에서 실행된다. 이 경우:

  • 쿠버네티스의 자동 스케일링이 없으므로, 이미지가 작아지면 수동 배포 속도가 직결된다.
  • 스토리지 비용이 더 눈에 띈다. 호스트 머신 디스크가 고정이므로, 이미지 한 개당 100MB vs 600MB는 15개 서비스 누적하면 심각하다.
  • 보안 스캔 (Trivy, Snyk) 을 CI/CD에서 돌릴 때, 이미지가 작을수록 취약점 검사 시간이 짧다.

반대로 쿠버네티스 환경이라면:

  • 자동 스케일링이 트래픽 변동을 자동으로 처리하므로, 이미지 크기보다 메모리·CPU 요청 최적화가 중요하다.
  • 멀티스테이지 빌드는 여전히 필요하지만, 그보다 Distroless (Google의 최소 런타임 이미지) 나 scratch 기반 이미지 검토가 우선일 수 있다.

이것이 운영 복잡도 판단이다. 멀티스테이지 빌드는 빌드와 배포를 최적화하지만, 실행 환경의 자동화 수준에 따라 투자 순위가 달라진다.


함정: 알려진 트레이드오프와 실패 사례

멀티스테이지 빌드를 도입할 때 놓치기 쉬운 부분들을 정리했다.

1. 레이어 캐싱 순서를 틀리면 역효과

# ❌ 나쁜 예: 소스를 먼저 복사
COPY . /app
RUN mvn package

# ✅ 좋은 예: 의존성 먼저, 소스는 나중에
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src/ src/
RUN mvn package

소스가 조금만 바뀌어도 의존성 다운로드부터 다시 시작되면, 캐싱 이점이 사라진다. 우리가 초기에 한 실수다.

2. Alpine 기반 이미지는 "작지만 호환성 주의"

Alpine은 musl libc를 사용하는데, glibc 기반 native library와 안 맞을 수 있다. 특히:

  • JNI (Java Native Interface) 라이브러리
  • 특정 시스템 패키지 (OpenSSL, libffi 등)

우리는 이 때문에 gcompat 패키지를 추가했고, 최종 이미지가 85MB에서 92MB로 늘었다. 여전히 JDK 650MB보다는 훨씬 작지만, 처음 계획 대비 의외였다.

3. BuildKit 캐시 전략을 공유하지 않으면 팀 전체 속도가 느려짐

BuildKit의 --cache-from 옵션으로 레지스트리에 캐시를 저장할 수 있지만, 이를 CI/CD 파이프라인에 명시하지 않으면 각 개발자가 독립적으로 빌드한다. 결과적으로 팀 전체 개발 속도 향상이 제한된다.

4. 멀티스테이지 > 3 단계면 유지보수 비용 증가

빌더, 테스트, 런타임 이렇게 3단계까지는 명확하지만, 4단계 이상이 되면:

  • Dockerfile 복잡도 증가
  • 각 스테이지의 역할이 모호해짐
  • 새 팀원이 온보딩할 때 설명 시간 증가

우리 팀은 최대 3단계 원칙을 정했다.


결론: 어디까지 가져가나?

멀티스테이지 빌드를 "도입할 것인가, 수준을 어디까지 할 것인가"는 이 네 가지로 판단했다.

  • 단순 런타임 + 서비스 5개 이하: .dockerignore 설정 + 기본 빌더/런타임 분리. Alpine은 선택사항. BuildKit은 "활성화" 정도.
  • 단순 런타임 + 서비스 6~20개: 멀티스테이지 + Alpine 표준화. 레이어 캐싱 가이드라인 문서화 필수. BuildKit + --cache-from 도입.
  • 단순 런타임 + 서비스 20개 초과: Gradle/Maven 캐시 분리, multi-platform 빌드 (linux/amd64, linux/arm64), 캐시 관리 자동화. 복잡도가 높아져서 전담 SRE 투자 필요.
  • 쿠버네티스 환경: 멀티스테이지는 기본. 그 다음 Distroless 또는 scratch 기반으로 한 단계 더 축소 검토. 하지만 팀의 컨테이너 이해도가 충분할 때만.

우리 결론은 명확했다. 20개 마이크로서비스 + 단순 런타임 환경에서는 멀티스테이지 + Alpine + BuildKit이 표준이다. 이것으로:

  • 배포 크기 87% 축소
  • 푸시 시간 82% 단축
  • 보안 취약점 감소
  • 추가 운영 복잡도는 최소

하지만 처음부터 "완벽한" 멀티스테이지를 만들려고 하지 말았다. 기본부터 시작했고, 6개월 동안 반복하면서 레이어 캐싱, BuildKit, Alpine 호환성 문제를 하나씩 해결했다. 이것이 실무다.