입사 첫날, 신입은 Slack에서 “레포 클론하고 실행해 보세요”라는 메시지 하나를 받았습니다. README에는 Node.js 설치, Python 가상환경, PostgreSQL 로컬 설치, 환경변수 작성까지 열두 단계가 적혀 있었습니다.
docker compose up을 실행하자 .env 파일이 없어 앱이 죽었고, 필요한 값은 팀장만 알고 있었습니다. 개발 환경은 문서만으로 표준화되지 않습니다. 실행 진입점과 예시 환경변수까지 코드로 고정해야 팀원이 혼자 시작할 수 있습니다.
Docker로 팀 개발 환경 표준화하기
입사 첫날, 김신입은 Slack에서 "레포 클론하고 실행해 보세요"라는 메시지 하나를 받았습니다. README를 펼쳤더니 Node.js 18 설치, Python 가상환경 설정, PostgreSQL 로컬 설치, 환경변수 파일 작성... 단계가 열두 개였습니다. docker compose up 을 실행하자 Error: Cannot find module 'dotenv'가 떴고, .env 파일이 없다는 걸 뒤늦게 알았습니다. 환경변수 이름을 물어봤더니 팀장이 "아, 그거 저만 알고 있었네요"라고 답했습니다. 오후가 다 가도록 개발 환경이 뜨지 않았습니다.
이 시나리오는 낯설지 않습니다. 팀마다 개발 환경 설정 방식이 다르고, 신입이 올 때마다 누군가 옆에 앉아 두 시간을 쓰는 일이 반복됩니다. 이번 모듈에서는 Makefile, .env.example, docker compose watch, 멀티 타겟 Dockerfile을 조합해 git clone && make dev 한 줄로 환경이 뜨는 팀 표준을 만드는 방법을 다룹니다.
Docker Compose 기초(이전 모듈)를 알고 있다는 전제에서 출발합니다. 이번 목표는 '내 컴퓨터에서는 되는데요' 문제를 구조적으로 없애고, 팀원 누구나 동일한 환경에서 즉시 작업을 시작할 수 있게 하는 것입니다.
- 1.env.example 패턴 — 비밀값 없이 필요한 변수 목록을 팀과 공유하는 방법
- 2Makefile로 make dev / make down / make logs 진입점 표준화
- 3개발용 Dockerfile 타겟(hot reload)과 프로덕션 타겟(final)을 하나로 관리
- 4docker compose watch — 소스 변경 시 자동 sync/rebuild로 볼륨 마운트의 한계 극복
- 5git clone && make dev 한 줄 온보딩 구조 설계
이 모듈의 실습은 실제 팀 프로젝트 디렉토리 구조를 직접 만들며 진행합니다. 완성된 구조는 실무 프로젝트에 그대로 복사해 사용할 수 있습니다.
docker compose versionmkdir -p ~/dev-workflow-lab && cd ~/dev-workflow-labmake --version || sudo apt-get install -y makedocker pull node:20-alpine.env.example → .env 복사 패턴과 팀 컨벤션
Docker Compose는 프로젝트 루트의 .env 파일을 자동으로 읽어 docker-compose.yml 안의 변수(${DB_PASSWORD} 등)를 치환합니다. 문제는 이 파일에 DB 비밀번호, API 키, JWT 시크릿 같은 민감한 값이 들어있다는 점입니다. 실수로 Git에 커밋하면 퍼블릭 레포라면 즉시 유출, 프라이빗 레포도 협력사 초대 등으로 언제든 위험해집니다.

