서비스 학습
SOURCE · fe/Dockerfile · 2026-08-30

한 파일에서 개발 환경과
작은 production image를 만든다

Node는 React를 build하는 데 사용하고, 최종 서비스에서는 제거한다. Multi-stage build가 “만드는 도구”와 “실행에 필요한 것”을 분리한다.

Node 22.23.2 npm ci Vite build Nginx 1.30.4
Core concepts

Image, container, layer를 구분해야 Dockerfile이 보인다

Blueprint
Dockerfile

어떤 기반 환경에 어떤 파일과 명령을 순서대로 적용할지 적은 build recipe다.

Immutable result
Image

실행 파일과 filesystem을 층별로 저장한 읽기 중심 template이다.

Running process
Container

Image 위에 쓰기 가능한 얇은 layer를 더하고 실제 process를 실행한 instance다.

비유하면 Dockerfile은 요리법, image는 포장된 완제품, container는 포장을 열고 실제로 동작 중인 제품이다. 같은 image로 여러 container를 만들 수 있으며 각 container의 process와 임시 filesystem 변경은 서로 독립적이다.

Virtual Machine과 같은 것인가?

완전히 같지 않다. VM은 보통 guest 운영체제 kernel까지 가상화하지만 container는 host 쪽 kernel 기능을 공유하면서 process와 filesystem, network를 격리한다. macOS의 Docker Desktop에서는 Linux container를 실행하기 위해 내부적으로 작은 Linux VM이 한 층 더 존재한다. 그래서 Metal 전용 TurboFieldfare를 일반 Linux container에 그대로 넣지 않고 macOS host에 남겨 둔다.

Why this file

“내 Mac에서는 된다”를 재현 가능한 build 절차로 바꾼다

Dockerfile은 실행 명령을 적어 둔 메모가 아니라 image를 만드는 선언이다. 새 컴퓨터에서도 같은 base image, 같은 package lock, 같은 build 명령을 순서대로 적용한다.

1 dependencies

package 설치

2 development

Vite 개발 서버

3 build

dist 생성

4 production

Nginx 제공

현재 전체 코드

# syntax=docker/dockerfile:1

FROM node:22.23.2-alpine AS dependencies
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM dependencies AS development
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

FROM dependencies AS build
COPY . .
RUN npm run build

FROM nginx:1.30.4-alpine AS production
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget -qO- http://127.0.0.1/healthz || exit 1
CMD ["nginx", "-g", "daemon off;"]

구간별 상세 해설

1–6행 · dependencies: 변경이 적은 package layer

FROM은 Linux·Node가 설치된 출발 image를 고른다. AS dependencies는 이 stage에 이름을 준다. WORKDIR /app 이후 명령의 현재 폴더를 고정한다.

먼저 package.jsonpackage-lock.json만 복사한다. Source code가 바뀌어도 package 목록이 같으면 Docker가 npm ci layer를 cache할 수 있기 때문이다. npm ci는 lock file과 정확히 맞는 dependency를 깨끗하게 설치하며 lock을 임의로 갱신하지 않는다.

8–11행 · development: host에서 접속 가능한 Vite

FROM dependencies는 설치된 node_modules layer를 재사용한다. COPY . .는 source를 /app에 넣는다.

Container 안의 127.0.0.1은 Mac의 loopback이 아니라 container 자신이다. 현재는 Vite를 0.0.0.0에 bind해 container의 네트워크 interface에서도 받게 한다. 127.0.0.1에만 bind하면 일반적인 Docker port publishing으로 들어오는 요청을 받지 못한다. EXPOSE 5173은 문서화이며 실제 host port 공개는 -p나 Compose가 수행한다.

