---
title: "Docker 멀티스테이지 빌드로 이미지 90% 줄이기"
description: "빌드 도구와 런타임을 분리하는 멀티스테이지 빌드로 도커 이미지를 줄이는 법을, 베이스 이미지(slim/alpine/distroless) 트레이드오프와 레이어 캐시 순서 최적화까지 정리한다."
date: 2026-06-06
updated: 2026-06-06T09:00:00.000Z
tags: [docker, devops, performance, infra]
canonical: https://blog.wooncloud.com/posts/docker-multistage-slim-image
---

![Docker 멀티스테이지 빌드로 이미지 90% 줄이기](/images/posts/docker-multistage-slim-image/9ac38087-6f74-49d5-a1bf-a67bea5e6781.webp)

## 왜 이미지 크기가 문제인가

컨테이너 이미지는 한 번 만들고 끝나는 산출물이 아니다. CI 가 돌 때마다 빌드되고, 레지스트리에 푸시되고, 배포 노드마다 풀(pull) 된다. 이미지가 1.2GB 인지 80MB 인지에 따라 다음이 전부 달라진다.

- **빌드/전송 시간**: 레이어가 크면 push/pull 에 그대로 비례한다. 오토스케일이나 롤링 업데이트처럼 노드가 동시에 같은 이미지를 끌어올 때 네트워크가 병목이 된다.
- **레지스트리 비용**: 대부분의 레지스트리는 저장 용량과 egress 트래픽으로 과금한다. 태그를 자주 쌓는 환경이면 이미지 크기 × 보관 개수가 곧 청구서다.
- **콜드 스타트**: 서버리스 컨테이너나 신규 노드 스케줄링에서 이미지 풀 시간이 곧 시작 지연이다. 큰 이미지는 첫 요청 지연을 늘린다.
- **취약점 표면(attack surface)**: 컴파일러, 패키지 매니저, 셸, 빌드용 라이브러리가 런타임 이미지에 그대로 남으면 CVE 스캔에 잡히는 패키지 수가 늘고, 컨테이너가 침해됐을 때 공격자가 쓸 도구도 늘어난다.

근본 원인은 단순하다. **빌드에 필요한 도구와, 실행에만 필요한 것을 한 이미지에 같이 담기 때문**이다. 컴파일러, devDependencies, 빌드 캐시는 아티팩트를 만들고 나면 런타임에 한 줄도 쓰이지 않는다. 그런데 단일 스테이지 Dockerfile 은 이 모든 걸 최종 이미지에 박아 넣는다. 멀티스테이지 빌드는 이 둘을 물리적으로 분리한다.

## 멀티스테이지 빌드의 개념

하나의 Dockerfile 안에 `FROM` 을 여러 번 쓴다. 각 `FROM` 이 하나의 **스테이지**다. 앞 스테이지에서 빌드를 끝내고, 마지막 스테이지에서 `COPY --from=<스테이지>` 로 **산출물만** 가져온다.

```dockerfile
# 스테이지 1: 빌드 (컴파일러·전체 의존성 포함, 무거움)
FROM node:22 AS builder
WORKDIR /app
COPY . .
RUN npm ci && npm run build

# 스테이지 2: 런타임 (산출물만, 가벼움)
FROM node:22-slim
WORKDIR /app
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/server.js"]
```

핵심은 **최종 이미지에 남는 건 마지막 스테이지뿐**이라는 점이다. `builder` 스테이지에 깔린 컴파일러, devDependencies, 소스 코드, 빌드 중간 산출물은 최종 이미지에 들어가지 않는다. `COPY --from=builder` 로 명시적으로 가져온 `/app/dist` 만 넘어온다.

이게 가능한 이유는 각 스테이지가 독립된 파일시스템이기 때문이다. 빌드 시점에는 모든 스테이지가 존재하지만, 최종 결과물은 마지막 `FROM` 이후의 레이어만으로 구성된다. 앞 스테이지는 "도구를 들고 작업한 작업실"이고, 최종 이미지는 "작업실을 비우고 완성품만 들고 나온 깨끗한 방"이다.

## Node.js 3단계 예시

실전에서 자주 쓰는 패턴은 **deps → builder → runner** 3단계다. 의존성 설치, 빌드, 실행을 각각 분리하면 레이어 캐시 효율과 최종 크기를 동시에 잡을 수 있다.

```dockerfile
# syntax=docker/dockerfile:1

# ── 스테이지 1: deps ──────────────────────────────
# 프로덕션 의존성만 따로 설치한다. 매니페스트만 먼저 복사해
# package.json/lock 이 안 바뀌면 이 레이어가 캐시된다.
FROM node:22-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

# ── 스테이지 2: builder ───────────────────────────
# devDependencies 포함 전체 설치 후 빌드. 이 스테이지의
# 무거운 도구들은 최종 이미지에 남지 않는다.
FROM node:22-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# ── 스테이지 3: runner ────────────────────────────
# 런타임에 필요한 것만: 빌드 산출물 + 프로덕션 node_modules.
FROM node:22-slim AS runner
WORKDIR /app
ENV NODE_ENV=production

# 비루트 사용자로 실행 (아래 '비루트' 절 참고)
# node 이미지에는 uid 1000 'node' 사용자가 이미 있다.
COPY --from=deps    --chown=node:node /app/node_modules ./node_modules
COPY --from=builder --chown=node:node /app/dist          ./dist
COPY --from=builder --chown=node:node /app/package.json  ./package.json

USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
```

