저는CEOS 21기에서 프로메사 프로젝트의 백엔드 개발자로 참여하고 있습니다. 이번 프로젝트에서 배포 환경을 직접 구성하는 작업을 맡아 진행했습니다.
이렇게 많은 서비스를 엮어서 구성하는 것이 익숙하지 않아 구현 과정에서 어려움이 많았습니다. 하지만 그만큼 해결 과정에서 많은 깨달음을 얻었고, 또 저와 같은 배포 환경을 생각 중이신 분들에게 도움이 되기를 바라며 기록으로 남깁니다.
이 시리즈에서는 배포 환경에 대한 이해부터 실제 구현 과정 및 코드 공유와 마지막으로는 배포 중 겪었던 트러블슈팅 이슈들과 해결 방법에 대한 팁도 공유할 계획입니다.
이번 글에서는 전체 시스템 아키텍처의 흐름을 짚어본 뒤, 실제 코드를 GitHub에 업로드하는 순간 해당 코드가 배포 파이프라인을 통해 어떻게 반영되는지를 스크린샷과 로그를 통해 직접 확인하면서 글을 마무리하겠습니다.
🍒전체 시스템 아키텍처

전체 흐름입니다. 총 4단계로 나누고 각 단계별로 자세히 알아보겠습니다.
1️⃣ 자동 배포 시작 – GitHub Actions 트리거하기

우선 로컬에서 열심히 코드를 작성하고, 변경사항을 GitHub 리포지토리에 push합니다. 이때 GitHub Actions의 워크플로우가 언제 작동할지는 ‘트리거 이벤트’ 설정에 따라 달라지지만 일단 설정된 조건을 만족하면 자동으로 GitHub Actions가 실행됩니다.
💡워크플로 : 하나 이상의 작업을 실행할 구성 가능한 자동화된 프로세스
💡이벤트 : 워크플로 실행을 트리거하는 리포지토리의 특정 활동입니다
GitHub Actions가 작동한다는 것은 앞으로 설명할 배포의 전체 흐름이 자동으로 실행된다는 뜻입니다. 단지 코드를 push하기만 하면, 이후의 빌드, 테스트, 배포 과정은 모두 자동으로 처리됩니다. 직접 서버에 접속해서 배포 명령을 입력하거나, 수동으로 배포하는 번거로운 작업을 하지 않아도 됩니다.
배포의 자동화와 GitHub Actions에 대한 자세한 설명이 필요하신 분은 더보기를 참고해주시길 바랍니다.
배포의 자동화를 이해하려면, 먼저 수동 배포에 대해서 알아야 합니다.
우리는 지금까지 로컬 컴퓨터/노트북에서 코드를 작성하고 실행해왔습니다. 하지만 실제 서비스는 전 세계 모든 사용자가 같은 화면을 볼 수 있어야 합니다. 비유하자면, 지금까지는 내 노트북에서만 볼 수 있도록 코드를 작성해왔지만 이제는 전 세계가 함께 쓰는 공용 노트북에 코드를 작성해 실행해야 하는 상황입니다. 이 공용 노트북이 바로 가상 컴퓨터(Virtual Machine)이고, 이 가상 컴퓨터를 대여해주는 대표적인 서비스가 AWS의 EC2입니다.
즉, 배포란 우리의 프로젝트 코드를 가상의 컴퓨터에 올려서 실행하는 것이라고 할 수 있습니다.

그렇다면, 우리가 작성한 모든 파일을 하나하나 복사해서 가상 컴퓨터에 붙여넣어야 할까요? 그럴 필요는 없습니다. 우리는 배포에 관한 모든 파일을 하나로 압축하여 usb에 담아뒀다가 새로운 컴퓨터로 파일을 옮겨 압축을 해제하기만 하면 됩니다! 즉 로컬에서 작성한 코드를 저장소에 올려두고 EC2 인스턴스에서 그것을 내려받아 실행하는 것이 수동 배포의 핵심입니다. 반면, 자동 배포는 이 모든 과정을 자동으로 처리해주는 방식입니다.

