Docker Compose로 Node API·PostgreSQL·Redis 한 번에 띄우기

이 글의 핵심

컨테이너 안의 API가 DB 주소를 localhost로 잡아 연결에 실패하는 것은 Compose를 처음 쓸 때 가장 흔한 실수입니다. 서비스 이름으로 통신하는 네트워크 구조, DB가 준비되기 전에 API가 먼저 뜨는 문제를 헬스체크로 막는 법, 시크릿을 저장소 밖에 두는 방식까지 짚어 배포 전 체크리스트로 정리합니다.

들어가며

Docker Compose는 여러 컨테이너를 한 프로젝트로 선언해 docker compose up 한 번에 로컬·스테이징 환경을 맞출 수 있게 합니다. Node.js API 뒤에 PostgreSQL과 Redis를 붙이는 구성은 실무에서 매우 흔하며, 이를 헬스체크·볼륨·재시작 정책까지 포함해 정의해두면 장애 시 자동 복구와 배포 자동화가 쉬워집니다. 다만 프로덕션에서는 비밀 번호 관리, 리소스 한도, 로그 드라이버, 네트워크 격리가 추가로 필요합니다. 이 글은 실행 가능한 compose 템플릿을 중심으로, 그 위에 얹을 운영 체크리스트를 덧붙입니다. 이미지를 빌드·배포하기 전에 Node.js 테스트와 GitHub Actions CI/CD로 같은 스택을 검증하는 편이 안전합니다. Kubernetes로 넘어가려면 minikube 배포를, C++·멀티 스테이지 패턴은 C++ Docker 가이드·C++ GitHub Actions와 비교해 보세요. 애플리케이션에서 DB에 붙는 방법은 Node.js 데이터베이스 연동·C++ DB 연동(libpq 등)을, 엔진 선택은 PostgreSQL vs MySQL을, 캐시 패턴은 Redis 캐싱을 함께 보시면 코드 ↔ 스택이 맞물립니다. 호스트 디스크·inode 이슈는 Linux 트러블슈팅과 겹칠 수 있습니다. 요청 흐름은 대략 클라이언트 → (호스트 포트) → api 컨테이너 → (DNS 이름 postgres/redis) → DB·캐시 순입니다. 외부에 열리는 것은 API의 포트 하나뿐이고, Postgres와 Redis는 Compose가 만든 내부 네트워크 안에서만 서비스 이름으로 접근됩니다. 이 구조를 머릿속에 두면 이 글에서 다루는 함정(호스트명, 포트 바인딩, 준비 순서, 마이그레이션 타이밍)이 왜 생기는지 자연스럽게 이해됩니다.

한 가지 전제를 분명히 해 두겠습니다. Compose는 호스트 한 대에서 여러 컨테이너를 묶는 도구입니다. 서버 한 대로 충분한 사내 서비스나 초기 제품이라면 Compose만으로 꽤 오래 운영할 수 있지만, 여러 서버에 걸친 롤링 배포나 노드 장애 시 자동 재배치가 필요해지면 Kubernetes 같은 오케스트레이터가 맞습니다. 이 글의 설정은 그 경계 안쪽을 대상으로 합니다.


개념: Compose와 프로덕션 스택

기본 개념

  • Service: 실행 단위(예: api, db, redis). 이미지·명령·포트·환경을 묶습니다.
  • Network: 기본 브리지 네트워크에서 서비스 이름이 DNS 이름으로 해석됩니다(api가 postgres에 postgres:5432로 접속).
  • Volume: 컨테이너 재생성 후에도 유지할 데이터를 저장합니다(Postgres 데이터 디렉터리 등).
  • Healthcheck: depends_on의 condition: service_healthy와 함께 쓰면 준비된 뒤에 앱을 띄울 수 있습니다(Compose v2+).
  • .env의 두 가지 역할: 프로젝트 루트의 .env는 Compose가 YAML 안의 ${VAR}를 치환할 때 자동으로 읽습니다. 반면 env_file: - .env는 그 파일의 내용을 컨테이너 환경 변수로 주입합니다. 이름이 같아서 헷갈리지만 서로 다른 기능이라, env_file을 빼도 ${POSTGRES_PASSWORD} 치환은 동작하고, 반대로 env_file로 넣으면 파일 안의 모든 변수(DB 비밀번호 포함)가 API 컨테이너에도 들어갑니다.

