Tool 활용법/Docker 활용법

[Docker 기초 4] Dockerfile 성능 최적화 방법 (Cache, Multi-Stage build)

gksyb4235 2026. 2. 6. 16:04

Dockerfile 성능 최적화: 빌드 속도와 이미지 용량 줄이기


나만의 Docker 이미지를 만들기 위해서는 Dockerfile에 레시피를 작성하면 된다.
이전 단계에서는 동작하는 Dockerfile을 만드는 데 집중했다면, 이제는 성능 관점에서 Dockerfile을 다듬어야 할 시점이다.

프로젝트 규모가 커질수록 다음 문제가 반드시 등장한다.

  • docker build 시간이 점점 길어진다
  • 배포할 때마다 기다리는 시간이 점점 길어진다.
  • 이미지 용량이 불필요하게 커진다

이번 포스트에서는 Dockerfile을 약간만 수정해서 빌드 속도를 단축하고,

이미지 품질을 개선할 수 있는 핵심 원리와 실전 패턴을 정리한다.

 

 

Docker build가 느려지는 이유


Docker 이미지를 빌드할 때 Docker는 Dockerfile의 각 명령어를 한 줄씩 순서대로 실행한다.

예를 들어,

  • COPY를 실행하고
  • RUN을 실행하고
  • 또 COPY를 실행하면

각 단계마다 Docker는 결과를 몰래 저장해두는데 이것이 이것이 Docker의 캐시다.

만약 다음에 docker build를 다시 실행할 때, 해당 단계의 입력이 이전과 동일하다면,

Docker는 실제로 명령을 다시 실행하지 않고 캐시된 결과를 그대로 재사용한다.

이 덕분에 Docker는 매우 빠르게 이미지를 재빌드할 수 있다.

 

 

캐시가 깨지는 순간


출처 : https://velog.io/@choiyoorim/%EC%B5%9C%EC%A0%81%ED%99%94%EB%90%9C-Dockerfile-%EC%9E%91%EC%84%B1-%EA%B8%B0%EB%B2%95

 

문제는 다음과 같은 경우다.

  • COPY . . 로 소스 코드를 통째로 복사한다
  • 소스 코드는 거의 매번 변경된다
  • COPY 단계가 바뀌면 그 아래 모든 단계의 캐시가 무효화된다

즉, 소스 코드가 조금만 바뀌어도

  • npm install
  • 빌드
  • 설정 작업

이 전부 다시 실행되는데, 이것이 docker build 시간이 점점 느려지는 이유다.

 

따라서, Dockerfile 최적화의 핵심 원칙은 단순하다.

  • 변하지 않는 것들은 위에 둔다
  • 자주 변하는 것들은 아래에 둔다

Docker는 위에서부터 순서대로 캐시를 판단하기 때문에, 안 변하는 작업을 위로 올릴수록 캐시를 오래 활용할 수 있다.

 

 

 

Node.js 프로젝트에서의 대표적인 최적화 포인트


Node.js 프로젝트에서 변동성이 가장 적은 것은 무엇일까? 바로 라이브러리이다.

  • package.json
  • package-lock.json

라이브러리는 매번 추가되지 않는 반면, 반면 소스 코드는 거의 매번 수정된다.

따라서 Dockerfile은 다음과 같은 순서가 이상적이다.

  1. package 관련 파일만 먼저 복사
  2. 라이브러리 설치
  3. 소스 코드 복사
  4. 서버 실행

 

 

COPY 전략 바꾸기


기존에는 다음과 같이 작성했을 것이다.

FROM node:20-slim
WORKDIF /app
COPY . .
RUN ["npm", "install"]
EXPOSE 8080
CMD ["node", "server.js"]

 

이를 다음과 같이 바꾼다.

FROM node:20-slim
WORKDIR /app
COPY package*.json .
RUN ["npm", "install"]

COPY . .
EXPOSE 8080
CMD ["node", "server.js"]
 

이렇게 하면 다음이 가능해진다.

  • package.json이 변경되지 않는 한
  • npm install 단계는 캐시를 그대로 사용한다

WORKDIR /app이 이미 설정되어 있다면,
이 파일들은 /app 디렉터리로 복사된다.

 

우선, package 파일을 복사한 다음, 라이브러리를 설치한다.

