좋은 Dockerfile 작성하기: Spring Boot 애플리케이션을 위한 multi-stage build

1. 개요

서둘러서 작성한 Dockerfile은 보통 수백 MB 크기의 이미지를 만듭니다. 이 이미지에는 JDK, Maven, 소스 코드, .m2 디렉토리가 모두 포함됩니다. 코드 한 줄만 수정해도 Docker는 모든 의존성을 처음부터 다시 다운로드합니다.

이 글에서는 multi-stage build를 사용하여 build 단계와 실행 단계를 분리하는 방법을 안내합니다. 이 글을 읽은 후에는 작은 이미지 크기, 캐시로 인한 빠른 rebuild, 별도의 stage에서 테스트 실행, root가 아닌 사용자로 프로세스 실행이 가능한 Spring Boot 3.x (Java 21) 애플리케이션용 Dockerfile을 작성할 수 있게 됩니다.

2. 단일 stage Dockerfile의 문제점

주문 상태를 반환하는 엔드포인트가 있는 작은 애플리케이션을 사용해봅시다:

package com.nonla.orders;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@SpringBootApplication
public class OrderApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderApplication.class, args);
    }
}

@RestController
class OrderController {

    @GetMapping("/orders/{id}/status")
    String status(@PathVariable long id) {
        return id > 0 ? "CONFIRMED" : "UNKNOWN";
    }
}

pom.xml의 의존성은 web과 test만 필요합니다:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

“작동하는” Dockerfile을 작성하는 방법은 보통 다음과 같습니다:

FROM maven:3.9-eclipse-temurin-21
WORKDIR /app
COPY . .
RUN mvn package -DskipTests
CMD ["java", "-jar", "target/orders-0.0.1-SNAPSHOT.jar"]

이 Dockerfile에는 세 가지 문제가 있습니다:

  • 무거운 이미지: 최종 이미지에는 Maven, 전체 JDK, 소스 코드, .m2 캐시 전체가 포함됩니다.
  • 부실한 캐시: COPY . .가 mvn package 전에 위치합니다. .java 파일 하나만 수정해도 이 layer가 무효화되어 모든 의존성을 다시 다운로드하게 됩니다.
  • 큰 공격 표면: build 도구와 소스 코드가 production 컨테이너에 그대로 포함되어 있습니다.

3. 기본 multi-stage build

multi-stage build를 사용하면 하나의 Dockerfile에서 여러 FROM 명령을 가질 수 있습니다. 각 FROM은 새로운 stage를 시작합니다. 다음 stage는 COPY --from=<stage 이름>을 사용하여 이전 stage에서 파일을 복사할 수 있습니다. 마지막 stage만 최종 이미지가 되고, 중간 stage들은 버려집니다.

3.1. build stage와 runtime stage

# Stage 1: 빌드
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .
COPY src ./src
RUN mvn -B package -DskipTests

# Stage 2: 런타임
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /workspace/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

각 부분 설명:

  • AS build는 첫 번째 stage에 이름을 붙입니다. 이 이름은 COPY --from=build에서 사용됩니다.
  • build stage는 Maven과 JDK가 있는 이미지를 사용하여 컴파일하고 jar를 패키징합니다.
  • runtime stage는 eclipse-temurin:21-jre를 사용합니다. 이 이미지는 JRE만 있고 javac나 Maven은 없습니다.
  • COPY --from=build는 jar 파일만 최종 이미지로 복사합니다.
  • ENTRYPOINT는 exec 형식(JSON 배열)입니다. 덕분에 java가 PID 1로 실행되고 컨테이너가 중지될 때 SIGTERM 신호를 받습니다.

3.2. 결과 비교

빌드하고 크기를 확인합니다:

docker build -t orders:single -f Dockerfile.single .
docker build -t orders:multi .
docker images orders

예시 결과 (실제 수치는 이미지 버전과 의존성에 따라 다릅니다):