두 파일로 역할을 분리한다
| 파일 | Git 커밋 | 역할 |
|---|---|---|
.env.example | 커밋 O | 필요한 변수 이름과 예시값/빈 값 |
.env | .gitignore | 실제 비밀값 (팀원 각자 관리) |
.env.example 예시:
# 실습 디렉토리 준비
mkdir -p /tmp/docker/part3/exam_12 && cd /tmp/docker/part3/exam_12
# 앱 설정
APP_PORT=3000
NODE_ENV=development
# 데이터베이스
DB_HOST=db
DB_PORT=5432
DB_NAME=myapp
DB_USER=myuser
DB_PASSWORD=change_me_locally # ← 예시값, 실제값은 .env에서 변경
# 외부 API
STRIPE_SECRET_KEY=sk_test_REPLACE_WITH_YOUR_KEY
.gitignore 설정
# .gitignore
.env
.env.local
.env.*.local
팀 온보딩 컨벤션
새 팀원이 레포를 클론하면 README에 딱 한 줄이 있습니다.
cp .env.example .env
# .env를 열어 DB_PASSWORD, STRIPE_SECRET_KEY 등을 실제 값으로 채우세요
# (값은 팀 1Password / Notion 비밀 문서에 있습니다)
이 패턴의 장점은 .env.example이 필요한 변수 목록 문서 역할을 한다는 것입니다. 새 변수를 추가하면 코드 변경과 함께 .env.example도 업데이트해 PR에 포함시키는 것이 팀 컨벤션이 됩니다. 코드를 보면 어떤 환경변수가 필요한지 바로 알 수 있어, "저만 알고 있었네요" 상황이 반복되지 않습니다.
실습 1: Makefile로 make dev, make down, make logs 표준화
docker compose up -d --build처럼 옵션이 길어지면 팀원마다 다르게 실행합니다. 어떤 사람은 --build를 빠뜨리고, 어떤 사람은 -d를 빠뜨려 터미널이 점령됩니다. Makefile은 이 진입점을 하나로 고정합니다.
실습 디렉토리 구조 만들기
mkdir -p ~/dev-workflow-lab
cd ~/dev-workflow-lab
# 필요한 파일들을 생성
touch Makefile docker-compose.yml .env.example .gitignore
Makefile 작성
.PHONY: dev down logs restart ps clean
dev:
docker compose up -d
down:
docker compose down
logs:
docker compose logs -f
restart:
docker compose restart
ps:
docker compose ps
clean:
docker compose down -v --remove-orphans
개발 환경 데이터까지 완전 삭제
안전한 실행 조건: 로컬 개발 환경을 의도적으로 초기화하려는 경우에만 실행합니다.
실행 전 반드시 확인
- 대상 Compose 프로젝트 디렉토리가 맞는지 확인했다
- 로컬 DB 데이터가 삭제되어도 괜찮다
- .env와 소스코드는 삭제 대상이 아님을 확인했다
docker compose down -v --remove-orphans위 항목을 모두 확인한 후 복사할 수 있습니다
주의: Makefile의 들여쓰기는 반드시 탭(Tab) 문자를 사용해야 합니다. 스페이스로 작성하면
Makefile:3: *** missing separator. Stop.오류가 납니다.
.PHONY는 같은 이름의 파일이 있어도 항상 명령을 실행하도록 선언합니다. dev라는 파일이 프로젝트에 생겨도 make dev가 그 파일이 아닌 명령을 실행합니다.
함께 쓸 docker-compose.yml 예시
services:
app:
build:
context: .
target: dev # Dockerfile의 dev 스테이지만 빌드
ports:
- "${APP_PORT:-3000}:3000"
env_file:
- .env
volumes:
- .:/app
- /app/node_modules # node_modules는 컨테이너 것 유지
depends_on:
- db
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${DB_NAME:-myapp}
POSTGRES_USER: ${DB_USER:-myuser}
POSTGRES_PASSWORD: ${DB_PASSWORD:-localpassword}
ports:
- "${DB_PORT:-5432}:5432"
volumes:
- db_data:/var/lib/postgresql/data
volumes:
db_data:
동작 확인
cp .env.example .env
make dev # docker compose up -d 와 동일
make ps # 컨테이너 상태 확인
make logs # 전체 로그 스트리밍
make down # 중지 및 컨테이너 삭제
- cp .env.example .env 실행 후 .env 파일이 생성됐는가?
- make dev가 docker compose up -d와 같은 진입점으로 동작하는가?
- make ps로 실행 중인 서비스 상태를 확인할 수 있는가?
- make logs가 팀 공통 로그 확인 명령으로 쓰일 수 있는가?
Makefile의 핵심 가치는 명령어 표준화입니다. 신입이 "어떻게 실행해요?"라고 물으면 "make dev요"라는 한 마디로 끝납니다. CI에서도 동일한 명령으로 환경을 올릴 수 있습니다.
개발용 이미지(hot reload) vs 프로덕션 이미지(final) — Dockerfile 멀티 타겟
Dockerfile 하나로 개발 환경과 프로덕션 환경을 모두 커버하는 것이 팀 표준의 핵심입니다. 멀티 스테이지 빌드(이전 모듈에서 학습)를 활용하면 FROM ... AS dev와 FROM ... AS final 두 타겟을 한 파일에 선언하고, 빌드 시 어떤 타겟을 쓸지 선택합니다.

