테스트와 CI에서 Docker 활용하기
테스트 환경도 컨테이너로 만든다
테스트가 실패했을 때 가장 먼저 확인해야 할 질문은 이것이다. 코드가 문제인가, 환경이 문제인가?
로컬에서는 PostgreSQL 16을 쓰는데 CI에서는 PostgreSQL 14가 실행되고, 로컬 Redis에는 오래된 데이터가 남아 있고, CI에서는 환경 변수가 빠져 있다면 테스트 결과를 믿기 어렵다. Docker와 Compose를 테스트에 사용하면 이런 차이를 줄일 수 있다.
핵심은 애플리케이션만 컨테이너로 실행하는 것이 아니다. 테스트에 필요한 데이터베이스, 캐시, 메시지 브로커까지 함께 임시로 만들고, 테스트가 끝나면 지우는 것이다. 그러면 매번 거의 같은 출발점에서 검증할 수 있다.
테스트 환경에서 Compose는 다음 문제를 해결한다.
테스트에 필요한 외부 서비스를 명령 한 번으로 실행한다.
서비스 이름으로 연결하므로 CI에서도 접속 주소가 일정하다.
개발용 포트, 볼륨, 환경 변수와 분리할 수 있다.
테스트 종료 후 네트워크와 데이터를 쉽게 정리할 수 있다.
이 장에서는 Node.js 애플리케이션을 예로 들지만, 구조는 Python, Java, Go 프로젝트에도 거의 같다.
AI 생성 이미지
테스트 전용 Compose 파일 만들기
개발용 compose.yaml을 그대로 테스트에 쓰면 충돌이 생기기 쉽다. 개발 중인 데이터베이스 볼륨을 테스트가 건드릴 수도 있고, 이미 사용 중인 5432, 6379 포트 때문에 CI가 실패할 수도 있다.
그래서 테스트 전용 파일을 따로 둔다.
services:
app:
image: myapp:${APP_TAG:-test}
build:
context: .
environment:
NODE_ENV: test
DATABASE_URL: postgresql://app:app@db:5432/app_test
REDIS_URL: redis://cache:6379
MESSAGE_BROKER_URL: amqp://app:app@broker:5672
depends_on:
db:
condition: service_healthy
cache:
condition: service_healthy
broker:
condition: service_healthy
command: sh -c 'npm run migrate:test && npm test'
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app_test
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U app -d app_test']
interval: 5s
timeout: 3s
retries: 10
cache:
image: redis:7-alpine
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 3s
retries: 10
broker:
image: rabbitmq:3
environment:
RABBITMQ_DEFAULT_USER: app
RABBITMQ_DEFAULT_PASS: app
healthcheck:
test: ['CMD', 'rabbitmq-diagnostics', '-q', 'ping']
interval: 10s
timeout: 5s
retries: 10이 파일에는 일부러 ports가 없다. 테스트 코드는 Compose 네트워크 안에서 db, cache, broker라는 서비스 이름으로 접속한다. 호스트에서 직접 PostgreSQL이나 Redis에 접속할 필요가 없다면 포트를 열지 않는 편이 낫다. 그래야 로컬 개발 환경과 CI에서 포트 충돌이 줄어든다.
depends_on의 condition: service_healthy는 db, cache, broker의 healthcheck가 성공한 뒤 app을 시작하게 해 준다. 다만 이것이 애플리케이션의 재시도 로직을 완전히 대신하지는 않는다. 서비스가 시작 직후 잠깐 불안정할 수도 있으므로, 실제 애플리케이션 코드나 테스트 준비 스크립트에서도 연결 실패를 짧게 재시도할 수 있게 해 두면 더 안전하다.
app 서비스의 command는 테스트 실행 명령이다. 여기서는 테스트 전에 마이그레이션을 실행한 뒤 npm test를 실행한다. 프로젝트에 따라 다음처럼 바꿀 수 있다.
command: sh -c 'pytest'command: sh -c './gradlew test'중요한 것은 테스트가 성공하면 종료 코드 0을, 실패하면 0이 아닌 값을 반환해야 한다는 점이다. CI는 이 종료 코드로 성공과 실패를 판단한다.
로컬에서 테스트 스택 실행하기
이제 테스트 환경을 직접 실행해 보자.
docker compose -f compose.test.yaml -p myapp-test up --build --abort-on-container-exit --exit-code-from app옵션의 의미는 다음과 같다.
-f compose.test.yaml: 테스트 전용 Compose 파일을 사용한다.-p myapp-test: Compose 프로젝트 이름을 지정한다. 네트워크, 컨테이너 이름 충돌을 줄인다.up --build: 필요한 이미지를 빌드하고 서비스를 실행한다.--abort-on-container-exit: 서비스 중 하나가 종료되면 전체 스택을 중지한다. 보통 테스트 환경에서는app컨테이너가 테스트를 마치고 종료되도록 설계한다.--exit-code-from app: 전체 명령의 종료 코드를app서비스의 종료 코드로 맞춘다.
이 명령은 CI와 잘 맞는다. 테스트가 실패하면 docker compose 명령도 실패로 끝난다. 따라서 별도 스크립트 없이도 파이프라인을 중단할 수 있다.
테스트가 끝난 뒤에는 정리한다.
docker compose -f compose.test.yaml -p myapp-test down -v --remove-orphansdown -v는 Compose가 만든 볼륨까지 삭제한다. 테스트 데이터는 다음 실행에 남기지 않는 것이 원칙이다. 테스트가 이전 실행 결과에 의존하면 재현성이 떨어진다.
단위 테스트처럼 데이터베이스가 필요 없는 경우에는 의존 서비스를 띄우지 않고 app만 실행할 수도 있다.
docker compose -f compose.test.yaml run --rm --no-deps app npm test--no-deps는 depends_on에 적힌 서비스를 시작하지 않는다. 빠른 테스트와 통합 테스트를 분리할 때 유용하다.
개발 환경과 테스트 환경을 분리하는 기준
테스트용 Compose 파일을 만들 때는 다음 기준을 지키면 좋다.
첫째, 테스트 서비스에는 가능하면 호스트 포트를 열지 않는다. db:5432, cache:6379처럼 Compose 내부 주소로만 접근한다. 이렇게 하면 로컬에서 이미 PostgreSQL을 실행 중이어도 테스트가 충돌하지 않는다.
둘째, 테스트 데이터는 오래 보존하지 않는다. 데이터베이스 볼륨을 만들었다면 테스트 후 down -v로 지운다. 더 빠른 임시 저장이 필요하면 tmpfs를 검토할 수 있지만, 처음에는 볼륨 없는 기본 구성만으로도 충분하다.
셋째, 환경 변수는 테스트용 값을 사용한다. 운영 비밀번호나 실제 외부 API 키를 테스트에 넣으면 안 된다. 테스트용 데이터베이스 비밀번호는 위 예제처럼 단순한 값이어도 괜찮다. 격리된 테스트 네트워크 안에서만 쓰고 실행 후 폐기하기 때문이다. 반대로 레지스트리 로그인 토큰, 클라우드 접근 키 같은 값은 CI의 secrets 기능으로 관리해야 한다.
넷째, 병렬 실행을 고려해 프로젝트 이름을 분리한다. 같은 CI 서버에서 여러 브랜치 테스트가 동시에 실행될 수 있다. -p myapp-test-1234처럼 실행마다 다른 이름을 쓰면 컨테이너와 네트워크 충돌을 줄일 수 있다.
설정이 예상대로 병합되었는지 확인하려면 다음 명령을 사용한다.
docker compose -f compose.test.yaml config테스트 실패 원인을 찾을 때는 로그와 디버깅에서 사용한 흐름을 그대로 적용한다.
docker compose -f compose.test.yaml -p myapp-test ps
docker compose -f compose.test.yaml -p myapp-test logs app
docker compose -f compose.test.yaml -p myapp-test logs dbCI에서도 결국 같은 명령이 문제 해결의 출발점이다.
CI 파이프라인의 기본 흐름
CI에서 Docker를 사용할 때의 기본 흐름은 단순하다.
소스 코드를 가져온다.
테스트에 사용할 애플리케이션 이미지를 빌드한다.
Compose로 데이터베이스, 캐시, 브로커를 포함한 테스트 환경을 실행한다.
테스트 결과와 관계없이 컨테이너와 볼륨을 정리한다.
테스트가 통과한 경우에만 이미지를 레지스트리에 푸시한다.
중요한 원칙은 이미지를 푸시하기 전에 테스트해야 한다는 것이다. 실패한 커밋의 이미지를 레지스트리에 올리면 이후 배포 단계에서 더 큰 문제가 된다.
다음은 GitHub Actions 예시다. Docker는 GitHub Actions에서 Buildx 설정, 레지스트리 로그인, 이미지 빌드와 푸시를 위한 공식 액션을 제공한다1.
name: ci
on:
pull_request:
push:
branches: [main]
jobs:
test-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build image for tests
uses: docker/build-push-action@v6
with:
context: .
load: true
tags: myapp:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Run tests with Compose
env:
APP_TAG: ${{ github.sha }}
COMPOSE_PROJECT_NAME: myapp-ci-${{ github.run_id }}
run: docker compose -f compose.test.yaml up --no-build --abort-on-container-exit --exit-code-from app
- name: Clean up
if: always()
env:
APP_TAG: ${{ github.sha }}
COMPOSE_PROJECT_NAME: myapp-ci-${{ github.run_id }}
run: docker compose -f compose.test.yaml down -v --remove-orphans
- name: Login to Docker Hub
if: github.ref == 'refs/heads/main'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Tag tested image
if: github.ref == 'refs/heads/main'
run: |
docker image tag myapp:${{ github.sha }} docker.io/${{ secrets.DOCKERHUB_USERNAME }}/myapp:${{ github.sha }}
docker image tag myapp:${{ github.sha }} docker.io/${{ secrets.DOCKERHUB_USERNAME }}/myapp:latest
- name: Push tested image
if: github.ref == 'refs/heads/main'
run: |
docker image push docker.io/${{ secrets.DOCKERHUB_USERNAME }}/myapp:${{ github.sha }}
docker image push docker.io/${{ secrets.DOCKERHUB_USERNAME }}/myapp:latest여기서 load: true는 빌드한 이미지를 CI 러너의 Docker Engine에 로드한다. 그래서 바로 다음 단계에서 Compose가 myapp:${{ github.sha }} 이미지를 사용할 수 있다.
테스트 단계의 --no-build는 Compose가 이미지를 다시 빌드하지 않게 한다. 앞 단계에서 만든 이미지가 실제 테스트 대상이라는 점을 분명히 하기 위한 옵션이다. 만약 이미지가 제대로 로드되지 않았다면 이 단계에서 실패하므로, 문제를 빨리 알아차릴 수 있다.
APP_TAG는 compose.test.yaml의 image: myapp:${APP_TAG:-test} 부분에 들어간다. 로컬에서는 기본값 test를 사용하고, CI에서는 커밋 SHA를 태그로 사용한다. 이렇게 하면 어떤 커밋으로 만든 이미지가 테스트되었는지 추적하기 쉽다.
COMPOSE_PROJECT_NAME은 -p 옵션과 같은 역할을 한다. 실행마다 고유한 프로젝트 이름을 주면 CI 병렬 실행에서 네트워크와 컨테이너 이름이 겹칠 가능성이 줄어든다.
Clean up 단계는 if: always()를 사용하므로 테스트가 실패해도 실행된다. 단, down -v는 컨테이너와 네트워크, 볼륨을 정리할 뿐 앞에서 빌드한 로컬 이미지를 지우지는 않는다. 그래서 정리 후에도 같은 이미지를 Docker Hub용 태그로 다시 붙여 푸시할 수 있다.
latest 태그는 편의용으로만 붙였다. 실제 배포나 롤백에서는 github.sha처럼 추적 가능한 태그를 기준으로 삼는 편이 안전하다. 태그는 사람이 다시 덮어쓸 수 있으므로 완전히 불변인 식별자가 필요한 환경에서는 이미지 digest까지 함께 기록하는 것이 좋다.
CI 시간을 줄이는 캐시 전략
CI에서 Docker를 쓰면 처음에는 빌드 시간이 길게 느껴질 수 있다. 매번 깨끗한 가상 머신에서 시작하기 때문이다. 이때 캐시를 잘 쓰면 시간을 크게 줄일 수 있다.
첫 번째 전략은 Dockerfile 순서다. 의존성 파일을 먼저 복사하고 설치한 뒤, 소스 코드를 복사한다.
FROM node:22-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .이 구조에서는 소스 코드만 바뀐 경우 npm ci 레이어를 다시 만들지 않아도 된다. 이미지 레이어와 빌드 최적화에서 배운 레이어 캐시가 CI에서도 그대로 중요하다.
두 번째 전략은 BuildKit 캐시 마운트다. 패키지 다운로드 캐시를 빌드 중에 재사용할 수 있다.
# syntax=docker/dockerfile:1
FROM node:22-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .이 방식은 node_modules를 무작정 저장하는 것과 다르다. 설치 결과를 통째로 재사용하는 것이 아니라 패키지 다운로드 캐시를 재사용한다. 실제 설치 버전은 package-lock.json 같은 lock 파일이 결정한다.
세 번째 전략은 CI 전용 빌드 캐시다. Docker Buildx는 GitHub Actions 캐시, 레지스트리 캐시, inline 캐시 같은 방식을 사용할 수 있다. GitHub Actions에서는 cache-from: type=gha, cache-to: type=gha,mode=max 조합을 자주 사용한다2.
프로젝트와 CI 환경에 따라 선택 기준은 다르다.
GitHub Actions만 사용한다면
type=gha가 간단하다.여러 CI 시스템이나 여러 러너가 같은 캐시를 공유해야 한다면 레지스트리 캐시가 더 적합할 수 있다.
단순한 이미지 배포에는 inline 캐시도 선택지가 될 수 있지만, 복잡한 빌드에서는 캐시 범위가 부족할 수 있다.
캐시는 성능을 위한 도구이지 정확성을 대신하지 않는다. 의존성 버전은 lock 파일로 고정하고, 테스트 데이터는 매번 초기화해야 한다.
테스트 자동화에서 기억할 원칙
Docker와 Compose를 테스트에 적용할 때 핵심은 거창한 파이프라인을 만드는 것이 아니다. 같은 환경에서, 같은 순서로, 반복 가능한 검증을 실행하는 것이다.
실무에서는 다음 원칙을 기준으로 삼으면 좋다.
테스트에 필요한 외부 서비스는 Compose로 함께 띄운다.
테스트용 Compose 파일은 개발용 설정과 분리한다.
테스트 데이터는 실행 후 삭제한다.
CI에서는 테스트가 통과한 이미지나 커밋의 이미지만 푸시한다.
커밋 SHA 같은 추적 가능한 태그로 어떤 코드가 검증되었는지 남긴다.
Dockerfile 레이어 캐시와 CI 캐시를 함께 사용해 실행 시간을 줄인다.
이 흐름이 자리 잡으면 로컬과 CI의 차이로 인한 실패가 줄어든다. 개발자는 내 컴퓨터에서만 성공하는 테스트가 아니라, 팀과 서버가 함께 믿을 수 있는 테스트를 만들 수 있다.
참고
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.