Spring Boot Docker 이미지, 한 줄 한 줄에 담긴 고민
처음 Spring Boot 애플리케이션을 Docker로 배포했을 때, Dockerfile은 딱 세 줄이었다.
FROM openjdk:17
COPY build/libs/app.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
동작은 했다. 하지만 이미지 크기는 700MB를 넘겼고, 코드 한 줄 고칠 때마다 전체 JAR를 다시 빌드해야 했다. 프로덕션에 올릴 때는 root 권한으로 실행되고 있었다. "동작한다"와 "잘 동작한다"는 다르다는 걸 깨닫는 데 오래 걸리지 않았다.
이 글은 내가 Spring Boot Dockerfile을 다듬어가며 했던 고민의 기록이다. 한 줄 한 줄에 "왜?"라는 질문을 던지고, 그 답을 찾아가는 과정을 공유한다.
최종 Dockerfile
먼저 완성된 Dockerfile을 보자. 이후 섹션에서 각 부분의 고민을 하나씩 풀어간다.
ARG JAR_FILE=application-api/build/libs/application-api-*-SNAPSHOT.jar
FROM eclipse-temurin:24-jre AS extractor
ARG JAR_FILE
WORKDIR /extractor
COPY ${JAR_FILE} app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --launcher --destination extracted
FROM eclipse-temurin:24-jre
WORKDIR /app
ENV TZ=UTC
RUN groupadd -r appgroup && useradd -r -g appgroup appuser && chown -R appuser:appgroup /app
COPY --from=extractor --chown=appuser:appgroup /extractor/extracted/dependencies/ ./
COPY --from=extractor --chown=appuser:appgroup /extractor/extracted/spring-boot-loader/ ./
COPY --from=extractor --chown=appuser:appgroup /extractor/extracted/snapshot-dependencies/ ./
COPY --from=extractor --chown=appuser:appgroup /extractor/extracted/application/ ./
USER appuser
EXPOSE 8080
ENTRYPOINT ["java", \
"-XX:+UseContainerSupport", \
"-XX:MaxRAMPercentage=75.0", \
"-Djava.security.egd=file:/dev/./urandom", \
"org.springframework.boot.loader.launch.JarLauncher"]
1. 멀티 스테이지 빌드 — 빌드와 실행을 분리하다
가장 먼저 눈에 들어오는 건 FROM이 두 번 등장한다는 점이다. 이것이 멀티 스테이지 빌드다.
첫 번째 스테이지(extractor)에서는 JAR 파일을 레이어별로 추출한다. 두 번째 스테이지에서는 추출된 결과물만 복사해서 최종 이미지를 만든다.
왜 이렇게 나눌까? 최종 이미지에 불필요한 것을 남기지 않기 위해서다.
만약 빌드 도구(Gradle, Maven)까지 포함된 단일 스테이지를 쓴다면, 빌드에만 필요한 도구들이 프로덕션 이미지에 고스란히 남는다. 이미지 크기가 커지는 건 물론이고, 공격자가 컨테이너에 침입했을 때 활용할 수 있는 도구가 늘어난다.
이 Dockerfile에서는 빌드 자체는 CI 환경에서 이미 완료되었다고 가정하고, JAR 파일의 추출과 실행만 컨테이너 안에서 처리한다. 빌드와 실행의 관심사를 깔끔하게 분리하는 것이다.
2. 왜 JRE인가 — JDK를 프로덕션에서 쓰지 않는 이유
FROM eclipse-temurin:24-jre AS extractor
베이스 이미지로 eclipse-temurin:24-jre를 선택했다. JDK가 아니라 JRE다.
JDK(Java Development Kit)에는 컴파일러(javac), 디버거, 프로파일러 등 개발 도구가 포함되어 있다. 프로덕션에서 이것들이 필요할까? 필요 없다. 애플리케이션을 실행만 하면 된다.
크기 차이도 무시할 수 없다. 같은 버전 기준으로 JDK 이미지는 JRE 이미지보다 2~3배 이상 크다. 컴파일러, 헤더 파일, 개발 도구 등이 모두 포함되기 때문이다.
하지만 크기보다 중요한 건 보안이다. JDK에 포함된 javac, jdb 같은 도구는 공격자에게 유용한 무기가 될 수 있다. 컨테이너가 침해되었을 때, 개발 도구가 없는 환경은 공격자의 행동 반경을 크게 제한한다.
Eclipse Temurin을 선택한 이유는 Adoptium 프로젝트에서 관리하는 검증된 OpenJDK 빌드이기 때문이다. LTS 지원, 정기적인 보안 패치, Docker Hub 공식 이미지 지원까지 갖추고 있어 프로덕션 환경에서 신뢰할 수 있다.
3. Spring Boot 레이어 추출 — Docker 캐시를 이해하면 보이는 것
RUN java -Djarmode=tools -jar app.jar extract --layers --launcher --destination extracted
이 한 줄이 이 Dockerfile의 핵심이다. Spring Boot의 레이어드 JAR 기능을 활용해 fat JAR를 4개 레이어로 분해한다.
일반적인 Spring Boot fat JAR는 모든 것이 하나의 파일에 담겨 있다. 의존성 라이브러리, Spring Boot 로더, 내 애플리케이션 코드까지. 그래서 코드 한 줄만 바꿔도 수십 MB짜리 JAR 전체를 다시 Docker 레이어에 올려야 한다.
Docker는 레이어 기반으로 동작한다. 변경되지 않은 레이어는 캐시에서 재사용한다. 이 원리를 활용하면, 자주 바뀌는 것과 거의 바뀌지 않는 것을 분리해서 빌드 시간을 극적으로 줄일 수 있다.
--layers 옵션으로 추출하면 4개의 디렉토리가 생긴다:
| 레이어 | 내용 | 변경 빈도 |
dependencies | 외부 라이브러리 (Spring, Jackson 등) | 거의 안 바뀜 |
spring-boot-loader | Spring Boot 로더 클래스 | Spring Boot 버전 업그레이드 시만 |
snapshot-dependencies | SNAPSHOT 버전 의존성 | 개발 중 가끔 |
application | 내 애플리케이션 코드 | 매번 |
--launcher 옵션은 Spring Boot 로더를 포함시켜서, 최종 이미지에서 java -jar 대신 org.springframework.boot.loader.launch.JarLauncher로 기동할 수 있게 한다. 이렇게 하면 Spring Boot의 클래스 로딩 최적화를 그대로 활용할 수 있다.
4. COPY 순서의 비밀 — 변경 빈도가 낮은 것부터
COPY --from=extractor --chown=appuser:appgroup /extractor/extracted/dependencies/ ./
COPY --from=extractor --chown=appuser:appgroup /extractor/extracted/spring-boot-loader/ ./
COPY --from=extractor --chown=appuser:appgroup /extractor/extracted/snapshot-dependencies/ ./
COPY --from=extractor --chown=appuser:appgroup /extractor/extracted/application/ ./
4개의 COPY가 특정한 순서로 나열되어 있다. 이 순서는 의도적이다.
Docker는 Dockerfile을 위에서 아래로 실행하면서 각 명령어의 결과를 레이어로 캐싱한다. 그런데 어떤 레이어가 변경되면, 그 아래의 모든 레이어도 무효화된다. 이것이 Docker의 레이어 캐시 무효화 규칙이다.
이 규칙을 이해하면 순서가 왜 중요한지 명확해진다:
dependencies— 거의 바뀌지 않는다. 맨 위에 놓으면 거의 항상 캐시된다.spring-boot-loader— Spring Boot 버전을 올릴 때만 바뀐다.snapshot-dependencies— 개발 중에 가끔 바뀐다.application— 매 배포마다 바뀐다. 맨 아래에 놓는다.
결과적으로, 일상적인 코드 변경에서는 마지막 application 레이어만 다시 빌드된다. 나머지 세 레이어는 캐시에서 가져온다. 이것이 빌드 시간을 단축시키고, 레지스트리에 푸시할 때도 변경된 레이어만 전송하므로 네트워크 비용도 줄어든다.
5. 눈으로 확인하는 차이 — 이미지 레이어 비교
이론은 충분하다. 실제로 어떤 차이가 나는지 확인해보자.
실제 운영 중인 Spring Boot 애플리케이션(멀티 모듈, 의존성 라이브러리 40여 개 규모)으로 두 방식의 Docker 이미지를 빌드하고 docker history로 레이어를 비교했다.
Fat JAR 방식의 레이어 구조
docker history 명령어로 이미지의 레이어 구조를 확인할 수 있다. 초기 Dockerfile로 만든 이미지를 보면:
$ docker history hanpyo:fat-jar
SIZE CREATED BY
89.3MB COPY app.jar . ← 전체 JAR, 단일 레이어
0B WORKDIR /app
274MB eclipse-temurin:24-jre 베이스 이미지
전체 JAR가 하나의 레이어(89.3MB)로 들어간다. 구조는 단순하지만, 코드 한 줄만 바꿔도 이 89.3MB 레이어 전체가 무효화된다.
Layered JAR 방식의 레이어 구조
동일한 애플리케이션을 레이어 추출 방식으로 빌드하면:
$ docker history hanpyo:layered
SIZE CREATED BY
1.7MB COPY .../application/ . ← 내 코드
4.1KB COPY .../snapshot-dependencies/ . ← SNAPSHOT
692KB COPY .../spring-boot-loader/ . ← 로더
88MB COPY .../dependencies/ . ← 외부 라이브러리
45.1KB RUN groupadd && useradd ...
0B WORKDIR /app
274MB eclipse-temurin:24-jre 베이스 이미지
fat JAR 안에 있던 89.3MB가 4개 레이어로 분해되었다. 핵심은 비율이다:
| 레이어 | 크기 | 전체 대비 비율 |
| dependencies | 88MB | 97.4% |
| spring-boot-loader | 692KB | 0.8% |
| snapshot-dependencies | 4.1KB | ~0% |
| application | 1.7MB | 1.9% |
전체의 97.4%를 차지하는 dependencies는 거의 바뀌지 않고, 매번 바뀌는 application은 전체의 1.9%에 불과하다.
직접 확인해보자.
docker history <이미지명>명령어로 자신의 이미지 레이어를 살펴보면, 대부분의 Spring Boot 애플리케이션에서 dependencies가 95% 이상을 차지하는 것을 확인할 수 있다.
코드 변경 시 재빌드 비교
실제로 코드를 한 줄 수정하고 다시 docker build를 실행했다. 두 방식의 차이를 시각화하면:
[Fat JAR — 코드 변경 후 재빌드]
┌──────────────────────────────┐
│ eclipse-temurin:24-jre │ 274MB 캐시 ──── 재사용
├──────────────────────────────┤
│ app.jar │ 89.3MB 재빌드 ── 전체 전송 ★
└──────────────────────────────┘
전송량: 89.3MB
[Layered JAR — 코드 변경 후 재빌드]
┌──────────────────────────────┐
│ eclipse-temurin:24-jre │ 274MB 캐시 ──── 재사용
├──────────────────────────────┤
│ dependencies │ 88MB 캐시 ──── 재사용
├──────────────────────────────┤
│ spring-boot-loader │ 692KB 캐시 ──── 재사용
├──────────────────────────────┤
│ snapshot-dependencies │ 4.1KB 캐시 ──── 재사용
├──────────────────────────────┤
│ application │ 1.7MB 재빌드 ── 변경분만 ★
└──────────────────────────────┘
전송량: 1.7MB
재빌드 시 전송량: 89.3MB → 1.7MB. 약 98% 감소.
빌드 + 푸시 벤치마크
말로만 하면 설득력이 없다. 코드를 한 줄씩 바꿔가며 10회 반복 빌드 + 레지스트리 푸시를 실측했다.
측정 환경: 로컬 Docker 레지스트리 (registry:2), 10회 반복, 코드 변경 후 재빌드
| 항목 | Fat JAR | Layered JAR | 개선 |
| 빌드 시간 (평균) | 3.34초 | 2.15초 | 35.8% |
| 푸시 시간 (평균) | 0.99초 | 0.79초 | 19.9% |
| 빌드+푸시 합계 (평균) | 4.33초 | 2.94초 | 32.2% |
| 푸시 시간 표준편차 | 0.121초 | 0.036초 | — |
주목할 점은 푸시 시간의 표준편차다. Fat JAR은 0.121초, Layered JAR은 0.036초. Layered 방식은 변경된 application 레이어(1.7MB)만 매번 동일하게 전송하므로 일관성이 높다. Fat JAR은 89.3MB 전체를 매번 전송하므로 I/O 상황에 따라 편차가 크다.
이 벤치마크는 로컬 레지스트리에서 측정한 것이라 네트워크 지연이 거의 없다. 실제 원격 레지스트리(Docker Hub, GHCR 등)에서는 차이가 훨씬 극적이다. 네트워크 대역폭 100Mbps 기준으로 추산하면:
- Fat JAR 푸시: 89.3MB ÷ 12.5MB/s ≈ ~7.1초
- Layered JAR 푸시: 1.7MB ÷ 12.5MB/s ≈ ~0.14초
CI/CD 파이프라인에서의 체감
Docker 이미지를 빌드하고 레지스트리에 푸시하는 CI/CD 파이프라인에서, 레이어 캐싱의 효과는 극적이다. 변경된 레이어만 I/O가 발생하기 때문이다.
| 시나리오 | Fat JAR 전송량 | Layered JAR 전송량 | 절감률 |
| 코드만 변경 | 89.3MB | 1.7MB | 98% |
| SNAPSHOT 의존성 추가 | 89.3MB | ~2MB | ~97% |
| 외부 의존성 변경 | 89.3MB | ~90MB | 동일 |
일상적인 개발에서 가장 빈번한 시나리오는 코드만 변경하는 경우다. 하루에 10번 배포하는 팀이라면:
- Fat JAR 방식: 89.3MB × 10 = 일일 893MB 전송
- Layered JAR 방식: 1.7MB × 10 = 일일 17MB 전송
- 한 달(20 영업일) 기준: 약 17.9GB → 340MB
실측에서도 로컬 환경 기준 빌드+푸시가 4.33초 → 2.94초로 32% 단축되었다. 원격 레지스트리 환경에서는 네트워크 전송 비중이 커지면서 이 차이가 수 배로 벌어진다.
레이어 캐싱의 핵심은 간단하다. 변하지 않는 것은 다시 보내지 않는다. 코드만 바꿨다면, 코드만 보내면 된다.
6. 컨테이너 안의 JVM — 메모리를 제대로 인식시키기
ENTRYPOINT ["java", \
"-XX:+UseContainerSupport", \
"-XX:MaxRAMPercentage=75.0", \
...
JVM은 원래 베어메탈 서버에서 태어났다. 호스트의 전체 CPU와 메모리를 자기 것으로 인식하는 게 기본 동작이다. 그런데 컨테이너 환경에서는 이것이 문제가 된다.
Docker는 Linux의 cgroup을 통해 컨테이너에 메모리 제한을 건다. 예를 들어, --memory=1g로 1GB를 할당했다고 하자. UseContainerSupport가 없는 구버전 JVM은 호스트의 전체 메모리(예: 16GB)를 보고 힙 크기를 계산한다. 그러면 할당된 1GB를 넘어서 메모리를 사용하려 하고, Docker는 이 컨테이너를 OOM Kill한다.
-XX:+UseContainerSupport는 JVM이 cgroup의 메모리/CPU 제한을 인식하도록 한다. Java 10에서 도입되어 기본 활성화되었고, Java 8u191에도 백포트되었다. 즉, 현대 Java에서는 이미 기본값이다. 그런데도 나는 이것을 명시적으로 선언한다. 코드가 의도를 드러내야 하듯, 설정도 의도를 드러내야 한다고 생각하기 때문이다.
-XX:MaxRAMPercentage=75.0은 컨테이너에 할당된 메모리의 75%를 JVM 힙으로 사용하라는 의미다. 왜 100%가 아닐까?
JVM은 힙만 쓰지 않는다. 메타스페이스, 스레드 스택, GC 오버헤드, 네이티브 메모리, 소켓 버퍼 등이 힙 바깥에서 메모리를 사용한다. 75%로 힙을 제한하고 나머지 25%를 이런 비힙 영역에 남겨두는 것이다.
경험적으로 70~80%가 적절한 범위다. 너무 높이면 비힙 영역이 부족해 OOM이 발생하고, 너무 낮추면 힙이 부족해 GC 빈도가 올라간다.
7. Non-root 실행 — 최소 권한의 원칙
RUN groupadd -r appgroup && useradd -r -g appgroup appuser && chown -R appuser:appgroup /app
...
USER appuser
Docker 컨테이너의 기본 실행 사용자는 root다. 컨테이너 안의 root가 호스트의 root와 동일한 건 아니지만, 그래도 위험하다.
컨테이너 런타임의 취약점이 발견되면, root로 실행 중인 프로세스는 컨테이너 탈출(container escape) 시 호스트에 더 큰 영향을 줄 수 있다. 이것은 이론적인 위협이 아니라, 실제로 CVE가 보고된 바 있는 공격 벡터다.
최소 권한의 원칙(Principle of Least Privilege)을 따라, 애플리케이션이 필요로 하는 최소한의 권한만 부여한다:
groupadd -r appgroup— 시스템 그룹 생성 (-r은 시스템 계정을 의미)useradd -r -g appgroup appuser— 시스템 사용자 생성, 홈 디렉토리 없이 최소 구성chown -R appuser:appgroup /app— 작업 디렉토리 소유권 부여USER appuser— 이후 모든 명령어를 이 사용자로 실행
주의할 점은 USER 지시어의 위치다. RUN으로 패키지 설치나 사용자 생성 같은 root 권한이 필요한 작업을 모두 먼저 수행하고, 그 이후에 USER appuser로 전환한다. COPY에서는 --chown=appuser:appgroup으로 파일 소유권을 함께 지정하여, 별도의 RUN chown 없이도 올바른 권한을 설정한다.
8. /dev/./urandom — 아직도 필요한가?
"-Djava.security.egd=file:/dev/./urandom"
이 설정은 Java의 SecureRandom이 난수를 생성할 때 사용하는 엔트로피 소스를 지정한다.
Linux에는 두 가지 난수 소스가 있다:
/dev/random— 충분한 엔트로피가 모일 때까지 블로킹된다/dev/urandom— 블로킹 없이 유사 난수를 생성한다
컨테이너 환경은 물리적 입력 장치가 없어 엔트로피 수집이 느리다. 초기 Java 버전에서는 SecureRandom이 /dev/random을 사용해서, 애플리케이션 시작 시 수십 초간 멈추는 문제가 있었다. 특히 TLS 핸드셰이크나 세션 ID 생성 같은 곳에서 SecureRandom이 호출되므로, 이 지연은 실질적인 영향을 미쳤다.
그런데 왜 /dev/urandom이 아니라 /dev/./urandom일까? 경로에 ./가 끼어 있는 이유가 있다.
JDK 8 이전에는 SeedGenerator 클래스의 초기화 과정에서 문제가 있었다. securerandom.source 속성값이 file:/dev/urandom과 정확히 일치하면, 내부적으로 항상 /dev/random에서 읽는 NativeSeedGenerator를 사용했다. 즉, /dev/urandom을 지정해도 실제로는 /dev/random이 사용되는 문자열 매칭 기반의 분기 로직 문제였다. file:/dev/./urandom은 이 exact string matching을 피하면서도, OS 레벨에서는 같은 /dev/urandom 장치를 가리키는 워크어라운드였다.
JDK 8에서 이 문제는 수정되었다. 그리고 현대 Java의 기본 구현인 NativePRNG는 난수 생성(nextBytes())에는 /dev/urandom을, 시드 생성(generateSeed())에는 /dev/random을 사용한다. 즉, 용도에 따라 적절한 소스를 자동으로 선택한다. 그렇다면 이 설정은 불필요한 것인가?
솔직히 말하면, JDK 24를 사용하는 이 Dockerfile에서 이 설정은 기술적으로 불필요하다. 하지만 나는 이것을 방어적 설정으로 남겨두었다. 다양한 환경에서 실행될 가능성, 베이스 이미지의 java.security 설정이 변경될 가능성을 고려한 것이다. 해가 되지 않는 설정이라면, 한 줄의 보험으로 남겨두는 편이 나의 성향이다.
다만, 이 선택에 대해서는 의견이 갈릴 수 있다. 불필요한 설정은 제거하는 것이 깔끔하다는 주장도 충분히 합리적이다.
마무리
Dockerfile은 단순한 빌드 스크립트가 아니다. 그 안에는 보안, 성능, 운영 효율성에 대한 수많은 결정이 녹아 있다.
이 글에서 다룬 각 설정의 의미를 정리하면:
- 멀티 스테이지 빌드 → 불필요한 도구를 프로덕션에서 제거
- JRE 사용 → 공격 표면 축소 + 이미지 경량화
- 레이어 추출 → Docker 캐시 최적화로 빌드/배포 속도 향상
- COPY 순서 → 변경 빈도 기반 레이어 배치
- 레이어 비교 → 코드 변경 시 전송량 89.3MB에서 1.7MB로 98% 감소
- UseContainerSupport + MaxRAMPercentage → 컨테이너 환경에서의 안정적 메모리 관리
- Non-root 실행 → 최소 권한 원칙으로 보안 강화
- /dev/./urandom → 방어적 설정으로 엔트로피 블로킹 방지
Dockerfile을 작성할 때 "동작하는 것"에서 멈추지 말고, 한 줄 한 줄 "왜?"를 물어보자. 그 질문들이 모여 프로덕션에서 견고하게 살아남는 이미지를 만든다.
참고 자료
- Dockerfiles :: Spring Boot — Spring Boot 공식 Docker 가이드
- 9 Tips for Containerizing Your Spring Boot Code | Docker — Docker 공식 블로그의 Spring Boot 컨테이너화 팁
- What Does the UseContainerSupport VM Parameter Do in Docker? — UseContainerSupport 파라미터 상세 설명
- Best Practices: Java Memory Arguments for Containers — 컨테이너 환경 JVM 메모리 설정 가이드
- Understanding the Docker USER Instruction | Docker — Docker USER 지시어와 보안
- Reusing Docker Layers with Spring Boot | Baeldung — Spring Boot Docker 레이어 재사용