왜 필요한가

로컬에서는 localhost에 각각 포트를 열어도 되지만, 팀원마다 DB 버전·Redis 설정이 달라지면 “재현 불가” 버그가 납니다. Compose는 버전 고정·동일 네트워크·동일 env를 코드로 고정합니다.


실전: docker-compose.yml 템플릿

프로젝트 루트 예시입니다. 실제 비밀번호는 .env를 Git에 넣지 말고 CI/CD 시크릿이나 서버의 env 파일로 주입하세요.

.env.example (저장소에 커밋)

# .env.example — 복사 후 .env로 사용
NODE_ENV=production
POSTGRES_USER=app
POSTGRES_PASSWORD=change_me
POSTGRES_DB=appdb
DATABASE_URL=postgresql://app:change_me@postgres:5432/appdb
REDIS_PASSWORD=redis_secret
REDIS_URL=redis://:redis_secret@redis:6379/0

원래 예제에는 REDIS_PASSWORD가 빠져 있었는데, 그러면 아래 compose의 --requirepass ${REDIS_PASSWORD}가 빈 문자열로 치환되어 Redis가 인자 오류로 시작하지 못하거나, 경고 없이 빈 값이 들어갑니다. docker compose config를 실행하면 치환이 끝난 최종 YAML과 함께 The "REDIS_PASSWORD" variable is not set. Defaulting to a blank string. 경고를 볼 수 있으므로, 배포 전에 한 번 돌려 보는 습관이 좋습니다. 비밀번호에 $가 들어가면 Compose가 변수로 해석하므로 .env가 아닌 YAML 안에서는 $$로 이스케이프해야 한다는 점도 기억해 두세요.

docker-compose.yml

# docker-compose.yml
services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
    image: myorg/api:${IMAGE_TAG:-latest}
    restart: unless-stopped
    ports:
      - "${API_PORT:-3000}:3000"
    environment:
      NODE_ENV: ${NODE_ENV:-production}
      DATABASE_URL: ${DATABASE_URL}
      REDIS_URL: ${REDIS_URL}
    env_file:
      - .env
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
      interval: 15s
      timeout: 5s
      retries: 5
      start_period: 40s
  postgres:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 5s
      timeout: 5s
      retries: 10
  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: >
      redis-server
      --appendonly yes
      --requirepass ${REDIS_PASSWORD}
    environment:
      REDIS_PASSWORD: ${REDIS_PASSWORD}
    volumes:
      - redisdata:/data
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
      interval: 5s
      timeout: 3s
      retries: 10
volumes:
  pgdata:
  redisdata:

주석 포인트

  • build.context / dockerfile: 이미지를 저장소의 Dockerfile로 빌드합니다. image는 빌드 결과에 붙일 이름·태그입니다.
  • restart: unless-stopped: 데몬 재시작·비정상 종료 후에 컨테이너가 다시 뜨도록 합니다(수동으로 멈춘 경우는 제외).
  • ports: "${API_PORT:-3000}:3000": 호스트의 포트(기본 3000)를 컨테이너 3000으로 넘깁니다. ${VAR:-기본값}은 셸과 비슷하게 환경 변수 미설정 시 기본값을 씁니다.
  • depends_on + condition: service_healthy: DB·Redis가 헬스체크를 통과한 뒤 API가 시작됩니다(단순 depends_on만으로는 “준비 완료”를 보장하지 않습니다).
  • healthcheck(interval/timeout/retries/start_period): 프로브 주기·실패 판정·기동 유예를 정합니다. API는 wget으로 로컬 /health를 두드립니다.
  • named volume pgdata, redisdata: 데이터가 도커 볼륨 영역에 남아 컨테이너를 재생성해도 유지됩니다(docker compose down -v는 볼륨까지 지우므로 주의하십시오).
  • Redis AOF(appendonly yes): 명령을 추가 로그로 남겨 재시작 시 복구력을 높입니다(요구 수준에 따라 RDB 스냅샷 병행을 검토합니다).
  • command: > redis-server ... --requirepass: 비밀번호 인증을 켭니다. REDIS_URL의 비밀번호와 반드시 맞춰야 합니다. 헬스체크의 redis-cli -a는 Warning: Using a password with '-a' or '-u' option on the command line interface may not be safe.를 매번 출력하는데, 동작에는 문제가 없고 REDISCLI_AUTH 환경 변수로 넘기면 경고 없이 인증할 수 있습니다.