REPOSITORY   TAG      SIZE
orders       single   ~560MB
orders       multi    ~290MB

남은 용량의 대부분은 JRE와 기본 운영 체제입니다. eclipse-temurin:21-jre-alpine과 같은 더 작은 기본 이미지를 사용하여 추가로 크기를 줄일 수 있습니다. 다만 애플리케이션이 네이티브 라이브러리를 사용한다면 신중하게 확인하세요.

4. layer 캐시 활용

각 RUN, COPY 명령은 하나의 layer를 생성합니다. 명령과 입력이 변경되지 않으면 Docker는 layer를 재사용합니다. layer가 변경되면 그 이후의 모든 layer를 다시 빌드해야 합니다. 따라서 일반적인 원칙은 변경이 적은 것을 먼저, 변경이 자주 되는 것을 나중에 배치하는 것입니다.

4.1. 의존성 다운로드 단계 분리

pom.xml은 소스 코드보다 훨씬 적게 변경됩니다. 먼저 이를 복사하고 의존성을 별도의 layer에서 다운로드합니다:

FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .
RUN mvn -B dependency:go-offline
COPY src ./src
RUN mvn -B package -DskipTests

이제 OrderController.java만 수정할 때, Docker는 dependency:go-offline layer를 재사용합니다. 빌드 로그는 다음과 같이 표시됩니다:

 => CACHED [build 3/5] COPY pom.xml .
 => CACHED [build 4/5] RUN mvn -B dependency:go-offline
 => [build 5/5] COPY src ./src

주의: dependency:go-offline이 항상 package 단계에서 Maven이 필요한 100%의 플러그인을 다운로드하는 것은 아닙니다. 일부 플러그인은 나중에 다운로드될 수 있습니다. 그래도 분리하지 않는 것보다는 훨씬 좋습니다.

4.2. BuildKit을 사용한 캐시 mount

BuildKit(현재 Docker의 기본값)은 --mount=type=cache를 지원합니다. mount된 디렉토리는 빌드 간에 유지되지만 이미지에는 포함되지 않습니다:

# syntax=docker/dockerfile:1
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn -B package -DskipTests
  • # syntax=docker/dockerfile:1 줄은 새로운 Dockerfile 구문을 모두 활성화합니다.
  • /root/.m2는 builder 쪽에서 캐시됩니다. pom.xml이 변경되어도 Maven은 새로운 의존성만 다운로드하고 전체를 다시 다운로드하지 않습니다.

두 기법을 모두 결합할 수 있습니다. CI에서는 빌더가 실행 간에 유지되는 경우에만 캐시 mount가 효과적입니다. 그렇지 않으면 섹션 4.1의 layer 분리와 레지스트리 캐시를 조합하는 것이 더 신뢰할 수 있는 선택입니다.

5. Spring Boot jar의 layer 분리

Spring Boot의 fat jar에는 여러분의 코드와 수십 MB의 라이브러리가 포함됩니다. 전체 jar를 이미지로 복사하면 코드를 수정할 때마다 수십 MB의 새로운 layer가 생성됩니다. 레지스트리는 이 layer를 저장해야 하고 서버는 전체를 다시 가져와야 합니다.

Spring Boot는 jar를 변경 빈도에 따라 layer로 압축 해제하는 기능을 지원합니다. Spring Boot 3.3부터는 jarmode=tools를 사용할 수 있습니다:

# syntax=docker/dockerfile:1
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 mvn -B dependency:go-offline
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn -B package -DskipTests

FROM eclipse-temurin:21-jre AS extract
WORKDIR /extract
COPY --from=build /workspace/target/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --launcher --destination layers

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=extract /extract/layers/dependencies/ ./
COPY --from=extract /extract/layers/spring-boot-loader/ ./
COPY --from=extract /extract/layers/snapshot-dependencies/ ./
COPY --from=extract /extract/layers/application/ ./
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

