
Spring Boot, React, Nginx, Jepetto(java/tomcat 기반 회사 프레임워크) 를 Docker를 통해 배포 시에 유지보수를 용이하도록 하고자 한다.
Spring Boot, React + Nginx 의 Dockerize 는 레퍼런스가 많이 있었지만 Jepetto의 경우에는 회사에서만 사용하는 프레임워크이기도 하고 Docker 내장 tomcat이 아닌 로컬 저장소의 tomcat을 사용하고 의존성 관리 툴을 사용하지 않고 tomcat/lib 경로에 .jar 파일들을 직접 추가하는 방식으로 라이브러리를 추가하기 때문에 세팅에서 어려움이 있었다.
maven이나 gradle 을 통해 간단히 war 또는 jar 파일을 만들 수 없고 각 .java 파일을 tomcat/lib 경로의 .jar 파일과 같이 컴파일 한 후에 컴파일 된 파일을 통해 jar -cvf 명령어를 사용하여 war 파일을 생성해야 한다. 이후 tomcat/webapps 경로에 생성된 war 파일을 가져다 놓고 환경 변수를 설정하면서 tomcat을 실행해야 한다.
처음에는 Jenkins 를 통한 CI/CD 구축을 하려 했다. 하지만 3개나 되는(+Nginx까지 4개) 애플리케이션을 하나의 배치 스크립트를 통해 관리하고 한 번에 CI/CD 를 구축하는데 어려움을 느끼고 Dockerize 를 먼저 진행하기로 하였다.
대략 4일간의 시행착오 끝에 해결한 Dockerize였다…
React Dockerfile (/pos4phill-web/Dockerfile)
# Node.js를 사용하여 React 애플리케이션 빌드
FROM node:20.13.1-alpine AS build
WORKDIR /usr/src/app
# 환경 변수 파일 복사
COPY .env .env
# 패키지 설치 및 애플리케이션 빌드
COPY package*.json ./
RUN npm install
COPY ./ ./
RUN npm run build
# Nginx를 사용하여 빌드된 React 애플리케이션 서비스
FROM nginx:alpine
COPY --from=build /usr/src/app/build /usr/share/nginx/html
COPY ./default.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
환경변수를 따로 복사해주는 이유는 npm run build 명령어를 실행할 때 .env 파일을 소스 코드에 주입하여 빌드 결과물을 생성하기 위함이다.
React Nginx 설정 파일 (pos4phill-web/default.conf)
server {
listen 3000;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
}
Nginx Dockerfile (/nginx/Dockerfile)
# Alpine Linux 기반의 Nginx 이미지 사용
FROM nginx:alpine
# 사용자 정의 Nginx 설정 파일 복사
COPY ./nginx.conf /etc/nginx/nginx.conf
# 80 포트 노출
EXPOSE 80
# Nginx 실행
CMD ["nginx", "-g", "daemon off;"]
Nginx 설정 파일 (/nginx/nginx.conf)
events {
# 최대 1024개의 클라이언트 연결을 동시에 처리
worker_connections 1024;
}
http {
server {
listen 80;
client_max_body_size 20M;
server_name localhost;
# Spring Boot 애플리케이션으로 라우팅
location /api2 {
proxy_pass http://spring:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Jepetto 애플리케이션으로 라우팅
location /gazapos {
proxy_pass http://jepetto:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# React 애플리케이션으로 라우팅
location / {
proxy_pass http://react:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}
docker 내부에 복사할 nginx 설정 파일을 정의하는 부분이다.
proxy_pass 를 통한 리버스 프록시를 설정해주는 부분이다. location /api2 proxy_pass http://spring:8081 이 부분은 /api2 경로로 요청이 들어오면 spring(service 명) 애플리케이션 8081 포트로 요청을 넘겨준다. 즉 http://localhost/api2/hello 이런 요청은 spring 애플리케이션으로 넘어간다는 의미이다. 마찬가지로 location /gazapos proxy_pass http://jepetto:8080 는 jepetto 애플리케이션 8080 포트로 요청을 넘겨주게 된다. 그 외의 경로는 모두 react 에서 사용하기 때문에 루트 경로 처리를 해주었다.
Spring Boot Dockerfile (gazapos_db/Dockerfile)
# Gradle과 JDK 17을 사용하여 애플리케이션 빌드
FROM gradle:7.6-jdk17 AS build
WORKDIR /home/gradle/src
COPY --chown=gradle:gradle . .
RUN gradle build -x test --no-daemon
# OpenJDK 17 Slim 이미지를 사용하여 실행 환경 구성
FROM openjdk:17-slim
WORKDIR /app
EXPOSE 8081
# SQLite 설치
RUN apt-get update && apt-get install -y sqlite3 libsqlite3-dev && rm -rf /var/lib/apt/lists/*
# 빌드된 JAR 파일 복사
COPY --from=build /home/gradle/src/build/libs/*.jar app.jar
# SQLite 데이터베이스 파일 복사
COPY pos.db /app/sqlite/pos.db
# SQLite 데이터베이스 연결 설정
ENV SPRING_SECOND_DATASOURCE_JDBCURL=jdbc:sqlite:/app/sqlite/pos.db
# 애플리케이션 실행
ENTRYPOINT ["java", "-jar", "app.jar"]
보통 많이 사용하는 이미 생성된 jar 파일을 docker 서버에 올려 실행하는 것이 아니라 gradle 을 사용해 clean build 까지 dockerfile에 포함시켜서 docker-compose build 할 때 spring application 의 build 까지 한 번에 처리해 주었다.
그리고 sqlite 라는 db를 사용하는데 이 db는 파일로 관리되기 때문에 파일을 복사해서 docker 내부에 저장해주었다.
jepetto Dockerfile (gazapos/Dockerfile)
# OpenJDK 11을 기반 이미지로 사용
FROM openjdk:11-jdk
# 작업 디렉토리 설정
WORKDIR /app
# Tomcat 서버 파일 복사
COPY ./gazapos/tomcat /app/tomcat
# 소스 코드 복사
COPY ./gazapos/src /app/src
# SQLite 데이터베이스 파일 복사
COPY ./gazapos_db/pos.db /app/sqlite/pos.db
# Java 클래스 컴파일
RUN mkdir -p /app/src/main/webapp/WEB-INF/classes && \
javac -cp "/app/tomcat/lib/*" -d /app/src/main/webapp/WEB-INF/classes $(find /app/src -name "*.java")
# WAR 파일 생성
RUN mkdir -p /app/war && \
cp -r /app/src/main/webapp/* /app/war/ && \
jar -cvf /app/gazapos.war -C /app/war .
# WAR 파일을 Tomcat webapps 디렉토리로 이동
RUN mv /app/gazapos.war /app/tomcat/webapps/
# 8080 포트 노출
EXPOSE 8080
# Tomcat 서버 실행 명령
CMD ["sh", "-c", "java -Dcatalina.base=${CATALINA_BASE} -Dcatalina.home=${CATALINA_HOME} -Dwtp.deploy=${WTP_DEPLOY} --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.util.concurrent=ALL-UNNAMED --add-opens=java.rmi/sun.rmi.transport=ALL-UNNAMED -Djepetto.properties=${JEPETTO_PROPERTIES} -cp ${CATALINA_HOME}/bin/bootstrap.jar:${CATALINA_HOME}/bin/tomcat-juli.jar org.apache.catalina.startup.Bootstrap"]
처음 말했듯이 가장 복잡하고 힘들었던 jepetto의 dockerfile이다.
lib, conf 등의 참조할 파일들의 관리를 용이하게 하기 위해 기존 tomcat 을 gazapos 프로젝트 내부 경로에 두고 복사하도록 하였다. (C:/tomcat 경로의 tomcat 파일은 로컬 환경 설정을 따르고 gazapos/tomcat 경로의 tomcat 파일은 docker 환경 설정을 따른다)
마찬가지로 sqlite의 db 파일을 사용하기 때문에 gazapos_db(spring application)의 db 파일을 복사 하였다. 이후 컴파일할 파일의 저장 경로(디렉토리)를 지정하고 tomcat/lib 경로의 파일과 .java 파일을 컴파일하여 저장한다.
그리고 컴파일 된 파일을 통해 war 파일을 생성하고 tomcat 에서 war 파일을 통해 애플리케이션이 실행되도록 tomcat/webapps 경로에 .war 파일을 이동시킨다.
마지막으로 CMD 부분에서는 애플리케이션 실행에 필요한 arguments를 모두 설정 후에 tomcat 서버를 실행하도록 한다.
/docker-compose.yml
version: "3"
name: gazapos # 프로젝트 이름
services: # 개별 서비스 정의
# Spring Boot 서비스
spring:
build:
context: ./gazapos_db
dockerfile: Dockerfile
container_name: gazaposspring
networks:
- gazapos_network
ports:
- "8081:8081" # 호스트의 8081 포트를 컨테이너의 8081 포트와 연결
volumes: # 컨테이너에 마운트할 호스트의파일 시스템
- ./gazapos_db/pos.db:/app/sqlite/pos.db
restart: always
# React 서비스
react:
build:
context: ./pos4phill-web
dockerfile: Dockerfile
container_name: gazaposreact
networks:
- gazapos_network
expose:
- "3000" # 내부적으로 사용할 포트
depends_on:
- spring # spring service 가 시작된 후에 시작
restart: always
# Nginx 서비스
nginx:
build:
context: ./nginx
dockerfile: Dockerfile
container_name: gazaposnginx
networks:
- gazapos_network
ports:
- "80:80" # 호스트의 80 포트를 컨테이너의 80 포트와 연결, 외부에서 nginx 접근 가능
depends_on: # 3가지 서비스가 시작된 후에 nginx 서비스가 시작
- react
- spring
- jepetto
restart: always
# Jepetto 서비스
jepetto:
build:
context: .
dockerfile: gazapos/Dockerfile
container_name: gazaposjepetto
networks:
- gazapos_network
ports:
- "8080:8080" # 호스트의 8080 포트를 컨테이너의 8080 포트와 연결
volumes: # 컨테이너에 마운트할 호스트의파일 시스템
- ./gazapos/tomcat:/app/tomcat
- ./gazapos_db/pos.db:/app/sqlite/pos.db
environment: # 환경변수
- CATALINA_BASE=/app/tomcat
- CATALINA_HOME=/app/tomcat
- JEPETTO_PROPERTIES=/app/tomcat/conf/jepetto.properties
- WTP_DEPLOY=/app/tomcat/webapps
restart: always
# 네트워크 설정
networks:
gazapos_network:
driver: bridge
디렉토리 구조는 project-root 라는 최상위 디렉토리 안에 gazapos(jepetto), gazapos_db(spring boot), pos4phill-web(react), nginx(nginx), docker-compose.yml 이 위치한다.
자세한 설명은 주석으로 첨부하였다.
Dockerfile의 COPY와 docker-compose의 volumes는 무슨 차이가 있을까?
volumes (docker-compose.yml)volumes는 Docker 컨테이너가 실행될 때, 호스트 시스템의 디렉토리나 파일을 컨테이너의 특정 경로에 마운트한다. 이 경우, 컨테이너 내부에서 변경된 내용은 호스트에도 실시간으로 반영되며, 반대로 호스트의 파일 변경도 컨테이너에서 즉시 반영된다.volumes를 사용하면 컨테이너가 삭제되더라도 데이터는 호스트 시스템에 남아 있어 데이터의 영속성을 유지할 수 있다.COPY (Dockerfile)COPY는 Docker 이미지를 빌드할 때, 호스트의 파일이나 디렉토리를 컨테이너 이미지에 복사한다. 이때 복사된 파일은 이미지의 일부가 되어, 이후에 생성된 모든 컨테이너에 동일한 파일이 포함된다.COPY로 복사된 파일은 이미지를 빌드한 후에는 변경되지 않습니다. 파일을 변경하려면 이미지를 다시 빌드해야 한다.volumes는 컨테이너 실행 시 호스트와 컨테이너 간의 실시간 데이터 공유를 가능하게 하며, 컨테이너가 삭제되더라도 데이터가 유지된다.COPY는 빌드 시점에 파일을 이미지에 포함시키며, 이미지가 동일한 파일 상태를 유지하도록 합니다. COPY된 파일은 이미지의 일부로, 변경하려면 이미지를 다시 빌드해야 한다.따라서, 애플리케이션이 실행될 때마다 동일한 환경을 보장하고 싶다면 COPY를 사용하고, 개발 중에 파일을 자주 수정하고 실시간으로 반영하고자 한다면 volumes를 사용하는 것이 좋다.
그렇다면 변경될 파일인 pos.db는 volumes에서 마운트하도록 하고 Dockerfile에서는 COPY pos.db 하는 부분을 빼는게 더 효율적인 방법인듯 하다.
따라서 Spring Boot와 Jepetto의 Dockerfile에서 COPY pos.db 하는 부분을 제거하였다.
FROM gradle:7.6-jdk17 AS build
WORKDIR /home/gradle/src
COPY --chown=gradle:gradle . .
RUN gradle build -x test --no-daemon
FROM openjdk:17-slim
WORKDIR /app
EXPOSE 8081
RUN apt-get update && apt-get install -y sqlite3 libsqlite3-dev && rm -rf /var/lib/apt/lists/*
COPY --from=build /home/gradle/src/build/libs/*.jar app.jar
ENV SPRING_SECOND_DATASOURCE_JDBCURL=jdbc:sqlite:/app/sqlite/pos.db
ENTRYPOINT ["java", "-jar", "app.jar"]
copy pos.db 제거
FROM openjdk:11-jdk
WORKDIR /app
COPY ./gazapos/tomcat /app/tomcat
COPY ./gazapos/src /app/src
RUN mkdir -p /app/src/main/webapp/WEB-INF/classes && \
javac -cp "/app/tomcat/lib/*" -d /app/src/main/webapp/WEB-INF/classes $(find /app/src -name "*.java")
RUN mkdir -p /app/war && \
cp -r /app/src/main/webapp/* /app/war/ && \
jar -cvf /app/gazapos.war -C /app/war .
RUN mv /app/gazapos.war /app/tomcat/webapps/
EXPOSE 8080
CMD ["sh", "-c", "java -Dcatalina.base=${CATALINA_BASE} -Dcatalina.home=${CATALINA_HOME} -Dwtp.deploy=${WTP_DEPLOY} --add-opens=java.base/java.lang=ALL-UNNAMED --add-opens=java.base/java.io=ALL-UNNAMED --add-opens=java.base/java.util=ALL-UNNAMED --add-opens=java.base/java.util.concurrent=ALL-UNNAMED --add-opens=java.rmi/sun.rmi.transport=ALL-UNNAMED -Djepetto.properties=${JEPETTO_PROPERTIES} -cp ${CATALINA_HOME}/bin/bootstrap.jar:${CATALINA_HOME}/bin/tomcat-juli.jar org.apache.catalina.startup.Bootstrap"]
copy pos.db 제거
version: "3"
name: gazapos
services:
spring:
build:
context: ./gazapos_db
dockerfile: Dockerfile
container_name: gazaposspring
networks:
- gazapos_network
ports:
- "8081:8081"
volumes:
- ./gazapos_db/pos.db:/app/sqlite/pos.db
restart: always
react:
build:
context: ./pos4phill-web
dockerfile: Dockerfile
container_name: gazaposreact
networks:
- gazapos_network
expose:
- "3000"
depends_on:
- spring
restart: always
nginx:
build:
context: ./nginx
dockerfile: Dockerfile
container_name: gazaposnginx
networks:
- gazapos_network
ports:
- "80:80"
depends_on:
- react
- spring
- jepetto
restart: always
jepetto:
build:
context: .
dockerfile: gazapos/Dockerfile
container_name: gazaposjepetto
networks:
- gazapos_network
ports:
- "8080:8080"
volumes:
- ./gazapos_db/pos.db:/app/sqlite/pos.db
environment:
- CATALINA_BASE=/app/tomcat
- CATALINA_HOME=/app/tomcat
- JEPETTO_PROPERTIES=/app/tomcat/conf/jepetto.properties
- WTP_DEPLOY=/app/tomcat/webapps
restart: always
networks:
gazapos_network:
driver: bridge
volumes의 tomcat 제거
그리고 docker-compose build를 통해 이미지를 빌드하고 docker-compose up -d 를 통해 컨테이너를 실행시켜보았다.
docker-compose build
docker-compose up -d
실행이 완료되었다면
docker exec -it gazaposspring bash
명령어 또는 docker desktop을 통해 터미널 내부로 접속하여 sqlite 가 정상적으로 마운트 되었는지 확인한다.

spring 컨테이너

jepetto 컨테이너
spring 컨테이너는 dockerfile에서 sqlite 를 install 하면 생기는 .db 관련 정보 파일이 들어있는 파일들이 있는데 jepetto에서는 pos.db 만을 마운트하기 때문에 해당 파일들이 없이 pos.db 파일만 존재한다.
부끄럽지만 Docker 세팅을 혼자서 처음부터 끝까지 한게 이번이 처음이었다. 간단한 spring boot + mysql 정도의 세팅을 해본 경험은 있지만 모두 강의를 보면서 따라한 수준이었고, 프로젝트를 진행하면 인프라 세팅을 잘하는 팀원이 세팅해 놓은걸 사용해본 것이 전부였다.
대략 4일동안 머리를 싸매고 혼자 세팅을 하다보니 많은 시행착오를 겪었고 docker에 대해 조금은 더 잘 알게 된 것 같다.
거기에 더해서 front든 back이든 dev 환경과 prod 환경의 세팅을 초기에 해두고 이에 맞춰 개발을 하는 것이 중요하다는 것도 새삼 깨달을 수 있었다. (환경변수, spring profile 등..)