스테이징 서버 구축하기

Jayson·2025년 2월 22일
post-thumbnail

들어가며

보통 프로젝트를 진행할 때, 사용한 배포방식은 feat 브랜치에서 api 작업을 진행하면서 develop브랜치에 PR을 올려 merge하고 어느정도의 api가 모이면 바로 main 브랜치로 push 보내는 방식을 사용했다. main 브랜치에 push하는 PR이 올라가면 해당 코드가 문제가 없는지 확인하고, 깃허브 액션을 통해 서버에 배포했다.

하지만 이런 방식에 문제점이 있음을 나중에야 깨달았다. develop에 머지하는 과정에서 생기는 컴파일 에러, 혹은 배포과정에서 에러가 생길 수 있다. 만약 에러가 발생한다면 프론트 팀원들이 스웨거나 포스트맨으로 테스트 자체를 진행할 수 없게 된다. 운영서버는 실제 배포된 서버이기 때문에 조심스럽게 접근해야 하지만 이러한 방식으로는 작은 실수에도 서비스 자체가 먹통이 될 수 있다.

이러한 문제를 해결하기 위해 스테이징 서버를 추가하여 두 서버의 환경을 완전히 분리하려고 한다.


스테이징 서버와 운영 서버

서버는 크게 4종류가 있다.

로컬 서버 (Local Server)

로컬 서버(Local Server)는 개발자들이 처음으로 실행시키는 서버라고 할 수 잇다.

흔히 말하는 http://local:8080 또는 https://localhost:8080 으로 접속하여 우리가 개발하는 화면들을 볼 수 있다.

이 로컬 서버에서는 개발자들의 개발 개발 환경에 따라 결과가 달라질 수 있다.

개발 서버 (Development Server)

개발 서버(Development Server)는 개발자들의 개인 개발환경이 아닌 1개의 통합된 환경으로 테스트를 할 수 있는 서버를 말한다. 대체적으로, 프로젝트에서 개발 서버는 스테이징 서버(Staging Server)와 환경을 비슷하게 구성하여 테스트를 하는 경우도 있다.

스테이징 서버 (Staging Server)

스테이징 서버(Staging Server)는 다른 말로 정말 많이 불린다.

예를 들어, 스테이징 서버를 테스트 서버(Test Server) QA 서버(QA Server)등으로 부른다.

이 스테이징 서버는 운영 서버 환경과 거의 100%로 비슷할 정도로 환경을 맞춘 다음, 운영 서버에서 사용되는 데이터를 가지고 실질적으로 운영 서버에 반영하기 전에 테스트를 거치는 곳이다.

즉, 운영 서버(Production Server)에 반영하기 전 최종 확인을 하는 서버라고 할 수 있다.

