☁️ 🏗️

클라우드/인프라 엔지니어가 되는 과정을 기록합니다.

홈서버 구축 일지

[7] 내 첫 앱 홈서버 상륙기 by Docker Compose 🚀

d4eh0 2026. 4. 22. 19:46

키워드
 : 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 — 민감 정보 환경변수 관리

 

배포를 위한 준비는 끝났다.
다음 편에서는 실제로 홈서버에 올리고, 외부 접속까지 확인해보자 !