웹 프로젝트 정리

서론

진행중이던 프로젝트가 어느정도 목표하던 수준까지 마무리가 되고 추가로 해보고 싶었던 기능들을 구현해보려던 중, 무언가 다른걸 하기 전에 지금까지 했던 것을 정리하고 넘어가야겠다는 생각이 들었다.
처음으로 진행해본 백엔드 프로젝트인 만큼 배우는게 굉장히 많았고, 다행히 페이스가 맞는 팀원을 만나 성장과 실습 중심으로 프로젝트를 원할히 진행할 수 있었다.
프로젝트의 제목은 여행 웹 어플리케이션인 ‘함가자’ 이지만, 기술적인 제목은 ‘Docker와 Nginx를 사용한 스프링 REST API의 CI/CD를 동반한 서버 배포 경험’ 정도가 될 것 같다.

프로젝트 개요

이번 포스팅은 2주 간 Spring Boot REST API 백엔드와 Vue.js 프론트엔드로 구성된 여행 계획 웹 애플리케이션을 개발하고 배포한 경험을 정리한 글입니다.
프로젝트 시작 전, 기능 구현도 분명 배울 것이 있고 중요한 파트지만 기능이 많으면 한정된 프로젝트 기간 내에 비슷비슷한 CRUD를 구현하다 시간을 많이 소비할 것 같다 판단했습니다.
따라서 본 프로젝트는 기능을 축소하고, 대신 실무에서 많이 사용되는 툴들이 어떤 방식으로 작동하고, 왜 그것을 사용하는지, 그리고 그것을 프로젝트에 어떻게 적용하는지를 고민하고 직접 사용해보는 것에 중점을 두었습니다.
본 프로젝트에서 메인 프로그래머로써 Docker를 통한 환경 격리, Nginx 리버스 프록시 구성, 멀티 컨테이너 배포, Github Action을 사용한 CI/CD, JPA를 사용한 백엔드 MVC 구현, 레이어 별 테스트 코드 및 통합 테스트 구성 등의 파트를 담당하였고, 그 과정에서 실무에서 자주 사용되는 기술들을 공부하고, 적용해본 경험을 작성하겠습니다.

기술 스택

  • Backend: Spring Boot 3.4.5, Spring Security, JPA, OpenAI API
  • Frontend: Vue.js 3, Vue Router, Nginx
  • Database: MySQL (Production), H2 (Development)
  • Infrastructure: Docker, Docker Compose, Nginx, Github Action

클래스 다이어그램

클래스

테스트

1. Docker를 통한 환경 격리와 표준화

백엔드 Docker 설정

Docker와 Docker compose를 사용함으로써 환경을 격리하여 팀원과의 개발 환경을, 그리고 개발 환경과 운영 환경을 일치시켜 안정적인 개발과 운영이 가능했습니다. 또한 이미지만 서버에 올려 서버 배포 절차를 간소화하며, 백과 프론트의 빌드 및 실행을 자동화하는 효과를 얻을 수 있었습니다.

Docker를 적용하게 된 계기(클릭 시 펼치기)

Docker를 적용하게 된 계기는 서버를 살 돈이 부족한 것이었습니다. 프로젝트 시작 전부터 AWS 서버에 9개 가량의 스프링 인스턴스를 띄우고 Nginx를 이용하여 로드밸런싱을 구축한 구조를 염두에 뒀습니다. 해당 구조와 Jmeter 등의 테스트 툴을 이용하여 로드밸런싱, 멀티스레딩, 기타 DB 최적화 등을 하나씩 적용하며 어느 정도의 차이가 나는지를 수치화 하는 그림을 그리고 있었습니다.
하지만 이러한 환경을 위해서는 프리 티어 서버로는 도저히 불가능했고, 대신 Docker의 컨테이너를 사용하면 로컬에서 비슷한 효과를 낼 수 있다는 사실을 알게 되었습니다.
따라서 서버에는 CI/CD 경험과 컨테이너 배포 경험을 위해 인스턴스 하나만을 배포하고, 기존의 테스트는 Docker를 사용하는 것으로 계획을 변경했습니다.
개발 도중 Vue를 빌드한 후 Nginx를 켜고 스프링 서버를 키는 일련의 과정이 반복되자(어디까지 핫 리로딩이 되는 지가 모호하여 안정성을 위해), 어차피 Docker를 사용할거면 일찌감치 적용해서 빌드 자동화 등의 수혜를 보자는 생각이 들어 적용하게 되었습니다.