그리고 이 자동 배포를 위한 플랫폼이 바로 GitHub Actions입니다. 공식 문서의 표현에 따르면, GitHub Actions는 빌드, 테스트 및 배포 파이프라인을 자동화할 수 있는 CI/CD(연속 통합 및 지속적인 업데이트) 플랫폼입니다. 여러분이 GitHub에 PR을 올리거나 커밋을 푸시하기만 해도, GitHub Actions는 해당 행위를 감지하여 자동으로 배포 과정을 시작합니다.
이러한 자동화 과정 전체를 워크플로(Workflow)라고 부르고, 워크플로를 실행시키는 행위를 이벤트(Event)라고 합니다. 워크플로는 템플릿을 미리 만들어두고 활용하며, GitHub Actions를 통해 배포뿐 아니라 빌드, 테스트 등 다양한 작업을 자동화할 수 있습니다.
자세한 내용은 GitHub Actions 공식 문서를 참고하시면 좋습니다.
https://docs.github.com/ko/actions/about-github-actions/understanding-github-actions
GitHub Actions 이해 - GitHub Docs
GitHub Actions는 빌드, 테스트 및 배포 파이프라인을 자동화할 수 있는 CI/CD(연속 통합 및 지속적인 업데이트) 플랫폼입니다. 리포지토리에 대한 모든 끌어오기 요청을 빌드 및 테스트하거나 병합된
docs.github.com
2️⃣ 빌드 및 배포 준비 – 워크플로 구성하기

GitHub Actions에서는 템플릿을 통해 워크플로를 미리 정의해둘 수 있습니다.
이번 배포에서는 다음과 같은 네 가지 주요 작업을 정의해뒀습니다.
- 도커 이미지를 빌드해서 Amazon ECR로 푸시한다.
- `appspec.yml`과 배포 실행 스크립트 등을 ZIP 파일로 묶어 S3에 업로드한다.
- CodeDeploy에게 배포 요청을 보낸다.
- CodeDeploy가 S3에 업로드된 ZIP 파일을 가져와 압축을 해제하고 배포를 실행한다.

이 중 도커 이미지를 빌드한다는 것은 애플리케이션 실행에 필요한 모든 환경을 파일로 만드는 과정입니다. 컴퓨터마다 운영체제가 다르기 때문에 로컬에서는 Windows 기반에서 잘 작동했더라도 EC2는 Linux 기반이므로 문제가 발생할 수 있습니다. 이렇게 만들어진 도커 이미지는 Amazon ECR(Elastic Container Registry)이라는 컨테이너 이미지 저장소에 업로드됩니다.
정리하자면, EC2 인스턴스에서 애플리케이션을 실행하기 위해 필요한 모든 파일을 도커 이미지로 만들고, 이를 ECR에 미리 업로드해두었다가 EC2에서 해당 이미지를 받아 실행하는 방식입니다.
배포 스크립트들은 `appspec.yml`과 함께 ZIP 파일로 압축하여 S3 버킷에 업로드됩니다. CodeDeploy는 GitHub Actions로부터 배포 요청을 받으면 S3에서 이 ZIP 파일을 받아 압축을 풀고, 그 안에 정의된 배포 스크립트를 순차적으로 실행합니다.
CodeDeploy는 Amazon EC2 인스턴스, 온프레미스 서버, Lambda 함수, Amazon ECS 서비스 등 다양한 환경에 애플리케이션을 배포할 수 있는 배포 자동화 서비스입니다.
아까 나온 GitHub Actions도 배포 자동화 도구 아닌가요? CodeDeploy와 역할이 겹치는 것 아닌가요? 두 가지를 함께 사용하는 이유에 대해서 의문이 들 수 있습니다. 아래 더보기에 남겨두었으니 참고하시길 바랍니다.
GitHub Actions vs CodeDeploy
GitHub Actions는 자동으로 배포를 시작해주는 트리거이자 자동화 플랫폼이며, 반면 CodeDeploy는 어디에 어떻게 배포할지를 실제로 실행하는 배포 도구라고 할 수 있습니다.
즉 GitHub Actions는 빌드, 테스트, Docker 이미지 만들기, S3 업로드, CodeDeploy 등 하는 일이 다양하며 CI/CD의 전체적인 흐름을 관리합니다. 하지만 실제로 애플리케이션을 배포하는 작업은 CodeDeploy에서 진행됩니다. 스크립트를 순차적으로 실행하며 실제 웹 서버를 띄우고 컨테이너를 실행시키는 등의 작업을 수행합니다.
따라서 이러한 조합은 실제 프로젝트에서도 매우 일반적으로 사용되고 있습니다.
https://docs.aws.amazon.com/ko_kr/codedeploy/latest/userguide/welcome.html
CodeDeploy란 무엇인가요? - AWS CodeDeploy
또한 일반적으로 기존 환경의 인스턴스에서 실행되는 애플리케이션 수정이 있지만, 블루/그린 배포의 경우 반드시 이럴 필요는 없습니다.
docs.aws.amazon.com
배포 스크립트에 대해서는 이후 실제 작동 과정에서 구체적인 내용을 알아보겠습니다.
3️⃣ 무중단 배포 – 블루/그린 전략 적용하기

