# Docker

코드 한 줄 고쳤는데 Docker는 왜 npm install부터 돌까?

@Hong2026-10-01

이 글에 담긴 코드와 결과는 직접 실행해 측정한 값을 바탕으로 작성했습니다. 실측은 Hong과 팀원들이 진행했고, 그 결과를 바탕으로 AI가 글을 쓰고 Hong이 검토합니다.

실습 코드 내려받기

질문
Docker 빌드에서 코드 한 줄만 고쳐도 npm install이 다시 돌고 이미지가 큰 것은 어떻게 고치는가?
답
package*.json만 먼저 복사해 npm ci를 하면 설치 단계가 세 번 모두 CACHED였고, 멀티 스테이지로 필요한 파일만 옮기자 이미지가 1326MB에서 170MB(distroless)가 됐다.
조건
2026년 10월, Apple Silicon Mac Docker Desktop(Docker Engine 27.5.1, BuildKit v0.18.2), Node.js 24, TypeScript 7, 빌드 세 번

환경

측정 시기
2026년 10월
장비
Apple Silicon Mac, Docker Desktop(Docker Engine 27.5.1, BuildKit v0.18.2)
이미지 저장소
Docker Desktop의 containerd 이미지 저장소, 이미지는 모두 arm64
앱
Node.js 24, TypeScript 7.0.2, express 5.2.1로 만든 Express 서버
반복
바꾼 파일마다 빌드 세 번(첫 빌드는 비교에서 뺌)
목차
  1. 실습 준비
  2. 실습 1: 첫 Dockerfile이 크고 느린 이유
  3. 실습 2: COPY 순서만 바꿔도
  4. 실습 3: 멀티 스테이지로 필요한 파일만 옮기기
  5. 정리

이번 시간에는 Node.js와 TypeScript로 만든 작은 앱 하나를 Dockerfile 여러 가지로 빌드하면서, 이미지 크기와 재빌드 시간을 docker build와 docker history로 측정하며 배워가는 시간입니다.

먼저 이런 상황을 생각해 볼게요. Node.js 앱의 소스 코드 한 줄을 고치고 이미지를 다시 빌드했더니 npm install이 처음부터 다시 돌았고, 그렇게 만든 이미지는 풀린 크기로 1326MB였습니다. 빌드가 느린 이유는 대개 캐시가 어느 줄에서 깨지는지 모르기 때문이고, 이미지가 큰 이유는 빌드할 때만 필요한 것들이 실행 이미지에 그대로 남기 때문입니다.

실습 준비

이 글의 명령은 모두 실습 코드의 docker-image-size/ 디렉터리에서 실행합니다.

  • app/: 실습 앱 소스, package.json·lock 파일, tsconfig.json, README.md, Dockerfile.v1·Dockerfile.v2·Dockerfile.v3·Dockerfile.v5
  • size.sh: 이미지의 풀린 크기와 압축 크기를 재는 스크립트
  • build.sh: 파일 하나를 바꾸고 빌드 시간을 재는 스크립트
  • buildlog.py: build.sh가 부르는 빌드 실행·로그 정리 스크립트

앱은 Express 서버 하나예요. TypeScript로 작성하고 npm run build로 dist/에 JavaScript를 만든 뒤, GET /에 버전 문자열을 돌려줍니다. app/package.json입니다.

{
  "name": "dimg-app",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "build": "tsc"
  },
  "dependencies": {
    "express": "5.2.1"
  },
  "devDependencies": {
    "@types/express": "5.0.6",
    "@types/node": "24.19.0",
    "typescript": "7.0.2"
  }
}

app/src/index.ts는 GET /에 dimg-app <VERSION>을 돌려주는 Express 서버이고, VERSION 상수의 처음 값은 "1.0.0"입니다.

저장소에 테스트용 큰 파일이 들어 있는 상황도 흉내 내 볼게요. fixtures/에 50MB 파일을 하나 둡니다. 0으로 채운 파일은 압축하면 거의 사라져서 압축 크기 비교가 흐려지니, 임의 바이트로 채워요.

mkdir -p app/fixtures
dd if=/dev/urandom of=app/fixtures/sample.bin bs=1m count=50
52428800 bytes transferred in 0.249117 secs (210458540 bytes/sec)