# 빌드 단계
FROM openjdk:17-jdk-slim as build

WORKDIR /app

# 그래들 파일 복사
COPY gradlew .
COPY gradle gradle
COPY build.gradle .
COPY settings.gradle .
# 운영체제 차이로 gradlew 파일을 못찾는 문제를 해결하기 위해 dos2unix install
RUN apt-get update && apt-get install -y dos2unix && dos2unix gradlew
RUN chmod +x ./gradlew
RUN ./gradlew dependencies --no-daemon

COPY src src

RUN ./gradlew bootJar --no-daemon


FROM openjdk:17-jdk-slim

WORKDIR /app

# 빌드 단계에서 생성된 jar 파일 복사
COPY --from=build /app/build/libs/*.jar app.jar

# 메모리 및 GC 최적화
ENV JAVA_OPTS="-Xms128m -Xmx256m -XX:+UseG1GC -XX:MaxGCPauseMillis=200"

# 애플리케이션 프로필은 환경 변수로 지정
ENV SPRING_PROFILES_ACTIVE=default

ENV SERVER_PORT=8080

ENV DB_HOST=host.docker.internal
ENV DB_PORT=3306
ENV DB_NAME=ssafytrip
ENV DB_USERNAME=ssafy
ENV DB_PASSWORD=ssafy

EXPOSE ${SERVER_PORT}

# 시작 명령어
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar --spring.profiles.active=$SPRING_PROFILES_ACTIVE --server.port=$SERVER_PORT"]

주요 포인트:

  • 멀티 스테이지 빌드: 빌드 도구와 소스코드가 포함된 무거운 이미지와 실행만을 위한 경량 이미지 분리
  • 의존성 캐싱: Gradle 의존성을 먼저 다운로드하여 Docker 레이어 캐싱 활용
  • 환경 변수: 개발/운영 환경 분리를 위한 유연한 설정
  • 메모리 최적화: JVM 힙 크기 제한으로 컨테이너 환경에 최적화

프론트엔드 Docker 설정

Vue.js 애플리케이션도 동일하게 멀티 스테이지 빌드를 적용했고, Docker에서 제공하는 nginx alpine 경량 이미지를 사용하여 로컬에서 nginx가 필요하지 않도록 구현했습니다.

# 빌드 단계
FROM node:18 as build-stage
WORKDIR /app

COPY package*.json ./
RUN npm install

COPY . .
RUN npm run build

# 프로덕션 단계
FROM nginx:alpine as production-stage
RUN rm -rf /etc/nginx/conf.d/*

COPY --from=build-stage /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Docker compose 설정

Docker compose는 3개의 파일로 환경을 각각 개발, 배포, 테스트(멀티 인스턴스)로 구분하여 작성했습니다. .env 파일로 중요한 정보를 환경변수로 유연하게 사용할 수 있도록 구현하였고, 배포 환경에서는 이미지를 빌드하여 결과물만 Docker hub로 전송할 수 있도록 구현했습니다.

개발 환경 docker-compose
services:
  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile.test
    container_name: spring-backend
    volumes:
      - gradle-cache:/root/.gradle  # Gradle 캐시 볼륨
    restart: always
    networks:
      - app-network
    environment:
      - SPRING_PROFILES_ACTIVE=local
      - JWT_KEY=${JWT_KEY}
      - API_KEY=${API_KEY}
    env_file:
      - .env

  frontend:
    build:
      context: ./frontend/final-front
    container_name: vue-frontend
    restart: always
    ports:
      - "8080:8080"  # 외부 8080 포트를 컨테이너 80 포트에 매핑
    volumes:
      - ./frontend/final-front/nginx.conf:/etc/nginx/nginx.conf # Nginx 설정 마운트
    depends_on:
      - backend
    networks:
      - app-network

volumes:
  gradle-cache:  # 도커 관리 볼륨 정의

networks:
  app-network:
    driver: bridge
배포 환경 docker-compose
services:
  backend:
    build:
      context: ./Backend
      dockerfile: Dockerfile.test
    image: rabent0207/final-penet-backend:latest
    container_name: spring-backend
    environment:
      - SPRING_PROFILES_ACTIVE=prod
      - SERVER_PORT=8080
      - DB_HOST=172.31.45.114
      - DB_PORT=3306
      - DB_NAME=ssafy
      - DB_USERNAME=ssafy
      - DB_PASSWORD=ssafy
      - JWT_KEY=${JWT_KEY}
      - API_KEY=${API_KEY}
    env_file:
      - .env
    volumes:
      - gradle-cache:/root/.gradle  # Gradle 캐시 볼륨
    restart: always
    networks:
      - app-network
      # 필요한 환경변수 추가 (DB 설정 등)

  frontend:
    build:
      context: ./Frontend/final-front
    image: rabent0207/final-penet-frontend:latest
    container_name: vue-frontend
    restart: always
    ports:
      - "8080:8080"  # 외부 8080 포트를 컨테이너 80 포트에 매핑
    volumes:
      - ./Frontend/final-front/nginx.conf:/etc/nginx/nginx.conf # Nginx 설정 마운트
    depends_on:
      - backend
    networks:
      - app-network

volumes:
  gradle-cache:  # 도커 관리 볼륨 정의

networks:
  app-network:
    driver: bridge
테스트 환경 docker-compose
version: '3'

services:
  app-build:
    build:
      context: ./backend
      dockerfile: Dockerfile.test
    image: my-spring-app:latest
    profiles:
      - build-only  # 이 서비스는 기본적으로 시작되지 않음

  # 앱 1
  app1:
    image: my-spring-app:latest
    container_name: spring-app1
    ports:
      - "8081:8080"
    environment:
      - SPRING_PROFILES_ACTIVE=prod
      - JAVA_OPTS=-Xms128m -Xmx256m
    deploy:
      resources:
        limits:
          memory: 600M
          cpus: '0.5'
    networks:
      - spring-network

  # 앱 2
  app2:
    image: my-spring-app:latest
    container_name: spring-app2
    ports:
      - "8082:8080"
    environment:
      - SPRING_PROFILES_ACTIVE=prod
      - JAVA_OPTS=-Xms128m -Xmx256m
    deploy:
      resources:
        limits:
          memory: 600M
          cpus: '0.5'
    networks:
      - spring-network

  # 앱 3
  app3:
    image: my-spring-app:latest
    container_name: spring-app3
    ports:
      - "8083:8080"
    environment:
      - SPRING_PROFILES_ACTIVE=prod
      - JAVA_OPTS=-Xms128m -Xmx256m
    deploy:
      resources:
        limits:
          memory: 600M
          cpus: '0.5'
    networks:
      - spring-network

  # 앱 4
  app4:
    image: my-spring-app:latest
    container_name: spring-app4
    ports:
      - "8084:8080"
    environment:
      - SPRING_PROFILES_ACTIVE=prod
      - JAVA_OPTS=-Xms128m -Xmx256m
    deploy:
      resources:
        limits:
          memory: 600M
          cpus: '0.5'
    networks:
      - spring-network

  # 앱 5
  app5:
    image: my-spring-app:latest
    container_name: spring-app5
    ports:
      - "8085:8080"
    environment:
      - SPRING_PROFILES_ACTIVE=prod
      - JAVA_OPTS=-Xms128m -Xmx256m
    deploy:
      resources:
        limits:
          memory: 600M
          cpus: '0.5'
    networks:
      - spring-network

  # 앱 6
  app6:
    image: my-spring-app:latest
    container_name: spring-app6
    ports:
      - "8086:8080"
    environment:
      - SPRING_PROFILES_ACTIVE=prod
      - JAVA_OPTS=-Xms128m -Xmx256m
    deploy:
      resources:
        limits:
          memory: 600M
          cpus: '0.5'
    networks:
      - spring-network

  # 앱 7
  app7:
    image: my-spring-app:latest
    container_name: spring-app7
    ports:
      - "8087:8080"
    environment:
      - SPRING_PROFILES_ACTIVE=prod
      - JAVA_OPTS=-Xms128m -Xmx256m
    deploy:
      resources:
        limits:
          memory: 600M
          cpus: '0.5'
    networks:
      - spring-network

  # 앱 8
  app8:
    image: my-spring-app:latest
    container_name: spring-app8
    ports:
      - "8088:8080"
    environment:
      - SPRING_PROFILES_ACTIVE=prod
      - JAVA_OPTS=-Xms128m -Xmx256m
    deploy:
      resources:
        limits:
          memory: 600M
          cpus: '0.5'
    networks:
      - spring-network

  # 앱 9
  app9:
    image: my-spring-app:latest
    container_name: spring-app9
    ports:
      - "8089:8080"
    environment:
      - SPRING_PROFILES_ACTIVE=prod
      - JAVA_OPTS=-Xms128m -Xmx256m
    deploy:
      resources:
        limits:
          memory: 600M
          cpus: '0.5'
    networks:
      - spring-network

  frontend:
    build:
      context: ./frontend/final-front
    container_name: vue-frontend
    restart: always
    ports:
      - "8080:8080"
    volumes:
      - ./frontend/final-front/nginx.test.conf:/etc/nginx/nginx.conf # Nginx 설정 마운트
    depends_on:
      - app1
      - app2
      - app3
      - app4
      - app5
      - app6
      - app7
      - app8
      - app9
    networks:
      - spring-network

networks:
  spring-network:
    driver: bridge

2. Nginx 리버스 프록시 구성

SPA 라우팅과 API 프록시 설정

추후 있을 테스트의 로드밸런싱을 위해, 그리고 당장은 CORS 문제를 해결하고 정적 파일 서빙에서의 이점을 얻기 위해 Nginx를 리버스 프록시로 활용했습니다.

server {
    listen 8080;
    server_name localhost;
    root /usr/share/nginx/html;
    index index.html;

    # 정적 에셋 처리
    location /assets/ {
        try_files $uri =404;
    }

    # API 프록시 - Spring 서버로 전달
    location /api/ {
        proxy_pass http://backend: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;
    }

    # Vue 라우팅을 위한 설정
    location / {
        try_files $uri $uri/ /index.html;
    }
}

핵심 구현 포인트:

  • API 라우팅: /api/ 경로는 백엔드로, 나머지는 Vue 앱으로 분기
  • SPA 지원: try_files를 통한 클라이언트 사이드 라우팅 지원
  • 프록시 헤더: 원본 클라이언트 정보 전달을 위한 헤더 설정

로드 밸런싱 실험

추후 성능 테스트를 위해 여러 컨테이너를 사용한 다중 백엔드 인스턴스와 Nginx를 통한 로드 밸런싱을 구현하였고, 로컬에서 컨테이너로 띄웠을 때 JWT를 사용했기에 로그인 등의 기능이 인스턴스를 오가며 잘 작동하는 것을 확인했습니다.

upstream spring_apps {
    server spring-app1:8080;
    server spring-app2:8080;
    server spring-app3:8080;
    # ... 최대 9개 인스턴스
}

location /api/ {
    proxy_pass http://spring_apps;
    # 프록시 헤더 설정...
}

3. 환경별 설정 관리

Spring Boot 프로파일 설정

개발과 운영 환경의 차이점을 효과적으로 관리하기 위해 Spring Boot의 프로파일 기능을 활용했습니다.

  • local: H2 데이터베이스, 개발용 설정
  • prod: MySQL 데이터베이스, 운영용 설정

환경 변수를 통한 설정 외부화

민감한 정보들은 환경 변수로 분리하여 보안을 강화했습니다.

# application-prod.properties
spring.datasource.url=jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME}
spring.datasource.username=${DB_USERNAME}
spring.datasource.password=${DB_PASSWORD}
jwt.secret=${JWT_KEY}
spring.ai.openai.api-key=${API_KEY}

4.멀티 컨테이너 배포

배포구조도

현재의 서버 배포 구조도입니다. Docker compose의 멀티 스테이징 빌드로 경량화된 결과물만 이미지로 Docker hub에 업로드 후, 서버 인스턴스에는 Nginx 설정 파일과 서버 내부 실행용 docker-compose 파일만 올려 이미지를 받아 컨테이너를 실행합니다.

services:
  backend:
    image: rabent0207/final-penet-backend:latest  # build 대신 image
    container_name: spring-backend
    environment:
      - SPRING_PROFILES_ACTIVE=prod
      - SERVER_PORT=8080
      - DB_HOST=172.31.45.114
      - DB_PORT=3306
      - DB_NAME=ssafy
      - DB_USERNAME=ssafy
      - DB_PASSWORD=ssafy
      - JWT_KEY=${JWT_KEY}
      - API_KEY=${API_KEY}
      - CORS_ALLOWED_ORIGINS=${CORS_ALLOW}
    volumes:
      - gradle-cache:/root/.gradle
    restart: always
    networks:
      - app-network

  frontend:
    image: rabent0207/final-penet-frontend:latest  # build 대신 image
    container_name: vue-frontend
    restart: always
    ports:
      - "8080:8080"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf
    depends_on:
      - backend
    networks:
      - app-network

volumes:
  gradle-cache:

networks:
  app-network:
    driver: bridge

기존의 SCP 방식으로 빌드 결과물인 JAR 파일 등을 보내던 방식에 비해 굉장히 과정이 간결해졌습니다. 또한 DB는 다른 EC2 인스턴스에 MySQL을 띄우고 TCP 방식으로 통신하는 구조인데 이런 구조가 된 이유는 다음과 같습니다.

도커

MYSQL

현재 EC2에서 프리티어로 사용할 수 있는 t2.micro는 1코어에 1gb 메모리를 제공하는데, 사진을 보시면 mysql과 도커의 백엔드가 각각 500mb 가까이를 점유하는 것을 볼 수 있습니다. 따라서 프리티어를 유지하기 위해, 그리고 추후 테스트에서 DB가 어플리케이션과 리소스 경합을 하여 테스트 결과에 영향을 미치는 것을 막기 위해 DB 서버 분리를 선택했습니다.
 
결과적으로

  • 쉬운 스케일 아웃
  • 스케일 아웃 시 무중단 배포 가능
  • 간결한 ci/cd 파이프라인
  • 서버가 db와 리소스 경합을 하지 않음
  • 컨테이너로 독립성, 이식성 확보
    와 같은 장점들을 가진 현재의 구조가 되었고 MSA 구조의 아주 기초적인 부분을 맛볼 수 있었습니다.

5. 높은 테스트 커버리지

현대를 살아가는 개발자의 AI 활용에 대한 특강을 듣던 중, 강사님이 AI가 테스트 코드 작성에 있어 특히 퀄리티가 좋고 생산성이 높다는 내용을 들어 프로젝트에 적용해보게 되었습니다.
Claude 3.7은 실제로 작성해야 하는 테스트 코드 중 많은 부분을 대신 작성해주었고, 저는 그 과정에서 테스트 코드에서 현재 DTO 구조와 맞지 않거나 추가로 테스트 해야 하는 부분을 보완하여 높은 테스트 커버리지를 이끌어냈습니다.
테스트 코드는 각 Controller, Repository, Service 단의 유닛 테스트 코드가 존재하고, JWT 등이 모두 통합된 통합 테스트가 도메인 별로 존재하는 구조로 완성되었고 Github action을 사용하여 pull request를 트리거로 테스트 코드들이 돌아가도록 구현했습니다.

문제 발생

그런데 개발 도중 이에 관해 문제가 발생했습니다. 아직 개발이 절반 정도 완성된 시점이었고 경험이 부족하여 DTO나 Entity 구조에 변경이 생길 일이 몇 번 있었는데, 그 때마다 테스트 코드를 모두 수정해주어야 하는 것이었습니다.
10분 정도 도메인을 수정하면 30분 가량을 변경된 부분의 테스트가 작동하는지 확인하고 변경하는 과정을 거쳐야 했고, 이 과정에서 개발 과정의 상당한 비효율성이 생겼다고 생각합니다.
개발이 테스트를 이끄는 것이 아닌, 테스트에 개발이 끌려다녔다고 느꼈고, 해당 경험은 Factory 패턴을 사용한 테스트 코드 등 구조 변화에 강건한 테스트 코드에 관심을 갖게 되는 계기가 되었습니다.
추후 만약 프로젝트에서 테스트 코드를 작성할 일이 생기면 개발이 어느정도 궤도에 오르기 전까지는 Swagger의 통합 테스트를 사용하는 것이 합리적이라 생각합니다. 이후 어느정도 궤도에 오르면 구조 변화에 강건한 행위 중심 테스트 등을 구성하는 것이 좋을 것 같습니다.

6. 트러블슈팅 경험

1. @Singular 어노테이션을 사용했음에도 null이 뜨는 문제

포스팅 참조

2. Spring security가 허용된 경로를 차단하는 문제

포스팅 참조

3. 다중 인스턴스 사용 시 실행 직후에 반응이 느린 문제

포스팅 참조

7. 성능 최적화 경험

Gradle 캐시 최적화

Docker 볼륨을 활용해 Gradle 의존성 캐시를 유지하여 빌드 시간을 단축했습니다.

이미지 크기 최적화

  • Alpine Linux 베이스 이미지 사용
  • 멀티 스테이지 빌드로 불필요한 빌드 도구 제거
  • .dockerignore를 통한 불필요한 파일 제외

Query 최적화

기존의 필요 없는 필드까지 조회하던 코드를 JPQL을 사용하여 필요한 부분만 DTO로 Projection하여 최적화 했습니다. 또한 변경이 없고, 컬럼 수가 적은 테이블은 Entity로 만드는 대신 어플리케이션 실행 시 sql 파일로 dump하고, 네이티브 쿼리로 불러오는 방식을 사용하여 더 가볍고 직관적으로 구현했습니다.

8. Github 활용

Git branch 전략

그동안의 프로젝트에서는 Github에서 issue 기능을 사용하거나 branch 전략을 두고 사용하지 않았고, Github를 공용 코드 저장소 정도로 사용하는 데에 그쳤습니다.
이전의 프로젝트에서 이러한 메이저한 협업 툴을 제대로 사용해보지 못한 것은 언제나 큰 아쉬움 중 하나였고, 이번에는 크지 않더라도 팀원과 협의하여 정립된 branch 전략 하에 협업해보도록 결정했습니다.
 
저희 프로젝트의 브랜치 전략은 다음과 같습니다.

  • MAIN : 메인 브랜치
  • DEV : 메인에 올리기전 버전용 브랜치 , 일정 수준마다 MAIN으로 릴리즈
    • FEATURE : 기능 개발
    • BUG : 버그 수정

저는 개발 내용이 Main 브랜치로 바로 들어가는 것은 좋지 않다고 생각하여, 중간에 완충이 될 수 있도록 Dev 브랜치를 위주로 작업하고, 일정 수준에 도달할 때 마다 Main으로 merge하도록 했습니다.
개발은 Github의 issue 기능을 활용하여 앞으로 구현해야 하거나 수정해야 할 부분을 issue로 등록하고, Dev 브랜치를 기준으로 issue 단위로 브랜치를 생성해 작업하는 방식으로 진행했습니다.
 
프론트

백엔드

다른 Gitflow 전략을 참고하고자 찾아봤을 땐 현재의 구조보다 hotfix 등의 계층이 더 많았지만, 2인이라는 적은 개발 인원과 빠르게 기능을 구현해야 하는 프로젝트의 상황을 보았을 때 현재의 구조가 합리적이라 판단하여 진행하게 되었습니다.
결과적으로 큰 층돌이나 문제 없이 기능을 효과적으로 나누어 개발할 수 있었던 원동력이 되었다고 생각합니다.

Git 컨벤션

브랜치 이름, 커밋, PR, Issue에 컨벤션을 정해 정해진 틀 내에서 가독성이 좋게 작성할 수 있도록 진행했습니다.

이슈

pr

또한 Github action을 통해 PR을 트리거로 테스트를 실행해 현재의 PR이 안정적인 지를 확인할 수 있도록 했습니다.
등록된 PR은 페어와 코드 리뷰를 진행해 자신의 파트가 아닌 부분이라도 구조와 방식을 인지하고 지속적으로 소통할 수 있도록 했습니다.

9.Github action을 통한 CI/CD

PR을 트리거로 하는 자동 테스트

테스트 커버리지가 높은 만큼 해당 테스트를 자동으로 실행하는 CI 과정이 있으면 좋겠다고 생각했고, 이전에 Github Action을 통해 구현해본 적이 있었기에 이번 프로젝트에도 적용해보았습니다.

name: Gradle CI

on:
  pull_request:
    branches: [ main, dev ]
    types: [opened, synchronize, reopened]

jobs:
  build:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: final/Backend  # 모든 run 명령에 대한 기본 작업 디렉토리 설정

    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'
          cache: gradle

      - name: Grant execute permission for gradlew
        run: chmod +x gradlew

      - name: Test with Gradle
        run: |
          ./gradlew test --info
        env:
          API_KEY: ${ { secrets.API_KEY } }
          JWT_KEY: ${ { secrets.JWT_KEY } }


      - name: test report upload
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: test-reports
          path: |
            /home/runner/work/final-penet/final-penet/final/Backend/build/reports/tests/test/
            /home/runner/work/final-penet/final-penet/final/Backend/build/test-results/test/

artifact로 테스트 리포트를 남기도록 하여 어느 부분에서 실패했는지를 직관적으로 볼 수 있도록 구성했습니다. 또한 민감한 정보는 Github secret으로 관리했습니다.
PR을 트리거로 했기 때문에 현재 브랜치가 안정적인지 확인할 수 있었고, 테스트를 만약 실패했다면 수정 후 다시 커밋하면 테스트도 다시 실행되었습니다.
개발하면서 굉장히 편하다고 느꼈고 잘 구성된 인프라의 힘을 또 한번 느낄 수 있었습니다.

Github Action을 통한 자동 배포

서버에 배포를 성공한 후 지금의 과정을 github action을 통해 자동화하면 좋겠다고 생각했고, CD 파이프라인을 구현하기 시작했습니다.

name: Deploy to Production

on:
  push:
    branches: [ main ]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: final/Backend

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'
          cache: gradle

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${ { secrets.DOCKER_USERNAME } }
          password: ${ { secrets.DOCKER_PASSWORD } }

      - name: Build and push Backend Docker image
        uses: docker/build-push-action@v6
        with:
          context: ./final/Backend
          push: true
          tags: rabent0207/final-penet-backend:latest
          build-args: |
            API_KEY=${ { secrets.API_KEY } }
            JWT_KEY=${ { secrets.JWT_KEY } }

      - name: Build and push Frontend Docker image
        uses: docker/build-push-action@v6
        with:
          context: ./final/Frontend/final-front
          push: true
          tags: rabent0207/final-penet-frontend:latest

      - name: Deploy to EC2
        uses: appleboy/ssh-action@v1
        with:
          host: ${ { secrets.EC2_HOST_IP } }
          username: ${ { secrets.EC2_USERNAME } }
          key: ${ { secrets.EC2_SSH_KEY } }
          script: |
            cd /home/ubuntu/Hamgaja
            docker compose -f docker-compose.server.yml pull
            docker compose -f docker-compose.server.yml up -d

서버에 미리 프로메테우스, docker compose, nginx 관련 설정 파일들을 넣어놓고, 이미지만 빌드 후 hub를 통해 전달받아 실행하는 방식입니다.
추후 SSL에서도 사용할 수 있도록 DuckDNS를 통해 도메인을 받아 사용하려 했지만 너무나 불안정하여 IP를 사용한 방식으로 구현하게 되었습니다.
서버 내의 설정파일은 크게 변경될 일이 없는 실행용 파일이라 현재 구조도 안정적이지만, 이후 서버 내의 필요한 설정파일도 줄이는 방식으로 리팩토링 할 예정입니다.

마무리

이번 프로젝트를 통해 단순한 웹 애플리케이션 개발을 넘어서 실제 서비스 배포와 운영에 필요한 기술들을 직접 경험해볼 수 있었습니다. 모르는 기술들을 실습하는 것이 목표인 프로젝트였기에 처음하는 것들이 매우 많았지만 2주 내에 어느정도 목표한 수준까지 도달하여 만족스러운 프로젝트였습니다. 학습한 내용들은 다음과 같습니다.

핵심 학습 내용:

  1. 환경 일관성: Docker를 통한 개발/운영 환경 표준화
  2. 서비스 분리: 마이크로서비스 아키텍처의 기초 경험
  3. 네트워크 이해: 컨테이너 간 통신과 리버스 프록시 설정
  4. CI/CD 파이프라인 구축: Github Action을 사용한 자동 빌드, 테스트 및 배포 파이프라인

향후 개선 사항:

  • 모니터링: Prometheus + Grafana와 헬스체크 도입
  • 보안 강화: SSL 인증서 적용, 보안 헤더 설정
  • 스케일 아웃: 로컬에서 다중 인스턴스로 로드밸런싱을 사용한 테스트 구현

백엔드 개발자로서 단순히 API를 만드는 것을 넘어서, 전체 시스템의 아키텍처를 이해하고 실제 서비스로 배포하는 전 과정을 경험할 수 있었던 의미 있는 프로젝트였습니다. 또한 Docker와 같은 인프라 기술이 이번에 정말 편리하게 사용했는데, 서버와 보안이 들어가기 시작하면 점점 러닝커브가 높아지고 쿠버네티스까지 가면 정말 복잡해진다. 그러면 백엔드 개발자는 이런 기술을 어느 수준까지 하는게 좋을까. 그런 고민도 해볼 수 있었던 좋은 프로젝트였습니다.
이후 헬스체크, 보안 부분을 보완하고 다중 인스턴스 구조로 변환하여 여러 테스트와 동시성 제어까지 구현해 볼 예정입니다.


프로젝트 저장소: https://github.com/rabent/Hamgaja
추후 테스트에 사용할 인프라 구조도 : 링크 참조