개발 vs 프로덕션 이미지의 차이
| 항목 | 개발(dev) | 프로덕션(final) |
|---|---|---|
| 소스 마운트 | 볼륨/watch로 실시간 반영 | COPY로 이미지에 포함 |
| 실행 방식 | nodemon, uvicorn --reload 등 | node server.js, gunicorn 등 |
| devDependencies | 포함 (nodemon 등) | 제외 (npm ci --only=production) |
| 이미지 크기 | 상대적으로 큼 | 최소화 |
| 디버그 도구 | 포함 가능 | 제외 |
Node.js 예시 — 단일 Dockerfile, 두 타겟
# ── 공통 베이스 ──────────────────────────────────────
FROM node:20-alpine AS base
WORKDIR /app
COPY package*.json ./
# ── 개발 스테이지 ────────────────────────────────────
FROM base AS dev
# devDependencies 포함 (nodemon, ts-node 등)
RUN npm install
COPY . .
# 소스는 볼륨/watch로 오버라이드되므로 COPY는 fallback
CMD ["npm", "run", "dev"] # nodemon 등 hot reload 실행
# ── 프로덕션 스테이지 ────────────────────────────────
FROM base AS final
RUN npm ci --only=production
COPY . .
RUN npm run build # 빌드 아티팩트 생성
CMD ["node", "dist/index.js"]
타겟 지정 빌드
# 개발 이미지 빌드 (Compose가 target: dev를 보고 자동 처리)
docker build --target dev -t myapp:dev .
# 프로덕션 이미지 빌드
docker build --target final -t myapp:prod .
docker-compose.yml에서 target: dev로 선언하면 make dev 실행 시 항상 개발 스테이지로 빌드됩니다. CI/CD 파이프라인은 target: final을 사용하거나 docker build --target final을 직접 호출합니다. 파일은 하나, 목적에 따라 다른 결과물을 얻는 구조입니다.
실습 2: docker compose watch로 소스 변경 자동 동기화
볼륨 마운트는 소스 변경을 컨테이너에 즉시 반영하지만, package.json이 바뀌면 어떻게 될까요? 새 패키지가 설치되지 않아 앱이 충돌합니다. 개발자는 컨테이너를 수동으로 재빌드해야 합니다. docker compose watch는 이 문제를 해결합니다.
docker compose watch란?
Compose 2.22+에서 정식 도입된 기능으로, 파일 변경을 감지해 sync(파일 복사), rebuild(이미지 재빌드), restart(컨테이너 재시작) 세 가지 액션 중 하나를 수행합니다.
docker-compose.yml에 watch 블록 추가
services:
app:
build:
context: .
target: dev
ports:
- "${APP_PORT:-3000}:3000"
env_file:
- .env
develop: # watch 설정 블록
watch:
- action: sync # 소스 변경 → 컨테이너로 즉시 복사
path: ./src
target: /app/src
- action: rebuild # package.json 변경 → 이미지 재빌드
path: package.json
- action: rebuild
path: package-lock.json
watch 실행
# watch 모드로 실행 (포그라운드 — 로그 스트리밍됨)
docker compose watch
# 또는 Makefile에 추가
# watch:
# docker compose watch
세 가지 액션 비교
| action | 언제 사용 | 동작 |
|---|---|---|
sync | 소스 코드(.js, .py, .go) 변경 | 파일을 컨테이너 내부로 복사 (재시작 없음) |
rebuild | package.json, requirements.txt, go.mod 변경 | 이미지를 다시 빌드하고 컨테이너 교체 |
restart | 설정 파일(.env, nginx.conf) 변경 | 재빌드 없이 컨테이너만 재시작 |
sync vs 볼륨 마운트 선택 기준
# 볼륨 마운트 방식 (기존)
volumes:
- ./src:/app/src
# 단순하고 즉각적이나, package.json 변경은 수동 대응 필요
# watch 방식
develop:
watch:
- action: sync
path: ./src
target: /app/src
- action: rebuild
path: package.json
# 파일 종류별 대응 규칙을 명시적으로 선언
볼륨 마운트가 더 단순한 경우도 많습니다. 프로젝트에 종속성 변경이 잦거나, 팀원들이 수동 재빌드를 자주 잊는다면 watch가 유리합니다.
git clone && make dev — 한 줄로 시작되는 팀 환경 구조
앞서 배운 요소들을 조합하면 온보딩이 단 두 단계로 압축됩니다.

