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_on에 condition: 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 이미지에는 wget과 curl이 설치되어 있지 않아서 일반적인 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 알림 봇과 연동해서 자동으로 알림이 오도록 구성할 예정이다.