API 헬스체크의 wget은 이미지에 따라 없을 수 있습니다. node:*-alpine에는 BusyBox wget이 들어 있지만, node:*-slim(Debian 기반)이나 distroless 이미지에는 wget도 curl도 없어서 헬스체크가 exec: "wget": executable file not found in $PATH로 계속 실패하고 API는 영원히 unhealthy로 남습니다. 이미지를 바꿀 때 가장 흔히 놓치는 부분입니다. Node 18 이상이라면 도구를 설치하지 않고 ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]처럼 Node 자체로 검사할 수 있습니다.

depends_on의 service_healthy 조건은 docker compose up으로 기동할 때 한 번만 적용됩니다. 운영 중에 Postgres 컨테이너가 재시작되면 API는 그 사실을 모른 채 계속 떠 있고, 끊어진 연결을 쓰다가 ECONNREFUSED나 Connection terminated unexpectedly를 냅니다. 그래서 앱 쪽에서 연결 풀의 재연결과 재시도를 반드시 구현해야 하고, Compose 2.17 이상이라면 depends_on에 restart: true를 주어 의존 서비스가 재시작될 때 API도 다시 띄우게 할 수 있습니다.

API의 /health 엔드포인트 (Node.js 예시)

// src/health.js — Express 예시
import express from 'express';
export function healthRouter() {
  const r = express.Router();
  r.get('/health', (_req, res) => {
    res.status(200).json({ status: 'ok', ts: Date.now() });
  });
  return r;
}

이 /health는 프로세스가 요청을 받을 수 있는지만 확인합니다. 여기서 DB 쿼리까지 실행하면 “DB가 잠깐 느려졌다 → API 헬스체크 실패 → 재시작 → 재시작 중 연결 폭주”처럼 장애가 번질 수 있습니다. 컨테이너 재시작 판단용(liveness)은 가볍게 두고, 의존성까지 확인하는 준비 상태(readiness)는 로드 밸런서나 모니터링용 엔드포인트(/ready)로 분리하는 편이 안전합니다. Compose 자체는 헬스체크가 실패해도 컨테이너를 자동으로 재시작하지 않고 상태만 unhealthy로 표시한다는 점도 알아 두세요. 재시작은 프로세스가 종료됐을 때 restart 정책이 담당합니다.

마이그레이션 실행 타이밍

앱 기동 스크립트에서 DB 연결 후 마이그레이션을 한 번 실행하는 패턴이 흔합니다.

{
  "scripts": {
    "migrate": "node scripts/migrate.js",
    "start": "node dist/index.js"
  }
}

엔트리에서 npm run migrate && npm run start 대신 코드에서 순차 실행하면 셸 의존을 줄일 수 있습니다.

다만 API를 여러 개(docker compose up --scale api=3) 띄우는 순간 이 방식은 위험해집니다. 세 컨테이너가 동시에 같은 마이그레이션을 실행하다가 한쪽이 relation "users" already exists로 실패하거나, 마이그레이션 도구의 잠금 테이블을 기다리며 기동이 늦어집니다. 저는 마이그레이션을 API와 분리된 일회성 서비스로 두는 방식을 선호합니다.

services:
  migrate:
    image: myorg/api:${IMAGE_TAG:-latest}
    command: ["node", "scripts/migrate.js"]
    env_file: [.env]
    depends_on:
      postgres:
        condition: service_healthy
    restart: "no"
  api:
    # ...
    depends_on:
      migrate:
        condition: service_completed_successfully