주요 사항:

  • extract stage는 jar를 dependencies, spring-boot-loader, snapshot-dependencies, application의 네 가지 디렉토리로 압축 해제합니다.
  • 네 개의 COPY 명령은 변경이 가장 적은 것부터 가장 자주 변경되는 것까지 네 개의 layer를 생성합니다.
  • 코드를 수정할 때 application layer만 변경됩니다. 이 layer는 보통 수백 KB 정도입니다.
  • --launcher 옵션은 launcher를 유지합니다. 따라서 ENTRYPOINT는 -jar 대신 JarLauncher를 호출합니다.

주의: Spring Boot 3.0–3.2의 경우 이전 문법 java -Djarmode=layertools -jar app.jar extract를 사용합니다. launcher의 package도 버전에 따라 다릅니다: 3.2 이상은 org.springframework.boot.loader.launch.JarLauncher, 이전 버전은 org.springframework.boot.loader.JarLauncher입니다. 잘못된 클래스 이름을 사용하면 컨테이너 시작 시 ClassNotFoundException 오류가 발생합니다.

6. 별도의 stage에서 테스트 실행

build stage에 -DskipTests를 설정하면 빌드 속도가 빨라지지만, 테스트를 거치지 않은 이미지를 레지스트리에 푸시하고 싶지 않습니다. multi-stage build를 사용하면 테스트를 별도의 stage로 분리하고 --target을 사용하여 빌드할 stage를 선택할 수 있습니다.

controller에 대한 테스트는 JUnit 5와 AssertJ를 사용합니다(spring-boot-starter-test에 포함되어 있음):

package com.nonla.orders;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.web.servlet.MockMvc;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    MockMvc mockMvc;

    @Test
    void givenPositiveId_whenGetStatus_thenConfirmed() throws Exception {
        String body = mockMvc.perform(get("/orders/42/status"))
                .andReturn().getResponse().getContentAsString();

        assertThat(body).isEqualTo("CONFIRMED");
    }

    @Test
    void givenZeroId_whenGetStatus_thenUnknown() throws Exception {
        String body = mockMvc.perform(get("/orders/0/status"))
                .andReturn().getResponse().getContentAsString();

        assertThat(body).isEqualTo("UNKNOWN");
    }
}

build stage를 기반으로 하는 test stage를 추가합니다:

FROM build AS test
RUN --mount=type=cache,target=/root/.m2 mvn -B test

CI에서는 먼저 테스트를 실행한 다음 runtime 이미지를 빌드합니다:

docker build --target test -t orders:test .
docker build -t orders:1.0.0 .

첫 번째 명령은 test stage까지만 빌드합니다. 하나의 테스트가 실패하면 RUN 명령이 0이 아닌 종료 코드를 반환하고 빌드가 중지됩니다:

[ERROR] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0
ERROR: failed to solve: process "/bin/sh -c mvn -B test" did not complete successfully: exit code: 1

두 번째 명령은 최종 stage를 빌드합니다. BuildKit에서는 test stage가 최종 stage에서 참조되지 않으므로 건너뜁니다. production 이미지는 테스트를 다시 실행할 필요가 없습니다. 캐시 덕분에 공유된 stage도 다시 빌드되지 않습니다.

주의: Docker가 필요한 통합 테스트(예: Testcontainers)는 docker build 내에서 실행하면 안 됩니다. 빌드 환경에는 Docker daemon이 없습니다. 이러한 유형의 테스트는 CI의 외부 단계에서 실행하세요.

7. 보안: non-root 사용자 및 .dockerignore

7.1. root로 실행하지 않기

기본적으로 컨테이너의 프로세스는 root로 실행됩니다. 애플리케이션이 악용되면 공격자는 컨테이너 내에서 root 권한을 가집니다. runtime stage에서 별도의 사용자를 만드세요:

