Docker Compose 멀티 서비스 구성

세 서비스가 올바른 순서로 떠야 한다.

db (PostgreSQL) → server (Express + Prisma) → client (React + nginx)

server가 DB보다 먼저 뜨면 Prisma 연결이 실패하고, client가 server보다 먼저 뜨면 API 프록시가 동작하지 않는다.

docker-compose.yml

services:
  db:
    image: postgres:16-alpine
    container_name: insa-db
    environment:
      POSTGRES_DB: unipost_insa
      POSTGRES_USER: insa
      POSTGRES_PASSWORD: insa1234
    volumes:
      - ./data/postgres:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U insa -d unipost_insa"]
      interval: 5s
      timeout: 5s
      retries: 10

  server:
    build: ./server
    container_name: insa-server
    ports:
      - "4000:4000"
    env_file:
      - ./server/.env
    environment:
      DATABASE_URL: postgresql://insa:insa1234@db:5432/unipost_insa
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "node", "-e",
        "require('http').get('http://localhost:4000/health', r => process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"]
      interval: 5s
      timeout: 5s
      retries: 10
    restart: unless-stopped

  client:
    build: ./client
    container_name: insa-client
    ports:
      - "3002:80"
    depends_on:
      server:
        condition: service_healthy
    restart: unless-stopped

depends_oncondition: service_healthy를 쓰면 단순히 컨테이너가 “시작됐는지"가 아니라 “준비됐는지"를 확인하고 다음 서비스를 시작한다.


DB healthcheck

healthcheck:
  test: ["CMD-SHELL", "pg_isready -U insa -d unipost_insa"]
  interval: 5s
  timeout: 5s
  retries: 10

pg_isready는 PostgreSQL이 연결을 받을 준비가 됐는지 확인하는 내장 커맨드다. 컨테이너가 떠도 PostgreSQL 프로세스가 완전히 초기화되기까지 몇 초 걸리기 때문에 이 healthcheck가 없으면 server가 너무 일찍 연결을 시도해서 실패한다.


server healthcheck — wget/curl 없을 때

server healthcheck에서 문제가 생겼다. node:20-slim 이미지에는 wgetcurl이 설치되어 있지 않아서 일반적인 HTTP healthcheck 방법이 안 된다.

# 이건 안 됨 (wget 없음)
test: ["CMD", "wget", "-q", "--spider", "http://localhost:4000/health"]

해결: Node.js 내장 http 모듈로 대체한다.

healthcheck:
  test: ["CMD", "node", "-e",
    "require('http').get('http://localhost:4000/health', r => process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"]
  interval: 5s
  timeout: 5s
  retries: 10

/health 엔드포인트가 200을 반환하면 process.exit(0) (성공), 아니면 process.exit(1) (실패)로 healthcheck 결과를 전달한다.

서버에 /health 엔드포인트를 추가해둔다.

app.get('/health', (req, res) => res.sendStatus(200));

entrypoint.sh — DB 대기 + Prisma 자동 마이그레이션

server Dockerfile에서 entrypoint.sh를 실행 진입점으로 쓴다.

#!/bin/sh
set -e

echo "DB 연결 대기 중..."
until npx prisma db push --skip-generate 2>/dev/null; do
  echo "DB 아직 준비 안 됨, 재시도..."
  sleep 2
done

echo "DB 준비 완료, 서버 시작"
exec node src/index.js

prisma db push는 스키마와 DB를 동기화한다. DB가 아직 준비 안 됐으면 실패하고, 성공할 때까지 2초마다 재시도한다.

healthcheck + depends_on이 있어도 이 루프를 유지하는 게 좋다. healthcheck 타이밍이 맞지 않는 엣지 케이스가 간혹 있기 때문이다.

server Dockerfile

FROM node:20-slim
RUN apt-get update && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY prisma ./prisma
RUN npx prisma generate
COPY src ./src
COPY entrypoint.sh ./
RUN chmod +x entrypoint.sh
EXPOSE 4000
ENTRYPOINT ["./entrypoint.sh"]

openssl 설치가 빠지면 안 된다. 트러블슈팅에서 다룬다.


client nginx 설정

React SPA를 서빙하면서 /api/ 요청은 server로 프록시한다.