설계 의도를 짚어보면,

- `deps` 는 `--omit=dev` 로 **프로덕션 의존성만** 설치한다. 최종 이미지로 이 `node_modules` 만 복사하므로 빌드 전용 패키지(타입 정의, 번들러, 테스트 도구)는 런타임에 빠진다.
- `builder` 는 별도로 전체 의존성을 설치해 빌드한다. devDependencies 가 여기 들어가지만 최종 이미지엔 영향이 없다.
- `runner` 는 `node:22-slim` 위에 산출물과 프로덕션 의존성만 올린다. 소스 `.ts` 파일, 테스트, `.git`, 번들러 캐시는 전부 빠진다.

결과적으로 단일 스테이지에서 1GB 를 넘기던 이미지가 수백 MB대, 베이스를 더 줄이면 100MB 안팎까지 떨어진다. "90% 감소"는 빌드 도구와 devDependencies 의 비중이 클수록 현실적인 숫자다.

## 베이스 이미지 선택: full / slim / alpine / distroless

최종 스테이지의 `FROM` 을 무엇으로 두느냐가 크기와 운영성의 트레이드오프를 결정한다.

| 베이스 | 크기 | 특징 | 주의점 |
| --- | --- | --- | --- |
| `node:22` (full) | 가장 큼 | 빌드 도구·셸·glibc 완비 | 런타임용으로는 과함 |
| `node:22-slim` | 중간 | Debian slim, glibc, 셸 있음 | 무난한 기본값 |
| `node:22-alpine` | 작음 | musl libc, 셸 있음 | glibc 비호환 이슈 가능 |
| `gcr.io/distroless/nodejs22` | 매우 작음 | 셸·패키지매니저 없음 | 디버깅·예외 케이스 어려움 |

- **full**: 빌드 스테이지에는 적합하지만 런타임에 두면 안 쓰는 도구가 통째로 남는다.
- **slim**: Debian 기반에서 문서/locale 등을 덜어낸 버전. glibc 를 그대로 쓰므로 네이티브 모듈 호환성 문제가 거의 없다. 대부분의 경우 가장 안전한 기본값이다.
- **alpine**: 가장 작은 일반 베이스 중 하나지만 **musl libc** 를 쓴다. glibc 가정으로 컴파일된 네이티브 애드온(`bcrypt`, `sharp` 등 prebuilt 바이너리)이 깨지거나 소스 빌드를 강제당할 수 있고, DNS 처리나 타임존 같은 미묘한 동작 차이도 보고된다. "작다"는 이점이 디버깅 시간으로 상쇄될 수 있어, 의존성이 순수 JS 에 가까울 때 가장 잘 맞는다.
- **distroless**: 애플리케이션과 런타임만 담고 셸·패키지 매니저·`ls` 조차 없다. 공격 표면이 극적으로 줄고 CVE 스캔 결과가 깨끗해지지만, **`docker exec` 로 들어가 셸을 띄울 수 없다**. 디버깅은 `debug` 태그 변형이나 사이드카, ephemeral 컨테이너로 우회해야 한다. 보안을 최우선으로 두고 운영 성숙도가 받쳐줄 때 선택한다.

정리하면, 무난함은 slim, 극단적 슬림·보안은 distroless, alpine 은 "정말 작아야 하고 네이티브 의존성이 적을 때" 고른다.

## 레이어 캐시 최적화: 명령 순서가 속도를 좌우한다

Docker 는 각 명령(`COPY`, `RUN` 등)을 레이어로 만들고, **입력이 안 바뀌면 그 레이어를 캐시에서 재사용**한다. 그리고 한 레이어가 무효화되면 그 아래 모든 레이어가 함께 무효화된다. 그래서 "자주 바뀌는 것을 뒤로" 두는 게 철칙이다.

가장 흔한 실수는 소스 전체를 먼저 복사한 뒤 설치하는 것이다.

```dockerfile
# 나쁜 예: 소스 한 줄만 고쳐도 npm ci 가 매번 다시 돈다
COPY . .
RUN npm ci
```

`COPY . .` 가 소스 변경마다 무효화되므로, 바로 아래 `RUN npm ci` 도 매번 다시 실행된다. 의존성은 그대로인데 설치만 반복되는 셈이다.

```dockerfile
# 좋은 예: 매니페스트를 먼저 복사해 install 레이어를 분리
COPY package.json package-lock.json ./
RUN npm ci            # package.json/lock 이 그대로면 캐시 재사용
COPY . .              # 소스만 바뀌면 여기서부터 무효화
RUN npm run build
```