13–15행 · build: TSX가 dist로 변한다
src/App.tsx + CSS + public asset → tsc -b: type 검사 → vite build: bundle·minify·hash → /app/dist/index.html + assets/*.js + assets/*.css

이 stage의 Node와 source는 다음 production stage로 자동 이동하지 않는다.

17–23행 · production: build 결과만 실행

새로운 Nginx image에서 시작하므로 앞 stage의 Node와 node_modules는 최종 image에 포함되지 않는다. 18행은 우리의 HTTP 규칙을 넣고, 19행은 build stage의 dist만 web root로 복사한다.

HEALTHCHECK의 기본 검사 간격은 30초이며, 시작 유예 기간 등을 고려해 실패가 세 번 연속 집계되면 health 상태가 unhealthy가 된다. 이것만으로 container가 종료·재시작되는 것은 아니다. 마지막 CMDdaemon off;는 Nginx를 foreground에 유지한다. 주 process가 끝나면 container도 끝나기 때문이다.

Layer cache

명령 순서 하나가 build 시간을 바꾼다

Docker는 대체로 각 명령의 결과를 layer로 저장하고, 명령과 입력이 같으면 이전 결과를 재사용한다. package 설치는 느리지만 source 수정은 자주 일어나므로 둘을 분리해야 한다.

좋은 순서 COPY package.json package-lock.json ./ ← package 목록이 같으면 cache 유지 RUN npm ci ← 느린 설치 결과 재사용 COPY . . ← 자주 바뀌는 source는 나중에 복사 나쁜 순서 COPY . . ← source 한 줄만 바뀌어도 이 layer 변경 RUN npm ci ← 이후 cache가 무효화되어 매번 재설치

여기서 cache는 “항상 최신 내용을 무시한다”는 뜻이 아니다. Docker는 해당 명령이 의존하는 입력의 digest를 비교한다. package-lock.json이 바뀌면 설치 layer는 정상적으로 다시 실행된다.

Multi-stage data flow

Stage 사이에서는 지정한 결과만 이동한다

dependencies: Node + package.json + node_modules ├─→ development: + source → Vite dev server └─→ build: + source → /app/dist │ └─ COPY --from=build production: Nginx + nginx.conf + dist ← Node와 source는 들어오지 않음

FROM dependencies는 이름 붙인 이전 stage의 filesystem과 설정을 기반으로 삼으므로 node_modules도 상속한다. 반면 production의 FROM nginx:...는 별도 Nginx image를 기반으로 시작해 Node stage를 상속하지 않는다. 이 production stage에 build 산출물을 전달하는 지점이 COPY --from=build다. 별도로 nginx.conf는 host의 build context에서 복사한다. 이것이 최종 image를 작게 만들고, compiler나 source code처럼 실행에 불필요한 대상을 줄이는 원리다.

보안상의 이점: 작은 image가 자동으로 안전한 것은 아니지만 공격자가 이용할 수 있는 도구와 package 수를 줄이는 데 도움이 된다. 이후에는 base image의 취약점 점검, non-root 사용자, read-only filesystem 같은 별도 hardening도 필요하다.
Build context

.dockerignore는 무엇을 보내지 않을지 정한다

node_modules
dist
.git
.DS_Store
npm-debug.log*
Dockerfile*
README.md

docker build .의 마지막 점은 현재 폴더를 build context로 Docker engine에 보낸다는 뜻이다. Host의 node_modules는 macOS용일 수 있고 크기도 크므로 보내지 않는다. dist도 image 안에서 다시 생성하므로 제외한다.

Observed result

초기 빌드에서 무엇을 관찰했나

Build exit 0
Image 26,017,241 B
Health healthy
HTTP 200 · 200

home · fallback

증명: lock file로 package를 설치하고, container 안에서 production build한 결과를 Nginx가 정상 제공한다.
아직 증명하지 않음: FastAPI 연결, SSE streaming, Compose network, iPad 접근은 다음 단계다.
Read the measurement

26 MB라는 숫자가 말하는 것과 말하지 않는 것

26,017,241 byte는 초기 검증에서 보고한 frontend image 크기 지표다. 이번 문서 검토에서 다시 측정한 값은 아니며 다른 build의 보장값도 아니다. Image 크기는 측정 명령·플랫폼·압축 여부와 공유 layer 집계 방식에 따라 다르게 표시될 수 있다. Multi-stage build 때문에 Node 개발 도구와 node_modules 전체가 빠졌다는 결과와 잘 맞는다. 그러나 이 숫자가 browser가 26 MB를 전부 내려받는다는 뜻은 아니다. Browser 전송량은 dist 안의 JS·CSS 크기, 압축 설정, cache hit 여부로 별도 측정해야 한다.

또한 healthy/healthz가 응답했다는 뜻이지, 채팅 기능이나 Gemma 추론이 정상이라는 뜻은 아니다. Health check의 범위가 좁기 때문에 이후 Backend와 추론 서버에는 각각 별도의 readiness 검사가 필요하다.

자주 발생하는 오류

증상 원인 확인
Cannot connect to Docker daemon Docker CLI는 있지만 Docker Desktop engine이 꺼짐 docker info
Vite가 container 안에서만 열림 --host 0.0.0.0 누락 bind address와 port mapping
npm ci 실패 package.json과 lock file 불일치 로컬에서 npm install 후 lock 검토
새 URL에서 404 SPA fallback 누락 Nginx try_files
← 이전TypeScript 설정 다음 →Nginx 설정