containerd 이미지 저장소를 쓰면 docker images의 SIZE가 압축된 레이어와 풀린 레이어를 더한 값으로 나와서 생각과 다른 값을 보여 줘요. 그래서 size.sh는 이미지 이름을 여러 개 받아서, 이미지마다 풀린 크기(docker history 레이어 크기 합)와 압축 크기(docker image inspect의 Size)를 MB로 찍어요. 풀린 크기는 컨테이너 안에서 보이는 파일 크기와 같은 기준이고, 압축 크기는 레지스트리에서 받을 때 내려받는 양에 가깝습니다.

build.sh는 ./build.sh <버전> <변경>처럼 불러요. 파일 하나를 바꾼 뒤 app/Dockerfile.<버전>으로 docker build를 실행해 걸린 시간을 잽니다. 이 글에서 쓰는 변경은 두 가지예요.

  • none: 아무것도 바꾸지 않아요.
  • src: src/index.ts의 VERSION 문자열을 바꿉니다. 코드만 고친 경우예요.

빌드와 측정은 buildlog.py가 맡아요. --progress=plain 로그에서 단계마다 캐시를 썼는지(CACHED), 다시 실행했다면 몇 초 걸렸는지(DONE)를 뽑아 출력하고, 빌드 컨텍스트를 얼마나 보냈는지(context)도 함께 출력해요. 마지막 줄의 elapsed가 빌드 전체에 걸린 시간입니다.

빌드할 때마다 시간이 흔들리기 때문에, 바꾼 파일별로 세 번씩 빌드해 범위로 적을게요. 첫 빌드는 베이스 이미지를 받는 시간이 섞이므로 비교에 쓰지 않습니다.

chmod +x build.sh size.sh

실습 1: 첫 Dockerfile이 크고 느린 이유

첫 번째 Dockerfile은 디렉터리 전체를 복사하고, 의존성을 설치하고, 빌드합니다. app/Dockerfile.v1이에요.

FROM node:24
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/index.js"]

한 번 빌드해 두고 크기와 레이어를 볼게요.

./build.sh v1 none
./size.sh dimg-app:v1
docker history --format '{{.Size}}\t{{.CreatedBy}}' dimg-app:v1 | head -7
dimg-app:v1  풀린 크기 1326MB  압축 크기 475MB
0B	CMD ["node" "dist/index.js"]
49.2kB	RUN /bin/sh -c npm run build # buildkit
53.6MB	RUN /bin/sh -c npm install # buildkit
52.5MB	COPY . . # buildkit
8.19kB	WORKDIR /app
0B	CMD ["node"]
0B	ENTRYPOINT ["docker-entrypoint.sh"]

docker history는 위에서부터 최근 레이어를 보여 줘요. WORKDIR 아래는 node:24의 레이어입니다.

1326MB 가운데 1220MB가 베이스 이미지이고, 이 Dockerfile이 더한 것은 106MB예요. COPY . .의 52.5MB는 거의 다 fixtures/sample.bin이고, npm install의 53.6MB는 node_modules와 npm이 내려받은 패키지를 보관하는 캐시 디렉터리입니다.

이제 src/index.ts의 문자열 하나만 바꾸고 다시 빌드해 볼게요.

./build.sh v1 src
context: 1.24kB
DONE 0.1s  [1/5] FROM docker.io/library/node:24@sha256:64af3819f9275802414d7cdc38
CACHED     [2/5] WORKDIR /app
DONE 0.9s  [3/5] COPY . .
DONE 5.4s  [4/5] RUN npm install
DONE 2.3s  [5/5] RUN npm run build
DONE 7.9s  [export] exporting to image
elapsed: 19.3s

소스 파일이 바뀌어 COPY . .의 체크섬이 달라졌고, 그 뒤의 npm install과 npm run build가 모두 다시 실행됐습니다. 왜 그럴까요?

Docker는 어떤 파일이 빌드에 필요한지 따지지 않고 캐시를 써요. COPY . .는 디렉터리 전체를 입력으로 받으므로, 디렉터리 안의 파일이 하나라도 바뀌면 그 뒤의 단계가 모두 다시 실행됩니다.

실습 2: COPY 순서만 바꿔도