배포 스크립트에는 현재 실행 중인 포트 번호를 확인하고 새로운 포트 번호로 ECR에서 도커 이미지를 pull한 뒤, 해당 이미지를 기반으로 컨테이너를 실행시키는 내용이 포함되어 있습니다. 저는 블루/그린 무중단 배포를 선택하였고 각각의 서버를 포트 `8081`과 `8082`로 지정하였습니다.
블루/그린 배포

블루/그린 배포에서 기존 서버(blue)를 띄우다가 새로운 서버(green)를 띄운 뒤, 이상이 없다면 모든 트래픽을 그린 환경으로 전환하는 방식입니다. 블루(Blue) 환경이 현재 프로덕션 환경이고, 그린(Green) 환경은 스테이징 환경이며 현재 프로덕션 환경과 동기화된 상태를 유지합니다.
그리고 모든 트래픽이 그린 환경으로 전환되면, 이전의 블루 환경은 오프라인 상태가 됩니다. 블루 환경은 재해 발생시 복구 옵션이 되어 대기상태가 되거나 다음 업데이트를 위한 컨테이너가 될 수 있습니다. 이번 배포에서는 그린 환경이 문제가 없고 모든 트래픽이 옮겨가면 블루 서버는 중지시켰습니다.
이 블루/그린 배포의 가장 큰 장점은 배포 중에도 서비스가 중단되지 않는다는 것입니다.

여기서 주의할 점이 포트 `8081`이 항상 블루, 포트 `8082`가 항상 그린은 아니라는 것입니다. 중요한 것은 색상이 아니라 상황에 따라 포트가 스위칭된다는 사실입니다.
예를 들어, 포트 `8081`이 현재 프로덕션 서버일 경우 포트 `8081`이 블루, `8082`가 그린이 됩니다. 그린 서버(`8082`)가 정상 작동하고 트래픽이 전환되면 이후 배포 과정에서는 포트 `8082`가 프로덕션 서버인 블루 서버가 되고 `8081`이 그린 서버가 됩니다.
즉 색상을 기준으로 포트 번호를 나누는 것이 아니라 배포 시마다 포트가 스위칭 되고 그에 맞춰 블루 또는 그린 서버가 정해지는 것입니다.
정리하여, 전체 흐름은 다음과 같습니다.
현재 nginx 포트 읽음 : Blue
↓
반대편 포트(Green)에서 도커 이미지를 pull해와서 새 컨테이너를 실행
↓
Green 서버 헬스체크
↓
nginx 포트 스위칭
↓
이전 포트의 컨테이너 삭제

그리고 Nginx에서 리버스 프록시(Reverse Proxy)를 사용했습니다. 리버스 프록시는 하나 이상의 웹 서버 앞에 위치하여 클라이언트 요청을 가로채는 역할을 합니다. 즉 클라이언트가 사이트에 접속하면 원본 서버와 직접 통신하는 것이 아니라 프록시 서버와 통신하고 해당 프록시 서버가 적절한 원본 서버로 이동시켜 줍니다.
그렇다면 왜 Reverse Proxy를 사용했을까요? 클라이언트는 항상 같은 주소로 접속하길 원합니다. 예를 들어 사용자는 `promesa.co.kr` 같은 도메인 하나만 알고 해당 주소로 늘 접속합니다. 하지만 백엔드 서버는 매 배포마다 `8081`, `8082`처럼 포트를 바꿔가며 새 버전을 띄웁니다. 이때 클라이언트가 매번 어떤 포트로 접속해야 하는지를 알아야 한다면 매우 번거롭기 때문에 클라이언트는 늘 같은 곳으로 접속하는 대신 Nginx가 클라이언트 요청을 받아 내부적으로는 현재 살아있는 블루나 그린 서버로 라우팅을 바꿔줍니다.
만약 리버스 프록시를 쓰지 않았다면 우리는 백엔드 서버의 직접적인 정보를 알아야합니다. 매번 바뀌는 포트 번호를 기억하고 주소 뒤에 붙여줘야하고 또한 HTTPS 인증서도 각 서버에 개별적으로 설치해야 하는 번거로움이 생깁니다.
마침 같은 의문을 가진 글이 있으니 답글들을 참고해보면 좋을 듯합니다. 모두 번역되어 있습니다
https://www.reddit.com/r/HomeServer/comments/lrbg89/why_should_i_use_a_reverse_proxy/?tl=ko
Reddit의 HomeServer 커뮤니티
HomeServer 커뮤니티에서 이 게시글을 비롯한 다양한 콘텐츠를 살펴보세요
www.reddit.com
리버스 프록시의 자세한 개념과 이와 대비되는 프론트 프록시의 개념이 궁금하다면 아래 링크를 참고하시길 바랍니다.
https://www.cloudflare.com/ko-kr/learning/cdn/glossary/reverse-proxy/
4️⃣ 상태 저장소 관리 – RDS와 ElastiCache

