Skip to main content
Summary

이번 장에서 만들 구조

이전 장에서는 Compose 파일의 기본 구조를 배웠다. 이번에는 조금 더 실제 서비스에 가까운 개발 환경을 만든다. 구성은 세 개의 서비스로 나눈다.

  • web: 외부 요청을 받는 Nginx 웹 서버

  • app: 비즈니스 로직을 처리하는 Node.js 애플리케이션 서버

  • db: 데이터를 저장하는 PostgreSQL 데이터베이스

사용자는 브라우저로 web에 접속한다. web은 요청을 app으로 전달한다. app은 db에 접속해 데이터를 읽는다. 이렇게 역할을 나누면 각 컨테이너가 하나의 책임만 갖는다. 웹 서버 설정을 바꿔도 데이터베이스 컨테이너를 건드릴 필요가 없고, 애플리케이션 코드를 수정해도 데이터 저장 공간은 유지할 수 있다.

작업 디렉터리를 하나 만들고 시작하자.

mkdir compose-web-db
cd compose-web-db
mkdir app web db

앞으로 만드는 파일은 이 compose-web-db 디렉터리 아래에 둔다. app에는 Node.js 애플리케이션 코드와 Dockerfile을, web에는 Nginx 설정을, db에는 PostgreSQL 초기화 SQL을 넣을 것이다.

Compose로 웹 애플리케이션과 데이터베이스 구성하기AI 생성 이미지

애플리케이션 서버 만들기

먼저 아주 작은 Node.js 애플리케이션을 만든다. 데이터베이스의 messages 테이블을 조회해 JSON으로 반환하는 서버다.

cd app
npm init -y
npm install express pg
cd ..

app/server.js 파일을 만든다.

const express = require('express');
const { Pool } = require('pg');

const app = express();
const port = Number(process.env.PORT) || 3000;

const pool = new Pool({
  host: process.env.DB_HOST,
  port: Number(process.env.DB_PORT) || 5432,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
});

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function waitForDatabase(retries = 10) {
  for (let attempt = 1; attempt <= retries; attempt += 1) {
    try {
      await pool.query('SELECT 1');
      console.log('database connection ready');
      return;
    } catch (err) {
      console.log(`database not ready (attempt ${attempt}/${retries}): ${err.message}`);

      if (attempt === retries) {
        throw err;
      }

      await sleep(2000);
    }
  }
}

app.get('/', async (req, res) => {
  res.send('Compose web, app, db stack');
});

app.get('/messages', async (req, res) => {
  try {
    const result = await pool.query('SELECT id, text FROM messages ORDER BY id');
    res.json(result.rows);
  } catch (err) {
    console.error('failed to query messages:', err);
    res.status(500).json({ error: 'database query failed' });
  }
});

async function start() {
  await waitForDatabase();

  app.listen(port, () => {
    console.log(`app listening on port ${port}`);
  });
}

start().catch((err) => {
  console.error('failed to start app:', err);
  process.exit(1);
});

코드가 조금 길어 보이지만 핵심은 단순하다. 서버를 바로 시작하지 않고, 먼저 waitForDatabase()에서 데이터베이스에 연결할 수 있는지 몇 번 확인한다. 데이터베이스가 아직 준비되지 않았다면 2초 뒤 다시 시도한다. 그리고 /messages 요청 중에 데이터베이스 오류가 나면 서버가 조용히 죽지 않도록 로그를 남기고 500 응답을 보낸다.

이 코드에서 특히 중요한 부분은 DB_HOST다. 일반적으로 로컬에서 애플리케이션을 실행하면 데이터베이스 주소를 localhost로 쓰는 경우가 많다. 하지만 Compose 안에서는 다르다. app 컨테이너 안에서 localhost는 app 컨테이너 자기 자신을 뜻한다. PostgreSQL은 같은 컨테이너 안에 있지 않고 db 서비스로 따로 실행된다.

따라서 app에서 데이터베이스에 접속할 때는 localhost가 아니라 Compose 서비스 이름인 db를 사용해야 한다. Compose는 같은 프로젝트 안의 서비스 이름을 네트워크 주소처럼 해석해 준다. 이것이 여러 컨테이너를 하나의 애플리케이션으로 묶을 때 가장 중요한 규칙이다.

이제 애플리케이션 이미지를 만들 Dockerfile을 작성한다.

# app/Dockerfile
FROM node:22-alpine

WORKDIR /app

COPY package*.json ./
RUN npm install

COPY . .

EXPOSE 3000
CMD ["node", "server.js"]

여기서는 학습을 위해 npm install을 사용했다. 실제 팀 프로젝트에서는 package-lock.json을 함께 관리하고 npm ci를 사용하는 편이 더 재현 가능하다.

Nginx를 앞단 웹 서버로 두기

