보통 프로젝트를 진행할 때, 사용한 배포방식은 feat 브랜치에서 api 작업을 진행하면서 develop브랜치에 PR을 올려 merge하고 어느정도의 api가 모이면 바로 main 브랜치로 push 보내는 방식을 사용했다. main 브랜치에 push하는 PR이 올라가면 해당 코드가 문제가 없는지 확인하고, 깃허브 액션을 통해 서버에 배포했다.
하지만 이런 방식에 문제점이 있음을 나중에야 깨달았다. develop에 머지하는 과정에서 생기는 컴파일 에러, 혹은 배포과정에서 에러가 생길 수 있다. 만약 에러가 발생한다면 프론트 팀원들이 스웨거나 포스트맨으로 테스트 자체를 진행할 수 없게 된다. 운영서버는 실제 배포된 서버이기 때문에 조심스럽게 접근해야 하지만 이러한 방식으로는 작은 실수에도 서비스 자체가 먹통이 될 수 있다.
이러한 문제를 해결하기 위해 스테이징 서버를 추가하여 두 서버의 환경을 완전히 분리하려고 한다.
서버는 크게 4종류가 있다.
로컬 서버(Local Server)는 개발자들이 처음으로 실행시키는 서버라고 할 수 잇다.
흔히 말하는 http://local:8080 또는 https://localhost:8080 으로 접속하여 우리가 개발하는 화면들을 볼 수 있다.
이 로컬 서버에서는 개발자들의 개발 개발 환경에 따라 결과가 달라질 수 있다.
개발 서버(Development Server)는 개발자들의 개인 개발환경이 아닌 1개의 통합된 환경으로 테스트를 할 수 있는 서버를 말한다. 대체적으로, 프로젝트에서 개발 서버는 스테이징 서버(Staging Server)와 환경을 비슷하게 구성하여 테스트를 하는 경우도 있다.
스테이징 서버(Staging Server)는 다른 말로 정말 많이 불린다.
예를 들어, 스테이징 서버를 테스트 서버(Test Server) QA 서버(QA Server)등으로 부른다.
이 스테이징 서버는 운영 서버 환경과 거의 100%로 비슷할 정도로 환경을 맞춘 다음, 운영 서버에서 사용되는 데이터를 가지고 실질적으로 운영 서버에 반영하기 전에 테스트를 거치는 곳이다.
즉, 운영 서버(Production Server)에 반영하기 전 최종 확인을 하는 서버라고 할 수 있다.
운영 서버(Production Server)는 실질적으로 운영을 하기 위한 서버다.
스테이징 서버에서 정상적으로 작동되는 기능들이 운영 서버에 반영된다.
1차 스프린트 당시에는 로컬 서버와 운영서버 2개만 구축해 사용하고 있었고, 2차 스프린트에는 스테이징 서버를 추가해, 분리하여 관리할 수 있는 환경을 구축하는 것이 이번 목표이다.
먼저 서버를 분리하는 것을 고려하기 이전에 프리티어 EC2에서 2개의 스프링을 돌릴 수 있는지를 확인했다. 현재 운영서버가 도커를 통해 실행되고 있어 docker stats로 확인해 보았다.