운영 서버 (Productions 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

DB의 경우에도 RDS는 스키마를 통해 분리하는 방법이 있고, 별도의 DB 인스턴스를 사용하는 방법이 있다.

스키마를 통해 분리하는 것의 장점과 단점을 정리해보았다.

스키마를 통해 분리하는 것의 장점과 단점

장점

첫번째. 자원을 절약할 수 있다

  • 두 개의 별도 데이터베이스 인스턴스를 운영하는 것보다 비용이 적게 들 수 있다. 동일한 DB 인스턴스를 사용하므로, 추가적인 인스턴스 비용이나 관리 비용이 발생하지 않는다.
  • 하나의 DB 인스턴스에서 여러 스키마를 관리하는 것이 두 개의 별도 인스턴스를 관리하는 것보다 더 간단하다. 백업, 모니터링, 유지보수 등의 관리 작업을 한 번에 수행할 수 있다.

두번째. 유사한 환경에서의 테스트가 가능하다

  • 동일한 DB 인스턴스에서 스테이징과 운영 환경이 동작하기 때문에, 테스트 환경을 운영 환경과 매우 유사하게 유지할 수 있다. 성능 테스트나 스키마 변경 테스트를 실제 운영 환경과 동일한 DB 리소스를 사용하여 수행할 수 있다.

세번째. 데이터 복제와 동기화가 용이하다.

  • 운영 환경의 데이터를 스테이징 스키마로 복제하는 것이 비교적 간단하다. 데이터 동기화가 필요할 때도 동일한 DB 인스턴스 내에서 작업을 수행할 수 있어 복제 및 동기화가 빠르고 효율적일 수 있다.

단점 및 리스크

첫번째. 데이터 손상 위험이 있다

  • 동일한 DB 인스턴스 내에서 스테이징과 운영 스키마를 구분하더라도, 실수로 잘못된 스키마에 쿼리를 실행할 위험이 있다. 만약 스테이징 스키마에 데이터를 삽입하거나 수정하려는 작업이 실수로 운영 스키마에서 실행되면 운영 데이터에 영향을 미칠 수 있다.
  • 또한 DB 사용자와 권한 관리가 복잡해질 수 있다. 개발자가 스테이징 스키마에 접근할 수 있는 권한을 가졌더라도, 운영 스키마에 대한 접근 권한을 잘못 설정하면 데이터 유출이나 손상이 발생할 수 있다.

두번째. 성능에 영향을 준다

  • 스테이징 스키마에서 실행되는 대량의 테스트나 데이터 처리 작업이 운영 스키마에 영향을 미칠 수 있다. 동일한 DB 인스턴스를 공유하기 때문에 자원을 과도하게 사용하게 되면 운영 환경의 성능이 저하될 위험이 있다.
  • 스테이징과 운영 스키마가 동일한 DB 인스턴스의 CPU, 메모리, I/O 리소스를 공유하기 때문에, 리소스 경합이 발생할 수 있어, 특히 트래픽이 많은 운영 환경에서 문제가 될 수 있다.

세번째. 복구 및 유지보수의 복잡성

  • 스테이징 환경에서 발생한 문제가 운영 환경에도 영향을 줄 수 있다. DB 인스턴스 자체에 문제가 발생하면 두 스키마 모두가 영향을 받을 수 있으므로, 복구 작업이 복잡해질 수 있다.
  • DB 인스턴스의 업그레이드나 유지보수 작업 시, 스테이징과 운영 환경 모두에 영향을 미칠 수 있다.

위의 장단점을 생각해 보았을 때, DB 환경을 아예 격리하여 각각 EC2가 하나씩 연결되도록 하는 것이 좋지만 비용적인 측면에서 생각해보았을때, 스키마로 분리하는 방향을 적용해보고 추후에 변경을 논의하는 것이 좋다고 판단했다. RDS도 2개를 돌리게 되면 약 16,150 KRW/월의 비용이 발생하기 때문에 좀 더 신중하게 고려해야 한다고 생각했다.

탄력적 IP

고정 IP 없이도 스테이징 서버를 구축할 수 있지만 몇가지 고려해야할 사항들이 존재한다.

고정 IP가 필요한 경우

  • 외부 서비스와의 통합 테스트가 필요한 경우: 스테이징 서버가 외부 API나 서비스와 통합되어 있고, 이 서비스에서 IP 화이트리스트를 사용한다면, IP가 고정되어 있어야 외부 서비스와 안정적으로 통신할 수 있다.
  • 도메인 네임 연동하려 할 경우: 만약 스테이징 서버에 도메인 네임을 연결하여 접근하고자 할 경우, 고정 IP를 사용하면 DNS 설정이 간편해진다. IP가 변하지 않으므로 DNS 레코드를 자주 변경할 필요가 없다.
  • 접근 제어의 경우: 개발자들이 특정 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 스크립트를 사용하고, 필요한 환경 변수나 설정을 파일에 명시적으로 구분할 수 있는 장점이 있다.

환경 분리

  1. Docker Repository 분리
  • 태그를 활용해서 구분해줄 수 있지만, 확실하게 환경을 분리해보고 싶어서 레포지토리를 분리하는 방식을 선택했다.
  • 각 환경이 별도의 Docker 이미지를 사용하기 때문에, 이미지가 서로 다른 기능이나 설정을 포함하고 있어도 문제가 없다.
  1. 포트 분리
  • 우선 Docker Repository를 새롭게 생성하여, 포트만 구분해 주었다. 이렇게 하면 운영 서버와 스테이징 서버에서 서로 다른 호스트 포트를 사용하기 때문에, 동일한 물리적 서버에서 실행되더라도 충돌이 발생하지 않는다. Nginx와 Docker가 각기 다른 포트에서 트래픽을 처리할 수 있도록 설정하였기 때문이다.
    • 운영서버 : 8080, 8081 포트 사용
    • 스테이징 서버 : 8082, 8083 포트 사용

별도의 DOCKER-CD-staging.yml 파일 생성하기

  • 운영용 DOCKER-CD.yml: 운영 서버에 대한 배포 파이프라인을 정의한 DOCKER-CD.yml 파일을 유지한다.
  • 스테이징용 DOCKER-CD-staging.yml: 스테이징 서버에 대한 배포 파이프라인을 정의한 새로운 DOCKER-CD-staging.yml 파일을 생성한다.
  • 전체적인 파일의 구조는 운영 서버와 비슷하게 구성하지만, 스테이징 서버의 IP, 사용자 정보, 포트 등은 다르게 설정해 주어야 한다.
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 파일 생성하기

# 스테이징용 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

/etc/nginx/conf.d/service-url.inc

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

트러블 슈팅

  1. 모든 파일을 제대로 생성한 이후에도 CD 플로우 진행과정에서 deploy-staging.sh 읽어오면서 계속 서버 변경 실패가 뜨는 상황이 발생했다.
  • /actuator/health 접속하니까 502 에러 502 Bad - Gateway 오류가 발생했다.
  • 다행히도 이전 CI/CD를 진행할 때, 이미 경험해본(?) 상황이라 docker image가 삭제되지 않고 남아있어 생기는 문제였다는 사실을 알게 되었고, 다음과 같이 해결했다.
    • docker ps → 지금 실행중인 도커 컨테이너
    • docker ps -a → 실행중 아닌것도 다 뜨는 컨테이너
    • docker rm id값(3글자만 입력해도 컨테이너를 자동으로 인식한다.) → 컨테이너 지우는 명령어
    • docker rmi 이미지이름(id로 못지운다고 한다.) → 컨테이너 이미지 지우는 명령어
    • ./deploy.sh → 도커 이미지 받아오고 도커 컨테이너 실행되는 지 확인하고, 다시 블루그린 배포 진행
  1. 하나의 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별로 분리할 때, 이 방법을 사용해야겠다.



출처

profile
Small Big Cycle

0개의 댓글