`package.json` 과 lockfile 이 바뀌지 않는 한 `npm ci` 레이어가 캐시되어, 소스만 고친 빌드는 의존성 설치를 통째로 건너뛴다. 의존성 수가 많을수록 이 한 줄 순서 차이가 빌드 시간을 분 단위로 가른다.

BuildKit 을 쓴다면 패키지 매니저 캐시를 마운트해 한 단계 더 줄일 수 있다.

```dockerfile
# syntax=docker/dockerfile:1
RUN --mount=type=cache,target=/root/.npm npm ci
```

이러면 lockfile 이 바뀌어 레이어가 무효화되더라도 npm 다운로드 캐시는 보존되어 재설치가 빨라진다.

## .dockerignore 로 컨텍스트 줄이기

`docker build` 는 시작할 때 빌드 컨텍스트(디렉터리)를 데몬으로 통째로 전송한다. `.dockerignore` 가 없으면 `node_modules`, `.git`, 빌드 산출물, 로컬 환경 파일까지 전부 올라가 빌드가 느려지고, `COPY . .` 가 의도치 않은 파일을 이미지에 넣어 캐시를 깨거나 비밀을 유출할 수 있다.

```dockerignore
# 의존성·산출물 — 이미지 안에서 다시 생성한다
node_modules
dist
build
.next

# VCS·CI
.git
.github

# 로컬 환경·비밀 — 절대 이미지에 넣지 않는다
.env
.env.*
*.pem

# 개발 부산물
*.log
coverage
.vscode
.idea

# 컨테이너 정의 자신
Dockerfile
.dockerignore
```

`node_modules` 를 제외하면 호스트의 (OS·아키텍처가 다를 수 있는) 모듈이 이미지로 새어 들어가는 것도 막아준다. 의존성은 항상 이미지 안에서 설치되도록 강제하는 효과가 있다.

## 비루트 사용자와 최소 런타임 파일

기본 이미지는 보통 root 로 컨테이너를 실행한다. 컨테이너가 침해됐을 때 root 권한은 그대로 위험으로 이어지므로, 런타임은 **권한 없는 사용자**로 돌리는 게 원칙이다.

```dockerfile
# node 공식 이미지에는 uid 1000 'node' 사용자가 미리 있다
COPY --from=builder --chown=node:node /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
```

직접 사용자를 만들 수도 있다.

```dockerfile
RUN addgroup --system --gid 1001 app \
 && adduser  --system --uid 1001 --ingroup app app
USER app
```

여기에 더해 **필요한 런타임 파일만 복사**하는 원칙을 지킨다. 최종 스테이지에서 `COPY . .` 로 전체를 가져오면 멀티스테이지의 의미가 사라진다. 빌드 산출물, 프로덕션 의존성, 시작에 필요한 설정 파일만 골라 복사해야 이미지가 작고 표면도 좁아진다.

## 크기 측정과 레이어 분석

줄였다고 말하려면 측정해야 한다.

```bash
# 이미지별 최종 크기 비교
docker images myapp

# 빌드 직후 한 줄로 크기 확인
docker image inspect myapp:slim --format '{{.Size}}' \
  | awk '{printf "%.1f MB\n", $1/1024/1024}'
```

레이어별로 무엇이 용량을 먹는지 보려면 `dive` 가 유용하다.

```bash
# 레이어별 추가 용량과 '낭비된' 파일을 시각화
dive myapp:slim
```

`dive` 는 각 레이어가 더한 바이트, 레이어 간 중복/삭제된 파일, 그리고 효율 점수(efficiency)를 보여준다. 어떤 `COPY`/`RUN` 이 예상보다 무거운지, 앞 레이어에서 만든 뒤 뒤 레이어에서 지워 결국 이미지에 두 번 박힌 파일은 없는지 짚어낼 수 있다. CI 에 `dive --ci` 로 효율 임계값을 걸어 회귀를 막을 수도 있다.

## 정리

이미지 슬림화는 세 축으로 압축된다.

1. **빌드/런타임 분리** — 멀티스테이지로 컴파일러·devDependencies·소스를 최종 이미지에서 빼고, `COPY --from` 으로 산출물만 가져온다. 보통 가장 큰 감소를 만든다.
2. **베이스 슬림화** — 런타임 베이스를 slim/alpine/distroless 로 낮춘다. 호환성·디버깅 난이도와의 트레이드오프를 보고 고르되, 무난함은 slim, 보안 극단은 distroless.
3. **캐시 친화적 순서** — 매니페스트를 소스보다 먼저 복사·설치하고, `.dockerignore` 로 컨텍스트를 줄인다. 크기뿐 아니라 빌드 속도까지 같이 잡는다.

여기에 비루트 실행과 최소 파일 복사로 보안 표면까지 좁히면, 단순히 "작은 이미지"가 아니라 빠르고 안전하게 굴러가는 런타임이 된다. 마지막으로 `docker images` 와 `dive` 로 실제 숫자를 확인하는 습관을 들이면, 슬림화가 추측이 아니라 측정 가능한 작업이 된다.
