/인프라/docker compose depends_on이 DB를 안 기다릴 때 — healthcheck로 connection refused 해결
인프라docker-composehealthcheck

docker compose depends_on이 DB를 안 기다릴 때 — healthcheck로 connection refused 해결

depends_on을 걸어도 앱이 connection refused로 죽나요? depends_on이 시작 순서만 보장하는 이유와 healthcheck + condition: service_healthy로 준비 완료까지 보장하는 법을 Postgres·MySQL·Redis·HTTP 복붙 예제로 정리했습니다.

docker compose depends_on이 DB를 안 기다릴 때 — healthcheck로 connection refused 해결

docker compose depends_on은 왜 DB를 안 기다릴까? healthcheck로 connection refused 끝내기

docker compose up 한 줄로 앱과 DB를 같이 띄웠는데, 앱 컨테이너만 connection refused로 죽어버린 경험 있으신가요? "분명히 depends_on: [db] 걸었는데 왜 DB를 안 기다리지?" 이 글은 그 멘탈모델을 바로잡고, Postgres·MySQL·Redis·HTTP별 복붙 가능한 healthcheck + condition 레시피로 문제를 근본부터 끝내드립니다.

"depends_on 걸었는데 왜 죽지?" — 전형적인 실패 시나리오

가장 흔한 형태는 이렇습니다.

YAML
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: appdb
  api:
    build: .
    depends_on:
      - db   # ← 시작 순서는 보장되지만 "준비 완료"는 아님

up 하면 콘솔에 이런 로그가 찍히며 앱이 종료됩니다.

TEXT
api-1  | could not connect to server: Connection refused
api-1  |   Is the server running on host "db" (172.18.0.2) and accepting
api-1  |   TCP/IP connections on port 5432?
api-1  | Error: dial tcp 172.18.0.2:5432: connect: connection refused
api-1 exited with code 1

db 컨테이너는 분명 떴는데, 그 안의 Postgres 프로세스는 아직 초기화(데이터 디렉터리 생성, WAL 준비)가 안 끝나 5432 포트로 연결을 받지 못하는 상태입니다. 앱은 그 0.5~3초의 공백을 못 견디고 죽는 거죠.

멘탈모델: depends_on은 '시작 순서'만 보장한다

핵심 한 줄입니다. depends_on은 컨테이너 시작(start) 순서만 제어하고, 컨테이너 내부 프로세스의 준비 완료(ready)는 전혀 모릅니다.

TEXT
[ depends_on의 세계 ]
db 컨테이너 created → started ──┐
                               ├─→ api 컨테이너 started (여기서 끝!)

db 내부 Postgres 부팅 중...... ─┘  ← 아직 포트 안 열림 = connection refused

즉 "컨테이너가 떴다(started)" ≠ "DB가 연결을 받을 준비가 됐다(healthy)"입니다. 기본 short-syntax depends_on은 started까지만 기다리기 때문에, readiness가 필요한 DB·메시지큐 앞에서는 무용지물입니다. 이걸 해결하려면 (1) 서비스가 "건강한지" 판단하는 healthcheck를 정의하고, (2) depends_on에서 condition: service_healthy로 그 신호를 기다리게 만들어야 합니다.

해결의 핵심 ① — healthcheck 복붙 레시피 4종

healthcheck의 옵션부터 정리합니다.

옵션의미권장치(DB 기준)
test헬스 판정 명령. 종료코드 0이면 healthy서비스별 ping 명령
interval체크 주기5s
timeout한 번의 체크 제한 시간5s
retries연속 실패 N회면 unhealthy5
start_period부팅 유예 시간(이 안의 실패는 카운트 안 함)10s~30s

팁: CMD-SHELL은 셸을 통해 실행하므로 ||, 환경변수 확장이 됩니다. CMD는 셸 없이 exec 합니다. Compose 환경변수와 헷갈리지 않도록 컨테이너 내부에서 평가할 변수는 $$로 이스케이프하세요.

Postgres

YAML
db:
  image: postgres:16
  environment:
    POSTGRES_USER: app
    POSTGRES_PASSWORD: secret
    POSTGRES_DB: appdb
  healthcheck:
    # $$ → compose가 아니라 컨테이너 셸이 변수를 확장하게
    test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
    interval: 5s
    timeout: 5s
    retries: 5
    start_period: 10s

MySQL

YAML
mysql:
  image: mysql:8
  environment:
    MYSQL_ROOT_PASSWORD: secret
    MYSQL_DATABASE: appdb
  healthcheck:
    test: ["CMD-SHELL", "mysqladmin ping -h localhost -p$$MYSQL_ROOT_PASSWORD"]
    interval: 5s
    timeout: 5s
    retries: 10
    start_period: 30s   # MySQL은 초기화가 길어 넉넉히

Redis

YAML
redis:
  image: redis:7
  healthcheck:
    # PONG이 오면 정상
    test: ["CMD", "redis-cli", "ping"]
    interval: 5s
    timeout: 3s
    retries: 5

HTTP 앱(자체 /health 엔드포인트)

YAML
web:
  build: .
  healthcheck:
    # curl 없는 alpine 이미지면 wget -qO- 사용
    test: ["CMD-SHELL", "curl -f http://localhost:8080/health || exit 1"]
    interval: 10s
    timeout: 5s
    retries: 3
    start_period: 15s

해결의 핵심 ② — depends_on long-syntax + condition

healthcheck를 정의했다면, 이제 의존하는 쪽에서 long-syntax로 조건을 명시합니다.