RUN ["npm", "install"]
 

그 다음에 소스 코드를 복사한다.

COPY . .
 

이렇게 작성하면,

  • 소스 코드가 바뀌어도
  • npm install 단계는 다시 실행되지 않는다

docker build 시간이 눈에 띄게 줄어든다.

 

 

npm install보다 npm ci를 써야 하는 이유


npm install은 package.json을 기준으로 라이브러리를 설치한다.
문제는 package.json에 다음과 같은 표현이 자주 등장한다는 점이다.

 
"express": "^5.2.1"

이 의미는 다음과 같다.

  • 5.x 버전이면 된다
  • 더 높은 마이너 버전이 있으면 그걸 설치한다

즉, 오늘 빌드한 이미지와 다음 달에 빌드한 이미지가 서로 다른 라이브러리 버전을 가질 수 있다

 

이를 방지하는 방법이 npm ci다.

npm ci는 다음을 전제로 한다.

  • package-lock.json을 기준으로
  • 정확히 동일한 버전을 설치한다

그래서 Docker 이미지에서는 npm install보다 npm ci가 훨씬 안정적이다.

RUN ["npm", "ci"]
 
 
 
 

그 밖의 Skill들


ENV로 실행 환경 명시하기


Dockerfile에서는 환경변수를 설정할 수 있다.

 
ENV NODE_ENV=production

이 설정은 다음과 같은 효과가 있다.

  • 일부 오래된 Node.js 라이브러리에서
  • 로그 출력 감소
  • 불필요한 디버그 코드 비활성화

요즘 라이브러리에서는 큰 차이가 없을 수도 있지만,
여전히 관례적으로 많이 사용된다.

컨테이너 실행 시에는 다음과 같이 동적으로 주입할 수도 있다.

docker run -e NODE_ENV=production ...
 
 
 
 

root 권한으로 실행하지 않는 습관


Dockerfile에서 실행되는 모든 명령어는 기본적으로 root 권한이다.

보안 관점에서는 다음이 더 안전하다.

  • 빌드는 root로 하되
  • 서버 실행은 일반 유저로 한다

다행히 Node.js 공식 이미지는 이미 node라는 유저를 제공한다.

USER node
 

이 한 줄만 추가해도,

  • 서버는 root가 아닌 권한으로 실행된다
  • 잠재적인 보안 리스크를 줄일 수 있다

다른 베이스 이미지를 쓰는 경우에도
유저를 직접 생성해 권한을 낮춰 실행하는 것이 권장된다.

 

 

 

이 모든 패턴은 공식 문서에 있다


지금까지 설명한 최적화 패턴은 특별한 비법이 아니다.

  • Node.js 공식 Docker 이미지 문서
  • Docker Best Practices 문서

이 안에 그대로 나와 있는 내용이다.

Dockerfile 최적화는 감각의 문제가 아니라, 문서를 읽고 그대로 적용하면 되는 영역이다.

 

 

 

 

 

Spring Boot Docker 이미지 최적화: Multi-Stage Build로 용량 줄이기


Node.js 서버에 이어, 이번에는 Spring Boot 프로젝트를 Docker 이미지로 만드는 방법을 살펴본다.
여기서 중요한 주제는 Spring 자체가 아니라 Docker 이미지 설계 방식, 그중에서도 Multi-Stage Build다.

 

 

Spring Boot 서버 실행 방식부터 정리

Spring Boot로 만든 웹 서버는 실행 방식이 매우 단순하다.

  1. Gradle로 빌드하여 .jar 파일을 만든다
  2. java -jar 명령으로 .jar 파일을 실행한다

즉, 서버 실행에 필요한 것은 최종적으로 생성된 .jar 파일 하나다.

보통은 다음과 같은 흐름이다.

./gradlew build java -jar build/libs/xxx.jar
 

이 단순한 구조 덕분에 Dockerfile 역시 비교적 쉽게 작성할 수 있다.

 

 

가장 단순한 Spring Boot Dockerfile


Spring Boot 서버를 그대로 Docker 이미지로 만들면 다음과 같은 Dockerfile이 된다.

FROM amazoncorretto:21.0.4
WORKDIR /app 
COPY . . 
RUN ./gradlew build 
CMD ["java", "-jar", "build/libs/xxx.jar"]
 