의존성 목록은 코드보다 훨씬 드물게 바뀌어요. 그래서 package.json과 lock 파일만 먼저 복사해 설치하고, 나머지 파일은 그 뒤에 복사하는 방식으로 가볼게요.

설치에는 lock 파일 그대로 설치하는 npm ci를 씁니다. npm ci는 package.json과 lock 파일이 맞지 않으면 lock을 고치지 않고 오류로 끝나므로, 빌드할 때마다 같은 버전이 설치돼요. app/Dockerfile.v2입니다.

FROM node:24
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["node", "dist/index.js"]

v1과 v2를 번갈아 가며 src 변경을 세 번씩 빌드했어요. v2의 첫 회차 출력입니다.

./build.sh v2 src
context: 1.24kB
DONE 0.2s  [1/6] FROM docker.io/library/node:24@sha256:64af3819f9275802414d7cdc38
CACHED     [2/6] WORKDIR /app
CACHED     [3/6] COPY package*.json ./
CACHED     [4/6] RUN npm ci
DONE 0.9s  [5/6] COPY . .
DONE 3.6s  [6/6] RUN npm run build
DONE 5.9s  [export] exporting to image
elapsed: 12.4s

package*.json이 그대로라 npm ci까지 캐시를 썼고, COPY . .부터 다시 실행됐어요. 세 번씩 잰 결과입니다.

  • src 변경, v1: 16.9~22.6초. npm install은 세 번 모두 다시 실행(5.4~5.7초)
  • src 변경, v2: 10.5~12.8초. npm ci는 세 번 모두 CACHED

이 측정은 다른 컨테이너 작업이 함께 도는 Mac에서 했어요. 같은 src 변경을 먼저 한 차례 더 돌렸을 때는 v2가 8.0~17.7초까지 흔들려서 v1(14.7~18.4초)과 범위가 겹쳤습니다. 그러니 순서를 바꾼 효과는 로그에서 설치 단계가 CACHED인지로 확인하시는 편이 확실합니다.

크기는 순서와 상관없이 같습니다.

dimg-app:v1  풀린 크기 1326MB  압축 크기 475MB
dimg-app:v2  풀린 크기 1326MB  압축 크기 475MB

실습 3: 멀티 스테이지로 필요한 파일만 옮기기

실행에는 TypeScript 컴파일러가 필요 없어요. 그런데 뒤의 RUN에서 지워도 이미지는 줄지 않습니다. 레이어는 한 번 만들어지면 바뀌지 않고, 뒤의 레이어에서 파일을 지우면 "이 파일은 지워졌다"는 표시만 새 레이어에 남기 때문이에요.

이럴 때 쓰는 방법이 멀티 스테이지 빌드입니다. Dockerfile에 FROM을 여러 번 쓰면 스테이지가 여러 개 생기고, 최종 이미지에는 마지막 스테이지의 레이어만 들어가요. 앞 스테이지에서 만든 파일은 COPY --from=<스테이지 이름>으로 필요한 것만 골라 옮깁니다.

v3은 스테이지를 셋으로 나눠요. deps는 npm ci --omit=dev로 실행 의존성만 설치하고, build는 개발 의존성까지 설치해 컴파일합니다. 마지막 스테이지는 두 스테이지에서 node_modules와 dist/만 가져와요. 베이스는 실행에 필요한 최소 패키지만 담은 node:24-slim으로 바꿨습니다. app/Dockerfile.v3이에요.

FROM node:24-slim AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev

