Part 1. Docker 기본 과정

이미지 생성 및 관리

학습 목표

  • Dockerfile의 주요 명령어(인스트럭션)를 이해하고 직접 작성할 수 있다.

  • 멀티 스테이지 빌드로 경량 이미지를 만들 수 있다.

  • .dockerignore로 불필요한 파일을 빌드 컨텍스트에서 제외할 수 있다.

  • docker image history로 레이어 구조를 확인할 수 있다.

  • 레이어 캐시 동작 방식을 이해하고 빌드를 최적화할 수 있다.

  • 이미지를 태깅하고 Docker Hub에 푸시(push)할 수 있다.

Docker 이미지는 컨테이너를 실행하기 위한 읽기 전용 템플릿이다. 이미지는 주로 Dockerfile을 작성하여 빌드하며, 빌드된 이미지는 Docker Hub 같은 레지스트리를 통해 공유할 수 있다.

1. Dockerfile 작성

1-1. Dockerfile 주요 인스트럭션

인스트럭션

설명

예시

FROM

베이스 이미지 지정 (필수, 사실상 첫 인스트럭션)

FROM ubuntu:24.04

RUN

이미지 빌드 시 실행할 명령어

RUN apt-get update && apt-get install -y curl

COPY

호스트 파일을 이미지에 복사

COPY . /app

ADD

COPY와 유사, 로컬 아카이브 압축 해제·URL 다운로드 기능 추가

ADD app.tar.gz /app

WORKDIR

작업 디렉토리 설정

WORKDIR /app

ENV

환경 변수 설정 (빌드 + 실행 시 모두 사용)

ENV PORT=8080

ARG

빌드 시점에만 사용하는 변수 (docker build --build-arg로 전달)

ARG VERSION=1.0

EXPOSE

컨테이너가 사용할 포트 선언 (문서 목적, 실제 개방 아님)

EXPOSE 8080

CMD

컨테이너 실행 시 기본 명령어 (덮어쓰기 가능)

CMD ["python3", "app.py"]

ENTRYPOINT

컨테이너 실행 시 항상 실행되는 명령어

ENTRYPOINT ["java", "-jar"]

CMD vs ENTRYPOINT

CMD는 컨테이너가 실행할 기본 명령어를 지정한다. docker run 뒤에 다른 명령어를 붙이면 CMD 전체가 교체된다.

ENTRYPOINT는 "무조건 실행할 프로그램"을 고정한다. docker run 뒤에 무엇을 붙여도 교체되지 않고, 붙인 내용이 ENTRYPOINT의 인자(argument)로 전달된다. Dockerfile에 여러 번 써도 마지막 것 하나만 유효하다.

둘을 함께 쓰면 ENTRYPOINT가 프로그램, CMD가 그 프로그램의 기본 인자 역할을 한다.

# ENTRYPOINT ["java", "-jar"], CMD ["app.jar"] 인 경우
docker run myimage              # java -jar app.jar    ← 프로그램 + 기본 인자
docker run myimage other.jar    # java -jar other.jar  ← 프로그램 유지, 인자만 교체

# CMD ["python3", "app.py"] 만 있는 경우
docker run myimage              # python3 app.py  ← CMD 그대로 실행
docker run myimage bash         # bash            ← CMD 전체가 bash로 교체됨

ENV vs ARG: ENV는 빌드 시점과 컨테이너 실행 시점 모두 사용 가능하지만, ARG는 빌드 시점에만 존재한다. 비밀값은 ARG로도 전달하지 말고 Secrets를 사용한다.

각 인스트럭션의 전체 문법과 추가 예시(VOLUME, USER 등)는 Dockerfile 인스트럭션 레퍼런스에 정리되어 있다.

1-2. Dockerfile 작성 예시 — Python 애플리케이션

# 1. 베이스 이미지 지정 — Python 이 이미 들어 있는 공식 이미지
FROM python:3.13-slim

# 2. 환경 변수 설정
ENV APP_HOME=/app \
    PYTHONUNBUFFERED=1 \
    PIP_NO_CACHE_DIR=1

# 3. 작업 디렉토리 설정
WORKDIR $APP_HOME