요청이 아예 없을 때 약 400MB이고, EC2 프리티어는 약 1GB의 메모리 가지게 된다. Swap 메모리를 사용하고 있지만, 메인 메모리에 비해 속도가 느리기 때문에 2개의 스프링을 돌리는 것은 무리라고 판단을 했다. 또한 api응답 속도와 같이 성능 테스트를 할경우 서로 영향을 미칠 수 있기 때문에 분리하는 것이 낫다고 판단했다. 그래서 동일한 환경의 EC2 2개를 돌리는 것이 더 나은 선택이라 생각한다.
하나 걸리는 것은 AWS의 프리티어 정책이 EC2의 경우 750시간인데(EC2 1개를 한달 내내 돌려도 남는 시간이다.) 2개를 돌리게 되면 반드시 과금이 될거라는 것.. 궁금해서 얼마나 나올지도 계산해보았더니 총 월 예상 비용은 약 15,540 KRW/월이다.
DB의 경우에도 RDS는 스키마를 통해 분리하는 방법이 있고, 별도의 DB 인스턴스를 사용하는 방법이 있다.
스키마를 통해 분리하는 것의 장점과 단점을 정리해보았다.
위의 장단점을 생각해 보았을 때, DB 환경을 아예 격리하여 각각 EC2가 하나씩 연결되도록 하는 것이 좋지만 비용적인 측면에서 생각해보았을때, 스키마로 분리하는 방향을 적용해보고 추후에 변경을 논의하는 것이 좋다고 판단했다. RDS도 2개를 돌리게 되면 약 16,150 KRW/월의 비용이 발생하기 때문에 좀 더 신중하게 고려해야 한다고 생각했다.
고정 IP 없이도 스테이징 서버를 구축할 수 있지만 몇가지 고려해야할 사항들이 존재한다.
고정 IP가 필요한 경우
만약 스테이징 서버의 EC2 인스턴스에도 탄력적 IP를 할당하면, 두 번째 탄력적 IP에 대해 약 4,850 KRW/월의 가격이 발생한다.
(EC2, RDS, ElasticIP를 모두 2개씩 운용할 경우 한달의 약 4만원의 비용이 발생한다는 사실...)
깃허브 엑션을 통한 도커 배포를 자동화해놓은 상태인데, EC2 인스턴스를 두개로 분리하여 운영 서버와 스테이징 서버를 각각 독립적으로 운영하려는 경우, CI/CD 파이프라인과 배포 스크립트(deploy.sh) 를 어떻게 관리해야 할지에 대한 전략을 고려해보아야 한다.
CI/CD 파이프라인(DOCKER-CD.yml)을 분리하는 방법을 사용해보고자 한다. 동일한 DOCKER-CD.yml 파일에서 조건부로 분기하는 방법도 있겠지만, 이 방법을 선택한 이유는 운영과 스테이징 환경에 각각 맞는 설정을 안전하게 독립적으로 관리할 수 있다는 점이다. 각 환경에 맞는 deploy.sh 스크립트를 사용하고, 필요한 환경 변수나 설정을 파일에 명시적으로 구분할 수 있는 장점이 있다.
8080, 8081 포트 사용8082, 8083 포트 사용DOCKER-CD.yml: 운영 서버에 대한 배포 파이프라인을 정의한 DOCKER-CD.yml 파일을 유지한다.DOCKER-CD-staging.yml: 스테이징 서버에 대한 배포 파이프라인을 정의한 새로운 DOCKER-CD-staging.yml 파일을 생성한다.name: DOCKER-CD-STAGING
on:
push:
branches: [ "staging" ]
jobs:
ci:
# Using Environment - Staging 환경 사용
# environment: staging
runs-on: ubuntu-24.04
env:
working-directory: .
# Checkout - 가상 머신에 체크아웃
steps:
- name: 체크아웃
uses: actions/checkout@v3
# JDK setting - JDK 21 설정
- name: Set up JDK 21
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: '21'
# Gradle caching - 빌드 시간 향상
- name: Gradle Caching
uses: actions/cache@v3
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
# create .yml - yml 파일 생성
- name: application.yml 생성
run: |
mkdir -p ./src/main/resources && cd $_
touch ./application.yml
echo "${{ secrets.YML }}" > ./application.yml
cat ./application.yml
working-directory: ${{ env.working-directory }}
- name: application-staging.yml 생성
run: |
cd ./src/main/resources
touch ./application-staging.yml
echo "${{ secrets.YML_STAGING }}" > ./application-staging.yml
working-directory: ${{ env.working-directory }}
# Gradle build - 테스트 없이 gradle 빌드
- name: 빌드
run: |
chmod +x gradlew
./gradlew build -x test
working-directory: ${{ env.working-directory }}
shell: bash
- name: docker 로그인
uses: docker/setup-buildx-action@v2.9.1
- name: login docker hub
uses: docker/login-action@v2.2.0
with:
username: ${{ secrets.DOCKER_LOGIN_USERNAME }}
password: ${{ secrets.DOCKER_LOGIN_ACCESSTOKEN }}
- name: docker image 빌드 및 푸시
run: |
docker build -f Dockerfile-staging --platform linux/amd64 -t terningpoint/terning-staging .
docker push terningpoint/terning-staging
working-directory: ${{ env.working-directory }}
cd:
needs: ci
runs-on: ubuntu-24.04
steps:
- name: docker 컨테이너 실행
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.STAGING_SERVER_IP }}
username: ${{ secrets.STAGING_SERVER_USER }}
key: ${{ secrets.STAGING_SERVER_KEY }}
script: |
cd ~
./deploy-staging.sh
# 스테이징용 deploy-staging.sh
nginx_config_path="/etc/nginx"
all_port=("8082" "8083") #운영 서버와 포트를 분리
available_port=()
user_name=terningpoint
server_name=terning-staging
docker_ps_output=$(docker ps | grep $server_name)
running_container_name=$(echo "$docker_ps_output" | awk '{print $NF}')
blue_port=$(echo "$running_container_name" | awk -F'-' '{print $NF}')
web_health_check_url=/actuator/health
if [ -z "$blue_port" ]; then
echo "> 실행 중인 서버의 포트: 없음"
else
echo "> 실행 중인 서버의 포트: $blue_port"
fi
# 실행 가능한 포트 확인 ( all_port 중 blue_port를 제외한 port )
for item in "${all_port[@]}"; do
if [ "$item" != "$blue_port" ]; then
available_port+=("$item")
fi
done
if [ ${#available_port[@]} -eq 0 ]; then
echo "> 실행 가능한 포트가 없습니다."
exit 1
fi
green_port=${available_port[0]}
echo "----------------------------------------------------------------------"
# docker image pull
echo "> 도커 이미지 pull 받기"
docker pull ${user_name}/${server_name}
echo "> ${green_port} 포트로 서버 실행"
echo "> docker run -d --name ${server_name} -p ${green_port}:8080 -e TZ=Asia/Seoul ${user_name}/${server_name}"
docker run -d --name ${server_name}-${green_port} -v /app -p ${green_port}:8080 -e TZ=Asia/Seoul ${user_name}/${server_name}
echo "----------------------------------------------------------------------"
sleep 10
for retry_count in {1..10}
do
echo "> 서버 상태 체크"
echo "> curl -s http://localhost:${green_port}${web_health_check_url}"
response=$(curl -s http://localhost:${green_port}${web_health_check_url})
up_count=$(echo $response | grep 'UP' | wc -l)
if [ $up_count -ge 1 ]
then
echo "> 서버 실행 성공"
break
else
echo "> 아직 서버 실행 안됨"
echo "> 응답 결과: ${response}"
fi
if [ $retry_count -eq 10 ]
then
echo "> 서버 실행 실패"
docker rm -f ${server_name}-${green_port}
exit 1
fi
sleep 5
done
echo "----------------------------------------------------------------------"
# nginx switching
echo "> nginx 포트 스위칭"
echo "set \$service_url http://127.0.0.1:${green_port};" | sudo tee ${nginx_config_path}/conf.d/service-url-staging.inc
sudo nginx -s reload
sleep 1
echo "----------------------------------------------------------------------"
response=$(curl -s http://localhost${web_health_check_url})
up_count=$(echo $response | grep 'UP' | wc -l)
if [ $up_count -ge 1 ]
then
echo "> 서버 변경 성공"
else
echo "> 서버 변경 실패"
echo "> 서버 응답 결과: ${response}"
exit 1
fi
if [ -n "$blue_port" ]; then
echo "> 기존 ${blue_port}포트 서버 중단"
echo "> docker rm -f ${server_name}-${blue_port}"
sudo docker rm -f ${server_name}-${blue_port}
docker rmi $(docker images -f "dangling=true" -q)
fi
set $service_url http://127.0.0.1:8083;
추가적으로 develop 브랜치에 PR이 올라가기 때문에, merge 하기 전 코드에 결함이 없는지 빌드 및 테스트하는 작업이 필요하다고 판단했고 CI 로직 (DEV-CI)을 추가해 주었다.
name: DEV-CI
on:
pull_request:
branches: [ "develop" ]
jobs:
build:
runs-on: ubuntu-24.04
env:
working-directory: .
# Checkout - 가상 머신에 체크아웃
steps:
- name: 체크아웃
uses: actions/checkout@v3
# JDK setting - JDK 21 설정
- name: Set up JDK 21
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: '21'
# Gradle caching - 빌드 시간 향상
- name: Gradle Caching
uses: actions/cache@v3
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
# Gradle build - 테스트 없이 gradle 빌드
- name: 빌드
run: |
chmod +x gradlew
./gradlew build -x test
working-directory: ${{ env.working-directory }}
shell: bash
deploy-staging.sh 읽어오면서 계속 서버 변경 실패가 뜨는 상황이 발생했다.docker ps → 지금 실행중인 도커 컨테이너docker ps -a → 실행중 아닌것도 다 뜨는 컨테이너docker rm id값(3글자만 입력해도 컨테이너를 자동으로 인식한다.) → 컨테이너 지우는 명령어docker rmi 이미지이름(id로 못지운다고 한다.) → 컨테이너 이미지 지우는 명령어./deploy.sh → 도커 이미지 받아오고 도커 컨테이너 실행되는 지 확인하고, 다시 블루그린 배포 진행하나의 RDS로 스키마를 분리하여 스테이징 서버용 스키마에 접근하려 할 때, 서버를 읽어오지 못하고, 서버 통신 실패가 계속해서 발생했다.
나는 스키마 자체에 접근을 못하는 문제라고 판단했고, 분리된 스키마에 어떻게 접근해야 하는지에 대해 고민해보았다.
처음에는 application.yml 파일에서 다음과 같이 접근했었다.
spring:
datasource:
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://{RDS주소}:5432/{DB명}?currentSchema={접근할 스키마 이름}
...
하지만 계속 실패해서 DB의 스키마를 자세히 살펴보니 postgres 기본 DB에서는 currentSchema 설정을 해도 public으로 접근이 되고 있었다..
그래서 스테이징 서버에 접근하는 yml파일을 분리했으니 내부의 default schema를 설정하도록 코드를 수정했다.
spring:
datasource:
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://{RDS주소}:5432/{DB명}?currentSchema={접근할 스키마 이름}
username: {사용자 이름}
password: {사용자 비밀번호}
jpa:
show-sql: true
hibernate:
ddl-auto: update
properties:
hibernate:
default_schema: develop //default schema를 develop으로 지정하는 코드
format_sql: true
show_sql: true
이렇게 코드를 수정하니 해결되었다!!! 나중에는 Schema별로 분리할 때, 이 방법을 사용해야겠다.