FROM node:24-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:24-slim
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
CMD ["node", "dist/index.js"]
./build.sh v3 none
./size.sh node:24-slim dimg-app:v2 dimg-app:v3
docker history --format '{{.Size}}\t{{.CreatedBy}}' dimg-app:v3 | head -6
context: 1.01kB
DONE 0.4s  [build 1/6] FROM docker.io/library/node:24-slim@sha256:0e0ff40c39bc087
DONE 0.1s  [build 2/6] WORKDIR /app
DONE 0.2s  [build 3/6] COPY package*.json ./
DONE 6.1s  [build 4/6] RUN npm ci
DONE 0.3s  [build 5/6] COPY . .
DONE 2.2s  [build 6/6] RUN npm run build
DONE 4.9s  [deps 4/4] RUN npm ci --omit=dev
DONE 0.2s  [stage-2 3/4] COPY --from=deps /app/node_modules ./node_modules
DONE 0.1s  [stage-2 4/4] COPY --from=build /app/dist ./dist
DONE 1.0s  [export] exporting to image
elapsed: 12.1s
node:24-slim  풀린 크기 268MB  압축 크기 81MB
dimg-app:v2  풀린 크기 1326MB  압축 크기 475MB
dimg-app:v3  풀린 크기 272MB  압축 크기 81MB
0B	CMD ["node" "dist/index.js"]
16.4kB	COPY /app/dist ./dist # buildkit
4.54MB	COPY /app/node_modules ./node_modules # buil…
8.19kB	WORKDIR /app
0B	CMD ["node"]
0B	ENTRYPOINT ["docker-entrypoint.sh"]

1326MB가 272MB로 줄었어요. 어디서 줄었는지 나눠 보면 이렇습니다.

  • 베이스 이미지: node:24 1220MB에서 node:24-slim 268MB로 바뀌었어요. 컴파일러, git, 각종 개발용 라이브러리처럼 빌드에 쓰는 패키지가 빠진 차이입니다.
  • 앱이 더한 부분: 106MB에서 4.6MB로 줄었어요. 실행 이미지에는 실행 의존성(4.54MB)과 dist/(16.4kB)만 들어 있고, typescript와 @types, 소스, fixtures/, npm 캐시는 앞 스테이지에만 남았습니다.

실행 스테이지의 베이스를 셸과 패키지 관리자를 뺀 gcr.io/distroless/nodejs24-debian13:nonroot로 바꾼 것이 v5예요. distroless에는 npm이 없어 설치를 할 수 없으므로 앞 두 스테이지는 node:24-slim을 그대로 씁니다. app/Dockerfile.v5입니다.

FROM node:24-slim AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev

FROM node:24-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM gcr.io/distroless/nodejs24-debian13:nonroot
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
CMD ["dist/index.js"]

distroless Node.js 이미지는 엔트리포인트가 node로 정해져 있어요. 그래서 CMD에는 실행할 .js 파일 경로만 적습니다. 빌드한 뒤 크기만 볼게요(발췌).

./build.sh v5 none
./size.sh gcr.io/distroless/nodejs24-debian13:nonroot dimg-app:v3 dimg-app:v5
gcr.io/distroless/nodejs24-debian13:nonroot  풀린 크기 166MB  압축 크기 55MB
dimg-app:v3  풀린 크기 272MB  압축 크기 81MB
dimg-app:v5  풀린 크기 170MB  압축 크기 56MB

같은 앱인데 Dockerfile만 바꿔 풀린 크기가 1326MB에서 170MB로 줄었어요. 다만 distroless에는 셸이 없어서 docker exec로 들어가 파일을 보거나 명령을 실행할 수 없습니다. 조사가 필요할 때는 busybox 셸이 들어 있는 :debug 태그(:debug-nonroot)로 바꿔 띄우시면 됩니다.

정리

범위: Apple Silicon Mac의 Docker Desktop(Docker Engine 27.5.1, BuildKit v0.18.2), Node.js 24와 TypeScript 7로 만든 Express 앱, 바꾼 파일마다 빌드 세 번.

  • COPY . . 뒤에 npm install을 둔 v1은 소스 한 줄만 바꿔도 설치를 세 번 모두 다시 실행했고, package*.json만 먼저 복사해 npm ci를 한 v2는 세 번 모두 CACHED였습니다. 자주 바뀌는 파일은 의존성 설치 뒤에 복사하세요.
  • 같은 src 변경을 먼저 한 차례 돌렸을 때 v2가 8.0~17.7초로 흔들려 v1과 범위가 겹쳤습니다. 순서를 바꾼 효과는 시간보다 설치 단계가 CACHED인지로 확인하세요.
  • 멀티 스테이지로 실행 의존성과 dist/만 옮기자 풀린 크기가 1326MB에서 272MB(slim), 170MB(distroless)가 됐습니다. 실행 이미지에는 COPY --from으로 필요한 파일만 옮기세요.
내 링크 만들기