Docker Compose Watch로 저장하는 순간 컨테이너에 반영하기

컨테이너로 개발 환경을 돌릴 때 가장 답답한 순간은 코드 한 줄 고치고 나서 이미지를 다시 빌드하고 컨테이너를 재시작할 때다. 저장하고, 빌드 기다리고, 브라우저 새로고침하는 이 루프가 하루에도 수십 번 반복된다. 예전에는 이걸 피하려고 소스 디렉토리를 volume으로 마운트했는데, 이 방식은 컨테이너 안팎의 파일 권한이나 경로가 꼬이기 쉽고 컴파일 언어에는 아예 맞지 않는다. Docker Compose Watch는 이 문제를 정면으로 겨냥해 나온 기능이다. 호스트에서 파일을 저장하는 순간, 미리 정의한 규칙에 따라 컨테이너에 동기화하거나 이미지를 다시 빌드해준다. 이번 포스팅에서는 Docker Compose Watch의 세 가지 동작 방식과 설정 문법, 그리고 실제로 쓰다 보면 반드시 만나는 함정까지 정리하고자 한다.

Docker Compose Watch가 volume 마운트와 뭐가 다른가

Docker Compose Watch는 호스트 파일 변경을 감지해 컨테이너에 반영하는 기능이며, 단순 volume 마운트와 달리 “무엇을, 어떻게 반영할지”를 파일 종류별로 다르게 지정할 수 있다는 점이 핵심이다. 소스 코드는 그대로 복사(sync)하고, 의존성 파일(package.json 등)이 바뀌면 이미지를 다시 빌드(rebuild)하는 식으로 규칙을 나눠 쓸 수 있다.

volume 마운트는 호스트 디렉토리를 컨테이너에 통째로 겹쳐 얹는 방식이라 몇 가지 문제가 따라온다. 컨테이너 내부에서 생성된 파일(빌드 산출물, node_modules 등)이 호스트 쪽으로 새어 나오거나 반대로 덮이고, 파일 소유권(uid/gid)이 어긋나며, 무엇보다 컴파일이 필요한 언어에서는 소스만 바꿔봐야 실행 바이너리는 그대로라 아무 효과가 없다. Docker Compose Watch는 마운트가 아니라 “변경 이벤트에 대한 동작 규칙”이다. 파일이 바뀌면 그 경로에 매칭되는 규칙을 찾아, 정의된 액션(sync/rebuild/sync+restart)을 실행한다. 덕분에 소스는 즉시 반영하되 의존성 변경 같은 무거운 변화만 골라 재빌드하는 세밀한 제어가 가능하다.

sync, rebuild, sync+restart는 각각 언제 쓰나

Docker Compose Watch의 액션은 세 가지이고, 파일이 바뀌었을 때 “얼마나 무거운 반영이 필요한가”로 나뉜다. 코드처럼 복사만 하면 되는 건 sync, 의존성처럼 이미지를 다시 만들어야 하는 건 rebuild, 설정 파일처럼 복사 후 프로세스만 재시작하면 되는 건 sync+restart를 쓴다.

액션동작언제 쓰나
sync호스트 파일을 컨테이너로 복사만Hot Reload 지원 프레임워크의 소스 코드 (React, Flask –reload 등)
rebuild이미지를 다시 빌드하고 컨테이너 교체의존성 파일(package.json, go.mod), 컴파일 언어 소스
sync+restart복사 후 컨테이너 프로세스만 재시작설정 파일(nginx.conf, application.yml) — 재빌드는 불필요

여기서 핵심은 sync가 “컨테이너 안에서 알아서 다시 읽어주는 프레임워크”를 전제로 한다는 점이다. 예를 들어 Vite나 Flask의 리로드 모드는 소스 파일이 바뀌면 스스로 감지해 다시 로드하므로, 파일을 컨테이너에 복사만 해줘도(sync) 즉시 반영된다. 반대로 Go나 Java처럼 실행 전에 컴파일이 필요한 언어는 소스를 복사해봐야 이미 실행 중인 바이너리는 바뀌지 않으므로 rebuild가 맞다. 설정 파일은 대개 프로세스 시작 시 한 번 읽으므로, 복사 후 재시작(sync+restart)이면 충분하고 이미지 재빌드까지 갈 필요가 없다.