YAML
services:
  api:
    build: .
    depends_on:
      db:
        condition: service_healthy            # db가 healthy 될 때까지 대기
      migrate:
        condition: service_completed_successfully  # 마이그레이션 잡이 0으로 끝날 때까지
      redis:
        condition: service_started            # 그냥 떴으면 OK

세 조건의 차이입니다.

condition기다리는 대상쓰는 곳
service_started컨테이너 startreadiness가 필요 없는 보조 서비스
service_healthyhealthcheck가 healthyDB·큐 등 연결 대상
service_completed_successfully종료코드 0으로 완료DB 마이그레이션/시드 잡

버전 주의점: condition 문법은 Compose Spec 표준에 정식 포함됐고 Docker Compose v2(docker compose 플러그인)에서 정상 동작합니다. 과거 version: "3.x" 스키마의 short-syntax(depends_on: [db])에서는 condition이 무시됐었죠. 요즘은 version 키 자체가 deprecated이니 빼고, 최신 docker compose로 통일하세요.

healthcheck를 못 쓰는 경우 — 대안 비교

이미지에 ping 도구가 없거나 healthcheck를 못 넣는 상황도 있습니다. 그럴 때의 선택지입니다.

방법동작장점단점 / 권장 상황
wait-for-it.shTCP 포트 열림까지 폴링 후 명령 실행의존성 없음, 간단포트만 보고 "쿼리 준비"는 모름. 가벼운 대기에
dockerize -wait포트/HTTP 대기 + 템플릿TCP·HTTP 다 지원바이너리 추가 필요. 다중 의존 대기에
앱 레벨 재시도(백오프)앱이 직접 재연결인프라 독립적, 가장 견고코드 작성 필요. 베스트 프랙티스

앱 레벨 백오프 예시(Node.js):

JavaScript
async function connectWithRetry(retries = 10, delay = 1000) {
  for (let i = 0; i < retries; i++) {
    try { return await db.connect(); }
    catch (e) {
      console.warn(`DB 연결 실패, 재시도 ${i + 1}/${retries}`);
      await new Promise(r => setTimeout(r, delay * 2 ** i)); // 지수 백오프
    }
  }
  throw new Error("DB 연결 최종 실패");
}

실무 경험상 healthcheck + condition으로 부팅 타이밍 문제는 90% 사라지지만, 운영 중 DB가 잠깐 끊겼다 복구되는 상황(롤링 업데이트, 네트워크 블립)은 healthcheck로 못 막습니다. 그래서 12-factor·클라우드네이티브 관점에서 **healthcheck는 "권장 가속기", 앱 레벨 재시도가 "필수 안전망"**이라고 봅니다. 둘은 경쟁이 아니라 보완 관계예요.

흔한 실수 FAQ

자주 묻는 질문 (FAQ)

Q. condition: service_healthy를 걸었는데도 unhealthy로 빠지면서 앱이 안 떠요. A. 십중팔구 start_period가 너무 짧습니다. MySQL처럼 초기화가 긴 이미지는 start_period: 30s 이상으로 늘리세요. 이 기간 내 실패는 retries 카운트에 포함되지 않아 부팅 중 일시 실패를 무시해 줍니다.

Q. healthcheck test가 계속 실패해요. 명령은 맞는 것 같은데요. A. (1) 셸 문법(||, 변수)을 쓰면서 CMD를 쓰면 실패합니다 → CMD-SHELL로 바꾸세요. (2) Compose 변수를 $로 쓰면 compose가 먼저 치환합니다 → 컨테이너에서 평가할 변수는 $$로 이스케이프하세요. (3) alpine 이미지엔 curl이 없을 수 있으니 wget -qO-로 대체하세요.

Q. db는 healthy인데 앱이 여전히 가끔 죽어요. A. healthcheck는 부팅 타이밍만 해결합니다. 운영 중 순간 단절은 앱 레벨 재시도(지수 백오프)로 막아야 합니다. 그리고 restart: unless-stopped처럼 재시작 정책을 함께 두되, healthcheck가 unhealthy인데 무한 재시작으로 로그가 쌓이지 않는지 확인하세요.

결론 — 실전 체크리스트와 최종 템플릿

  1. 시작 순서(started) ≠ 준비 완료(healthy)임을 기억한다.
  2. DB·큐에 healthcheck를 정의한다.
  3. 의존 서비스에서 condition: service_healthy로 연결한다.
  4. 그래도 앱 레벨 재시도는 깔아둔다(운영 안전망).

바로 복붙해서 쓰는 최종 compose.yml:

YAML
services:
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: appdb
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
      interval: 5s
      timeout: 5s
      retries: 5
      start_period: 10s

  migrate:
    build: .
    command: ["npm", "run", "migrate"]
    depends_on:
      db:
        condition: service_healthy

  api:
    build: .
    depends_on:
      db:
        condition: service_healthy
      migrate:
        condition: service_completed_successfully
    restart: unless-stopped

이제 connection refused로 새벽에 깨는 일은 끝입니다. depends_on에 condition을 붙이는 순간, "왜 안 기다리지?"가 "알아서 기다리네"로 바뀝니다.

✦ ✦ ✦
편집 검토 · Editorial Review

이 글은 AI 에이전트가 1차 초안을 작성한 뒤, 사람 편집자가 사실관계·출처·톤과 맥락을 검토하여 발행했습니다. 오류나 부정확한 내용이 확인되면 24시간 이내에 정정합니다.

작성 · Content Reviewer·검토 · 사람 편집자·발행 · 2026년 6월 14일

댓글

불러오는 중...