
이번 작업은 단순히 Spring Boot 프로젝트를 실행해보는 수준이 아니라, 실제 배포를 염두에 두고 백엔드 실행 환경을 컨테이너 기반으로 정리하는 과정이었다. 기존에는 로컬 IDE 환경에서만 애플리케이션이 동작하는 구조였다면, 이번에는 Docker를 이용해 Spring Boot 애플리케이션과 Redis를 함께 실행할 수 있도록 구성했고, 민감한 설정은 환경변수 기반으로 분리하여 실무형 구조에 가깝게 다듬었다.
현재 CoreERP의 백엔드 스택은 Java 17, Spring Boot 3.x, Spring Data JPA, MariaDB, JWT Authentication, Redis 기반 Refresh Token Rotation 구조로 되어 있다. 프론트는 React + TypeScript + Vite를 사용하고 있으므로, 최종적으로는 프론트가 API를 호출하고, 백엔드는 DB와 Redis를 각각 영속 저장소와 인증 토큰 저장소로 활용하는 구조가 된다. 이번 단계는 이 전체 구조 중에서 백엔드 실행 환경과 인증 인프라를 안정적으로 배포 가능한 형태로 만드는 작업이었다.
이번 작업의 핵심 목적은 세 가지였다.
즉, 단순히 로컬 개발 편의성을 위한 설정이 아니라, 이후 AWS EC2와 RDS로 자연스럽게 이어질 수 있는 배포 기반을 먼저 잡는 작업이었다.
웹 애플리케이션은 보통 Frontend, Backend, Database라는 세 층으로 나뉜다. 이번 작업은 그중에서도 Backend 실행 환경과 인증 인프라 레이어를 정리한 것이다.
Frontend는 React에서 Axios를 통해 Spring Boot API를 호출한다. Backend는 요청을 받아 비즈니스 로직을 처리하고, 데이터는 MariaDB에 저장한다. 인증 흐름에서는 로그인 성공 후 Access Token과 Refresh Token이 발급되며, Refresh Token은 Redis에 저장된다. 따라서 Redis는 단순 캐시가 아니라, 인증 상태를 안전하게 검증하기 위한 저장소 역할을 한다.
이번에 Docker로 묶은 것은 바로 이 Backend와 Redis 부분이다. 이 구성을 통해 로컬 개발 환경에서도 실제 운영 환경과 유사한 흐름을 재현할 수 있게 되었다.
가장 먼저 진행한 것은 Spring Boot 애플리케이션을 Docker 이미지로 만들기 위한 Dockerfile 작성이었다. Dockerfile은 애플리케이션을 어떤 베이스 이미지 위에서 빌드하고, 어떤 방식으로 실행할지를 정의하는 파일이다.
이번에는 실무적으로 많이 쓰이는 multi-stage build 방식을 사용했다. 첫 번째 단계에서는 Gradle로 JAR 파일을 빌드하고, 두 번째 단계에서는 실행에 필요한 JAR 파일만 포함한 경량 이미지로 실행하도록 구성했다. 이렇게 하면 최종 이미지 크기를 줄일 수 있고, 실행 환경을 더 깔끔하게 유지할 수 있다.
FROM eclipse-temurin:17-jdk AS builder
WORKDIR /app
COPY gradlew .
COPY gradle gradle
COPY build.gradle .
COPY settings.gradle .
RUN chmod +x ./gradlew
RUN ./gradlew dependencies --no-daemon || true
COPY src src
RUN ./gradlew bootJar --no-daemon
FROM eclipse-temurin:17-jre
WORKDIR /app
RUN addgroup --system spring && adduser --system spring --ingroup spring
USER spring:spring
COPY --from=builder /app/build/libs/*.jar app.jar
EXPOSE 8080
ENV TZ=Asia/Seoul
ENV JAVA_OPTS=""
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
여기서 중요한 점은 Dockerfile은 Git에 반드시 포함되어야 한다는 것이다. Dockerfile은 민감정보를 담는 파일이 아니라, 이 프로젝트가 어떤 방식으로 실행되는지를 설명하는 실행 스펙이기 때문이다. 즉, 포트폴리오 관점에서도 매우 중요한 파일이다.
다음으로는 Spring Boot 앱과 Redis를 함께 실행할 수 있도록 docker-compose.yml을 구성했다. Dockerfile이 개별 서비스 이미지를 만드는 규칙이라면, docker-compose는 여러 서비스를 하나의 실행 단위로 오케스트레이션하는 역할을 한다.
이번 단계에서는 app과 redis 두 개의 컨테이너를 구성했다. DB는 최종적으로 RDS를 사용할 예정이기 때문에, 로컬 테스트 단계에서는 기존 로컬 MariaDB를 그대로 사용하고, 컨테이너 내부에서는 host.docker.internal을 통해 접근하도록 설정했다.
services:
app:
build:
context: ./backend
dockerfile: Dockerfile
container_name: coreerp-app
ports:
- "8080:8080"
environment:
SPRING_DATASOURCE_URL: ${SPRING_DATASOURCE_URL}
SPRING_DATASOURCE_USERNAME: ${SPRING_DATASOURCE_USERNAME}
SPRING_DATASOURCE_PASSWORD: ${SPRING_DATASOURCE_PASSWORD}
SPRING_DATA_REDIS_HOST: ${SPRING_DATA_REDIS_HOST}
SPRING_DATA_REDIS_PORT: ${SPRING_DATA_REDIS_PORT}
SPRING_DATA_REDIS_PASSWORD: ${SPRING_DATA_REDIS_PASSWORD}
COREERP_AUTH_COMPANY_CODE: ${COREERP_AUTH_COMPANY_CODE}
COREERP_AUTH_JWT_SECRET: ${COREERP_AUTH_JWT_SECRET}
COREERP_AUTH_JWT_ACCESS_EXPIRATION: ${COREERP_AUTH_JWT_ACCESS_EXPIRATION}
COREERP_AUTH_JWT_REFRESH_EXPIRATION: ${COREERP_AUTH_JWT_REFRESH_EXPIRATION}
JAVA_OPTS: ${JAVA_OPTS}
depends_on:
- redis
restart: unless-stopped
redis:
image: redis:7-alpine
container_name: coreerp-redis
ports:
- "6379:6379"
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
volumes:
- redis_data:/data
restart: unless-stopped
volumes:
redis_data:
이 구조의 장점은 설정값을 전부 environment로 빼서 관리할 수 있다는 점이다. 즉, compose 파일 자체는 Git에 올려도 안전하고, 실제 비밀번호나 secret은 외부 `.env` 파일에서 읽어오도록 만들 수 있다.
기존에는 application.properties 안에 DB 비밀번호나 JWT secret이 직접 들어갈 수 있는 구조였다. 하지만 이렇게 되면 Git에 올라가거나, 배포 환경마다 설정을 바꾸기 어려워진다. 그래서 이번에는 application.properties가 직접 값을 가지는 방식이 아니라, 환경변수를 참조하도록 변경했다.
이 구조의 장점은 로컬 직접 실행, Docker Compose 실행, EC2 운영 배포까지 동일한 설정 구조를 유지할 수 있다는 점이다. 실제 값만 바꾸면 되기 때문에 확장성이 훨씬 좋아진다.
spring.application.name=coreerp-backend
server.port=8080
spring.datasource.url=${SPRING_DATASOURCE_URL}
spring.datasource.username=${SPRING_DATASOURCE_USERNAME}
spring.datasource.password=${SPRING_DATASOURCE_PASSWORD}
spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.data.redis.host=${SPRING_DATA_REDIS_HOST}
spring.data.redis.port=${SPRING_DATA_REDIS_PORT}
spring.data.redis.password=${SPRING_DATA_REDIS_PASSWORD}
spring.data.redis.timeout=3000
coreerp.auth.company-code=${COREERP_AUTH_COMPANY_CODE}
coreerp.auth.jwt.secret=${COREERP_AUTH_JWT_SECRET}
coreerp.auth.jwt.access-expiration=${COREERP_AUTH_JWT_ACCESS_EXPIRATION}
coreerp.auth.jwt.refresh-expiration=${COREERP_AUTH_JWT_REFRESH_EXPIRATION}
그리고 실제값이 들어간 application.properties는 Git에서 제외하고, 대신 application-example.properties를 템플릿 파일로 포함시키는 방식으로 정리했다. 이 방식은 협업이나 포트폴리오에서도 구조를 보여주기에 좋다.
이번 작업에서 중요한 부분 중 하나는 .env와 application.properties를 Git에서 제외하는 것이었다. Dockerfile이나 docker-compose.yml은 Git에 반드시 포함되어야 하지만, 실제 비밀번호와 secret 값은 절대 포함되면 안 된다.
그래서 루트 .gitignore를 정리하여 .env와 각종 로컬 민감설정 파일이 추적되지 않도록 했다. 반면 .env.example은 템플릿 역할을 하기 때문에 Git에 포함되도록 예외 처리했다.
# OS .DS_Storelogs
.log
npm-debug.log
yarn-debug.log
yarn-error.log
pnpm-debug.log
lerna-debug.log
.env
.env.
!.env.example
.local
.vscode/
!.vscode/extensions.json
.idea
.iws
.iml
.ipr
.suo
.ntvs*
.njsproj
.sln
*.sw?
node_modules
dist
dist-ssr
build/
.gradle/
out/
bin/
backend/src/main/resources/application.properties
backend/src/main/resources/application.yml
backend/src/main/resources/application-dev.yml
backend/src/main/resources/application-prod.yml
docker-compose.override.yml
.class
.jar
*.war
이렇게 정리한 뒤 실제로 Git 추적 여부를 다시 확인했다. `.env`와 `application.properties`는 추적되지 않고, Dockerfile과 docker-compose.yml은 정상적으로 Git 포함 대상이 되는 상태를 최종 기준으로 삼았다.
설정이 끝난 뒤에는 Docker 이미지를 빌드하고, app과 redis를 함께 띄워 실제로 실행되는지 검증했다. 이때 중요한 점은 Docker build 성공만으로 끝나는 것이 아니라, 컨테이너가 실제로 기동되고 DB와 Redis까지 정상 연결되는지 확인하는 것이다.
docker compose down
docker compose up -d --build
docker ps
docker compose logs -f app
실행 결과 `coreerp-app`과 `coreerp-redis` 컨테이너가 모두 정상적으로 떠 있는 것을 확인했고, Spring Boot 로그에서도 Tomcat 시작, HikariPool 시작, JPA EntityManagerFactory 초기화, 애플리케이션 기동 완료 로그가 확인되었다. 즉 단순 빌드 성공이 아니라, 실제 런타임까지 정상 상태로 검증된 것이다.
이번에 환경변수 기반 설정으로 바꿔두었기 때문에, Docker 없이도 로컬에서 직접 실행하는 것은 가능하다. 단, 이 경우에는 DB와 Redis host가 컨테이너 기준이 아니라 localhost 기준으로 달라진다는 점만 신경 쓰면 된다.
즉 Docker Compose 환경에서는 app 컨테이너에서 Redis를 `redis`라는 서비스명으로 찾지만, 로컬 IDE 실행에서는 Redis를 `localhost`로 지정해야 한다. 같은 방식으로 DB도 Docker 환경에서는 `host.docker.internal:3310`, 로컬 직접 실행에서는 `localhost:3310`을 사용한다.
이 구조 덕분에 개발 시에는 IDE에서 직접 빠르게 실행하고, 통합 테스트나 배포 검증 시에는 Docker Compose를 사용하는 방식으로 유연하게 운영할 수 있게 되었다.
처음에는 Docker 이미지 빌드가 실패하는 것으로 보였지만, 실제 원인을 추적해보니 Dockerfile 자체의 문제가 아니라 Gradle compile 단계에서 코드 에러가 발생하고 있었다. 이 과정에서 `AuthService`와 `RefreshTokenService` 클래스명 및 파일명 불일치, `AuditLog` 관련 getter/builder 인식 문제 등을 수정했다.
이 경험을 통해 Docker 에러처럼 보여도 실제로는 내부의 Gradle build 실패일 수 있다는 점을 다시 확인했다. 따라서 Docker build가 실패하면 먼저 로컬에서 `gradlew.bat clean build` 또는 `gradlew.bat bootJar`가 성공하는지부터 확인하는 습관이 중요하다는 점을 정리할 수 있었다.
초기 compose 실행 시 `Unable to determine Dialect without JDBC metadata` 에러가 발생했다. 처음에는 Hibernate 설정 문제처럼 보였지만, 실제 원인은 단순했다. compose에서 DB 비밀번호 환경변수가 누락되어 있었고, 그 결과 Spring Boot가 DB에 접속하지 못해 JDBC 메타데이터를 읽지 못한 것이다.
이 문제를 해결하면서 느낀 점은, 환경변수 기반 설정 구조에서는 코드보다도 `.env`와 compose 환경변수 연결이 매우 중요하다는 것이다. 즉, 운영 환경에서는 설정 파일보다 실제 주입되는 환경변수 값이 더 중요할 수 있다는 점을 체감했다.
compose를 반복 실행하는 과정에서 `coreerp-redis` 컨테이너 이름 충돌이 발생했다. 이는 기존 컨테이너가 살아 있는 상태에서 같은 `container_name`으로 다시 실행하려 했기 때문이다. 해결은 `docker compose down` 또는 `docker rm -f coreerp-redis`로 기존 컨테이너를 정리한 뒤 다시 올리는 방식으로 진행했다.
이 과정에서 앞으로는 테스트 실행 전 `docker compose down`으로 상태를 정리하고 시작하는 습관이 더 안정적이라는 점도 함께 정리했다.
보안 예외 처리 과정에서 `LocalDateTime not supported by default` 에러가 발생했다. 원인은 `JwtAuthenticationEntryPoint`에서 ObjectMapper를 직접 생성하고 있었기 때문이다. Spring Boot가 제공하는 기본 ObjectMapper는 Java Time Module이 등록되어 있지만, `new ObjectMapper()`로 만든 객체에는 그런 설정이 적용되지 않았다.
결국 해결 방법은 Spring이 관리하는 ObjectMapper Bean을 주입받는 방식으로 수정하는 것이었다. 이 수정으로 ErrorResponse의 timestamp 필드인 LocalDateTime도 정상적으로 JSON 직렬화되었다.
@Component
@RequiredArgsConstructor
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper;
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
ErrorResponse body = new ErrorResponse(
401,
"인증이 필요합니다.",
LocalDateTime.now()
);
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(body));
}
}
처음에는 Docker 관련 파일도 민감정보가 들어갈 수 있으니 Git에서 제외해야 하나 고민했지만, 실제로는 반대였다. Dockerfile과 docker-compose.yml은 프로젝트 실행 방법을 설명하는 핵심 파일이므로 반드시 Git에 포함되어야 한다. 반면 민감정보는 `.env`나 `application.properties`에 존재하고, 이 파일들만 Git에서 제외하는 것이 맞다.
이 정리를 통해 실행 스펙과 민감 설정을 명확히 분리할 수 있었고, 포트폴리오 관점에서도 프로젝트 구조를 더 깔끔하게 보여줄 수 있게 되었다.
이번 작업으로 CoreERP 백엔드는 더 이상 IDE에서만 돌아가는 로컬 전용 프로젝트가 아니게 되었다. Spring Boot 백엔드를 Docker 이미지로 빌드하고, Redis와 함께 Compose로 실행할 수 있으며, 환경변수 기반 설정 구조를 통해 운영 환경으로도 자연스럽게 이행할 수 있는 기반이 완성되었다.
특히 JWT Authentication과 Redis Refresh Token Rotation 구조를 컨테이너 환경에서도 그대로 검증했다는 점이 중요했다. 단순 CRUD 프로젝트를 넘어서, 인증과 인프라를 함께 고려한 백엔드 설계 경험을 코드와 실행 환경 양쪽에서 모두 정리할 수 있었다는 점에서 의미가 크다.
이제 다음 단계는 AWS 운영 배포다. 구체적으로는 EC2에 Docker와 Docker Compose를 설치하고, GitHub에서 프로젝트를 clone한 뒤 운영용 `.env`를 구성해 실행하는 흐름으로 진행할 예정이다. DB는 로컬 MariaDB 대신 RDS를 사용하게 되므로, datasource URL만 RDS endpoint 기준으로 바꾸면 현재 구성한 환경을 거의 그대로 사용할 수 있다.
프론트는 Cloudflare Pages에 배포하고, 백엔드는 EC2에서 Spring Boot + Redis 컨테이너를 운영하며, 최종적으로는 도메인 연결까지 정리하여 실제 서비스 구조로 완성하는 것이 다음 목표다.
이번 작업은 처음에는 Dockerfile 하나 만드는 단순 작업처럼 보였지만, 실제로는 백엔드 실행 방식, 인증 저장소, 설정 보안, Git 관리, 로컬 통합 테스트까지 모두 연결되는 과정이었다. 중간중간 컴파일 에러, DB 환경변수 누락, Redis 컨테이너 충돌, Jackson 직렬화 문제 같은 트러블슈팅도 있었지만, 하나씩 원인을 분리해서 해결하면서 결과적으로는 더 실무적인 구조를 만들 수 있었다.
지금 시점에서 CoreERP 백엔드는 로컬 IDE 실행도 가능하고, Docker Compose 기반 실행도 가능하며, 운영 배포로 이어질 준비도 거의 끝난 상태다. 다음 단계에서는 이 기반 위에 AWS EC2와 RDS를 연결해 실제 배포 구조를 완성하는 작업으로 이어갈 예정이다.