compose.yaml에 watch 규칙은 어떻게 쓰나

Docker Compose Watch 규칙은 서비스 아래 develop.watch 배열에 정의하며, 각 규칙은 action, path, target(sync 계열만) 필드로 구성된다. path는 감시할 호스트 경로, target은 컨테이너 내부의 반영 위치다.

services:
  web:
    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
YAML

위 설정에서 ./src 아래 파일을 저장하면 컨테이너의 /app/src로 즉시 복사되고, package.json을 바꾸면 이미지 전체를 다시 빌드한다. ignore는 감시에서 제외할 패턴인데, 주의할 점은 이 패턴이 프로젝트 루트가 아니라 해당 규칙의 path 기준으로 적용된다는 것이다. 위 예에서 node_modules/./src/node_modules/를 의미한다. .dockerignore 규칙과 .git, 그리고 Vim·JetBrains 같은 에디터의 임시 파일은 별도로 지정하지 않아도 자동으로 무시된다.

target은 sync와 sync+restart에만 있고 rebuild에는 없다. rebuild는 파일을 복사하는 게 아니라 빌드를 트리거하기만 하므로 컨테이너 내부 목적지가 필요 없기 때문이다. 경로 매핑은 직관적이다. path: ./srctarget: /app/src./src/index.js/app/src/index.js로 간다.

watch는 어떻게 실행하나

Docker Compose Watch는 docker compose watch 또는 docker compose up --watch로 실행한다. 전자는 watch 로그만 보여주고, 후자는 애플리케이션 로그와 watch(재빌드) 로그를 한 화면에서 함께 보여준다.

# watch 로그만 (애플리케이션 로그와 분리해서 보고 싶을 때)
docker compose watch

# 애플리케이션 로그 + watch 로그 함께
docker compose up --watch
ShellScript

둘 중 어느 쪽을 쓸지는 취향과 상황에 따라 갈린다. 애플리케이션이 요청을 처리하며 찍는 로그를 실시간으로 봐야 한다면 up --watch가 편하고, 동기화·재빌드 이벤트만 깔끔하게 추적하고 싶다면 watch가 낫다. 실행하면 Compose가 develop.watch에 정의된 경로들을 감시하기 시작하고, 파일이 바뀔 때마다 매칭된 액션을 콘솔에 로그로 남긴다. 예를 들어 src/app.js를 저장하면 Syncing service "web" after changes were detected처럼 어떤 서비스에 무슨 동작이 일어났는지 즉시 출력된다.

sync를 켰는데 컨테이너에 기존 파일이 안 보인다면

Docker Compose Watch의 sync는 기본적으로 “watch를 시작한 이후의 변경분”만 컨테이너로 복사한다. 그래서 watch를 처음 켠 시점에 이미 존재하던 파일들이 컨테이너의 target 경로에 없어서 당황하는 경우가 많다. 이 초기 동기화 문제는 initial_sync: true로 해결한다.

develop:
  watch:
    - action: sync
      path: ./src
      target: /app/src
      initial_sync: true   # watch 시작 시 기존 파일을 먼저 한 번 전부 동기화
      ignore:
        - node_modules/
YAML