FROM eclipse-temurin:21-jre
RUN groupadd --system spring && useradd --system --gid spring spring
WORKDIR /app
COPY --from=extract --chown=spring:spring /extract/layers/dependencies/ ./
COPY --from=extract --chown=spring:spring /extract/layers/spring-boot-loader/ ./
COPY --from=extract --chown=spring:spring /extract/layers/snapshot-dependencies/ ./
COPY --from=extract --chown=spring:spring /extract/layers/application/ ./
USER spring:spring
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

빠른 확인:

docker run --rm --entrypoint id orders:1.0.0
uid=999(spring) gid=999(spring) groups=999(spring)

구체적인 uid/gid는 다를 수 있지만 0이 아니면 됩니다. Alpine 이미지의 경우 groupadd와 useradd 대신 addgroup -S spring && adduser -S spring -G spring을 사용합니다.

7.2. .dockerignore 추가

build context는 docker build에 전달하는 전체 디렉토리입니다. 필터링하지 않으면 Docker는 target/, .git/, IDE 구성 파일 등을 모두 builder로 전송합니다. 이는 빌드를 느리게 하고 캐시를 무효화하며 민감한 정보를 노출할 수 있습니다.

target/
.git/
.idea/
*.iml
.vscode/
.env
Dockerfile*

이 파일을 프로젝트 루트 디렉토리에 Dockerfile과 함께 배치합니다.

8. 흔한 실수들

  • 너무 빨리 COPY . .: 의존성 캐시를 무효화합니다. pom.xml을 먼저 복사하고 src를 나중에 복사하세요.
  • runtime stage에서 JDK 사용: 작동하지만 수십에서 수백 MB가 낭비됩니다. production에는 jcmd와 같은 진단 도구가 필요하지 않은 한 JRE만 필요합니다.
  • Wildcard가 여러 jar와 일치: target/에 *-plain.jar 또는 *-sources.jar가 있으면 COPY --from=build /workspace/target/*.jar app.jar이 실패합니다. pom.xml에서 <finalName>을 고정하거나 보조 artifact를 비활성화하세요.
  • ENTRYPOINT의 Shell 형식: ENTRYPOINT java -jar app.jar는 /bin/sh -c를 통해 실행됩니다. 이 경우 java가 SIGTERM을 받지 못하므로 Spring은 graceful shutdown을 하지 못하고 컨테이너가 대기 시간 후에 강제 종료됩니다.
  • 기본 이미지의 latest 태그: 오늘의 빌드와 한 달 후의 빌드가 달라질 수 있습니다. 버전을 고정하고(eclipse-temurin:21-jre) digest를 고정하세요 (완전히 재현 가능하게 하려면).
  • ARG/ENV에 secret 배치: 값이 docker history를 통해 노출될 수 있습니다. private 의존성을 다운로드하기 위해 토큰이 필요할 때는 RUN --mount=type=secret을 사용하세요.

언제 복잡할 필요가 없을까요? 작은 스크립트나 개인 dev 환경용 이미지의 경우 하나의 stage로 충분합니다. 또한 팀에서 이미 mvn spring-boot:build-image(Cloud Native Buildpacks) 또는 Jib을 사용하고 있다면 이러한 도구가 layer 분리와 non-root를 자동으로 처리합니다. 더 이상 Dockerfile을 직접 작성할 필요가 없습니다.

9. 결론

multi-stage build는 “build 환경”과 “실행 환경”을 명확하게 분리합니다. 최종 이미지는 애플리케이션이 실제로 필요한 것만 포함합니다. 합리적인 layer 순서, 캐시 mount, Spring Boot layer 분리, 별도의 test stage, non-root 사용자와 결합하면 빌드가 빠르고 이미지가 작으며 훨씬 더 안전한 Dockerfile을 얻게 됩니다.

섹션 3의 Dockerfile부터 시작할 수 있습니다. 그 다음 각 개선 사항을 점진적으로 적용하고 각 단계 후에 빌드 시간과 이미지 크기를 다시 측정하여 효과를 명확히 보세요.

dockerdockerfilemulti-stage buildspring bootjava