개발 생산성을 높이는 Compose 워크플로
개발 환경의 목표는 빠른 반복이다
Compose를 개발 환경에 잘 적용하면 팀원이 새로 합류했을 때 긴 설치 문서를 따라가며 하루를 쓰지 않아도 된다. Node.js, PostgreSQL, Redis, Nginx처럼 여러 도구가 필요한 프로젝트라도 다음 명령 하나로 시작할 수 있다.
docker compose up -d개발 환경에서 중요한 것은 "완벽한 운영 환경 복제"가 아니다. 코드를 바꾸고, 즉시 확인하고, 테스트하고, 다시 수정하는 반복이 빨라야 한다. 이를 위해 Compose에서는 보통 네 가지를 조합한다.
소스 코드는 바인드 마운트로 컨테이너에 연결한다.
개발 서버는 핫 리로드 모드로 실행한다.
의존성 캐시는 named volume으로 분리한다.
테스트, 린트, 마이그레이션은 일회성 컨테이너로 실행한다.
이 장에서는 Node.js 애플리케이션을 예로 들지만, 원리는 Python, Java, Go 프로젝트에도 비슷하게 적용할 수 있다. 언어나 프레임워크마다 캐시 경로와 실행 명령만 달라질 뿐, "빠르게 수정하고 확인한다"는 목표는 같다.
AI 생성 이미지
소스 코드 바인드 마운트와 핫 리로드
이미지를 다시 빌드해야만 코드 변경이 반영된다면 개발 속도는 느려진다. 개발 중에는 호스트의 프로젝트 디렉터리를 컨테이너 내부 작업 디렉터리에 연결해 두는 방식이 편하다. 이를 바인드 마운트라고 한다. 호스트 파일을 컨테이너 안에 그대로 보여 주는 방식이며, 소스 코드 공유에 자주 사용된다.1
예를 들어 다음과 같이 compose.yaml을 작성할 수 있다.
services:
app:
build: .
ports:
- '3000:3000'
environment:
NODE_ENV: development
DATABASE_URL: postgres://app:secret@db:5432/appdb
volumes:
- .:/app
- node_modules:/app/node_modules
command: npm run dev
depends_on:
- db
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: appdb
volumes:
- db_data:/var/lib/postgresql/data
volumes:
node_modules:
db_data:여기서 핵심은 두 줄이다.
volumes:
- .:/app
- node_modules:/app/node_modules첫 번째 줄은 현재 프로젝트 디렉터리를 컨테이너의 /app에 연결한다. 이제 로컬 편집기에서 파일을 수정하면 컨테이너 안의 파일도 바뀐 것처럼 보인다. 두 번째 줄은 /app/node_modules를 별도 Docker 볼륨으로 분리한다.
이 줄이 없으면 .:/app 마운트가 이미지 안에 설치된 node_modules를 가려 버릴 수 있다. 바인드 마운트는 컨테이너 내부의 기존 파일을 덮어 보이게 만들 수 있기 때문이다.1 반대로 node_modules를 별도 볼륨으로 빼 두면 호스트의 빈 디렉터리나 운영체제가 다른 환경 때문에 컨테이너 안의 의존성이 흔들리는 일을 줄일 수 있다.
개발 서버는 npm run dev처럼 파일 변경을 감지하는 명령으로 실행한다. Express 프로젝트라면 nodemon, Vite나 Next.js라면 각 프레임워크의 dev 서버가 이 역할을 한다.
실행해 보자.
docker compose up -d --build로그는 다음 명령으로 확인한다.
docker compose logs -f app이제 src 또는 server.js를 수정한 뒤 브라우저에서 http://localhost:3000을 새로고침한다. 컨테이너를 다시 빌드하지 않아도 변경 내용이 반영된다. 이것이 개발 환경에서 가장 중요한 피드백 루프다.
단, 바인드 마운트는 기본적으로 쓰기 가능하다. 컨테이너 안의 프로세스가 호스트 파일을 수정하거나 삭제할 수도 있다.1 설정 파일처럼 컨테이너가 읽기만 해야 하는 파일은 다음처럼 읽기 전용으로 연결하는 습관이 좋다.
volumes:
- ./config/app.yaml:/app/config/app.yaml:ro의존성 캐시는 볼륨으로 분리한다
개발 환경에서 시간이 오래 걸리는 작업 중 하나는 의존성 설치다. npm install, pip install, bundle install, mvn dependency:go-offline 같은 명령은 네트워크와 디스크를 많이 사용한다. 컨테이너를 다시 만들 때마다 이 작업을 반복하면 생산성이 떨어진다.
해결 방법은 자주 바뀌는 소스 코드와 덜 자주 바뀌는 의존성 저장소를 분리하는 것이다. 앞의 예제에서 node_modules를 named volume으로 분리한 이유가 여기에 있다.
volumes:
- .:/app
- node_modules:/app/node_modules이렇게 하면 컨테이너를 지웠다가 다시 만들어도 node_modules 볼륨은 남아 있다.
docker compose down
docker compose up -ddocker compose down은 컨테이너와 기본 네트워크를 제거하지만 named volume은 기본적으로 삭제하지 않는다. 볼륨까지 지우려면 명시적으로 -v를 붙여야 한다.
docker compose down -v의존성 상태가 꼬였거나 처음부터 다시 설치하고 싶을 때만 이 명령을 사용한다. 특히 down -v는 데이터베이스 볼륨도 함께 지울 수 있으므로, 개발용 데이터라도 필요한 내용이 남아 있지 않은지 한 번 확인하는 편이 안전하다.
언어별로 캐시 위치는 다르다. 프로젝트에 맞게 다음과 같은 볼륨을 추가할 수 있다.
| 환경 | 자주 분리하는 경로 예시 |
|---|---|
| Node.js | /app/node_modules, /root/.npm |
| Python | /root/.cache/pip, 가상환경 디렉터리 |
| Java Gradle | /home/gradle/.gradle |
| Maven | /root/.m2 |
| Ruby | /usr/local/bundle |
예를 들어 npm 다운로드 캐시까지 보존하고 싶다면 다음처럼 쓸 수 있다.
services:
app:
volumes:
- .:/app
- node_modules:/app/node_modules
- npm_cache:/root/.npm
volumes:
node_modules:
npm_cache:주의할 점도 있다. 캐시 볼륨은 빠르지만, 오래된 의존성을 붙잡고 문제를 만들 때가 있다. package-lock.json이나 requirements.txt를 바꿨는데 이상한 오류가 난다면 볼륨을 지우고 다시 설치해 본다.
docker compose down -v
docker compose up -d --build개발 속도를 위해 캐시를 쓰되, 문제가 생기면 깨끗하게 재생성할 수 있어야 한다.
Compose Watch로 변경 감지 자동화하기
바인드 마운트와 핫 리로드만으로도 대부분의 개발은 충분히 편하다. 하지만 어떤 파일은 단순 동기화만으로 부족하다. 예를 들어 package.json이 바뀌면 의존성을 다시 설치하거나 이미지를 다시 빌드해야 한다. Nginx 설정 파일이 바뀌면 컨테이너를 재시작해야 할 수 있다.
이때 Compose Watch를 사용할 수 있다. Compose Watch는 docker compose up --watch 또는 docker compose watch 명령으로 파일 변경을 감지하고, 설정한 동작을 자동으로 수행하는 기능이다.2 공식 문서 기준으로 Docker Compose 2.22.0 이상에서 사용할 수 있으므로, 명령이 동작하지 않는다면 먼저 Compose 버전을 확인하자.
docker compose version예시는 다음과 같다.
services:
app:
build: .
command: npm run dev
ports:
- '3000:3000'
develop:
watch:
- action: sync
path: ./src
target: /app/src
ignore:
- node_modules/
- action: rebuild
path: ./package.json
- action: rebuild
path: ./package-lock.jsonsync는 호스트의 변경 파일을 컨테이너로 동기화한다. 핫 리로드 서버와 잘 맞는다. rebuild는 이미지를 다시 빌드하고 서비스를 교체한다. 의존성 파일이나 빌드 결과에 영향을 주는 파일에 적합하다. sync+restart는 파일을 동기화한 뒤 컨테이너를 재시작한다. 설정 파일 변경에 유용하다.3
예를 들어 Nginx 설정 파일을 바꿀 때 컨테이너 재시작까지 자동으로 하고 싶다면 다음처럼 규칙을 추가할 수 있다.
services:
web:
build: ./nginx
develop:
watch:
- action: sync+restart
path: ./nginx/nginx.conf
target: /etc/nginx/nginx.conf실행 명령은 다음과 같다.
docker compose up --watch또는 이미 컨테이너를 띄워 둔 상태에서 watch만 별도로 실행할 수도 있다.
docker compose watch다만 Compose Watch가 바인드 마운트를 무조건 대체한다고 생각할 필요는 없다. 공식 문서에서도 Watch는 바인드 마운트의 대체라기보다, 컨테이너 안에서 개발할 때 더 세밀한 동기화 규칙을 제공하는 보조 도구에 가깝다고 설명한다.2 또한 로컬 소스로 빌드하는 서비스에 맞춰 설계되어 있으므로, 단순히 image:만 지정한 외부 이미지 서비스에는 기대한 방식으로 동작하지 않을 수 있다.
처음 Docker를 배우는 단계에서는 바인드 마운트 방식부터 익히는 것이 좋다. 그다음 프로젝트가 커지고 "어떤 파일은 동기화, 어떤 파일은 재빌드"라는 규칙이 필요해질 때 Compose Watch를 도입하면 된다.
일회성 작업은 run --rm으로 실행한다
개발 중에는 웹 서버를 계속 띄워 두는 작업만 있는 것이 아니다. 데이터베이스 마이그레이션, 테스트, 린트, 타입 검사, 빌드처럼 필요할 때 한 번 실행하고 끝나는 작업도 많다.
이런 작업을 호스트에 직접 설치한 도구로 실행하면 팀원마다 결과가 달라질 수 있다. 대신 애플리케이션 컨테이너와 같은 이미지, 같은 환경 변수, 같은 네트워크 안에서 실행하면 결과가 더 일관된다.
마이그레이션을 실행해 보자.
docker compose run --rm app npm run migrate테스트와 린트도 같은 방식이다.
docker compose run --rm app npm test
docker compose run --rm app npm run lint
docker compose run --rm app npm run build여기서 run은 특정 서비스 정의를 바탕으로 새 컨테이너를 하나 만들어 명령을 실행한다. --rm은 명령이 끝난 뒤 그 일회성 컨테이너를 자동 삭제한다. 개발 환경을 깨끗하게 유지하는 데 도움이 된다.
이미 실행 중인 app 컨테이너 안에서 명령을 실행하고 싶다면 exec를 쓴다.
docker compose exec app npm test둘의 차이는 간단하다.
docker compose run --rm app ...: 새 컨테이너를 만들어 한 번 실행한다.docker compose exec app ...: 이미 실행 중인 컨테이너 안에서 실행한다.
마이그레이션이나 빌드처럼 독립적으로 실행해도 되는 작업은 run --rm이 깔끔하다. 실행 중인 서버의 현재 상태를 확인하거나 셸에 들어가고 싶을 때는 exec가 편하다.
docker compose exec app sh데이터베이스가 필요한 테스트라면 먼저 의존 서비스를 띄운다.
docker compose up -d db
docker compose run --rm app npm test서비스 정의에 depends_on이 있다면 run이 의존 서비스를 함께 시작할 수 있다. 반대로 필요 없는 의존 서비스를 자동으로 띄우고 싶지 않을 때는 --no-deps를 사용할 수 있다.
docker compose run --rm --no-deps app npm run lint린트는 데이터베이스가 필요 없으므로 더 빠르게 실행할 수 있다.
팀 공통 명령을 README와 스크립트로 묶기
좋은 Compose 환경은 compose.yaml만으로 끝나지 않는다. 팀원이 어떤 명령을 써야 하는지 쉽게 알 수 있어야 한다. README에 최소 실행 흐름을 적어 두자.
## 개발 환경 실행
```bash
docker compose up -d --build
```
## 로그 확인
```bash
docker compose logs -f app
```
## 테스트
```bash
docker compose run --rm app npm test
```
## 마이그레이션
```bash
docker compose run --rm app npm run migrate
```
## 초기화
```bash
docker compose down -v
docker compose up -d --build
```위 예시처럼 README에 그대로 복사해 넣을 수 있는 명령을 적어 두면, 팀원이 매번 "어떤 명령을 실행해야 하죠?"라고 묻지 않아도 된다.
반복 명령은 Makefile이나 스크립트로 감싸면 더 편하다. 예를 들어 macOS나 Linux 개발자가 많은 팀이라면 다음과 같이 작성할 수 있다.
up:
docker compose up -d --build
logs:
docker compose logs -f app
test:
docker compose run --rm app npm test
lint:
docker compose run --rm --no-deps app npm run lint
migrate:
docker compose run --rm app npm run migrate
down:
docker compose down
reset:
docker compose down -v
docker compose up -d --build이제 팀원은 긴 명령을 외우지 않아도 된다.
make up
make test
make migrateWindows 사용자가 많다면 scripts/dev.ps1, scripts/test.ps1 같은 PowerShell 스크립트를 함께 제공하는 편이 좋다. 중요한 것은 도구가 Makefile인지 셸 스크립트인지가 아니다. 팀이 같은 명령으로 같은 개발 환경을 실행하도록 만드는 것이다.
작은 실습: 개발 루프 완성하기
마지막으로 이번 장의 흐름을 한 번에 정리해 보자.
개발 환경을 실행한다.
docker compose up -d --build애플리케이션 로그를 따라간다.
docker compose logs -f app소스 파일을 수정하고 브라우저에서 결과를 확인한다.
# 예: src/server.js 수정 후
# http://localhost:3000 접속테스트를 일회성 컨테이너로 실행한다.
docker compose run --rm app npm test린트를 빠르게 실행한다.
docker compose run --rm --no-deps app npm run lint의존성이나 볼륨 상태가 꼬였을 때만 초기화한다.
docker compose down -v
docker compose up -d --build이 워크플로가 자리 잡으면 신규 팀원은 로컬에 데이터베이스나 런타임을 하나씩 설치하지 않아도 된다. Docker와 Compose만 준비한 뒤 저장소를 내려받고, README의 첫 명령을 실행하면 된다.
Compose의 가치는 여기서 분명해진다. 컨테이너를 여러 개 실행하는 도구를 넘어, 팀의 개발 방식을 문서화하고 자동화하는 도구가 된다. 빠른 피드백 루프, 재사용 가능한 캐시, 일관된 작업 컨테이너, 짧은 공통 명령이 합쳐질 때 개발 환경은 개인의 노트북 설정이 아니라 프로젝트의 일부가 된다.
참고
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.