금요일 오후 4시, 슬랙에 메시지가 올라왔다. "저는 되는데 스테이징에서 안 되네요." 백엔드 개발자가 보낸 메시지에 프론트 팀 전체가 반응했다. "저도요." "저는 아예 빌드가 안 돼요." 확인해보니 원인은 Node.js 버전이었다. 한 명은 18, 한 명은 20, 한 명은 21. package-lock.json의 lockfileVersion이 달라서 npm ci가 실패하고, 특정 패키지의 네이티브 바이너리가 버전에 따라 다르게 빌드됐다. "nvm으로 맞추면 되잖아"라고 할 수 있는데, 프로젝트가 3개이고 각각 Node 버전이 다르면 매번 nvm use를 치는 것도 까먹기 쉽다.
이 문제를 해결하는 데 2시간이 걸렸다. 스프린트 마지막 날에 배포 대신 환경 맞추기로 시간을 날린 거다. 그 다음 주 회고에서 누군가가 "Docker 쓰면 이런 거 안 생기지 않나요?"라고 했다.
"내 컴퓨터에서는 되는데"의 근본 원인
이 문장이 나올 때 원인은 거의 정해져 있다. Node 버전, OS 차이로 인한 네이티브 바이너리 문제, 환경변수 누락, 로컬에만 있는 설정 파일. 전부 "환경이 다르다"는 한 문장으로 요약된다.
Docker는 이 문제를 컨테이너라는 격리된 환경으로 해결한다. "Node 20이 설치되어 있고, 이 프로젝트의 코드가 들어 있고, npm install이 완료된 상태"를 Dockerfile이라는 텍스트 파일로 정의한다. 이 파일만 있으면 누가, 어디서, 어떤 OS에서 실행하든 같은 환경이 만들어진다.
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]처음에 이 파일을 봤을 때 "이게 끝이야?" 싶었다. 근데 이 6줄이 "내 컴퓨터에서는 되는데"를 없애준다. 팀원 전원이 정확히 같은 Node 20 Alpine 이미지 위에서 개발한다. lockfileVersion이 다를 수가 없다.
package.json을 소스 코드보다 먼저 복사하는 이유가 있다. Docker는 각 명령어를 레이어로 캐싱하는데, 소스 코드가 바뀌어도 package.json이 안 바뀌었으면 npm ci 레이어를 캐시에서 가져온다. 매번 의존성을 새로 설치하지 않으니까 빌드가 훨씬 빠르다. 이걸 모르고 COPY . .을 맨 위에 놓으면 코드 한 줄 바꿀 때마다 npm ci가 돌아간다. 처음에 이 실수를 했는데, 빌드가 3분씩 걸려서 뭐가 문제인지 한참 찾았다.
docker-compose로 백엔드까지 한 방에 띄우기
프론트엔드만 Docker에 넣으면 효과가 반감된다. 진짜 편해지는 건 백엔드 API, 데이터베이스, Redis까지 한 번에 띄울 때다.
services:
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.dev
ports:
- "3000:3000"
volumes:
- ./frontend/src:/app/src
- ./frontend/public:/app/public
environment:
- NEXT_PUBLIC_API_URL=http://localhost:4000
depends_on:
- api
api:
build:
context: ./backend
ports:
- "4000:4000"
environment:
- DATABASE_URL=postgresql://user:password@db:5432/myapp
- REDIS_URL=redis://redis:6379
depends_on:
- db
- redis
db:
image: postgres:16-alpine
ports:
- "5432:5432"
environment:
- POSTGRES_USER=user
- POSTGRES_PASSWORD=password
- POSTGRES_DB=myapp
volumes:
- pgdata:/var/lib/postgresql/data
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
pgdata:docker-compose up 하나로 전부 뜬다. 새 팀원이 합류해도 Docker Desktop 설치하고 이 명령어 하나만 치면 된다. PostgreSQL이 로컬에 있는지, Redis 설정은 어떻게 하는지 신경 쓸 필요 없다.
이전 프로젝트에서는 신규 입사자 온보딩에 반나절이 걸렸다. "Node 버전 맞추고, Postgres 설치하고, .env 파일 받아서 넣고, Redis도 깔아야 하고..." Docker 도입 후에는 15분이면 전체 개발 환경이 돌아갔다. 이것만으로도 Docker를 배운 보람이 있었다.
1.2GB에서 180MB로: 멀티 스테이지 빌드
개발용 Dockerfile은 간단하다. 문제는 프로덕션이다. 처음에 프로덕션 이미지를 빌드했더니 1.2GB가 나왔다. node_modules 전체가 이미지에 들어가 있었다. CI/CD에서 이미지 푸시하는 데만 4분씩 걸렸다.
멀티 스테이지 빌드로 해결했다. 아이디어는 간단하다. 빌드에 필요한 것(소스 코드, 전체 node_modules)은 1단계에서만 쓰고, 2단계에서는 빌드 결과물만 가져간다.
# Stage 1: 빌드
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: 실행
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]Next.js의 standalone 출력 모드가 핵심이다.
// next.config.js
module.exports = {
output: "standalone",
};이 한 줄을 추가하면 Next.js가 node_modules에서 실제로 필요한 파일만 추출해서 .next/standalone 디렉토리에 넣어준다. 결과적으로 이미지 크기가 1.2GB에서 180MB로 줄었다. CI/CD 시간도 4분에서 45초로 단축됐다. 배포 속도가 빨라지니까 핫픽스 대응도 빨라졌다.
macOS 볼륨 마운트: 아무도 안 알려주는 함정
Docker를 쓰면서 가장 고통스러웠던 삽질이 이거다. macOS에서 Docker 볼륨 마운트가 느리다. 엄청나게 느리다. 아무도 미리 경고해주지 않았다.
처음에 프로젝트 전체를 볼륨으로 마운트했다.
volumes:
- ./frontend:/app두 가지 문제가 터졌다. 첫째, 로컬의 macOS용 node_modules가 컨테이너의 Linux 환경을 덮어쓴다. sharp, esbuild 같은 네이티브 바이너리 패키지가 전부 에러를 뱉었다. 플랫폼이 다르니까 당연한 건데, 처음에는 원인을 몰라서 한참 헤맸다.
둘째, Hot Module Replacement가 안 됐다. Docker 볼륨은 기본적으로 macOS의 파일 시스템 이벤트(inotify)를 전달하지 않는다. 코드를 수정해도 컨테이너가 변경을 감지하지 못한다. 파일을 저장하고, 브라우저를 새로고침하고, "왜 안 바뀌지?" 하면서 20분을 날렸다.
해결은 두 단계였다. 먼저 마운트 범위를 소스 코드만으로 제한한다.
volumes:
- ./frontend/src:/app/src
- ./frontend/public:/app/publicnode_modules는 마운트하지 않고 컨테이너 내부 걸 쓰게 한다. 그러면 네이티브 바이너리 문제가 해결된다.
HMR 문제는 webpack의 polling 모드로 해결했다.
// next.config.js
module.exports = {
webpack: (config) => {
config.watchOptions = {
poll: 1000,
aggregateTimeout: 300,
};
return config;
},
};1초마다 파일 변경을 체크하는 거라 CPU를 좀 더 먹지만, 확실하게 동작한다. 이 두 가지를 해결하는 데 반나절이 걸렸다. 블로그나 튜토리얼에서 이 문제를 제대로 다루는 곳이 거의 없어서 더 오래 걸렸다.
.dockerignore 파일도 빼먹으면 안 된다. 이걸 설정 안 하면 COPY . . 할 때 node_modules, .next, .git 전부가 빌드 컨텍스트에 포함된다. Docker 데몬에 컨텍스트를 보내는 것만 30초씩 걸린 적이 있다.
node_modules
.next
.git
*.md
Docker가 과하다는 것을 인정하는 것도 실력이다
여기까지 읽으면 "Docker 좋으니까 무조건 쓰자"로 들릴 수 있는데, 솔직히 말하면 대부분의 프론트엔드 프로젝트에서 Docker는 과하다.
프론트만 단독으로 개발하고, API는 mock으로 대체하고, Vercel이나 Netlify로 배포하는 환경이라면 Docker를 쓸 이유가 거의 없다. 이런 플랫폼은 프레임워크를 자동 감지하고, 빌드하고, 배포하고, 스케일링까지 해준다. 설정이 zero에 가깝다. 프리뷰 URL, 자동 배포, 롤백까지 컨테이너 오케스트레이션의 복잡함 없이 다 된다. 이런 환경에서 Docker를 도입하면 복잡성만 늘어난다. 모던 프론트엔드 배포 플랫폼이 인프라 관심사를 완전히 추상화해버린 시대인 거다.
Docker가 진짜 필요한 순간은 명확하다.
- 백엔드와 함께 로컬에서 돌려야 할 때. API, DB, Redis를 한 번에 띄워야 하는 상황.
- 팀원 간 환경 차이가 반복적으로 문제를 일으킬 때. "내 컴퓨터에서는 되는데"가 스프린트마다 나오는 상황.
- AWS, GCP에 직접 배포할 때. Vercel을 못 쓰는 사정이 있는 경우.
- CI/CD 파이프라인에서 빌드 환경을 일관되게 유지해야 할 때.
이 네 가지 중 하나라도 해당되면 Docker를 배울 가치가 충분하다. 하나도 해당 안 되면 지금 당장은 안 배워도 된다.
한 엔지니어링 블로그에서 읽은 글이 인상적이었다. 도구 도입의 기준은 "이 도구가 멋있는가"가 아니라 "이 도구가 우리 팀의 구체적인 문제를 해결하는가"여야 한다고. Docker도 마찬가지다. 금요일 오후에 "내 컴퓨터에서는 되는데"로 2시간을 날려본 적이 있다면, 그때가 Docker를 배울 때다. 그런 경험이 없다면, Dockerfile 작성법과 docker-compose 기본 사용법 정도만 알아두고 넘어가도 충분하다. Kubernetes나 Docker Swarm 같은 오케스트레이션은 프론트엔드 범위를 한참 벗어난다. 적어도 지금은.
