키워드
: Docker Compose, Container, Reverse Proxy
완성 아키텍처

🐳 Docker Compose를 쓰는 이유?
[6]에서 Docker로 nginx를 컨테이너에 감싸 띄워봤다.
이제 실제 애플리케이션을 배포하려고 한다.
하지만 실제 서비스는 [6]의 실습처럼 하나의 컨테이너로 끝나지 않는다!
Spring Boot 애플리케이션, Nginx, MySQL
그리고 이후 모니터링을 위한 Prometheus, Grafana까지...
이때 하나의 컨테이너에는 하나의 프로세스만 담아야 한다.
Docker의 핵심 철학 중 하나 단일 책임 원칙 때문
덕분에 각 서비스를 독립적으로 관리하고, 교체하고, 스케일할 수 있다.
하지만 컨테이너가 여러 개가 되는 순간 문제가 생긴다.
각각을 docker run으로 띄우는 순간 생기는 고민들..
컨테이너 간 네트워크는 어떻게 연결하지? 실행 순서는? 환경변수 관리는?
명령어는 길어지고, 동시에 휴먼 에러의 위험이 커지는 등... 훨씬 복잡해진다.
그래서 우리는 Docker Compose를 사용한다.
여러 컨테이너를 하나의 docker-compose.yml 파일로 정의하고,
docker compose up 한 줄로 전부 올린다.
이번 편에서는 올리고자 했던 출석 체크 애플리케이션 CloudAttend를
Docker Compose로 홈서버에 배포해볼 것이다.
☁️ "무슨 서비스를 하려고?" - 내가 만든 CloudAttend 애플리케이션 !
간단하게 내가 올리고자 하는 애플리케이션에 대해 설명해보자면.
CloudAttend는 대학교 출석 체크 애플리케이션이다.
주요기능
(1) 학생이 수업 시작 시간 10분 전후로 GPS기반 출석 체크를 진행한다.
(2) 출결 현황을 확인할 수 있다.
기술스택
(1) Frontend : React, Vite
(2) Backend : SpringBoot 3, JPA, MySQL
(3) Infra : 홈서버(Galaxy Book 3 Pro, Ubuntu) → AWS (ECS, ALB, Auto Scaling, RDS)
단순한 출석 앱이지만,
수업 시작 시간에 수백~수천명이 동시에 접속하는 트래픽 스파이크 상황이 발생한다는 점이 특징이다.
이 애플리케이션을 홈서버에 올리고, 실제 트래픽을 측정해볼 것이다.
Docker Compose로 서버 컨테이너를 여러 개 띄우고 nginx로 로드밸런싱까지 구성해볼 것이다.
하지만 결국 물리 하드웨어는 Galaxy Book 한 대.
CPU와 RAM의 물리적 한계, 가용성 보장 불가, DR 전략 부재 등
홈서버의 구조적 한계를 직접 경험하고, 이후 AWS로 마이그레이션하는 과정까지 이어질 예정이다.
🎯 이번 글의 목표
: 외부 브라우저에서도 CloudAttend에 접속 가능한 상태를 만드는 것 !
이를 위해서 4개의 컨테이너를 Docker Compose로 구성하고자 한다.
| 컨테이너 | 역할 |
| nginx | Reverse Proxy : 외부 요청을 FE/BE로 분기 |
| frontend | React 빌드 결과물 정적 서빙 |
| backend | Spring Boot API 서버 |
| mysql | DB |
요청 흐름
- 외부 브라우저 → Nginx(80) → / 는 Frontend로, /api 는 Backend로
- 이때 Backend → MySQL
🔹 Step 1. Backend Dockerfile 작성
SpringBoot 애플리케이션을 컨테이너에 담기 위해서는 두 가지가 필요하다.
(1) Java 실행환경(JDK)
(2) 빌드된 .jar 파일
Dockerfile은 이 순서로 작성한다.
1. 베이스 이미지 선택 // Java17 환경
2. 작업 디렉토리 설정
3. 소스코드 복사
4. Gradle로 빌드 // .jar 생성
5. 포트 노출
6. 실행 명령어
*작성한 backend Dockerfile
# Stage 1: 빌드
# JDK + Gradle이 포함된 이미지로 .jar파일 생성
FROM gradle:8-jdk17 AS builder
WORKDIR /app
COPY . .
# 테스트는 건너뛰고 빌드
RUN ./gradlew bootJar -x test --no-daemon
# Stage 2: 실행
# JDK는 컴파일러까지 포함해서 무거움
# 실행할때는 JRE(실행환경)만 있으면 충분
FROM eclipse-temurin:17-jre
WORKDIR /app
# Stage 1에서 만든 .jar파일 가져오기
COPY --from=builder /app/build/libs/*.jar app.jar
# Spring Boot 기본 포트
EXPOSE 8080
ENTRYPOINT ["java", "-Dspring.profiles.active=prod", "-jar", "app.jar"]
🔹 Step 2. Frontend Dockerfile 작성
프론트엔드는 백엔드와 구조가 다르다.
백엔드는 Java가 직접 실행했다면, 프론트는 빌드 결과물을 Nginx가 대신 서빙하는 구조다.
(1) Node로 React 빌드 → dist/ 폴더 생성
(2) Nginx가 dist/ 폴더를 정적 파일로 서빙
*작성한 frontend Dockerfile
# Stage 1: 빌드
# Node.js가 포함된 이미지로 React 앱을 정적 파일로 빌드
FROM node:20-alpine AS builder #alpine은 가벼운버전을 뜻함
WORKDIR /app
# 의존성 설치
COPY package*.json ./
RUN npm ci
# 소스 복사 후 빌드
COPY . .
RUN npm run build
# Stage 2: 서빙
# nginx로 빌드 결과물(dist/)을 정적 파일로 서빙
FROM nginx:alpine #alpine은 가벼운버전을 뜻함
# Stage 1에서 만든 빌드 결과물을 nginx 기본 서빙 경로로 복사
COPY --from=builder /app/dist /usr/share/nginx/html
# HTTP 기본 포트
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
🔹 Step 3. nginx.conf 작성
Step 2에서 Frontend Dockerfile을 작성할 때 nginx를 사용했다.
이때는 빌드된 React 결과물을 서빙하는 '웹서버'로서 사용했다.
이번엔 nginx를 다른 용도로 사용해보자.
외부에서 들어오는 요청을 받아 적절한 컨테이너로 전달하는 '리버스 프록시'다.
지금 내가 원하는 흐름은 다음과 같다.
/ 로 오는 요청 → frontend 컨테이너 (React 정적 파일)
/api 로 오는 요청 → backend 컨테이너 (Spring Boot API)
이 라우팅 규칙을 nginx.conf 파일로 정의를 한다.
nginx
server {
listen 80;
# / 로 시작하는 요청 → frontend 컨테이너로
location / {
proxy_pass http://frontend:80;
}
# /api 로 시작하는 요청 → backend 컨테이너로
location /api {
proxy_pass http://backend:8080;
}
}
여기서 재밌는 건 frontend, backend는 IP 주소가 아니라는 것 !
Docker Compose로 띄운 컨테이너의 이름이다.
이처럼 같은 Compose 네트워크 안에서는 컨테이너 이름으로 서로를 찾을 수 있다.
🔹 Step 4. docker-compose.yml 작성
자 이제 지금까지 만든 파일들을 하나로 묶어보자.
참고로 mysql은 공식 이미지 그대로 띄우면 된다.
backend/Dockerfile : 백엔드 이미지 빌드 방법 정의
frontend/Dockerfile : 프론트 이미지 빌드 방법 정의
nginx/nginx.conf : 라우팅 규칙 정의
* mysql : 공식 이미지 그대로 사용
이 4개를 docker-compose.yml 하나로 정의하고,
docker compse up 명령어 한 줄로 한 번에 띄운다 !
작성한 docker-compose.yml
services:
# MySQL 데이터베이스
mysql:
image: mysql:8
environment:
MYSQL_DATABASE: ${MYSQL_DATABASE}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
# 컨테이너가 꺼져도 DB 데이터 날아가면 안되니까 볼륨에 저장
volumes:
- mysql_data:/var/lib/mysql
networks:
- app-network
# Spring Boot 백엔드
backend:
build: ./backend
environment:
DB_HOST: mysql # IP주소 대신 컨테이너 이름으로 DB에 접근하는 모습
DB_PORT: 3306
DB_NAME: ${MYSQL_DATABASE}
DB_USERNAME: ${MYSQL_USER}
DB_PASSWORD: ${MYSQL_PASSWORD}
JWT_SECRET: ${JWT_SECRET}
depends_on:
- mysql # 실행순서 !!!, mysql 컨테이너 먼저 뜬 다음에 backend 실행
networks:
- app-network
# React 프론트엔드
frontend:
build: ./frontend
depends_on:
- backend
networks:
- app-network
# Nginx 리버스 프록시
nginx:
image: nginx:alpine
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf
depends_on:
- frontend
- backend
ports:
- "80:80"
networks:
- app-network
volumes:
mysql_data:
networks:
app-network:
💡 지금 yml에서 backend, frontend, mysql 모두 ports를 열지 않았다.
- 외부 요청은 모두 nginx(80)을 통해 들어오는데,
nginx는 지금 같은 네트워크안에서 컨테이너 이름으로 직접 접근하기 때문에
굳이 호스트 포트를 열 필요가 없다.
오히려 포트를 열어두면 nginx를 우회해서 직접 접근이 가능해진다.
외부에 노출할 필요가 없는 포트는 닫아두는 것이 맞다.
→ 현재 외부에 열린 포트는 nginx(80) 딱 하나다.
🔹 Step 5. 환경변수 설정 (.env)
docker-compose.yml에서 ${MYSQL_PASSWORD} 같은 형식으로 값을 참조했다.
실제 비빌번호나 시크릿 키 같은 민감 정보는 코드에 직접 쓰면 안된다.
.env 파일에 분리하고, git에 올리지 않게 주의해야 함.
정리
이번 편에서 작성한 파일은 총 5개다.
backend/Dockerfile — Spring Boot 이미지 빌드
frontend/Dockerfile — React 빌드 후 nginx로 서빙
nginx/nginx.conf — 리버스 프록시 라우팅 설정
docker-compose.yml — 4개 컨테이너 통합 실행 정의
.env — 민감 정보 환경변수 관리
배포를 위한 준비는 끝났다.
다음 편에서는 실제로 홈서버에 올리고, 외부 접속까지 확인해보자 !