RDS와 ElastiCache는 배포와 독립적인 구성 요소입니다. 이들은 항상 떠 있는 데이터 저장소 및 캐시 서버로, 배포가 이루어질 때마다 재시작되거나 새로 띄워지는 것이 아니라 한 번 띄워두면 그대로 유지되어 지속적으로 데이터를 주고받을 수 있습니다.
🍒배포 시 실제 흐름
IntelliJ에서 코드를 작성하여 GitHub의 dev 브랜치로 push합니다.
저는 워크플로우에서 dev 브랜치에 push가 이루어지는 이벤트를 deploy 워크플로우의 트리거로 설정해두었습니다. 로컬에서 dev 브랜치로 push를 했으므로, deploy 워크플로우가 실행되기 시작합니다.

초록색 체크 표시는 이상 없이 진행되었다는 것을 의미합니다. 해당 워크 플로우를 눌러서 확인해보면 아래와 같은 화면을 볼 수 있습니다.

1분 56초 만에 워크플로우 안에 정의되어 있던 작업들이 순차적으로 잘 이루어진 것을 확인할 수 있습니다.

Spring Boot 프로젝트를 빌드한 결과물입니다. `promesa-0.0.1-SNAPSHOT.jar`은 배포하거나 실행할 때 사용되는 파일로, 이 파일을 기반으로 Docker 이미지를 빌드합니다.
그리고 `Build Docker image` 단계에서 도커 이미지가 만들어지고, `Tag and Push to ECR` 과정에서 ECR에 만들어진 도커 이미지가 올라가게 됩니다. 업로드 된 이미지는 AWS 사이트에서 직접 확인할 수 있습니다.

크기 264.87MB의 이미지가 업로드된 것을 확인할 수 있습니다.

그리고 `appspec.yml`과 배포 스크립트들이 ZIP 파일로 압축되었습니다. 저는 `before_install.sh`, `after_install.sh`, `switch.sh`, `start_app.sh` 등 4개의 스크립트들을 정의해두었습니다.

zip 파일 내부를 출력해본 결과 모두 잘 들어간 것을 확인할 수 있습니다.
그리고 S3에 업로드하는데, 마찬가지로 AWS 사이트에서 확인할 수 있습니다.