service_completed_successfully는 migrate가 종료 코드 0으로 끝난 뒤에만 API를 시작합니다. 마이그레이션이 실패하면 API가 아예 뜨지 않으므로, 스키마와 코드가 어긋난 상태로 트래픽을 받는 일을 막을 수 있습니다. 같은 이미지를 쓰므로 빌드도 한 번이면 됩니다.


고급: 리소스·시크릿·프로파일

리소스 제한 (프로덕션 권장)

    deploy:
      resources:
        limits:
          cpus: '1.0'
          memory: 512M

예전 docker-compose(v1, 파일 형식 3.x)에서는 deploy 키가 Swarm에서만 동작해 mem_limit 같은 별도 키를 써야 했지만, 현재의 Docker Compose v2(Compose Specification)는 Swarm이 아니어도 deploy.resources.limits의 CPU·메모리 제한을 컨테이너에 적용합니다. 적용됐는지는 docker stats의 MEM USAGE / LIMIT 열로 확인할 수 있습니다.

메모리 한도를 걸면 Node.js 쪽 설정도 함께 봐야 합니다. 컨테이너가 한도를 넘으면 커널 OOM killer가 프로세스를 죽이고, docker inspect에 "OOMKilled": true, 종료 코드 137이 남습니다. 최신 Node는 cgroup 한도를 보고 힙 크기를 정하지만, 한도에 너무 바짝 붙으면 GC가 따라가지 못하므로 NODE_OPTIONS=--max-old-space-size=384처럼 컨테이너 한도보다 여유 있게 힙 상한을 두는 편이 안정적입니다.

프로파일로 dev만 부가 서비스

# docker-compose.dev.yml
services:
  adminer:
    image: adminer
    profiles: [dev]
    ports:
      - "8080:8080"
docker compose --profile dev up

시크릿 (Docker Swarm 또는 외부 비밀 저장소)

단일 서버라면 파일 기반 env + 권한 chmod 600이 현실적입니다. Compose의 최상위 secrets:(파일 기반)도 Swarm 없이 쓸 수 있는데, 값이 환경 변수가 아니라 컨테이너의 /run/secrets/<이름> 파일로 마운트됩니다. 환경 변수는 docker inspect나 크래시 덤프, 자식 프로세스에 그대로 노출되기 쉬우므로, 공식 Postgres 이미지가 지원하는 POSTGRES_PASSWORD_FILE=/run/secrets/db_password처럼 파일 경로를 받는 방식이 더 안전합니다. Kubernetes를 쓰면 Secret 리소스로 치환합니다.


성능: 볼륨과 연결 풀

항목권장비고
Postgres 볼륨named volume호스트 바인드는 OS별 성능·권한 이슈
연결 풀앱에서 max 제한컨테이너 수 × 풀 크기 ≤ DB max_connections
Redis적절한 maxmemory + 정책캐시 전용이면 allkeys-lru 등
로그JSON 드라이버 또는 파일 로테이션컨테이너 로그 디스크 폭주 방지

트레이드오프: restart: always는 편하지만 재시작 루프에 빠지면 서비스가 계속 죽을 수 있으므로 헬스체크·로그 알림과 함께 써야 합니다.

표의 로그 항목은 가볍게 넘기기 쉬운데, 실제로 서버 디스크를 채우는 가장 흔한 원인 중 하나입니다. Docker의 기본 json-file 드라이버는 로테이션 없이 로그를 계속 쌓기 때문에, 요청마다 로그를 찍는 API는 몇 주 만에 /var/lib/docker/containers/ 아래에 수 GB를 만들 수 있습니다. 서비스마다 다음처럼 상한을 거는 것이 좋습니다.

    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "5"