여기서 의미는 명확하다.

  • Java 21이 설치된 이미지 사용
  • 소스 코드 전체 복사
  • 컨테이너 안에서 Gradle 빌드
  • 생성된 .jar 실행

이 방식만으로도 서버 이미지는 정상적으로 만들어진다.


하지만 이 Dockerfile의 문제점

이 방식에는 치명적인 단점이 있는데, 이미지 안에 다음이 전부 포함된다는 것이다

  • 전체 소스 코드
  • Gradle 실행 파일
  • Gradle 캐시
  • 빌드에 사용된 각종 중간 산출물

하지만 실제 서버 실행에 필요한 것은 아래와 같다.

  • .jar 파일 하나
  • Java 런타임
  • 리눅스 OS

따라서 나머지는 전부 불필요한 짐이다.

이미지 용량은 커지고, 보안 관점에서도 굳이 노출할 필요 없는 파일들이 포함된다.

이 문제를 해결하는 방법이 바로 Multi-Stage Build다.

 

 

Multi-Stage Build란 무엇인가


Dockerfile에서는 FROM을 여러 번 사용할 수 있다.

중요한 특징은 다음과 같다.

  • FROM을 만날 때마다 완전히 새로운 스테이지가 시작된다
  • 이전 스테이지의 파일은 기본적으로 사라진다
  • 단, 명시적으로 요청하면 이전 스테이지의 파일을 가져올 수 있다

이 특성을 이용하면 다음과 같은 구조가 가능해진다.

  • 첫 번째 스테이지: 빌드만 담당
  • 두 번째 스테이지: 실행만 담당

즉, 빌드 결과물만 훔쳐서 가져오는 방식이다.

 

 

Spring Boot Multi-Stage Build 예제


아래는 Spring Boot 프로젝트에서 자주 사용하는 Multi-Stage Dockerfile 예제다.

# Build stage 
FROM amazoncorretto:21.0.4 AS build 
WORKDIR /app 
COPY . . 
RUN ./gradlew build 

# Runtime stage 
FROM amazoncorretto:21.0.4 AS runtime 
WORKDIR /app 
COPY --from=build /app/build/libs/*.jar /app/server.jar 
CMD ["java", "-jar", "/app/server.jar"]

 

단계별로 무슨 일이 일어나나?


1단계: 빌드 전용 스테이지

 
FROM amazoncorretto:21.0.4 AS build
  • 이 스테이지는 오직 .jar 파일을 만들기 위한 용도다
  • 전체 소스 코드와 Gradle을 사용해 빌드만 수행한다
  • 결과물은 /app/build/libs/*.jar

이 스테이지 자체는 최종 이미지에 포함되지 않는다.

 

 

2단계: 실행 전용 스테이지

 
FROM amazoncorretto:21.0.4 AS runtime
  • 완전히 새롭고 깨끗한 환경에서 시작한다
  • 이전 스테이지의 흔적은 전혀 없다

여기서 핵심이 되는 명령어가 이것이다.

 
COPY --from=build /app/build/libs/*.jar /app/server.jar

 

의미는 다음과 같다.

  • build 스테이지에서
  • 빌드 결과로 생성된 .jar 파일만
  • 현재 스테이지로 복사한다

말 그대로 필요한 결과물만 훔쳐오는 것이다.

 

 

최종 이미지에는 무엇만 남는가


Multi-Stage Build를 사용하면 최종 이미지에는 다음만 남는다.

  • 리눅스 OS
  • Java 21 런타임
  • 단 하나의 .jar 파일

즉,

  • 소스 코드 없음
  • Gradle 없음
  • 빌드 캐시 없음

이미지 용량은 크게 줄고, 불필요한 파일 노출도 사라진다.

 

 

왜 Multi-Stage Build가 중요한가


Multi-Stage Build의 장점은 명확하다.

  • 이미지 크기 감소
  • 빌드 도구 제거로 보안 향상
  • 실행 환경 단순화
  • 운영 환경에서 불필요한 파일 제거

그래서 빌드 과정이 필요한 프로젝트에서는 Multi-Stage Build가 사실상 표준처럼 사용된다.

Spring Boot, Next.js, React, Go, Rust 등 빌드 산출물이 명확한 프로젝트일수록 효과가 크다.