initial_sync: true를 주면 watch 세션이 시작될 때 path의 현재 파일 전체를 target으로 한 번 밀어 넣은 뒤 감시를 시작한다. 이 옵션이 없으면 target 디렉토리가 빈 상태로 시작해서 “새로 저장한 파일만” 나타나는, GitHub 이슈로도 여러 번 보고된 증상(docker/compose #11102)이 나타난다. 처음 watch를 붙이는 프로젝트라면 sync 규칙에 initial_sync: true를 기본으로 넣어두는 편이 혼란을 크게 줄인다.

한 가지 더, sync가 동작하려면 컨테이너 이미지 안에 stat, mkdir, rmdir 같은 기본 유틸리티가 있어야 한다. scratch나 극단적으로 경량화한 distroless 이미지에서는 이들이 없어 sync가 실패할 수 있으므로, 개발용 이미지는 alpine이나 slim 정도의 베이스를 쓰는 것이 안전하다.

rebuild가 옛날 코드로 컨테이너를 띄운다면

rebuild 액션은 sync보다 아직 거친 부분이 있어서, 이미지를 다시 빌드했는데도 새 컨테이너가 이전 데이터로 뜨는 문제가 보고된다. 대부분 빌드 캐시나 익명 볼륨에 남은 이전 상태 때문이다.

이 증상(docker/compose #11852)이 나타나면 먼저 문제가 되는 파일이 정말 rebuild 규칙의 path에 매칭되는지 확인한다. 예를 들어 path: ./package.json으로 좁게 잡아두면 package-lock.json 변경은 트리거되지 않는다. 의존성 관련 파일을 한 번에 감시하려면 디렉토리나 여러 경로로 규칙을 넓혀야 한다. 그다음으로는 익명 볼륨이 이전 node_modules나 빌드 산출물을 붙들고 있는 경우가 흔하므로, docker compose down -v로 볼륨까지 정리한 뒤 다시 watch를 실행하면 깨끗한 상태에서 재빌드된다. 다만 -v는 익명 볼륨뿐 아니라 데이터베이스 데이터 같은 named volume까지 함께 삭제하므로, DB가 포함된 스택에서는 로컬 개발 데이터가 날아갈 수 있다는 점을 염두에 두고 써야 한다.

경험상 sync 기반 워크플로는 이미 충분히 안정적이라 소스 리로드 용도로는 바로 써도 되지만, rebuild는 “언제 트리거되는지”와 “이전 상태가 남아 있지 않은지”를 한 번씩 점검하는 습관이 필요하다. 무거운 재빌드를 매번 기다리기보다, 가능한 부분은 sync로 처리하고 rebuild는 의존성 변경처럼 정말 필요한 경우로 좁히는 것이 개발 루프를 빠르게 유지하는 핵심이다.

Docker Compose Watch 최소 예제로 저장-반영 확인하기

개념만으로는 감이 안 오므로, 파일 하나로 동작을 확인할 수 있는 최소 구성을 만들어 본다. 정적 파일을 서빙하는 간단한 웹 서버에 sync 규칙을 붙인 예다.

# compose.yaml
services:
  web:
    image: python:3.12-slim
    working_dir: /app
    command: python -m http.server 8000
    ports:
      - "8000:8000"
    develop:
      watch:
        - action: sync
          path: ./public
          target: /app
          initial_sync: true
YAML
<!-- public/index.html -->
<!doctype html>
<html><body><h1>Docker Compose Watch 데모 v1</h1></body></html>
HTML

이 상태에서 docker compose watch를 실행하고 브라우저로 localhost:8000에 접속하면 “v1″이 보인다. 이제 public/index.htmlv1v2로 고쳐 저장하면, 콘솔에 sync 로그가 찍히고 브라우저를 새로고침하는 즉시 “v2″로 바뀐다. 이미지를 다시 빌드하지도, 컨테이너를 재시작하지도 않았는데 파일이 컨테이너 안으로 곧장 복사된 결과다. python -m http.server는 매 요청마다 디스크에서 파일을 다시 읽으므로 sync만으로 충분하고, 만약 서버가 시작 시 파일을 메모리에 올려두는 종류라면 여기에 sync+restart를 써야 한다.

Docker Compose Watch 실행 결과 - watch 시작 직후 404, 파일 저장 후 Syncing 로그와 함께 재빌드 없이 200으로 반영되는 실제 터미널 출력
Docker Compose Watch 실행 결과 – watch 시작 직후 404, 파일 저장 후 Syncing 로그와 함께 재빌드 없이 200으로 반영되는 실제 터미널 출력

이 예제를 실제로 Compose v2.31에서 돌려보며 두 가지를 확인했다. 첫째, watch는 build:로 정의된 서비스에만 붙는다. 위처럼 image:만 지정한 서비스로 docker compose watch를 실행하면 “none of the selected services is configured for watch”라는 메시지가 뜨고 감시가 시작되지 않는다. 그래서 실제 데모에서는 같은 이미지를 쓰되 FROM python:3.12-slim 한 줄짜리 Dockerfile을 두고 build: .로 바꿔야 “Watch enabled”가 출력됐다. 공식 문서의 예제들이 하나같이 build:를 쓰는 것도 이 때문이다.

둘째, 위 캡처에서 watch 시작 직후 첫 요청이 404인 것은 앞서 설명한 초기 동기화 문제 그대로다. initial_sync로 해결한다고 했지만, 사용 중인 Compose 버전이 이 필드를 지원하는지도 함께 확인해야 한다. v2.31.0에서는 initial_sync를 넣으면 “Additional property initial_sync is not allowed”로 검증 단계에서 거부됐다. 이 필드는 비교적 최근 Compose에 추가됐으므로, 오래된 버전이라면 필드가 아예 막히거나 무시될 수 있다. docker compose version으로 버전을 확인하고, 지원되지 않으면 Compose를 업데이트하거나 watch 시작 후 파일을 한 번 저장해 첫 동기화를 트리거하는 방식으로 우회한다.

FAQ

Docker Compose Watch는 volume 마운트를 완전히 대체하나

용도가 다르다. volume은 데이터베이스 데이터처럼 컨테이너가 사라져도 유지돼야 하는 영속 데이터에 여전히 필요하다. Docker Compose Watch는 개발 중 소스 코드 변경을 반영하는 용도에 특화돼 있어, 개발 워크플로에서는 소스 동기화를 watch가 맡고 영속 데이터는 volume이 맡는 식으로 함께 쓰는 것이 일반적이다.

sync를 썼는데 코드 변경이 브라우저에 반영되지 않는다

sync는 파일을 컨테이너로 복사만 할 뿐, 실행 중인 프로세스가 그 파일을 다시 읽게 만들지는 않는다. 프레임워크가 Hot Reload(파일 변경 감지 후 자동 리로드)를 지원하는지 먼저 확인하고, 지원하지 않는다면 sync+restart로 바꿔 프로세스를 재시작해야 변경이 반영된다.

action에 rebuild와 sync를 한 서비스에 같이 쓸 수 있나

가능하고 오히려 권장되는 패턴이다. 소스 코드는 sync로 즉시 반영하고, package.json이나 go.mod 같은 의존성 파일에는 rebuild를 걸어두면, 평소에는 빠른 sync로 개발하다가 의존성이 바뀔 때만 재빌드가 일어난다. develop.watch 배열에 규칙을 여러 개 나열하면 된다.

docker compose watch와 docker compose up –watch의 차이는

동작은 같고 로그 표시가 다르다. docker compose watch는 동기화·재빌드 관련 로그만 보여주고, docker compose up --watch는 애플리케이션 로그와 watch 로그를 한 화면에 함께 출력한다. 애플리케이션의 런타임 로그를 실시간으로 봐야 하면 후자를, watch 이벤트만 깔끔하게 보려면 전자를 쓴다.

어떤 Docker 버전부터 쓸 수 있나

Compose Watch는 Docker Compose v2.22.0 이상에서 사용할 수 있다. docker compose version으로 확인하고, 낮으면 Docker Desktop이나 Compose 플러그인을 업데이트하면 된다. develop.watch는 Compose 파일 스펙에 정식 반영된 필드다.

마치며

처음 Docker Compose Watch를 붙였을 때 가장 헷갈렸던 게 바로 sync의 초기 동기화 문제였다. 컨테이너에 들어가 보니 target 디렉토리가 텅 비어 있어서 한참 설정을 의심했는데, 알고 보니 watch는 시작 이후 변경분만 복사한다는 기본 동작 때문이었다. initial_sync: true 한 줄로 해결되는 문제였지만, 이걸 모르면 “watch가 고장 났나” 싶어 시간을 꽤 쓴다. 그 뒤로는 sync 규칙을 만들 때 이 옵션을 거의 반사적으로 같이 넣는다. rebuild는 여전히 이따금 이전 상태를 물고 오는 경우가 있어 down -v를 한 번씩 섞어주지만, sync만으로도 컨테이너 개발의 저장-반영 루프가 확연히 빨라진 건 분명하다.