안녕하세요, 자바파커입니다.
docker compose up을 했더니 이런 줄이 찍힙니다.psql: error: connection to server at "db" (172.20.0.2), port 5432 failed: Connection refused분명히depends_on: [db]를 걸어 뒀는데요.
결론부터 말씀드리면 — depends_on은 DB 컨테이너가 "시작"되기를 기다릴 뿐, DB가 접속을 받을 "준비"가 되기를 기다리지 않습니다. 준비까지 기다리게 하려면 DB에 healthcheck를 넣고, 앱 쪽 depends_on에 condition: service_healthy를 걸면 됩니다.
이 차이를 제 PC에서 직접 재 봤습니다. 앱은 DB 컨테이너가 시작되고 0.26초 만에 접속을 시도하는데, DB가 실제로 접속을 받기까지는 2.2초가 걸렸습니다. 그 사이에 들어온 접속은 전부 거절됩니다. 이 장면을 41초 영상으로도 정리했습니다.
depends_on은 무엇을 기다리나 — 시작과 준비는 다르다
카페로 비유하면 쉽습니다.
| 카페 | 컨테이너 | |
|---|---|---|
| 시작(started) | 셔터를 올렸다 | DB 컨테이너 프로세스가 떴다 |
| 준비(ready) | 커피 머신 예열이 끝났다 | DB가 5432 포트로 접속을 받는다 |
depends_on만 썼을 때 |
셔터가 올라가자마자 손님이 들어와 주문한다 | 컨테이너가 뜨자마자 앱이 접속한다 |
| 결과 | "아직 준비 중이에요" | Connection refused |
depends_on을 짧게 쓰면 Compose는 만드는 순서만 정합니다. db 컨테이너가 시작 상태가 되면 바로 app을 띄웁니다. postgres는 컨테이너가 뜬 뒤에도 데이터 디렉터리를 만들고(첫 실행), 임시 서버로 초기화를 하고, 다시 본 서버를 띄우는 과정을 거칩니다. 그동안 앱이 들어오면 거절당하는 거죠.
Docker 공식 문서 「Control startup order」도 같은 이야기를 합니다. 준비 상태까지 기다리려면 service_healthy 조건과 헬스체크를 쓰라고요.
docker compose depends_on 실패를 직접 재 봤습니다
말로만 하면 감이 안 와서, 앱 대신 psql로 접속을 한 번만 시도하는 구성을 만들어 다섯 번씩 돌렸습니다. 매번 down -v로 볼륨까지 지우고 띄웠습니다(= 첫 실행).
# compose.naive.yml — depends_on 만
services:
db:
image: postgres:16-alpine
environment: { POSTGRES_PASSWORD: pw }
app:
image: postgres:16-alpine
depends_on: [db]
environment: { PGPASSWORD: pw }
command:
["sh", "-c", "psql -h db -U postgres -tAc 'select 1' && echo APP_OK"]| 측정 (5회, 중앙값) | 결과 |
|---|---|
depends_on만 |
5번 중 5번 실패 — Connection refused |
| db 컨테이너 시작 → app 시작 | 0.26초 |
| db 컨테이너 시작 → 실제 접속 가능 | 2.2초 (마지막 ready to accept connections 로그 기준) |
healthcheck + service_healthy |
5번 중 5번 성공 (up부터 앱 성공까지 약 4.8초) |
※ 제 PC(Docker Desktop 28, postgres:16-alpine) 기준입니다. 숫자는 PC마다 다르지만, 앱이 DB보다 먼저 준비된다는 순서는 어디서나 같습니다.
해결 1 — healthcheck + condition: service_healthy
가장 정석적인 방법입니다. DB가 스스로 "준비 완료"를 알리게 하고, 앱은 그 신호를 기다립니다.
services:
db:
image: postgres:16-alpine
environment: { POSTGRES_PASSWORD: pw }
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -h 127.0.0.1"]
interval: 1s
timeout: 2s
retries: 30
app:
image: my-app
depends_on:
db:
condition: service_healthy| 옵션 | 뜻 | 팁 |
|---|---|---|
test |
준비됐는지 확인하는 명령 | 종료 코드 0이면 healthy |
interval |
확인 간격 | 개발용은 1~2초면 충분 |
timeout |
한 번 확인할 때 기다리는 시간 | |
retries |
몇 번 실패하면 unhealthy | interval × retries가 최대 대기 시간 |
start_period |
처음 이 시간 동안의 실패는 세지 않음 | 초기화가 긴 DB에 유용 |
depends_on의 condition에는 세 가지가 있습니다.
| condition | 기다리는 것 |
|---|---|
service_started |
컨테이너 시작 (짧은 문법과 같다) |
service_healthy |
헬스체크 통과 |
service_completed_successfully |
컨테이너가 성공(0)으로 끝남 — 마이그레이션 작업에 쓴다 |
pg_isready에 -h 127.0.0.1을 붙이는 이유
postgres 이미지는 첫 실행 때 초기화용 임시 서버를 띄웁니다. 이 임시 서버는 TCP를 열지 않고(listen_addresses='') 유닉스 소켓으로만 받습니다. 이미지 안의 docker-entrypoint.sh에서 직접 확인했습니다.
-h 없이 pg_isready를 쓰면 유닉스 소켓으로 확인하기 때문에, 임시 서버가 떠 있는 순간을 "준비 완료"로 볼 여지가 있습니다. 정작 다른 컨테이너는 아직 TCP로 못 붙는데요.
솔직히 말하면 제 PC에서는 -h 없이도 5번 다 성공했습니다. 로그를 보니 임시 서버가 뜬 뒤 본 서버가 TCP를 열기까지의 틈이 0.2초 정도로 짧아서, 1초 간격 헬스체크가 그 순간에 걸리기 어려웠던 겁니다. 그래도 틈은 있으니 -h 127.0.0.1을 붙여 TCP까지 확인하는 편이 확실합니다.
다른 DB에서 자주 쓰는 헬스체크도 정리해 둡니다.
| DB | healthcheck test |
|---|---|
| PostgreSQL | pg_isready -U postgres -h 127.0.0.1 |
| MySQL / MariaDB | mysqladmin ping -h 127.0.0.1 |
| Redis | redis-cli ping |
해결 2 — 앱에서 재시도하기
솔직히 말하면, 헬스체크만으로는 반쪽입니다. 헬스체크는 "처음 뜰 때"만 지켜 줍니다. 운영 중에 DB가 잠깐 재시작되면 앱은 또 연결이 끊깁니다. 그래서 앱도 연결 실패 시 잠깐 기다렸다가 다시 시도하는 게 좋습니다.
// Node.js — 연결될 때까지 최대 10번, 1초 간격으로 재시도
async function connectWithRetry(connect, tries = 10) {
for (let i = 1; i <= tries; i++) {
try {
return await connect()
} catch (e) {
if (i === tries) throw e
console.log(`DB 연결 실패 (${i}/${tries}) — 1초 뒤 재시도`)
await new Promise(r => setTimeout(r, 1000))
}
}
}드라이버나 커넥션 풀마다 시작할 때의 동작이 다르니 꼭 확인해 보세요. 예를 들어 Spring Boot의 기본 커넥션 풀인 HikariCP는 기본 설정(initializationFailTimeout)이면 시작할 때 첫 연결을 못 얻으면 바로 실패합니다. 그래서 컨테이너 환경에서는 헬스체크가 더 중요해집니다. 헬스체크(처음) + 재시도(운영 중) 를 같이 쓰는 게 가장 튼튼합니다.
restart: on-failure는 어떤가요?
앱이 연결 실패로 죽으면 Compose가 다시 띄우게 하는 방법입니다. 결국은 붙긴 하지만, 로그에 에러와 재시작이 몇 번씩 찍혀서 진짜 장애와 구분하기 어렵습니다. 임시방편으로만 추천합니다.
쿠버네티스에서는 readinessProbe
쿠버네티스에는 depends_on 같은 시작 순서가 없습니다. 대신 각 파드가 readinessProbe로 "나 준비됐어"를 알리고, 준비된 파드에만 트래픽이 갑니다. 앱이 DB를 기다려야 한다면 initContainer로 기다리거나, 앞에서 본 재시도를 넣습니다. 쿠버네티스 기본은 「K8S에 대한 이해」에 정리해 두었습니다.
내 PC에선 되는데 CI에선 실패하는 이유
depends_on만 걸어 둔 프로젝트에서 자주 보는 증상입니다.
- 내 PC: 볼륨이 이미 있어서 DB 초기화를 건너뛴다 → 준비가 빠르다 → 우연히 성공
- CI: 매번 새 환경이라 초기화부터 한다 → 준비가 느리다 → 실패
제가 잰 2.2초도 첫 실행(초기화 포함) 기준입니다. 볼륨이 있으면 더 짧아지지만 0이 되지는 않습니다. 운에 맡기지 말고 헬스체크를 넣어 두세요.
자주 묻는 것
Q. depends_on만 써도 될 때가 있나요?
A. 상대가 "뜨자마자 바로 받는" 서비스이거나, 앱이 재시도를 확실히 한다면 괜찮습니다. DB처럼 초기화 시간이 있는 서비스라면 service_healthy를 권합니다.
Q. healthcheck를 넣었는데 계속 unhealthy예요.
A. docker inspect --format '{{json .State.Health}}' <컨테이너>로 헬스체크 로그를 보세요. 컨테이너 안에 명령(pg_isready, curl 등)이 없거나, 사용자·포트가 틀린 경우가 대부분입니다.
Q. 예전 문서의 wait-for-it.sh는요?
A. 앱 이미지에 스크립트를 넣어 포트가 열릴 때까지 기다리는 방법입니다. 지금은 Compose의 service_healthy로 같은 일을 설정만으로 할 수 있어서, 새 프로젝트라면 헬스체크를 권합니다.
정리
| 하고 싶은 것 | 쓸 것 |
|---|---|
| 시작 순서만 정하기 | depends_on: [db] |
| DB 준비까지 기다리기 | db에 healthcheck + app에 condition: service_healthy |
| 운영 중 DB 재시작에도 버티기 | 앱에서 재시도 |
| 쿠버네티스 | readinessProbe (+ initContainer 또는 재시도) |
한 줄로 기억하세요. 시작 말고, 준비를 기다리기.
여러분 compose 파일에는 healthcheck가 들어 있나요? 아니면 재시도로 버티고 계신가요? 댓글로 알려 주세요.
함께 보면 좋은 글: 커넥션 풀 고갈 — 멀쩡한 서버가 멈추는 진짜 이유