# 4. 의존성 파일 먼저 복사 (레이어 캐시 최적화)
COPY requirements.txt .
RUN pip install -r requirements.txt

# 5. 애플리케이션 소스 복사
COPY . $APP_HOME

# 6. 포트 선언
EXPOSE 8080

# 7. 컨테이너 시작 명령어
CMD ["python", "app.py"]

FROM ubuntu 에 apt-get install python3 python3-pip 로 까는 방식은 쓰지 않는다. 오래된 자료에 흔히 나오지만 지금은 두 가지 문제가 있다.

  • Ubuntu 24.04 이상에서는 pip install 이 아예 막힌다. PEP 668 에 따라 OS 가 관리하는 파이썬 환경을 보호하기 때문에 error: externally-managed-environment 가 난다. 가상환경을 만들거나 --break-system-packages 를 붙여야 하는데, 컨테이너에서 그런 우회를 할 이유가 없다.

  • 파이썬 버전을 내가 고를 수 없다. 배포판이 주는 버전을 그대로 받게 된다. 공식 python:3.13-slim 은 태그로 버전을 고정한다.

apt-get 을 꼭 써야 한다면 캐시를 지우는 것까지 한 RUN 에 넣는다. apt-get clean 만으로는 부족하고 rm -rf /var/lib/apt/lists/* 가 있어야 목록 파일까지 사라진다.

RUN apt-get update && \
    apt-get install -y --no-install-recommends libpq5 && \
    rm -rf /var/lib/apt/lists/*

1-3. .dockerignore — 빌드 컨텍스트 정리

docker build 실행 시 지정한 경로가 빌드 컨텍스트가 되어 빌더로 전송된다. .dockerignore 파일로 불필요한 파일을 제외하면 빌드 속도가 빨라지고, 민감한 파일이 이미지에 포함되는 것도 막을 수 있다.

현재 기본 빌더인 BuildKit 은 COPY 가 실제로 참조하는 파일만 전송한다. 그래서 COPY app.txt . 처럼 파일을 콕 집어 복사하면 .dockerignore 가 없어도 큰 파일이 전송되지 않는다. 반대로 이 교안의 예시처럼 COPY . . 로 컨텍스트 전체를 참조하면 제외 목록이 없는 한 전부 전송되므로 .dockerignore 효과가 크다.

# .dockerignore
.git
.gradle
build/
*.log
.env
secrets/
README.md

.dockerignore가 없으면 node_modules/, build/, .git/ 같은 대용량 디렉토리가 모두 빌드 컨텍스트에 포함되어 빌드가 느려진다. 특히 .env나 secrets/ 같은 민감한 파일이 실수로 이미지에 포함될 수 있으므로 항상 작성하는 것을 습관화한다.

2. 멀티 스테이지 빌드

멀티 스테이지 빌드는 Dockerfile에 FROM을 2번 이상 사용하는 것이다. FROM이 나올 때마다 새로운 단계(stage)가 시작되고, 최종 이미지는 마지막 FROM 단계만 남는다. 앞 단계는 빌드 결과물을 만들기 위한 임시 환경으로, 빌드가 끝나면 버려진다. 이를 통해 빌드 환경과 실행 환경을 분리하여 최종 이미지 크기를 크게 줄일 수 있다. 빌드에 필요한 JDK, Gradle, Node.js 등을 최종 이미지에 포함하지 않아도 된다.

2-1. 일반 빌드 vs 멀티 스테이지 빌드

# ❌ 일반 빌드 — JDK + Gradle + 빌드 캐시까지 모두 포함
FROM gradle:8.14-jdk21
WORKDIR /app
COPY . .
RUN gradle bootJar --no-daemon
CMD ["java", "-jar", "build/libs/app.jar"]
# 결과 이미지 크기: 약 1.2GB
# ✅ 멀티 스테이지 빌드 — 실행에 필요한 JAR만 복사
# ── 1단계: 빌드 환경 ──────────────────────────────────────
FROM gradle:8.14-jdk21 AS builder
WORKDIR /build
COPY . .
RUN gradle bootJar --no-daemon

# ── 2단계: 실행 환경 (JRE만 포함한 경량 이미지) ──────────
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=builder /build/build/libs/*.jar app.jar
EXPOSE 8080
CMD ["java", "-jar", "app.jar"]
# 결과 이미지 크기: 약 450MB

항목

일반 빌드

멀티 스테이지 빌드

최종 이미지 포함 내용

JDK + Gradle + 소스 + JAR

JAR만

이미지 크기

약 1.2GB

약 450MB

보안

빌드 도구 노출

빌드 도구 미포함

빌드 속도

동일

동일 (빌드 단계는 같음)

실행 단계 베이스가 21-jdk 가 아니라 21-jre 인 이유. 컴파일은 1단계의 gradle:8.14-jdk21 안에서 이미 끝났다. 최종 이미지에 필요한 것은 JAR 를 실행할 JVM 뿐이고, 컴파일러가 남아 있을 이유가 없다.

eclipse-temurin:21-jdk    726MB
eclipse-temurin:21-jre    459MB

21-jdk 를 그대로 쓰면 쓰지도 않을 컴파일러 때문에 이미지가 약 270 MB 커진다. 배포할 때마다 그만큼을 더 내려받게 된다.

AS builder 이름은 자유롭게 지정할 수 있다. COPY --from=builder에서 해당 단계의 파일을 참조한다. 단계가 3개 이상인 경우에도 --from=단계명으로 원하는 단계의 파일을 가져올 수 있다.

2-2. Node.js 멀티 스테이지 빌드 예시

# ── 1단계: 빌드 ────────────────────────────────────────────
FROM node:22 AS builder
WORKDIR /app
COPY package*.json .
RUN npm ci
COPY . .
RUN npm run build   # dist/ 디렉토리 생성

# ── 2단계: Nginx로 정적 파일 서빙 ──────────────────────────
FROM nginx:1.30
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80

npm ci 는 package-lock.json 이 없으면 실패한다. 프로젝트에 package.json 과 package-lock.json 이 모두 있어야 한다.

npm error The `npm ci` command can only install with an existing package-lock.json

더 헷갈리는 점은 COPY package*.json . 이 매칭되는 파일이 하나도 없어도 조용히 통과한다는 것이다. 그래서 파일을 안 만들었어도 COPY 가 아니라 npm ci 에서 처음 에러를 보게 된다.

lock 파일은 로컬에서 npm install 을 한 번 돌리면 생긴다. 생성된 lock 파일은 반드시 Git 에 커밋한다. npm ci 가 이 파일의 버전을 그대로 설치해 빌드를 재현 가능하게 만드는 것이 npm install 대신 쓰는 이유다.

3. 이미지 빌드

3-1. docker build 주요 옵션

# 구형
docker build [OPTIONS] PATH

# 신형
docker image build [OPTIONS] PATH

옵션

설명

예시

-t

이미지 이름과 태그 지정

-t myapp:1.0

-f

사용할 Dockerfile 경로 지정

-f Dockerfile.prod

--build-arg

ARG 변수에 값 전달

--build-arg VERSION=2.0

--no-cache

레이어 캐시 무시하고 처음부터 빌드

--no-cache

--platform

대상 플랫폼 지정 (크로스 빌드)

--platform linux/amd64

# 기본 빌드
docker image build -t visit-counter:1.0 .

# 특정 Dockerfile 사용
docker image build -f Dockerfile.prod -t visit-counter:prod .

# ARG 값 전달
docker image build --build-arg VERSION=2.0 -t visit-counter:2.0 .

# 캐시 무시 (의존성 버전 변경 등 완전히 새로 빌드할 때)
docker image build --no-cache -t visit-counter:1.0 .

3-2. 레이어 캐시 이해

Docker는 Dockerfile의 각 인스트럭션을 레이어(Layer) 로 저장하며, 변경되지 않은 레이어는 캐시를 재사용하여 빌드 속도를 높인다.

빌드 최적화 팁: 자주 바뀌는 소스 코드(COPY . .)보다 잘 바뀌지 않는 의존성 설치(pip install, npm install)를 먼저 배치하면 캐시 효율이 높아진다.

# ❌ 비효율적 — 소스 변경 시 pip install도 매번 재실행
COPY . /app
RUN pip3 install -r requirements.txt

# ✅ 효율적 — requirements.txt가 바뀌지 않으면 pip install 캐시 재사용
COPY requirements.txt .
RUN pip3 install -r requirements.txt
COPY . /app

4. 이미지 관리

4-1. 이미지 목록 조회

docker image ls 자체는 Docker 기본 명령어의 Step 8 에서 다뤘다. 여기서는 내가 만든 이미지를 확인하는 용도로만 쓴다.

docker image ls visit-counter

4-2. 이미지 레이어 확인

# 이미지의 레이어 히스토리 확인 — 각 인스트럭션이 만든 레이어 크기 확인
docker image history visit-counter:1.0

# 예상 출력:
# IMAGE         CREATED       CREATED BY                       SIZE    COMMENT
# 15e7ed9eab62  17 sec ago    CMD ["java" "-jar" "app.jar"]    0B      buildkit.dockerfile.v0
# <missing>     17 sec ago    EXPOSE [8080/tcp]                0B      buildkit.dockerfile.v0
# <missing>     17 sec ago    COPY /build/.../app.jar app.jar  12.3kB  buildkit.dockerfile.v0
# <missing>     27 hours ago  RUN /bin/sh -c set -eux; ...     166MB   buildkit.dockerfile.v0
# ...
#
# 이미지 ID 는 맨 윗줄에만 있고 나머지는 <missing> 이다.
# 중간 레이어는 그 자체로 이미지가 아니라 최종 이미지의 구성요소이기 때문이다.

# 특정 레이어가 크다면 해당 인스트럭션에서 불필요한 파일을 포함하고 있을 가능성이 있다

4-3. 이미지 태깅

# 버전 태그 추가 (이미지 복사가 아닌 동일 레이어 참조)
docker image tag visit-counter:1.0 visit-counter:latest

# Docker Hub 업로드를 위한 태깅 (username/repository:tag 형식 필수)
docker image tag visit-counter:1.0 myusername/visit-counter:1.0

4-4. 이미지 삭제 — 빌드가 남기는 찌꺼기

docker image rm 의 기본 사용법은 Docker 기본 명령어의 Step 8 에 있다. 이미지를 직접 빌드하기 시작하면 거기서는 볼 일이 없던 것이 생긴다. 같은 태그로 다시 빌드할 때마다 태그를 잃은 이전 이미지(dangling 이미지)가 쌓이는 것이다.

# dangling 이미지(태그가 없는 이미지)만 삭제
docker image prune

# 컨테이너가 사용하지 않는 이미지를 전부 삭제 (태그가 있어도 지운다)
docker image prune -a

dangling 이미지가 보이는 방식은 버전에 따라 다르다. 같은 태그로 다시 빌드하면 이전 이미지는 태그를 잃는데, 구버전에서는 이런 이미지가 docker image ls -a 목록에 <none> 으로 남아 눈에 띄었다. 최신 버전(Docker 29의 containerd 이미지 저장소)에서는 목록에 나타나지 않을 수 있고 docker image ls -f dangling=true 도 비어 있을 수 있다. 목록이 비어 있다고 해서 docker image prune 이 필요 없다는 뜻은 아니므로, 디스크 사용량은 docker system df 로 확인하는 편이 확실하다.

실행 중이거나 중지된 컨테이너가 사용 중인 이미지는 삭제할 수 없다. 먼저 docker container rm으로 컨테이너를 제거한 뒤 이미지를 삭제해야 한다.

5. Docker Hub에 이미지 공유

5-1. Docker Hub 계정과 액세스 토큰 준비

이미지를 푸시하려면 먼저 Docker Hub 계정이 있어야 한다. hub.docker.com에서 가입한 뒤, 여기서 정한 Docker ID가 곧 이미지 이름 앞에 붙는 myusername 자리가 된다.

비밀번호 대신 액세스 토큰을 쓴다. 로그인한 뒤 계정 메뉴 → Personal access tokens 로 들어가 [Generate new token] 을 누른다.

Personal access tokens — Generate new token

계정 메뉴 → Personal access tokens → Generate new token

생성 폼에서 채울 것은 설명과 Access permissions 두 가지다.

Create access token — Access permissions 선택

Access permissions 를 Read & Write 로 바꾼다

권한

할 수 있는 일

Public Repo Read-only

공개 저장소 pull 만. 기본값이다

Read-only

내 비공개 저장소까지 pull

Read & Write

pull + push. 이 실습은 이것을 고른다

Read, Write, Delete

위에 더해 태그·저장소 삭제

기본값 Public Repo Read-only 로 만들면 docker push 가 실패한다. 로그인은 멀쩡히 되기 때문에 원인을 찾기 어렵다. 반드시 Read & Write 로 바꾸고 발급한다.

Expiration date 는 기본이 None(무기한)이다. 실습용이라면 30일 정도로 정해 두는 편이 안전하다.

토큰 값은 발급 직후 한 번만 보인다. 그 자리에서 복사해 두지 않으면 다시 볼 수 없고, 새로 발급하는 수밖에 없다. 잃어버린 토큰은 목록에서 삭제한다.

5-2. Docker Hub 로그인

docker login
# Username(Docker ID)과 Password(액세스 토큰) 입력

토큰을 표준 입력으로 넘기면 셸 히스토리에 값이 남지 않는다. 셸에 따라 형식이 다르다.

# 토큰을 파일에 저장해두고 파일에서 읽어 넘긴다
cat ~/dockerhub-token.txt | docker login -u myusername --password-stdin
rm ~/dockerhub-token.txt
# PowerShell
Get-Content ~/dockerhub-token.txt | docker login -u myusername --password-stdin
Remove-Item ~/dockerhub-token.txt

토큰을 명령줄에 직접 쓰면 --password-stdin 을 써도 소용없다. 아래 두 줄은 흔히 보이지만 나쁜 예다.

docker login -u myusername --password-stdin <<< "my-access-token" 처럼 here-string 으로 넘기거나, PowerShell 에서 "my-access-token" | docker login ... 으로 파이프에 문자열을 직접 쓰면 토큰이 명령줄에 포함되므로 ~/.bash_history 나 PowerShell 기록에 그대로 남는다. --password-stdin 이 막아 주는 것은 ps 로 다른 프로세스에 보이는 것뿐이다.

토큰은 파일에 넣고 파일에서 읽어 넘긴 뒤 지운다. 위 예시가 그 형태다.

5-3. 이미지 푸시(Push)

# 이미지명은 반드시 'username/repository:tag' 형식이어야 한다
docker image tag visit-counter:1.0 myusername/visit-counter:1.0
docker image push myusername/visit-counter:1.0

5-4. 올린 것을 되받아 확인하기

docker image pull 은 Docker 기본 명령어의 Step 8 에서 다뤘다. 여기서는 내가 올린 이미지가 실제로 레지스트리에 있는지 확인하는 용도로 쓴다. 로컬 이미지를 지운 뒤 받아 보면 확실하다.

docker image rm myusername/visit-counter:1.0
docker image pull myusername/visit-counter:1.0

Docker Hub 무료 계정은 Private 저장소 1개만 제공된다. 팀 프로젝트에서는 GitHub Container Registry(ghcr.io) 또는 사내 Private Registry를 활용하는 경우가 많다.

6. 종합 실습 — visit-counter 이미지 빌드·최적화·배포

visit-counter 앱을 대상으로 앱 준비부터 이미지 빌드, 최적화, Docker Hub 배포까지 전 과정을 실습한다.

6-1. 앱과 Dockerfile 준비

visit-counter 는 요청할 때마다 숫자를 하나 올려서 돌려주는 작은 Spring Boot 앱이다. 카운트를 /data/count.txt 파일에 저장하기 때문에, 볼륨을 붙였을 때와 안 붙였을 때의 차이가 눈에 보인다. 이 앱은 Docker Compose와 Part 4 의 AWS 배포까지 이어서 쓴다.

프로젝트 구조

visit-counter/
├── build.gradle
├── settings.gradle
├── .dockerignore
├── Dockerfile
└── src/main/java/com/example/
    └── VisitCounterApplication.java

코드와 Dockerfile 은 호스트 PC(본인 컴퓨터)에서 작성한다. docker image build 로 이미지를 만들고, docker container run 으로 그 이미지를 컨테이너로 실행하는 것이 전체 흐름이다. 컨테이너 내부에서 코드를 작성하는 것이 아니다.

Dockerfile 안의 RUN gradle bootJar 같은 명령도 호스트에서 직접 실행되는 것이 아니라, Docker 가 빌드 중 임시 컨테이너 안에서 대신 실행한다. 그래서 호스트에 Java 나 Gradle 이 없어도 빌드가 된다.

settings.gradle

rootProject.name = 'visit-counter'

build.gradle

plugins {
    id 'org.springframework.boot' version '4.1.1'
    id 'io.spring.dependency-management' version '1.1.7'
    id 'java'
}

group = 'com.example'
version = '1.0.0'
java { toolchain { languageVersion = JavaLanguageVersion.of(21) } }

repositories { mavenCentral() }

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
}

io.spring.dependency-management 를 빼면 빌드가 깨진다. 의존성에 버전을 적지 않은 spring-boot-starter-web 이 해석되지 않기 때문이다.

> Could not find org.springframework.boot:spring-boot-starter-web:

이 플러그인이 Spring Boot BOM(버전 목록)을 적용해 주기 때문에 버전을 생략할 수 있는 것이다. Boot 4.1.1 + dependency-management 1.1.7 조합으로 gradle bootJar 가 정상 동작하는 것을 확인했다.

VisitCounterApplication.java

package com.example;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.*;

import java.io.IOException;
import java.nio.file.*;

@SpringBootApplication
@RestController
public class VisitCounterApplication {

    // 카운트 파일 경로 — 볼륨 마운트 실습의 핵심
    private static final Path COUNT_FILE = Paths.get("/data/count.txt");

    public static void main(String[] args) throws Exception {
        Files.createDirectories(COUNT_FILE.getParent());
        SpringApplication.run(VisitCounterApplication.class, args);
    }

    @GetMapping("/")
    public String visit() throws IOException {
        long count = readCount() + 1;
        writeCount(count);
        return "방문 횟수: " + count + "\n";
    }

    @GetMapping("/count")
    public String count() throws IOException {
        return "현재 카운트: " + readCount() + "\n";
    }

    @GetMapping("/reset")
    public String reset() throws IOException {
        writeCount(0);
        return "카운터를 초기화했습니다.\n";
    }

    private long readCount() {
        try { return Long.parseLong(Files.readString(COUNT_FILE).trim()); }
        catch (Exception e) { return 0; }
    }

    private void writeCount(long n) throws IOException {
        Files.writeString(COUNT_FILE, String.valueOf(n));
    }
}

Dockerfile (멀티 스테이지 빌드)

# ── 1단계: Gradle로 JAR 빌드 ──────────────────────────────
FROM gradle:8.14-jdk21 AS builder
WORKDIR /build

# 의존성 정의 파일만 먼저 복사해 의존성을 받아 둔다.
# 이 두 파일이 바뀌지 않으면 아래 RUN 은 캐시로 건너뛴다.
COPY settings.gradle build.gradle ./
RUN gradle dependencies --no-daemon > /dev/null

# 자주 바뀌는 소스는 나중에 복사한다
COPY src ./src
RUN gradle bootJar --no-daemon

# ── 2단계: JRE만 포함한 경량 실행 이미지 ─────────────────
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=builder /build/build/libs/visit-counter-1.0.0.jar app.jar
RUN mkdir /data
EXPOSE 8080
CMD ["java", "-jar", "app.jar"]

AS builder 는 이 단계에 이름을 붙이는 것으로, 아래 COPY --from=builder 에서 참조한다. COPY 를 두 번에 나눈 것은 3-2 레이어 캐시의 원칙 그대로다. 잘 바뀌지 않는 build.gradle 과 의존성 내려받기를 앞에, 매번 바뀌는 src 를 뒤에 둔다. COPY --from=builder /build/build/libs/... 는 builder 단계의 파일시스템(WORKDIR /build + bootJar 결과 경로)에서 JAR 를 가져와 app.jar 라는 이름으로 저장한다.

JAR 파일 이름은 settings.gradle 의 rootProject.name + version 으로 정해진다. 위 설정이면 visit-counter-1.0.0.jar 다. 프로젝트 이름이나 버전을 바꾸면 COPY --from=builder 의 경로도 함께 고쳐야 하고, 안 그러면 빌드가 file not found 로 멈춘다. 이름에 의존하지 않으려면 와일드카드를 쓴다.

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

6-2. .dockerignore 작성

# visit-counter/.dockerignore
.git
.gradle
build/
*.log
.env
secrets/

6-3. 이미지 빌드 및 크기 확인

# 이미지 빌드 (멀티 스테이지)
docker image build -t visit-counter:1.0 .

# 이미지 크기 확인
docker image ls visit-counter

# 레이어별 크기 확인
docker image history visit-counter:1.0

빌드한 이미지를 실제로 띄워 본다.

docker container run -d -p 8080:8080 --name visit-counter visit-counter:1.0

curl http://localhost:8080        # 방문 횟수: 1
curl http://localhost:8080        # 방문 횟수: 2
curl http://localhost:8080/count  # 현재 카운트: 2

브라우저에서 http://localhost:8080 으로도 접속해 보자. 요청할 때마다 숫자가 올라간다.

방문자 카운터 실행 화면

브라우저에서 새로고침할 때마다 숫자가 올라간다

카운트 조회

/count 는 숫자를 올리지 않고 현재 값만 보여준다

6-4. 빌드 캐시 효과 확인

# 소스 코드만 수정 후 재빌드
# VisitCounterApplication.java의 응답 메시지를 변경한다
# return "🎉 방문 횟수: " + count + " 번\n";

docker image build -t visit-counter:2.0 .
# → COPY src 이전 단계(의존성 내려받기)는 캐시 재사용, 이후만 재빌드
# 빌드 로그에서 단계 앞에 CACHED 가 붙은 줄이 캐시를 재사용한 단계다
# (Using cache 는 BuildKit 이전 구형 빌더의 문구다)

# 1.0과 2.0 이미지 비교
docker image ls visit-counter
 => CACHED [builder 3/6] COPY settings.gradle build.gradle ./
 => CACHED [builder 4/6] RUN gradle dependencies --no-daemon > /dev/null
 => [builder 5/6] COPY src ./src
 => [builder 6/6] RUN gradle bootJar --no-daemon

이 교안을 쓰며 소스 한 줄을 고친 뒤 다시 빌드한 시간을 재 봤다.

Dockerfile

첫 빌드

소스 수정 후 재빌드

COPY . . 를 먼저 하는 형태

61초

75초 — 의존성을 처음부터 다시 받는다

위처럼 의존성을 먼저 받는 형태

65초

32초

더 줄이고 싶다면 BuildKit 캐시 마운트를 쓴다.

RUN --mount=type=cache,target=/root/.gradle gradle bootJar --no-daemon

Gradle 이 내려받은 파일을 레이어가 아니라 빌드 사이에 유지되는 캐시 디렉터리에 둔다. 같은 측정에서 재빌드가 16초였다. 다만 그 캐시는 빌드한 PC 에만 남으므로, 매번 새 가상 머신에서 빌드하는 GitHub Actions 에서는 효과가 없다. 그래서 본문은 어디서나 통하는 레이어 분리 방식을 쓴다.

6-5. Docker Hub 배포

# Docker Hub 로그인
docker login

# 태그 추가
docker image tag visit-counter:1.0 myusername/visit-counter:1.0

# 푸시
docker image push myusername/visit-counter:1.0

# 다른 환경에서 내려받기
docker image pull myusername/visit-counter:1.0

6-6. 정리

# 컨테이너 삭제 후 이미지 삭제
docker container rm -f visit-counter
docker image rm visit-counter:1.0 visit-counter:2.0

# 사용하지 않는 이미지 일괄 정리
docker image prune -a

추가 도전 과제

  1. --no-cache 옵션으로 처음부터 빌드할 때와 캐시 사용 빌드의 시간 차이를 측정해보자.

  2. .dockerignore에서 build/를 제거한 후 빌드 컨텍스트 크기 변화를 확인해보자. 빌드 로그의 [internal] load build context 아래 transferring context: <크기> 줄을 비교하면 된다.

  3. docker image history에서 가장 크기가 큰 레이어가 어떤 인스트럭션인지 확인하고, 최적화 방법을 생각해보자.

마지막 수정 2026년 10월 22일