이후 CodeDeploy에 배포 요청을 보냅니다. CodeDeploy에서 배포가 실행된 것을 확인해볼 수 있습니다.
해당 배포 내역을 자세히 누르면 다양한 이벤트들이 순차적으로 진행된 것을 확인할 수 있습니다. 해당 이벤트들은 `appspec.yml`이라는 파일에 적힌 내용을 순차적으로 실행된 결과입니다.
AppSpec 파일은 CodeDeploy가 애플리케이션을 배포할 때 필요한 동작을 정의하는 파일입니다. EC/2온프레미스 환경에서는 `appspec.yml`이라는 YAML 형식의 파일을 사용하며 다음 내용을 지정합니다.
- Amazon S3 또는 GitHub의 애플리케이션 수정에서 인스턴스에 설치해야 할 항목
- 배포 수명 주기 이벤트에 대한 응답으로 실행될 수명 주기 이벤트 후크
로드 밸런서를 사용하지 않으므로 이번 배포에서는 왼쪽과 같은 이벤트 순서로 진행됩니다. 실제 CodeDeploy에서 똑같은 작업들이 실행되는 것을 확인했습니다.
후크에서 실행할 하나 이상의 스크립트를 지정할 수 있습니다. 아래는 이번 배포에서 정의한 `appspec.yml`파일입니다.실제로 각 후크마다 앞서 언급했던 스크립트들을 지정해두었습니다.
version: 0.0
os: linux
files:
- source: scripts/
destination: /home/ubuntu/app/scripts/
hooks:
ApplicationStop: []
BeforeInstall:
- location: scripts/before_install.sh
timeout: 300
runas: root
AfterInstall:
- location: scripts/after_install.sh
timeout: 300
runas: root
ApplicationStart:
- location: scripts/switch.sh
timeout: 300
runas: root
`start_app.sh`는 `switch.sh` 에서 내부적으로 호출됩니다.
CodeDeploy와 훅에 관해서 더 궁금하신 분은 아래 공식 문서를 참고하시길 바랍니다.
https://docs.aws.amazon.com/ko_kr/codedeploy/latest/userguide/reference-appspec-file-structure-hooks.html#appspec-hooks-server
AppSpec 'hooks' 섹션 - AWS CodeDeploy
배포의 Start, DownloadBundle, Install, BlockTraffic, AllowTraffic 및 End 이벤트는 스크립팅할 수 없기 때문에 이 다이어그램에서 회색으로 표시됩니다. 그러나 AppSpec 파일의 'files' 섹션을 편집하여 Install 이벤
docs.aws.amazon.com
#!/bin/bash
# scripts/switch.sh
INC_FILE=/home/ubuntu/app/service_url.inc
# 현재 포트 읽어오기
CURRENT=$(grep -Eo '127\.0\.0\.1:808(1|2)' "$INC_FILE" | awk -F: '{print $3}')
# 반대편 포트를 대상 포트로 선택
if [ "$CURRENT" == "8081" ]; then
# Blue(8081) → Green(8082)
TARGET_PORT=8082
else
# Green(8082) → Blue(8081)
TARGET_PORT=8081
fi
echo "[INFO] 현재 포트: $CURRENT → 대상 포트: $TARGET_PORT"
# 대상 포트에 새 버전 컨테이너 실행
bash /home/ubuntu/app/scripts/start_app.sh "$TARGET_PORT"
# 헬스 체크 (Green:8082가 200을 줄 때까지 최대 30초 대기)
for i in {1..6}; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:${TARGET_PORT}/actuator/health)
if [ "$STATUS" == "200" ]; then
echo "Green(8082) is healthy."
break
fi
echo "Waiting for Green(8082) to become healthy..."
sleep 5
done
# nginx 설정 전환
echo "server 127.0.0.1:${TARGET_PORT};" | sudo tee "$INC_FILE" > /dev/null
# Nginx 문법 검사 후 reload
if sudo nginx -t; then
sudo systemctl reload nginx
echo "Nginx reloaded successfully."
else
echo "ERROR: nginx 설정 문법 오류. reload 취소합니다."
exit 1
fi
# 이전 포트 컨테이너 정리
if [ "$CURRENT" == "8081" ]; then
OLD_PORT=8081
else
OLD_PORT=8082
fi
docker rm -f promesa-${OLD_PORT}
echo "[INFO] 이전 서버 promesa-${OLD_PORT} 중지 및 삭제 완료"
`switch.sh`에서는 현재 포트를 확인하고 새 컨테이너를 띄울 대상 포트를 지정합니다. 그리고 `start_app.sh`에서 대상 포트에 새 컨테이너를 띄웁니다. 그리고 그린 서버의 헬스 체크를 실행해 이상이 없는지 확인합니다. 이상이 없을 경우 트래픽을 전환하고 이전의 블루 서버는 정리합니다.
실제 로그를 확인해보면

잘 실행된 것을 확인할 수 있습니다. 그리고 구매한 도메인으로 접속 시 서버가 502에러 없이 잘 떠 있는 것을 확인할 수 있습니다!
배포 흐름은 처음에 가장 이해하기 어려운 부분 중 하나인 것 같습니다. AWS에서 직접 확인한 결과물과 로그가 이해에 도움이 되기를 바랍니다.
혹시 이 글에서 부족한 부분이나 궁금한 점이 있다면 댓글로 알려주시면 감사하겠습니다.
