목차
- 왜 레이어 캐시가 깨지는가
- 명령어 순서 — 자주 변하는 것은 아래로
- .dockerignore — 비어 있으면 안 된다
- 멀티스테이지 빌드로 한 번 더
- 자주 놓치는 함정 체크리스트
- 적용 후 측정
=> [internal] load build context
=> => transferring context: 487.3MB 42.1s
=> CACHED [2/7] WORKDIR /app
=> [3/7] COPY . . 12.4s
=> [4/7] RUN npm install 218.6s
=> [5/7] RUN npm run build 71.2s
total build time: 8m 12s
그러나, CI에서 한 줄짜리 문서 수정만 해도 8분이 통째로 다시 돌았다. 이런 빌드 로그가 매 PR마다 찍히면 머지 큐가 밀린다. dockerfile 최적화 레이어 캐시를 제대로 쓰지 않으면 컨텍스트 전송, 의존성 설치, 빌드까지 전부 무효화된다. 캐시가 깨지는 지점은 보통 한 줄이다. 명령어 순서 하나, .dockerignore 한 줄.
그런데, 프론트에서 백엔드로 넘어온 지 2년 차다. 처음에 docker build를 보고 ‘webpack이랑 비슷한 거 아닌가’ 싶었는데, 다르다. webpack은 모듈 그래프를 다시 그릴 뿐이지만 Docker는 레이어 단위 해시 체인이라 한 단계가 바뀌면 그 아래 전부 다시 만든다. 이 차이를 모르면 빌드 시간이 5배까지 늘어난다.
왜 레이어 캐시가 깨지는가
docker build는 Dockerfile의 명령어 한 줄마다 레이어 하나를 만든다. 각 레이어는 직전 레이어의 해시와 명령어, 파일 변경 여부로 식별된다. 어느 한 레이어가 바뀌면 그 아래 모든 레이어가 무효화되어 처음부터 다시 실행된다. (출처: Docker 공식 문서 — Optimize cache usage in builds, 2026-02 업데이트 기준)
문제는 대부분의 팀이 Dockerfile을 처음 작성할 때 ‘편한 순서’로 쓴다는 점이다. WORKDIR 설정, 소스 코드 전체 복사, 의존성 설치, 빌드. 이 순서는 직관적이지만 캐시 관점에서는 최악이다. 소스 코드는 매 커밋마다 바뀌고, 그 위에 올라간 npm install이나 pip install이 통째로 무효화된다.
여기서 헷갈리기 쉬운 게 ‘COPY . . 한 줄이 캐시를 깬다’는 사실이다. 매번 모든 파일의 메타데이터를 비교해 hash를 새로 만든다. node_modules가 컨텍스트에 포함되어 있으면 수백 MB를 매번 전송한다.
명령어 순서 — 자주 변하는 것은 아래로
핵심 원칙은 단 하나다. 변경 빈도가 낮은 명령어를 위에, 자주 바뀌는 명령어를 아래에 둔다. 의존성 명세 파일(package.json, requirements.txt, go.mod)은 소스 코드보다 훨씬 덜 바뀐다. 이걸 먼저 복사하고 설치한 다음 소스 코드를 복사한다.
# Node.js 예시 — 의존성 먼저, 소스는 나중에
FROM node:20.11.1-alpine3.19 AS deps
WORKDIR /app
# 1. 의존성 명세만 먼저 복사 (변경 빈도 낮음)
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# 2. 소스 코드는 나중에 복사 (변경 빈도 높음)
COPY . .
RUN npm run build
이 한 가지 순서 변경만으로 매 커밋마다 npm install이 돌던 것이 사라진다. package.json이 그대로면 deps 레이어는 캐시 히트, COPY . . 부터 다시 실행된다.
Python에서도 같은 원리
실제로, Python 프로젝트도 동일하다. requirements.txt를 먼저 복사하고 pip install을 끝낸 다음 소스 코드를 복사한다.
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "main.py"]
예를 들어, requirements.txt에 패키지 한 줄을 추가하면 pip install부터 다시 돈다. 다만 소스 코드만 수정하면 그 위 레이어는 그대로 캐시를 쓴다.
RUN 명령어는 묶어서
즉, RUN 한 줄이 레이어 하나가 된다. apt-get update 따로, apt-get install 따로 쓰면 이미지가 커지고 캐시 무효화 범위도 넓어진다. 한 줄로 묶고 후처리까지 함께 하는 것이 표준이다.
RUN apt-get update && \
apt-get install -y --no-install-recommends \
curl ca-certificates && \
rm -rf /var/lib/apt/lists/*
apt 캐시까지 같은 RUN에서 지워야 이미지에 캐시가 남지 않는다. 다른 RUN으로 분리해서 지우면 위 레이어에 캐시가 그대로 박혀 이미지 용량이 커진다.
.dockerignore — 비어 있으면 안 된다
이처럼, .dockerignore가 없거나 비어 있으면 docker build 컨텍스트로 프로젝트 전체가 전송된다. node_modules, .git, dist, 로컬 환경변수까지 전부. 가장 흔한 함정이다.
빌드 컨텍스트 크기가 487MB였던 프로젝트에 .dockerignore를 추가했더니 12MB로 줄었다. 컨텍스트 전송 시간 자체가 40초에서 1초 이내로 떨어졌다.
# .dockerignore 최소 권장 항목
.git
.gitignore
node_modules
npm-debug.log
.env
.env.local
.env.*.local
dist
build
coverage
.vscode
.idea
Dockerfile
docker-compose*.yml
.dockerignore
README*
특히 .env 계열은 보안 관점에서도 반드시 제외한다. 개발용 API 키가 컨테이너 이미지에 박혀서 레지스트리에 올라가는 사고가 가끔 일어난다.
멀티스테이지 빌드로 한 번 더
게다가, 레이어 순서를 잡고 .dockerignore를 정리해도 최종 이미지에는 빌드 도구가 그대로 남는다. node_modules의 dev 의존성, 빌드 캐시, 컴파일러까지 전부. 멀티스테이지는 빌드 단계와 런타임 단계를 분리해서 최종 이미지에는 실행에 필요한 것만 남긴다.
# Stage 1: build
FROM node:20.11.1-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: runtime
FROM node:20.11.1-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/package.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/main.js"]
물론, 빌드 단계의 산출물만 런타임 단계로 복사한다. 같은 프로젝트에서 멀티스테이지로 바꿨더니 최종 이미지 크기가 1.2GB에서 240MB로 줄었다. (개인적으로 production stage에서 dev 의존성을 빼는 단계까지 가면 더 줄어드는데, 일단 여기서는 기본형만 둔다.)
BuildKit 캐시 마운트
BuildKit이 기본 활성화된 이후로는 캐시 마운트가 훨씬 강력해졌다. npm cache, pip cache, apt cache를 영구 저장소에 두고 매 빌드마다 재사용한다.
# syntax=docker/dockerfile:1.7
FROM node:20.11.1-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
RUN npm run build
--mount=type=cache는 레이어 캐시와 별개로 동작한다. package.json이 바뀌어 레이어가 무효화되어도 npm 캐시 자체는 살아 있어서 다운로드를 건너뛴다. 체감상 두 번째부터의 빌드는 npm ci 시간이 절반 이하로 줄었다.
자주 놓치는 함정 체크리스트
신입이 처음 Dockerfile을 만질 때 거의 다음 함정에 한 번씩 빠진다. PR 리뷰할 때 쓰는 체크리스트다.
- [ ] COPY . . 가 의존성 설치보다 위에 있지 않은가
- [ ] .dockerignore에 node_modules, .git, .env가 빠져 있지 않은가
- [ ] RUN apt-get install 뒤에
rm -rf /var/lib/apt/lists/*가 같은 RUN 안에 붙어 있는가 - [ ] FROM에
:latest태그를 쓰고 있지 않은가 - [ ] 빌드 산출물을 runtime stage가 아닌 builder stage에서 실행하고 있지 않은가
- [ ] 환경변수에 비밀값이 평문으로 들어 있지 않은가
- [ ] alpine 베이스를 쓰면서 glibc 의존 패키지를 설치하려고 하지 않는가
특히 :latest 태그는 캐시 관점에서도 위험하다. 같은 태그가 가리키는 이미지가 외부에서 바뀌면 캐시가 의도와 다르게 깨진다. Node 20을 쓰면 node:20.11.1-alpine3.19처럼 정확한 버전을 박아두는 게 안전하다.
CI 환경에서의 캐시
GitHub Actions나 GitLab CI에서는 로컬 데몬이 매번 새로 뜨기 때문에 추가 설정 없이는 캐시가 보존되지 않는다. docker/build-push-action을 쓰면 GHA cache나 registry cache 같은 외부 백엔드에 캐시를 저장할 수 있다. (참고: docker/build-push-action v5 README, GitHub)
- uses: docker/build-push-action@v5
with:
context: .
push: true
tags: myapp:1.0.0
cache-from: type=gha
cache-to: type=gha,mode=max
mode=max로 두면 중간 레이어까지 모두 푸시한다. 저장소 용량은 더 쓰지만 두 번째 빌드부터의 히트율이 확연히 높아진다. mode=min과 max는 트레이드오프가 명확한데, 빌드 시간이 길수록 max가 유리하다.
적용 후 측정
위 내용을 한 프로젝트에 모두 적용한 결과는 다음과 같다. 같은 코드, 같은 머신, 같은 CI 러너 기준이다.
| 항목 | 적용 전 | 적용 후 |
|---|---|---|
| 컨텍스트 전송 | 487MB / 42s | 12MB / 1s |
| npm install | 매번 실행 / 218s | 캐시 히트 / 0s |
| 최종 이미지 | 1.2GB | 240MB |
| 전체 빌드 (코드만 수정) | 8분 12초 | 1분 28초 |
즉, 가장 큰 효과는 컨텍스트 전송과 의존성 설치 두 군데서 나왔다. 빌드 스크립트 자체를 최적화한 게 아니라, 캐시가 깨지지 않는 구조로 바꾼 것만으로 이만큼 줄었다. 처음에 webpack 감각으로 접근했을 때는 이 차이를 전혀 예상 못 했다.
특히, 당장 적용한다면 순서는 이렇다. (1) .dockerignore부터 채워서 컨텍스트 크기를 본다. (2) Dockerfile에서 COPY . . 를 의존성 설치 아래로 내린다. (3) 멀티스테이지로 분리한다. 이 셋만 해도 대부분의 프로젝트는 절반 이하로 줄어든다.
결국, 다음엔 BuildKit의 --mount=type=secret을 정리해볼 생각이다. private npm registry 토큰을 빌드 인자로 넣다가 이미지에 박혀 나가는 사고를 본 적이 있어서, secret mount 패턴을 팀 표준으로 만들고 싶다.
관련 글
- Docker Multi-Stage Build 완벽 가이드 — 이미지 크기 90% 줄이는 실전 방법 – Docker multi-stage build는 빌드 환경과 런타임 환경을 분리해 최종 이미지에서 불필요한 도구를 제거하는 표준 패턴이다. …
- Docker 빌드 캐시 CI 절반 줄이는 Buildx 설정 — TIL – GitHub Actions에서 Docker 빌드 캐시 CI를 손봤더니 12분 걸리던 워크플로가 5분 30초로 떨어졌다. type=gha m…
- Docker 컨테이너 DNS 설정 트러블슈팅 — 서비스 이름 해석 실패와 TTL 조정 – Docker 컨테이너 DNS 설정 때문에 두 번 막혔다. default bridge에서 서비스 이름이 안 풀린 게 첫 번째, alpine …