git clone https://github.com/myorg/myapp.git
cd myapp
cp .env.example .env # 비밀값 채우기 (팀 문서 참고)
make dev # 끝
전체 파일 구조
팀 표준 개발 환경을 위한 최소 파일 세트입니다.
myapp/
├── Makefile # 진입점 표준화
├── docker-compose.yml # 서비스 정의 (develop.watch 포함)
├── Dockerfile # dev / final 멀티 타겟
├── .env.example # 필요한 변수 목록 (커밋)
├── .env # 실제 비밀값 (.gitignore)
├── .gitignore
└── src/
└── ...
README에 넣는 딱 세 줄
## 개발 환경 시작하기
git clone ... && cd myapp
cp .env.example .env # .env 파일에 팀 문서의 값을 채워주세요
make dev
이 구조가 해결하는 문제
| 문제 | 해결 |
|---|---|
| "어떤 환경변수가 필요해요?" | .env.example을 보면 바로 알 수 있음 |
| "docker compose 옵션을 어떻게 써요?" | make dev 한 줄로 통일 |
| "소스 수정했는데 반영이 안 돼요" | docker compose watch가 자동 sync |
| "package.json 바꿨더니 오류 나요" | watch의 rebuild 액션이 자동 처리 |
| "개발용/프로덕션용 이미지 따로 관리해요?" | Dockerfile 멀티 타겟으로 하나로 통합 |
신입이 환경을 올리는 데 두 시간 걸리던 것이 15분으로 줄어듭니다. 더 중요한 것은 시니어가 옆에 앉아줄 필요가 없다는 점입니다.
볼륨 마운트를 사용할 때 로컬의 node_modules가 컨테이너 내부 node_modules를 덮어쓰는 문제입니다.
# 증상
docker compose up
# Error: Cannot find module 'express'
# 원인: 볼륨 마운트가 컨테이너의 /app 전체를 덮어씀
# 컨테이너 내 npm install로 설치된 node_modules가 사라짐
volumes:
- .:/app # 이 줄이 컨테이너의 node_modules를 가림
# 해결: node_modules만 별도 볼륨으로 분리
volumes:
- .:/app
- /app/node_modules # 익명 볼륨으로 컨테이너 것 유지
익명 볼륨(/app/node_modules)은 Docker가 관리하는 별도 공간에 저장됩니다. 호스트의 node_modules가 없거나 비어 있어도 컨테이너에서 npm install한 결과가 유지됩니다.
로컬에 PostgreSQL이 이미 실행 중이거나, 이전에 실행한 컨테이너가 아직 포트를 점유하고 있습니다.
# 1. 점유 프로세스 확인 (Mac/Linux)
lsof -i :5432
# 또는
ss -tlnp | grep 5432
# 2. 컨테이너가 이미 실행 중인지 확인
docker ps | grep 5432
# 3-A. 로컬 PostgreSQL 서비스 중지
sudo systemctl stop postgresql # Linux
brew services stop postgresql@16 # Mac
# 3-B. 또는 .env에서 호스트 포트만 변경 (팀 파일은 그대로)
# .env 파일 수정
DB_PORT=5433 # 5432 → 5433으로 변경
docker-compose.yml을 직접 수정하면 Git에 올라가 팀 전체에 영향을 줍니다. .env의 DB_PORT만 바꾸면 내 로컬에서만 포트가 달라집니다. 이것이 포트도 환경변수로 관리하는 이유입니다.
많은 팀에서 새 서비스나 기능 브랜치를 만들 때 PR 본문 또는 README에 "이 변경을 로컬에서 어떻게 실행하는가" 를 명시하는 관행이 있습니다.
실무에서 흔히 보는 PR 체크리스트
## 개발 환경 변경사항
- [ ] .env.example에 신규 환경변수 추가 여부 확인
- [ ] docker-compose.yml 변경 시 팀원 슬랙 공지
- [ ] 새 서비스 추가 시 README 시작 가이드 업데이트
- [ ] Makefile 신규 target 추가 시 `make help` 출력 업데이트
Makefile에 help target 추가하기
규모가 커지면 make 명령이 많아집니다. make help로 목록을 출력하는 관행이 있습니다.
.PHONY: dev down logs restart ps clean watch help
dev: ## 개발 서버 시작 (백그라운드)
docker compose up -d
down: ## 컨테이너 중지 및 삭제
docker compose down
logs: ## 전체 로그 스트리밍
docker compose logs -f
watch: ## watch 모드로 실행 (소스 변경 자동 반영)
docker compose watch
clean: ## 컨테이너 + 볼륨 전체 삭제 (데이터 초기화)
docker compose down -v --remove-orphans
help: ## 사용 가능한 명령 목록 출력
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}'
make help
# dev 개발 서버 시작 (백그라운드)
# down 컨테이너 중지 및 삭제
# logs 전체 로그 스트리밍
# watch watch 모드로 실행 (소스 변경 자동 반영)
# clean 컨테이너 + 볼륨 전체 삭제 (데이터 초기화)
시니어가 코드 리뷰에서 체크하는 것
팀 환경 표준화가 잘 된 조직에서는 PR 리뷰 시 코드 외에 이런 항목도 확인합니다.
- 신규 환경변수를
.env.example에 추가했는가? - DB 스키마 변경이 있다면 마이그레이션 스크립트가 포함됐는가?
make dev로 로컬 실행이 가능한가? (리뷰어가 직접 확인)
이 관행이 쌓이면 팀 전체의 개발 속도가 높아집니다. 누가 온보딩을 해줄 여유가 없어도, 문서와 도구 자체가 온보딩을 대신합니다.
다음 모듈에서는 컨테이너가 CPU와 메모리를 과점유하지 못하도록 자원 제한을 설정하는 방법을 다룹니다. docker stats와 cgroups 기반 제한을 직접 확인합니다.