web 서비스는 Nginx를 사용한다. Nginx는 외부 요청을 받고 내부의 app:3000으로 넘긴다. 이를 리버스 프록시라고 한다. 사용자는 localhost:8080으로 접속하지만, 실제 애플리케이션 코드는 app 컨테이너에서 실행된다.

web/default.conf 파일을 만든다.

server {
    listen 80;

    location / {
        proxy_pass http://app:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

여기서도 주소는 localhost:3000이 아니다. Nginx 컨테이너 안에서 localhost는 Nginx 자신이다. 애플리케이션 서버는 app 서비스이므로 http://app:3000으로 연결한다.

데이터베이스 초기 스키마와 테스트 데이터 준비하기

개발 환경은 반복 가능해야 한다. 팀원이 저장소를 내려받고 docker compose up을 실행했을 때 같은 테이블과 기본 데이터가 준비되어야 한다. 이를 위해 PostgreSQL 공식 이미지의 초기화 기능을 사용한다.

PostgreSQL 이미지는 처음 데이터 디렉터리가 비어 있을 때 /docker-entrypoint-initdb.d 아래의 .sql, .sql.gz, .sh 파일을 실행한다. POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB 같은 환경 변수도 초기 데이터베이스를 만들 때 사용된다. 단, 이 동작은 데이터 디렉터리가 비어 있을 때만 적용된다.1

db/init.sql 파일을 만든다.

CREATE TABLE IF NOT EXISTS messages (
    id SERIAL PRIMARY KEY,
    text TEXT NOT NULL
);

INSERT INTO messages (text) VALUES
    ('Hello from PostgreSQL'),
    ('Docker Compose connects services by name'),
    ('Volumes keep database data outside containers');

이 파일을 PostgreSQL 컨테이너의 /docker-entrypoint-initdb.d/init.sql로 마운트하면, 데이터베이스가 처음 만들어질 때 테이블과 테스트 데이터가 들어간다.

여기서 볼륨과 초기화 스크립트의 관계를 정확히 이해해야 한다. 데이터베이스 볼륨이 이미 존재하면 PostgreSQL은 기존 데이터를 그대로 사용한다. 이때 init.sql은 다시 실행되지 않는다. 그래서 초기 데이터를 다시 만들고 싶다면 컨테이너만 지우는 것으로는 부족하다. 볼륨까지 삭제해야 한다.

compose.yaml 작성하기

이제 세 서비스를 하나의 Compose 파일로 묶는다. 프로젝트 루트, 즉 compose-web-db 디렉터리에 compose.yaml 파일을 만든다.

services:
  web:
    image: nginx:1.27-alpine
    ports:
      - "8080:80"
    volumes:
      - ./web/default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - app

  app:
    build: ./app
    environment:
      PORT: 3000
      DB_HOST: db
      DB_PORT: 5432
      DB_USER: appuser
      DB_PASSWORD: apppassword
      DB_NAME: appdb
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_USER: appuser
      POSTGRES_PASSWORD: apppassword
      POSTGRES_DB: appdb
    volumes:
      - db_data:/var/lib/postgresql/data
      - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
      interval: 5s
      timeout: 3s
      retries: 10

volumes:
  db_data:

구성을 하나씩 보자.

web은 외부에 공개되는 유일한 서비스다. ports에 "8080:80"을 지정했으므로 브라우저에서는 http://localhost:8080으로 접속한다. 포트 매핑은 문자열로 적어 두는 편이 안전하다. 8080:80처럼 콜론이 들어간 값은 YAML에서 뜻하지 않게 해석될 여지가 있으므로, Compose 파일에서는 보통 따옴표로 감싼다.

반면 app과 db에는 ports를 쓰지 않았다. 같은 Compose 네트워크 안에서만 접근하면 충분하기 때문이다. 개발 중 데이터베이스 클라이언트로 직접 접속해야 한다면 임시로 "5432:5432"를 추가할 수 있지만, 기본값으로는 내부에 숨겨 두는 편이 안전하다.

app은 build: ./app을 사용한다. Docker Hub에서 이미지를 내려받는 대신, 우리가 작성한 app/Dockerfile로 이미지를 빌드한다. 데이터베이스 접속 정보는 environment로 전달한다. 여기서 DB_HOST: db가 핵심이다.

db는 PostgreSQL 공식 이미지를 사용한다. db_data 볼륨을 /var/lib/postgresql/data에 연결해 데이터가 컨테이너 삭제와 분리되도록 했다. 컨테이너를 다시 만들어도 같은 볼륨을 사용하면 데이터가 유지된다.

depends_on의 역할과 한계

depends_on은 서비스 시작 순서를 제어한다. 위 파일에서 app은 db에 의존하고, web은 app에 의존한다. 따라서 Compose는 db, app, web 순서로 컨테이너를 만들고 시작하려고 한다.

하지만 짧은 형식의 depends_on은 의존 대상 컨테이너가 실행 상태가 되었는지만 확인한다. 데이터베이스 프로세스가 막 시작되었지만 아직 연결을 받을 준비가 안 되었을 수도 있다. Docker 공식 문서도 Compose가 기본적으로 컨테이너가 실행 중인지 확인할 뿐, 애플리케이션 내부가 완전히 준비되었는지까지 보장하지 않는다고 설명한다.2

그래서 위 예제에서는 db에 healthcheck를 추가했다. pg_isready는 PostgreSQL이 접속 가능한 상태인지 확인하는 명령이다. 그리고 app의 depends_on에는 다음 조건을 붙였다.

depends_on:
  db:
    condition: service_healthy

이렇게 하면 app은 db 컨테이너가 단순히 시작된 뒤가 아니라, 헬스체크 결과가 healthy가 된 뒤 시작된다.

그래도 실무에서는 애플리케이션의 재시도 로직이 필요하다. 네트워크는 언제든 끊길 수 있고, 데이터베이스가 실행 중간에 재시작될 수도 있다. Compose의 depends_on은 시작 순서를 도와주는 기능이지, 애플리케이션의 안정성을 대신 책임지는 기능은 아니다. 그래서 앞에서 만든 server.js도 시작 시점에 데이터베이스 연결을 여러 번 재시도하고, 요청 처리 중 오류가 나면 명확한 응답을 보내도록 했다.

실행하고 확인하기

이제 전체 스택을 실행한다.

docker compose up -d --build

상태를 확인한다.

docker compose ps

db 서비스의 상태에 healthy가 보이면 정상이다. 로그도 확인해 보자.

docker compose logs app
docker compose logs db

브라우저에서 다음 주소를 열어 본다.

http://localhost:8080
http://localhost:8080/messages

또는 터미널에서 확인할 수 있다.

curl http://localhost:8080/messages

응답에 init.sql에서 넣은 메시지들이 보이면 web → app → db 연결이 모두 성공한 것이다.

데이터베이스 안에 직접 들어가 보고 싶다면 db 컨테이너에서 psql을 실행한다.

docker compose exec db psql -U appuser -d appdb

프롬프트가 열리면 다음 SQL을 입력한다.

SELECT * FROM messages;

종료할 때는 다음을 입력한다.

q

반복 가능한 개발 환경으로 되돌리기

컨테이너만 중지하고 삭제하려면 다음 명령을 사용한다.

docker compose down

이 경우 db_data 볼륨은 남아 있다. 다시 docker compose up -d를 실행하면 기존 데이터베이스를 그대로 사용한다. 따라서 init.sql은 다시 실행되지 않는다.

처음 상태부터 다시 만들고 싶다면 볼륨까지 삭제한다.

docker compose down -v
docker compose up -d --build

이 명령은 개발 환경을 초기화할 때 유용하다. 테스트 데이터가 꼬였거나 스키마 실습을 처음부터 다시 하고 싶을 때 사용한다. 단, 볼륨 삭제는 데이터 삭제다. 운영 데이터베이스에는 절대 습관적으로 사용하면 안 된다.

정리

이번 장에서는 웹 서버, 애플리케이션 서버, 데이터베이스를 하나의 Compose 프로젝트로 구성했다. 핵심은 네 가지다.

첫째, 여러 컨테이너는 서비스 이름으로 서로를 찾는다. app에서 PostgreSQL에 접속할 때는 localhost가 아니라 db를 사용한다.

둘째, 외부에 공개할 서비스와 내부에서만 사용할 서비스를 구분한다. 사용자는 web의 8080 포트로 접속하지만, app과 db는 Compose 네트워크 안에 둔다.

셋째, 데이터베이스 데이터는 볼륨에 저장한다. 컨테이너는 언제든 다시 만들 수 있지만 데이터는 보존되어야 한다.

넷째, depends_on은 시작 순서를 도와주지만 준비 상태를 완전히 보장하지 않는다. 데이터베이스처럼 준비 시간이 필요한 서비스에는 healthcheck를 붙이고, 애플리케이션에도 연결 재시도 전략을 두는 것이 안전하다.

이제 Compose는 단순히 긴 docker run 명령을 줄여 주는 도구가 아니다. 웹, 앱, 데이터베이스가 함께 움직이는 개발 환경을 코드로 기록하고, 누구나 같은 방식으로 실행하게 해 주는 프로젝트 실행 설명서다.

참고

1Postgres Docker Official Image

2Docker Docs - Control startup and shutdown order in Compose

책 목록

Docker와 Docker Compose 입문: 컨테이너 개발 환경 구축부터 운영 준비까지

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기