도커(Docker)를 활용하여 애플리케이션을 배포하는 것은 현대 클라우드 네이티브 개발에서 선택이 아닌 필수가 되었습니다. 하지만 처음 도커 이미지를 빌드하다 보면 빌드용 컴파일러, 패키지 매니저의 무거운 캐시 파일, 소스코드 빌드 툴 등이 고스란히 최종 이미지에 포함되어 이미지 용량이 수백 메가바이트에서 기가바이트 단위로 기하급수적으로 커지는 현상을 겪게 됩니다. 이미지가 무거워지면 CI/CD 파이프라인의 배포 속도가 현저히 느려질 뿐만 아니라, 개발 단계에서 쓰이던 불필요한 라이브러리들이 상용 이미지 안에 잔존하여 해커들의 공격 표면(Attack Surface)이 넓어지는 보안상 치명적인 약점을 갖게 됩니다.
이러한 이미지 비대화 문제와 보안 취약점을 완벽하게 해결하기 위해 도커에서 공식적으로 제공하는 표준 설계 패턴이자 베스트 프랙티스가 바로 **멀티 스테이지 빌드(Multi-stage Build)**입니다. 이 글에서는 멀티 스테이지 빌드의 등장 배경부터 작동 원리, 장단점, 그리고 실무에서 즉시 써먹을 수 있는 아주 상세한 적용 예제까지 완벽하게 파헤쳐 보겠습니다.
1. 멀티 스테이지 빌드의 등장 배경과 기존의 문제점
과거에는 가벼운 도커 이미지를 만들기 위해 개발자들이 많은 고생을 해야 했습니다. 그 이유는 도커가 이미지를 생성할 때 사용하는 레이어(Layer) 아키텍처의 특성 때문입니다.
1.1. 도커 레이어 캐싱의 함정
Dockerfile의 각 명령어(RUN, COPY, ADD 등)는 실행될 때마다 기존 이미지 위에 새로운 '읽기 전용 레이어'를 얹습니다. 초보 개발자들은 종종 패키지를 다운로드해 컴파일한 뒤, 동일한 빌드 스텝 안에서 RUN rm -rf /var/cache/apk/* 와 같은 명령어를 통해 불필요한 파일을 지우면 전체 용량이 줄어들 것이라 착각합니다. 하지만 도커의 레이어 시스템 구조상, 앞선 레이어에서 이미 생성된 파일들은 다음 레이어에서 삭제(rm) 명령을 내리더라도 실제 하부 레이어에는 데이터가 고스란히 압축되어 숨겨진 채로 남아있게 됩니다. 결과적으로 최종 도커 이미지의 총 용량은 전혀 줄어들지 않습니다.
1.2. 기존의 비효율적 해결책 (Builder 패턴 스크립트)
이 문제를 해결하기 위해 과거에는 두 개의 Dockerfile을 작성했습니다.
Dockerfile.build: 컴파일러와 소스코드가 들어있는 무거운 빌드용 이미지.Dockerfile.prod: 런타임 환경만 있는 배포용 이미지.
개발자는 쉘 스크립트(build.sh)를 작성하여 먼저 빌드 이미지를 컨테이너로 띄우고, 컴파일이 완료된 바이너리 산출물(.jar, node_modules, 바이너리 실행파일 등)을 docker cp 명령어를 통해 호스트 머신(로컬 PC)으로 복사해 빼옵니다. 그 다음 두 번째 프로덕션용 Dockerfile에서 이 산출물만 COPY하여 다시 이미지를 굽는 매우 번거롭고 수동적인 과정을 거쳤습니다. 이는 관리 포인트가 늘어나고 CI 스크립트가 복잡해지는 큰 단점이 있었습니다.
1.3. 멀티 스테이지의 등장
도커 17.05 버전부터 이 복잡한 과정을 단 하나의 Dockerfile 안에서 우아하게 처리할 수 있는 멀티 스테이지 빌드(Multi-stage Build) 기능이 추가되었습니다. 이제 개발자는 여러 개의 FROM 구문을 한 파일 안에 작성하여 여러 가상 컨테이너 환경(스테이지)을 정의하고, 특정 스테이지에서 만들어진 결과물만 다음 스테이지로 쏙 골라서 복사할 수 있게 되었습니다.
2. 멀티 스테이지 빌드 작동 구조 및 주요 장점
멀티 스테이지 빌드는 보통 두 단계(Stage) 이상의 파이프라인으로 구성됩니다.
- 빌드 스테이지 (Build Stage): 첫 번째
FROM구문입니다. 빌드에 필요한 모든 무거운 도구들(JDK, Go 컴파일러, Node.js SDK, Python 빌드 툴 체인, GCC 등)과 원천 소스코드를 컨테이너 내부로 가져와 코드를 컴파일하고 실행 가능한 바이너리나 번들 파일을 생성합니다. 이 스테이지의 최종 용량은 매우 무겁습니다. 보통AS builder와 같이 별칭을 지정하여 사용합니다. - 런타임 스테이지 (Runtime Stage / Production Stage): 마지막
FROM구문입니다. 소스코드나 컴파일 도구가 전혀 없는 아주 가벼운 베어본 이미지(Alpine Linux, JRE, Nginx, Distroless 등)를 베이스로 선택합니다. 그리고 이전 스테이지(builder)의 경로에서 딱 실행에 필요한 컴파일 산출물(Jar 파일, Go binary, 프론트엔드 dist 폴더)만COPY --from=builder명령어로 가져옵니다.
이 아키텍처가 가져다주는 엄청난 장점
- 드라마틱한 이미지 용량 감소:
golang:1.20베이스 이미지로 빌드하면 약 800MB가 넘지만, 멀티 스테이지를 적용해alpine에 바이너리만 올리면 15MB~20MB 수준으로 용량이 95% 이상 감량됩니다. 네트워크 전송 비용 절감과 컨테이너 구동 속도가 비약적으로 향상됩니다. - 보안성 극대화 (Attack Surface 최소화): 상용 배포용 이미지 내부에는
npm,maven,git, 컴파일러는 물론 원본 소스코드조차 존재하지 않습니다. 만약 해커가 컨테이너 내부에 침입(RCE 등)하더라도, 추가적인 악성 스크립트를 다운로드하거나 실행할 도구가 전혀 없어 2차 공격을 방어하는 데 탁월한 효과를 발휘합니다. - 효율적 캐싱과 빌드 속도 향상: 코드 빌드 단계와 런타임 구성 단계가 분리되어 있어, 소스코드가 변경되어도 런타임 베이스 레이어의 캐시가 깨지지 않고 유지되어 빌드 속도가 빨라집니다.
3. 실전 언어별 멀티 스테이지 Dockerfile 작성 예제
이론을 넘어 실제 현업에서 가장 많이 쓰이는 형태의 멀티 스테이지 Dockerfile을 언어/환경별로 상세히 살펴봅니다. 각 줄의 의미를 완벽하게 숙지하세요.
3.1. Node.js (TypeScript) 프로젝트 예제
TypeScript 기반의 NestJS나 Express 서버를 배포할 때 가장 추천하는 방식입니다.
# ---------------------------------------------
# Stage 1: Build & Compile Stage (의존성 설치 및 TS 컴파일)
# ---------------------------------------------
# 무거운 전체 Node 런타임과 패키지 매니저가 포함된 빌더 이미지
FROM node:20-alpine AS builder
# 작업 디렉토리 설정
WORKDIR /app
# 패키지 매니저 파일만 먼저 복사하여 레이어 캐싱 극대화
COPY package*.json ./
COPY tsconfig*.json ./
# npm ci를 사용해 package-lock.json 기준으로 정확하게 의존성 설치
RUN npm ci
# 전체 원본 소스코드 복사
COPY . .
# TypeScript 코드를 JavaScript로 트랜스파일링 (결과물은 /app/dist 폴더에 생성됨)
RUN npm run build
# 프로덕션 배포 시에는 devDependencies가 필요 없으므로 운영용 의존성만 다시 설치
RUN npm ci --omit=dev && npm cache clean --force
# ---------------------------------------------
# Stage 2: Runtime Stage (최종 배포본)
# ---------------------------------------------
# 아주 가볍고 보안성이 높은 최소한의 Alpine Node 이미지 사용
FROM node:20-alpine AS runner
WORKDIR /app
# 보안을 위해 root 유저 대신 node 유저 사용
USER node
# 환경 변수를 운영(production)으로 명시
ENV NODE_ENV=production
# [핵심] Stage 1(builder)에서 프로덕션 구동에 필수적인 파일들만 복사해 옴
# 소스코드(.ts), 컴파일러 등은 가져오지 않음
COPY --from=builder --chown=node:node /app/package*.json ./
COPY --from=builder --chown=node:node /app/node_modules ./node_modules
COPY --from=builder --chown=node:node /app/dist ./dist
# 애플리케이션 포트 개방
EXPOSE 3000
# 최종 실행 명령어
CMD ["node", "dist/main.js"]
설명 포인트:
builder스테이지에서는npm run build를 통해 컴파일을 수행합니다.runner스테이지에서는 원본 소스코드는 놔두고, 빌드된 결과물인dist폴더와 운영에 필수적인node_modules만 복사해옵니다. 이로 인해 불필요한 TS 컴파일러와 소스코드가 상용 이미지에서 완전히 배제됩니다.
3.2. Java (Spring Boot) 프로젝트 예제
자바 애플리케이션은 소스 코드를 .jar 파일로 묶는 과정에 Gradle이나 Maven 같은 무거운 빌드 툴이 필요합니다. 멀티 스테이지를 쓰면 이 툴들을 최종 이미지에서 뺄 수 있습니다.
# Stage 1: Build Stage
# Gradle이 포함된 무거운 이미지를 사용해 빌드 진행
FROM gradle:8.2-jdk17-alpine AS builder
WORKDIR /app
# 의존성 캐싱을 위해 build.gradle 등 먼저 복사
COPY build.gradle settings.gradle ./
# 소스코드 복사
COPY src ./src
# Gradle을 이용해 빌드 수행 (테스트 스킵으로 속도 향상)
RUN gradle clean build -x test --no-daemon
# Stage 2: Runtime Stage
# JRE만 포함된 극도로 가벼운 배포용 이미지 사용 (JDK 불필요)
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
# [핵심] 빌더 스테이지의 libs 디렉토리에서 생성된 jar 파일만 복사
COPY --from=builder /app/build/libs/*.jar app.jar
# JVM 타임존 설정 및 실행
ENV TZ=Asia/Seoul
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
4. 자주 묻는 질문 (FAQ)
Q1. 여러 스테이지를 만들면 빌드 시간이 너무 오래 걸리지 않나요?
초기 1회 빌드 시에는 컴파일 환경을 다운로드해야 하므로 다소 시간이 소요될 수 있습니다. 하지만 도커의 레이어 캐싱 기능 덕분에 소스코드가 변경되지 않는 package.json이나 build.gradle 복사 단계는 완벽하게 캐시를 타게 되어, 이후 빌드부터는 오히려 빠르거나 비슷한 속도를 보여줍니다. 또한 최종 이미지를 도커 레지스트리(ECR, Docker Hub)에 푸시(Push)하고 운영 서버에서 풀(Pull) 받는 속도가 비약적으로 빨라지기 때문에, 전체 CI/CD 파이프라인 관점에서는 압도적인 시간 단축 효과가 있습니다.
Q2. COPY --from=builder를 할 때 특정 파일이 복사되지 않고 에러가 납니다. 왜 그런가요?
가장 흔한 실수는 경로 지정 오류입니다. 빌더 스테이지(WORKDIR /app)에서 최종 파일이 어디에 생성되는지 명확히 파악해야 합니다. (예: dist/, build/libs/, target/ 등). 로컬에서 빌드를 한 번 수행해 보고 산출물이 떨어지는 정확한 절대/상대 경로를 확인한 후 COPY --from 경로를 지정해 주어야 정삭 작동합니다.
Q3. 베이스 이미지로 alpine 버전을 많이 쓰던데 단점은 없나요?
알파인(Alpine) 리눅스는 5MB 남짓한 초경량 OS 이미지로 용량 축소에 매우 좋습니다. 하지만 glibc 대신 musl libc라는 경량화된 C 라이브러리를 사용합니다. 때문에 C/C++ 네이티브 모듈에 의존하는 특정 라이브러리(예: Node.js의 node-sass, Python의 numpy 등)를 빌드하거나 실행할 때 알 수 없는 컴파일 에러나 런타임 오류가 발생할 수 있습니다. 만약 이런 라이브러리를 사용한다면 alpine 대신 데비안 기반의 -slim 태그(예: node:20-slim)를 사용하는 것이 훨씬 안전합니다.
5. 마무리 및 권장사항 (한 걸음 더: Distroless 이미지)
멀티 스테이지 빌드는 도커를 다루는 개발자라면 반드시 손에 익혀야 하는 핵심 패턴입니다. 이를 적용하는 것만으로도 서비스의 배포 속도와 보안 인프라 수준이 한 단계 도약하게 됩니다.
보안을 한 단계 더 끌어올리는 강력한 팁 (Distroless): 마지막 런타임 스테이지의 베이스 이미지로 alpine이나 slim 대신 구글에서 제공하는 Distroless (디스트롤리스) 이미지를 사용해 보시길 강력히 권장합니다. Distroless 이미지는 컨테이너 내부에 bash나 sh 같은 쉘(Shell) 프로세스나 리눅스 기본 유틸리티(ls, grep 등)조차 완전히 제거된, 오직 애플리케이션 실행을 위한 런타임(Node, Java, Python 등)만을 포함한 궁극의 보안 이미지입니다. 해커가 애플리케이션의 취약점을 뚫고 들어오더라도 쉘이 없기 때문에 터미널 명령을 단 하나도 실행할 수 없어 치명적인 침해 사고를 원천 차단할 수 있습니다.