server {
    listen 80;
    root /usr/share/nginx/html;
    index index.html;

    # API 요청 → server 컨테이너로 프록시
    location /api/ {
        proxy_pass http://server:4000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }

    # SPA 라우팅 (새로고침 시 404 방지)
    location / {
        try_files $uri $uri/ /index.html;
    }
}

Docker Compose 내부 네트워크에서는 서비스 이름으로 통신할 수 있다. http://server:4000처럼 쓰면 된다.

client .env

VITE_API_URL=/api

Vite 빌드 시 API URL을 /api로 설정해두면 nginx가 알아서 server로 프록시한다. 서버 IP를 하드코딩할 필요가 없다.

client Dockerfile

FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci --silent
COPY . .
RUN npm run build

FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

멀티스테이지 빌드로 최종 이미지에 node, npm이 포함되지 않는다.


배포 명령어

# 최초 배포
cd unipost_insa
docker compose up -d --build

# 최초 데이터 수집 (배치 수동 실행)
docker exec insa-server node -e "require('./src/batch').runBatch().catch(console.error)"

# 서버 코드 변경 후
docker compose up -d --build server

# 클라이언트 변경 후
docker compose up -d --build client

# 로그 확인
docker logs insa-server -f
docker logs insa-client -f

로컬 개발 (Docker 없이)

# DB만 Docker로 띄우기
docker compose up -d db

# 서버
cd server
npm install
npx prisma db push
npm run dev   # localhost:4000

# 클라이언트 (새 터미널)
cd client
npm install
npm run dev   # localhost:5173

트러블슈팅

Prisma + Alpine OpenSSL 오류

Error: Unable to require('node_modules/.prisma/client/libquery_engine-linux-musl.so.node')
PrismaClientInitializationError: Unable to open the libssl.so.1.1 file.

node:20-alpine은 musl libc 기반이라 Prisma가 요구하는 OpenSSL 버전과 맞지 않는다.

해결: node:20-slim(Debian 기반)으로 변경하고 OpenSSL을 명시적으로 설치한다.

FROM node:20-slim
RUN apt-get update && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*

schema.prisma에도 binaryTargets를 추가해야 한다.

generator client {
  provider      = "prisma-client-js"
  binaryTargets = ["native", "debian-openssl-3.0.x"]
}

Docker 시작 순서 문제

depends_on만 써도 순서는 보장되지만, 이건 컨테이너가 “시작됐는지"만 확인한다. PostgreSQL이 실제로 연결을 받을 준비가 되기 전에 server가 연결을 시도하면 실패한다.

# 이것만으론 부족
depends_on:
  - db

# 이렇게 해야 함
depends_on:
  db:
    condition: service_healthy

service_healthy는 healthcheck가 통과한 후에 다음 서비스를 시작한다.

server healthcheck wget/curl 없음

위에서 다룬 것처럼 node:20-slim에는 wget/curl이 없다.

# Node.js 내장 http로 대체
test: ["CMD", "node", "-e",
  "require('http').get('http://localhost:4000/health', r => process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"]

AG Grid v33 테마 충돌

2편에서 다뤘지만 배포 후에도 동일하게 적용해야 한다.

<AgGridReact theme="legacy" ... />

인사 API 빈 배열 반환

배치를 돌렸는데 API 응답: 0명이 나오는 경우가 있었다. Request Body 없이 호출하면 빈 배열을 반환하는 API 특성 때문이었다. 파라미터를 명시적으로 넘겨줘야 전체 인원이 조회된다.


마무리

3편에 걸쳐 인사정보 관리 시스템을 만든 과정을 정리했다.

내용
1편 아키텍처 + Prisma 스키마 설계
2편 배치 변동 감지 + React AG Grid UI
3편 Docker Compose 멀티 서비스 배포 + 트러블슈팅 ← 지금 여기

핵심은 시작 순서 보장이다. healthcheck + depends_on condition: service_healthy + entrypoint.sh 재시도 루프, 세 가지를 같이 써야 안정적으로 동작한다.

향후 이 시스템에서 감지한 입퇴사·부서이동 변동을 Slack 알림 봇과 연동해서 자동으로 알림이 오도록 구성할 예정이다.