1편에서는 Python 버전과 의존성을 프로젝트의 설정 파일에 기록하는 방법을 살펴봤습니다. .python-version, pyproject.toml, uv.lock이 있으면 기존 .venv를 삭제해도 필요한 패키지를 다시 설치할 기준이 생깁니다.
하지만 마지막에 한 가지 문제가 남았습니다. Python 패키지를 맞췄다고 그 패키지가 사용하는 운영체제나 데이터베이스까지 같아지는 것은 아닙니다. psycopg를 같은 버전으로 설치해도 접속할 PostgreSQL이 없거나 설정이 다르면 프로그램은 실행되지 않습니다.
새로 합류한 개발자에게 데이터베이스 설치 방법과 설정을 하나씩 안내할 수도 있습니다. 다만 그 과정이 사람의 손을 거칠수록 기록에서 빠지는 단계가 생기기 쉽습니다. 1편에서 다룬 원칙은 여기에도 적용할 수 있습니다. 애플리케이션이 실행되는 환경과 함께 필요한 서비스를 파일로 표현하는 것입니다.
Docker와 Docker Compose는 이 구성을 표현하는 도구입니다. 이번 글에서는 Python 애플리케이션과 PostgreSQL을 예로 들어, 이미지에 담을 것과 실행할 때 연결할 것을 나누고 각각을 관리하는 방법을 살펴보겠습니다.
Docker에서는 프로그램 실행에 필요한 파일과 설정을 이미지로 만들고, 그 이미지를 바탕으로 컨테이너를 실행합니다. Dockerfile은 이미지를 만드는 절차를 적은 파일입니다.
예를 들어 Python이 설치된 Debian 이미지를 선택하고, 그 위에 프로젝트 의존성과 소스 코드를 추가할 수 있습니다. 개발자가 각자 운영체제에 패키지를 설치하는 대신, 같은 이미지 정의를 공유하는 것입니다.
다만 Linux 컨테이너가 독립된 운영체제 전체를 포함하는 것은 아닙니다. 컨테이너는 실행 호스트의 커널을 공유하며, macOS나 Windows에서는 일반적으로 Linux 가상 머신 위에서 실행됩니다. 여기서 맞추는 것은 주로 컨테이너 안의 파일 시스템, 실행 도구와 라이브러리입니다.
도구별 역할을 나누면 다음과 같습니다.
| 파일 또는 도구 | 기록하는 내용 |
|---|---|
| uv 설정 파일 | Python 버전과 프로젝트 의존성 |
Dockerfile | 베이스 이미지와 빌드 절차 |
compose.yaml | 함께 실행할 서비스와 연결 관계 |
.env.example | 실행에 필요한 설정의 이름과 예시 |
1편의 설정을 Docker에서도 그대로 사용하면 Python 의존성을 두 벌로 관리할 필요가 없습니다. 로컬과 컨테이너 모두 같은 pyproject.toml과 uv.lock을 기준으로 설치합니다.
Dockerfile을 작성할 때도 1편과 같은 질문을 할 수 있습니다. 어떤 버전을 사용하며, 그 선택이 어디에 기록되는가?
FROM python:latest나 버전 조건 없는 pip install만 사용하면 다시 빌드할 때 선택되는 버전이 달라질 수 있습니다. 컨테이너로 실행한다는 사실만으로 이 차이가 사라지지는 않습니다.
uv로 관리하는 Python 애플리케이션의 Dockerfile은 다음처럼 구성할 수 있습니다. Python은 1편의 예시와 같은 3.13.15, uv는 0.12.9를 사용합니다. 실제 프로젝트에서는 팀이 검증한 버전으로 맞추고, .python-version 및 requires-python과도 호환되는지 확인합니다. 아래의 app.py는 애플리케이션 진입점의 예시 이름이며, 실행 명령은 프로젝트 구조에 맞게 바꿉니다.
# syntax=docker/dockerfile:1
FROM python:3.13.15-slim-bookworm
COPY --from=ghcr.io/astral-sh/uv:0.12.9 /uv /uvx /bin/
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
UV_PYTHON_DOWNLOADS=never \
UV_LINK_MODE=copy
WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked
CMD ["uv", "run", "--no-sync", "python", "app.py"]FROM은 Python과 Debian Bookworm 기반 파일 시스템을 선택합니다. 다음 COPY는 공식 uv 이미지에서 실행 파일을 가져옵니다. Python과 uv를 어떤 버전으로 설치할지 Dockerfile에서 확인할 수 있습니다.
UV_PYTHON_DOWNLOADS=never는 uv가 다른 Python을 자동으로 내려받지 않게 합니다. 베이스 이미지의 Python으로 요구 조건을 만족할 수 없다면 설정을 수정해야 합니다.
나머지 환경 변수는 Python의 바이트코드 파일 생성을 막고, 출력을 바로 로그에 남기며, uv가 캐시의 패키지를 복사해서 설치하도록 설정합니다.
첫 번째 uv sync에서는 의존성 파일만 임시로 연결해 외부 패키지를 설치합니다. --no-install-project를 붙였으므로 아직 복사하지 않은 프로젝트 자체는 설치하지 않습니다.
이 순서에는 이유가 있습니다. 소스 코드만 수정했다면 의존성을 설치한 단계의 결과를 재사용할 수 있습니다. Docker는 이런 빌드 결과를 레이어라는 단위로 저장합니다. 패키지 다운로드 캐시도 별도로 유지하므로 다시 설치할 때 다운로드를 줄일 수 있습니다.
그다음 소스를 복사하고 uv sync --locked로 프로젝트까지 설치합니다. 이 예제는 개발용이므로 dev 그룹에 선언한 테스트·코드 검사 도구도 설치합니다. 워크스페이스나 로컬 경로 의존성을 사용하는 프로젝트라면 첫 단계에 필요한 파일을 추가로 전달해야 합니다. 이러한 빌드 구성은 uv의 Docker 통합 문서에서 더 살펴볼 수 있습니다.
마지막 CMD는 이미 준비한 환경에서 애플리케이션을 실행하는 명령입니다. 빌드 단계에서 설치를 마쳤으므로 --no-sync로 시작 시점의 동기화를 생략했습니다.
COPY . /app은 편리하지만 로컬의 .venv까지 복사하면 문제가 생깁니다. 호스트에서 만든 가상 환경이 컨테이너용 환경을 덮어쓸 수 있기 때문입니다.
.dockerignore에는 컨테이너로 가져갈 필요가 없는 로컬 환경과 임시 파일을 지정할 수 있습니다.
.git
.venv
.env
.env.*
!.env.example
**/__pycache__
**/*.py[cod]
.pytest_cache
.ruff_cache
.mypy_cache
build
dist
**/*.egg-info.gitignore는 Git이 추적하지 않을 파일을 정하고, .dockerignore는 이미지 빌드에 전달할 파일에서 무엇을 제외할지 정합니다. 이때 빌더에 전달하는 파일들의 범위를 빌드 컨텍스트라고 합니다. Git에서 제외한 파일도 별도 설정이 없으면 Docker 빌드에 포함될 수 있습니다. 빌드 컨텍스트와 캐시의 관계는 Docker 빌드 캐시 문서에 설명되어 있습니다.
실제 접속 정보가 들어 있는 .env도 이미지에 넣지 않습니다. 이미지는 실행 파일과 의존성을 담고, 환경별 설정은 실행할 때 전달하도록 나눕니다.
애플리케이션 이미지를 공유하더라도 데이터베이스를 각자 설치한다면 환경 차이는 남습니다. 데이터베이스 버전과 접속 설정, 데이터를 보관할 위치도 함께 정해야 합니다.
Docker Compose는 여러 컨테이너의 실행 설정을 compose.yaml에 표현합니다. Python 애플리케이션이 DATABASE_URL로 PostgreSQL에 접속하는 개발 환경이라면 다음과 같은 구성을 사용할 수 있습니다.
services:
app:
build:
context: .
command:
- sh
- -ec
- |
uv sync --locked
exec uv run --no-sync python app.py
env_file:
- .env
volumes:
- .:/app
- app-venv:/app/.venv
depends_on:
db:
condition: service_healthy
db:
image: postgres:18.6-bookworm
environment:
POSTGRES_DB: ${POSTGRES_DB:?Set POSTGRES_DB in .env}
POSTGRES_USER: ${POSTGRES_USER:?Set POSTGRES_USER in .env}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
healthcheck:
test:
- CMD-SHELL
- pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}"
interval: 5s
timeout: 5s
retries: 10
start_period: 10s
volumes:
- postgres-data:/var/lib/postgresql
volumes:
app-venv:
postgres-data:app은 프로젝트의 Dockerfile로 빌드하고, db는 PostgreSQL 공식 이미지를 사용합니다. Compose는 두 서비스를 기본 네트워크에 연결하므로 app에서 서비스 이름인 db로 접속할 수 있습니다.
컨테이너 안의 localhost는 그 컨테이너 자신입니다. 애플리케이션에서 PostgreSQL을 찾을 때는 localhost 대신 db를 사용해야 합니다. 컨테이너끼리 연결할 때는 호스트에 DB 포트를 공개할 필요가 없습니다.
이 예제의 볼륨 경로는 PostgreSQL 18 기준입니다. 17 이하 공식 이미지의 기본 구성에서는 보통 /var/lib/postgresql/data에 연결합니다. 버전을 바꿀 때는 태그뿐 아니라 저장 경로와 데이터 업그레이드 절차도 확인해야 합니다. PostgreSQL 이미지 문서에 버전별 차이가 설명되어 있습니다.
.:/app은 내 컴퓨터의 프로젝트 폴더를 컨테이너의 /app에 연결한다는 뜻입니다. 왼쪽의 .은 현재 폴더이고, 오른쪽의 /app은 컨테이너 안에서 그 폴더가 보일 위치입니다. 이렇게 호스트의 특정 경로를 연결하는 방식을 **바인드 마운트(bind mount)**라고 부릅니다.
로컬에서 수정한 파일이 컨테이너에도 바로 보이고, 기본 설정에서는 컨테이너에서 수정한 내용도 로컬에 반영됩니다. 따라서 소스를 바꿀 때마다 이미지를 다시 만들 필요는 없지만, 수정된 코드를 실행하려면 프로그램은 다시 실행해야 합니다.
다만 폴더를 연결하면 이미지에 원래 있던 /app 내용은 가려집니다. 이미지에서 만든 가상 환경도 보이지 않게 되므로, /app/.venv에는 Docker가 별도로 관리하는 저장 공간을 연결한 것입니다. app-venv처럼 이름을 붙여 사용하는 이 저장 공간을 **이름 있는 볼륨(named volume)**이라고 합니다.
즉, .:/app으로 소스 코드를 공유하고 app-venv:/app/.venv로 컨테이너용 가상 환경을 따로 보관합니다. 내 컴퓨터에서 만든 .venv와 컨테이너에서 사용할 .venv가 섞이지 않도록 나눈 것입니다.
빈 볼륨에는 이미지의 기존 파일이 복사될 수 있고, 이미 쓰던 볼륨은 이전 내용을 유지합니다. 따라서 이미지가 바뀌었다고 볼륨 안의 패키지도 자동으로 바뀐다고 생각하면 안 됩니다. 볼륨의 동작은 Docker 볼륨 문서에서 확인할 수 있습니다.
Compose의 command가 시작할 때 다시 uv sync --locked를 실행하는 이유입니다. 현재 소스와 lockfile을 기준으로 볼륨의 환경을 맞춥니다. sh -ec의 -e는 동기화에 실패하면 다음 실행 명령으로 넘어가지 않게 합니다.
DB 컨테이너가 실행되었다고 즉시 연결을 받을 수 있는 것은 아닙니다. 초기화가 끝날 때까지 시간이 걸릴 수 있습니다.
healthcheck는 pg_isready로 연결 수락 상태를 확인하고, condition: service_healthy는 그 검사를 통과한 뒤 app을 시작하게 합니다. $${POSTGRES_USER}의 $$는 Compose의 변수 치환을 피하고 컨테이너 안의 셸에서 값을 읽게 하는 표현입니다.
이 검사는 애플리케이션의 인증이나 테이블 준비까지 보장하지는 않습니다. 실행 중 DB가 끊겼을 때의 재연결도 애플리케이션에서 처리해야 합니다. 시작 순서의 세부 동작은 Compose 공식 문서에서 확인할 수 있습니다.
.env.example로 공유하기실행에 필요한 설정은 다음처럼 .env.example에 기록합니다.
DATABASE_URL=postgresql://app:app@db:5432/app
POSTGRES_DB=app
POSTGRES_USER=app
POSTGRES_PASSWORD=app위 값은 로컬 개발용 예시입니다. DATABASE_URL의 사용자, 비밀번호, 데이터베이스 이름은 아래의 PostgreSQL 설정과 맞아야 합니다. 실제 프로젝트의 비밀 값은 각자의 .env에 넣고, Git에는 .env.example만 커밋합니다. .gitignore에도 .env를 추가합니다.
개발자는 .env.example을 바탕으로 자신의 .env를 준비할 수 있습니다. 필요한 변수와 값의 형식을 예시로 남기면 설정 누락을 줄이는 데 도움이 됩니다.
여기서 .env는 두 가지 용도로 사용됩니다. Compose는 ${POSTGRES_DB} 같은 표현을 해석할 때 값을 읽습니다. app의 env_file은 파일의 변수를 애플리케이션 컨테이너에 전달합니다. .env 파일이 있다는 이유만으로 모든 변수가 모든 컨테이너에 자동 전달되는 것은 아닙니다. Compose 변수 치환 문서에서 이 차이를 확인할 수 있습니다.
또한 PostgreSQL의 POSTGRES_* 설정은 빈 데이터 디렉터리를 처음 초기화할 때 사용됩니다. 이미 데이터가 있는 볼륨에서 .env의 비밀번호를 바꾼다고 DB 계정의 비밀번호가 자동으로 바뀌지는 않습니다.
Dockerfile과 Compose 설정을 관리하면 실행 명령도 공유할 수 있습니다. 이미지를 빌드하고 서비스를 시작할 때는 다음 명령을 사용합니다.
docker compose up --build이 명령의 역할은 Compose에 선언한 환경을 실행하는 것입니다. 실제로 애플리케이션이 정상 동작하는지는 프로젝트의 로그, 상태 확인 기능과 테스트로 검증해야 합니다.
테스트와 코드 검사도 같은 서비스 설정을 재사용할 수 있습니다. 예를 들어 pytest와 ruff를 사용하는 프로젝트에서는 다음처럼 일회성 컨테이너에서 실행합니다.
docker compose run --rm app \
sh -ec 'uv sync --locked; uv run --no-sync pytest'
docker compose run --rm app \
sh -ec 'uv sync --locked; uv run --no-sync ruff check .'run --rm은 지정한 명령을 실행할 컨테이너를 만들고 종료 후 제거합니다. 명령만 바꾸고 이미지, 환경 변수와 서비스 연결은 재사용하므로 검증 환경을 따로 구성하는 일을 줄일 수 있습니다.
CI에서도 이 원칙을 적용할 수 있습니다. 새 작업 환경에서 이미지를 빌드하고 테스트하면 개발자의 기존 컨테이너나 가상 환경에만 남아 있던 설정을 발견하는 데 도움이 됩니다. 다만 CI에서는 소스 공유용 폴더 연결이 필요한지, 작업이 끝난 뒤 컨테이너와 볼륨을 어떻게 정리할지도 함께 정해야 합니다.
1편에서는 .venv를 삭제한 뒤 재설치하는 검증 방법을 살펴봤습니다. Docker에서도 같은 방법으로 숨은 의존성을 확인할 수 있지만, 이번에는 데이터 볼륨이 있다는 점이 다릅니다.
컨테이너의 종료와 데이터의 삭제는 구분해야 합니다. 예를 들어 다음 명령은 실행 환경을 정리할 때 사용합니다. 컨테이너와 기본 네트워크는 제거하지만 이름 있는 볼륨에 저장한 데이터는 남습니다.
docker compose down반면 --volumes를 추가하면 Compose가 관리하는 볼륨도 삭제합니다. 기존 상태 없이 환경을 다시 구성할 때 사용할 수 있지만, 아래 구성에서는 PostgreSQL 데이터까지 삭제되므로 버려도 되는 검증용 환경에만 적용해야 합니다.
docker compose down --volumes
docker compose build --no-cache
docker compose up이런 재생성 절차로 기존 가상 환경과 DB 데이터 없이 시작할 수 있는지 확인할 수 있습니다. --no-cache도 다운로드 캐시까지 모두 지우는 옵션은 아니며, 파일을 다시 받을 수 있는 네트워크와 레지스트리는 여전히 필요합니다.
실제 애플리케이션에 테이블과 초기 데이터가 필요하다면 테이블 구조를 생성하거나 변경하는 마이그레이션과 개발용 데이터 생성 절차도 저장소에 있어야 합니다. 볼륨만 삭제하면 이전 데이터가 복원되는 것은 아닙니다. 다시 만들 수 있어야 하는 실행 환경과 보존해야 하는 데이터는 별도로 관리해야 합니다.
지금까지는 구체적인 이미지 태그를 사용했습니다. 다만 같은 태그도 이미지가 다시 배포되면 가리키는 내용이 달라질 수 있습니다. 입력을 더 엄격하게 고정하려면 이미지 내용을 식별하는 해시 값인 **다이제스트(digest)**를 함께 기록할 수 있습니다.
FROM python:3.13.15-slim-bookworm@sha256:<실제 이미지 digest>위 표기는 형식 예시이므로 실제 digest로 바꿔야 합니다. uv를 가져오는 이미지와 Compose의 PostgreSQL 이미지도 같은 방식으로 고정할 수 있습니다. 이후에는 1편의 lockfile 업데이트처럼 이미지 변경을 검토하고 테스트한 뒤 반영합니다.
digest를 고정해도 모든 컴퓨터의 실행 결과가 완전히 같아지는 것은 아닙니다. 여러 플랫폼을 포함한 이미지에서는 CPU 아키텍처에 따라 선택되는 이미지가 다르고, Python wheel도 달라질 수 있습니다. 커널, 빌드 중 외부에서 받는 파일, 환경 변수와 DB 상태도 영향을 줍니다.
이 글의 구성은 환경 차이를 줄이고 그 원인을 확인할 근거를 만드는 출발점입니다. 프로젝트가 요구하는 재현 수준에 맞춰 플랫폼과 추가 입력도 관리하면 됩니다.
1편에서는 Python 버전과 의존성을 기록하는 방법을, 이번 글에서는 그 선언을 Docker와 Compose에서 활용하는 방법을 살펴봤습니다. 설치 순서와 실행에 필요한 설정이 프로젝트 파일에 남으므로, 나중에 환경을 다시 구성하거나 다른 개발자와 공유할 때도 같은 절차를 사용할 수 있습니다.
환경을 기록한다고 변화가 없어지는 것은 아닙니다. Python과 이미지 버전은 업데이트해야 하고, 데이터베이스 구조도 바뀝니다. 다만 변경 사항을 파일과 명령으로 남겨 두면 함께 검토하고 다시 실행해 볼 수 있습니다.
개발 환경의 재현성은 결국 이 과정에 있습니다. 누군가의 컴퓨터에서 우연히 동작하던 상태를, 다른 사람도 따라 실행하고 검증할 수 있는 절차로 바꾸는 것입니다.