연결 풀 항목의 계산도 실제로 해 봐야 합니다. Postgres의 기본 max_connections는 100이고, pg의 기본 풀 크기는 10입니다. API를 5개로 늘리고 마이그레이션·관리 도구 연결까지 더하면 금방 한도에 닿아 sorry, too many clients already 오류가 납니다. 스케일 아웃 계획이 있다면 풀 크기를 줄이거나 PgBouncer 같은 커넥션 풀러를 앞에 두는 것을 고려해야 합니다.


실무 사례

  • 스테이징 = 프로덕션과 동일 compose: 변수만 STAGING_*로 바꿔 동일 파일을 재사용합니다.
  • CI에서 compose 테스트: docker compose up -d 후 스모크 테스트·docker compose down으로 통합 검증을 자동화합니다(GitHub Actions 글 참고).
  • Nginx 앞단: TLS 종료와 리버스 프록시는 Nginx 구성 글에서 다룹니다. Nginx를 같은 compose에 넣으면 API의 ports:를 아예 지우고 expose만 남겨, 외부에서는 Nginx를 거쳐서만 API에 닿게 할 수 있습니다.

포트 공개에는 Linux 서버에서 특히 조심해야 할 함정이 있습니다. ports: "5432:5432"처럼 DB 포트를 공개하면 Docker가 iptables 규칙을 직접 추가하기 때문에, ufw로 5432를 막아 두었어도 외부에서 접속이 됩니다. 방화벽을 믿고 DB 포트를 열어 두었다가 인터넷에 노출되는 사고가 드물지 않습니다. 이 템플릿처럼 DB와 Redis에는 ports를 두지 않는 것이 기본이고, 호스트에서 직접 접속해야 한다면 "127.0.0.1:5432:5432"처럼 루프백에만 바인딩합니다.


트러블슈팅

증상원인해결
API가 DB에 연결 실패localhost 사용DB 호스트는 postgres 서비스명
password authentication failed.env 불일치DATABASE_URL과 Postgres env 동기화
헬스체크 무한 실패/health 없음 또는 포트 불일치경로·포트·wget/curl 설치 확인
Redis NOAUTH비밀번호 불일치REDIS_URL과 requirepass 동일
디스크 꽉 참로그·AOF 증가로그 로테이션, Redis maxmemory
API가 unhealthy로 멈춤이미지에 wget/curl 없음Node로 헬스체크하거나 도구 설치
sorry, too many clients already풀 크기 × 인스턴스 수 > max_connections풀 축소, PgBouncer
종료 코드 137메모리 한도 초과(OOMKilled)한도 상향, --max-old-space-size 조정

디버깅 팁: docker compose logs -f api postgres redis로 동시에 보며, docker compose exec postgres psql -U app -d appdb로 DB만 따로 확인합니다.


마무리

  • Compose로 API·Postgres·Redis를 한 번에 정의하시면 환경 재현성과 온보딩이 빨라집니다.
  • 헬스체크·볼륨·재시작 정책은 프로덕션 인근 환경에서 필수에 가깝습니다.
  • 다음으로 Nginx 리버스 프록시로 Node.js 서비스 앞단 구성하기에서 공개 엔드포인트와 TLS를 정리해 보세요.

프로덕션 배포 전 체크리스트

  • .env·비밀번호가 Git에 올라가지 않았는지, 스테이징·프로덕션 값이 섞이지 않았는지 확인합니다.
  • DB·Redis 백업 주기와 down -v 실수 시 복구 절차를 문서화합니다.
  • API max_connections 대비 연결 풀 합이 DB 한도를 넘지 않는지 점검합니다.

자주 묻는 질문 (FAQ)

Q. API 컨테이너가 DB에 연결하지 못하는데 localhost로 설정한 게 문제인가요?

A. 그렇습니다. 컨테이너 안의 localhost는 API 컨테이너 자기 자신을 가리키므로, DB 호스트는 compose 파일의 서비스명(예: postgres)으로 지정해야 합니다. 연결은 되는데 password authentication failed가 난다면 .env의 DATABASE_URL과 Postgres 컨테이너 환경 변수가 일치하는지 확인합니다. docker compose logs -f api postgres로 두 서비스 로그를 함께 보면 원인을 빨리 찾을 수 있습니다.


같이 보면 좋은 글