MOA 운영 서비스에 적용하기 전, POC 프로젝트로 먼저 검증해본 기록
IOS_MOA 설치
안드로이드 MOA 설치
POC를 위해 이전에 다른 토이 프로젝트를 위해 만들어둔 홈서버를 사용해보고자 한다.
이미 Docker랑 Nginx는 이미 깔려 있었기에 빠르게 시작해볼 수 있었다.
하지만 이전에 설정한 도메인이 만료됐고 서비스도 안 돌아가는 상태라서, 기존 설정을 다 걷어내고 Blue-Green용으로 새로 세팅하기로 했다.
server {
listen 80 default_server;
return 301 https://$host$request_uri;
location / {
try_files $uri $uri/ =404; # ← return 301 때문에 여기 도달 불가 (dead code)
}
}
server {
listen 443 ssl;
server_name www.shinhan-sosol.store;
ssl_certificate /etc/letsencrypt/live/www.shinhan-sosol.store/fullchain.pem;
location /api/ {
proxy_pass http://localhost:8080/api/; # ← 이전 프로젝트 백엔드
}
}
문제점이 몇 개 보였다.
return 301 아래의 location / { try_files... }는 절대 실행 안 된다전부 걷어내고 새로 구성하기로 했다.
docker --version # Docker 설치 확인
nginx -v # Nginx 설치 확인
sudo docker ps # 돌아가는 컨테이너 확인
sudo ss -tlnp | grep -E ':(80|8080|8081) ' # 포트 사용 현황
Docker, Nginx 모두 이미 깔려있었고, 돌아가는 컨테이너는 없었다.
mkdir -p /home/hp-server/Desktop/project/zero-v1/logs
echo "blue" > /home/hp-server/Desktop/project/zero-v1/active-color
touch /home/hp-server/Desktop/project/zero-v1/deploy-history
active-color 파일에 blue를 넣어두면, 첫 배포 시 deploy.sh가 이걸 읽고 "현재 blue니까 다음은 green"으로 판단한다.
sudo rm /etc/nginx/sites-enabled/default
이전 프로젝트 설정이 통째로 들어있던 파일을 제거해줬다.
# upstream 설정 — deploy.sh가 이 파일을 배포마다 덮어씀
sudo tee /etc/nginx/conf.d/moa-upstream.conf > /dev/null <<'EOF'
upstream moa_backend {
server 127.0.0.1:8080;
}
EOF
# server 블록 — 모든 요청을 upstream으로 프록시
sudo tee /etc/nginx/conf.d/zero-downtime.conf > /dev/null <<'EOF'
server {
listen 80;
server_name _;
location / {
proxy_pass http://moa_backend;
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;
proxy_connect_timeout 5s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}
EOF
핵심은 moa-upstream.conf다.
이 파일의 server 127.0.0.1:8080;을 server 127.0.0.1:8081;로 바꾸고 nginx -s reload 하면 트래픽이 전환된다. deploy.sh가 이 작업을 자동으로 해준다.
sudo nginx -t # 설정 문법 검증
sudo nginx -s reload # 적용
curl -I http://localhost
curl 결과로 502 Bad Gateway가 나왔는데, 이게 정상이다.
Nginx는 떠있지만 upstream인 8080에 아무 컨테이너도 없으니까 502가 맞다.
이제 첫 배포 후에 200이 나오면 된다.
리포지토리 Settings → Actions → Runners → New self-hosted runner에서 안내하는 명령어를 따라 했다.
# runner 디렉토리에서
bash config.sh --url https://github.com/subsub97/blue-green --token <토큰>
실행하면 이런 화면이 뜬다:

"Connected to GitHub"가 뜨면 성공이다. 이후 Runner group, name, labels 등을 물어보는데 전부 Enter(기본값)로 넘기면 된다.
Configure 끝나고 서비스 등록:
sudo bash svc.sh install
sudo bash svc.sh start
리포지토리 Settings → Secrets and variables → Actions에서:
| Name | Value |
|---|---|
DOCKER_USERNAME | godqhr721 |
DOCKER_PASSWORD | Docker Hub 토큰 |
여기까지 하면 서버 세팅은 끝이다. main에 push하면 자동으로 빌드→배포가 시작된다.
첫 배포가 돌아가면 GitHub Actions에서 이런 로그를 볼 수 있다:

Current: blue(:8080), Next: green(:8081)으로 첫 배포가 green 컨테이너에 올라간다. 배포 완료 후 API를 찍어보면:

version에 git commit SHA, color에 green이 찍힌다. 이게 나오면 첫 배포는 성공이다.
서버 세팅하면서 생각보다 잔에러가 많이 났다. 하나하나 정리해보자.
$ sudo chown -R ubuntu:ubuntu /home/hp-server/Desktop/project/zero-v1/
chown: invalid user: 'ubuntu:ubuntu'
원인: 이 서버의 유저명이 ubuntu가 아니라 hp-server였다. 스크립트나 가이드에서 ubuntu를 기본으로 쓰는 경우가 많은데, 실제 서버의 유저명에 맞춰야 한다.
해결:
sudo chown -R hp-server:hp-server /home/hp-server/Desktop/project/zero-v1/
서버 세팅 가이드를 그대로 복붙하면 이런 데서 막힌다. 자기 서버의 유저명은
whoami로 확인하자.
$ sudo ./config.sh --url ...
Must not run with sudo
원인: GitHub Actions Runner의 config.sh는 root로 실행하면 안 된다. 보안 정책상 일반 유저로 실행해야 한다.
해결: root로 접속해있었으면 일반 유저로 전환 후 실행한다.
su - hp-server
cd ~/Desktop/project/zero-v1/actions-runner
bash config.sh --url https://github.com/... --token ...
$ sudo ./svc.sh install
sudo: ./svc.sh: command not found
원인: sudo로 실행할 때 상대 경로가 제대로 해석이 안 되는 경우가 있다.
해결: bash로 직접 절대 경로를 지정해서 실행한다.
sudo bash /home/hp-server/Desktop/project/zero-v1/actions-runner/svc.sh install
$ ls
actions-runner-linux-x64-2.333.1.tar.gz bin config.sh env.sh externals run.sh ...
svc.sh가 파일 목록에 없었다.
원인: svc.sh는 config.sh를 실행한 후에 생성된다. config 과정에서 Runner 설정이 완료되면 서비스 등록에 필요한 파일들이 생긴다.
해결: config.sh를 먼저 실행하고 나면 svc.sh가 생성되어 있다.
~/Desktop/project/zero-v1 $ bash config.sh --url ...
bash: config.sh: No such file or directory
원인: config.sh는 actions-runner 디렉토리 안에 있는데, 한 단계 상위에서 실행하고 있었다.
해결:
cd ~/Desktop/project/zero-v1/actions-runner
bash config.sh --url ...
에러 메시지가 "No such file or directory"이면 일단
pwd로 현재 위치부터 확인하자.
$ bash config.sh --url ...
touch: cannot touch '.env': Permission denied
System.UnauthorizedAccessException: Access to the path '..._diag' is denied.
원인: actions-runner 디렉토리를 root로 압축 해제해서, 파일 소유자가 root였다. 일반 유저(hp-server)로 실행하니까 권한이 없었던 거다.
해결:
sudo chown -R hp-server:hp-server ~/Desktop/project/zero-v1/actions-runner
소유권 변경 후 다시 config.sh 실행하니까 정상적으로 진행됐다.
$ curl http://58.227.208.141//api/deploy-info
# 404 Not Found
원인: URL에 슬래시가 두 개 들어갔다. //api/deploy-info가 아니라 /api/deploy-info여야 한다.
해결:
curl http://58.227.208.141/api/deploy-info
사소한 오타지만 404가 나오면 "API가 안 되나?" 하고 다른 데서 원인을 찾게 된다. URL부터 다시 확인하는 습관이 필요하다.
서버 세팅하면서 몇 가지 "이거 어떻게 되는 거지?" 싶었던 부분이 있었다. 정리해본다.
GitHub Actions의 deploy job이 self-hosted runner에서 실행한다.
main에 push
→ GitHub Actions 트리거
→ build job (GitHub 서버): 테스트 + Docker 이미지 빌드
→ deploy job (내 서버의 runner): deploy.sh 실행
방금 설치한 runner가 GitHub의 에이전트 역할을 해서, GitHub 리포에 있는 코드를 서버에 내려받고 실행해주는 거다.
Spring Boot의 ${DEPLOY_COLOR:local}은 환경변수에서 값을 읽는다. 이 환경변수는 deploy.sh가 docker run할 때 넣어준다.
# deploy.sh의 Step 1에서 상태 파일을 읽고 다음 컬러를 결정
NEXT="green" # (현재가 blue이므로)
# Step 3에서 컨테이너 시작할 때 환경변수로 주입
sudo docker run -e DEPLOY_COLOR="${NEXT}" -e APP_VERSION="${DOCKER_TAG}" ...
컨테이너 자체는 자기가 blue인지 green인지 모른다. deploy.sh가 상태 파일을 보고 결정해서 환경변수로 알려주는 거다.
여기가 제일 중요한 부분이다. "진짜 무중단인가?"를 어떻게 확인했는지.
터미널 2개를 열고:
터미널 1 — 모니터링 (0.5초마다 요청)
while true; do
RESULT=$(curl -s -o /tmp/resp.txt -w "%{http_code}" http://<서버IP>/api/deploy-info 2>/dev/null)
BODY=$(cat /tmp/resp.txt)
VERSION=$(echo "$BODY" | grep -o '"version":"[^"]*"' | head -1)
COLOR=$(echo "$BODY" | grep -o '"color":"[^"]*"' | head -1)
echo "$(date '+%H:%M:%S') | HTTP ${RESULT} | ${VERSION} | ${COLOR}"
sleep 0.5
done
터미널 2 — 코드 수정 후 push해서 배포 트리거
git add . && git commit -m "test deploy v2" && git push
22:30:01 | HTTP 200 | "version":"abc1234" | "color":"green"
22:30:01 | HTTP 200 | "version":"abc1234" | "color":"green"
22:30:02 | HTTP 200 | "version":"abc1234" | "color":"green"
... (배포 진행 중 — 계속 200) ...
22:31:15 | HTTP 200 | "version":"abc1234" | "color":"green"
22:31:15 | HTTP 200 | "version":"def5678" | "color":"blue" ← 여기서 전환!
22:31:16 | HTTP 200 | "version":"def5678" | "color":"blue"
22:31:16 | HTTP 200 | "version":"def5678" | "color":"blue"
실제로 돌려본 결과:

502가 단 하나도 없다. 모든 요청이 HTTP 200이고, version과 color가 한 순간에 green→blue로 바뀌는 걸 볼 수 있다. 전환 사이에 요청 실패가 없었다.
이걸로 Blue-Green 무중단 배포가 정상 동작한다는 걸 확인했다.
| 결과 | 의미 |
|---|---|
| 모든 응답이 HTTP 200 | 무중단 성공 |
| version/color가 한 순간에 바뀜 | Blue-Green 전환 정상 |
| 중간에 HTTP 502가 있음 | 무중단 실패 — 다운타임 발생 |
EC2 단일 서버에서 Nginx + Docker + GitHub Actions로 Blue-Green 무중단 배포를 구현하고 검증까지 완료했다.
핵심 포인트를 정리하면:
Nginx upstream 전환이 Blue-Green의 핵심이다. 새 컨테이너가 완전히 준비된 후에 트래픽을 전환하니까 다운타임이 0이다.
Health Check만으로는 부족하다. Nginx를 경유하는 Smoke Test까지 해야 "실제 사용자 관점에서 정상"인지 확인할 수 있다.
서버 세팅에서 삽질이 제일 많았다. 코드 구현보다 Runner 권한 문제, 경로 문제, 소유권 문제를 잡는 데 시간이 더 걸렸다.
자동 롤백 구조가 있으니까 배포가 편하다. Health check 실패하면 기존 컨테이너가 계속 돌아가니까 사용자 영향 없이 롤백된다.
이제 이 구조를 MOA 운영 서비스에 적용하면 되는데, POC에서는 안 다뤘지만 실제 MOA에 붙이면서 고민해야 할 것들이 있다.
POC는 순수한 API 서버였다. 근데 MOA는 그렇게 단순하지 않다. 실제로 적용하면서 추가로 풀어야 할 문제들이 있는데, 미리 정리해둔다.
MOA에는 매일 12시에 돌아가는 알림 배치가 있다. 만약 이 배치가 FCM을 100건 보내는 도중에 배포가 들어오면?
12:00:00 Blue에서 알림 배치 시작 (FCM 100건 발송 중...)
12:00:30 배포 → Nginx 전환 → Blue에 SIGTERM
12:00:32 graceful shutdown 시작
→ HTTP 요청은 보호됨
→ ★ 근데 @Scheduled 배치는 HTTP 요청이 아님
12:01:02 30초 후 강제 종료 → 배치가 50건만 보내고 죽음 💥
server.shutdown: graceful이 보호하는 건 HTTP 요청뿐이다. @Scheduled 배치는 graceful shutdown 대상이 아니라서, Spring이 컨텍스트를 닫으면 그냥 중간에 끊긴다.
이 문제를 어떻게 풀 건지 — 배치를 graceful shutdown 대상에 포함시키는 방법, 배치 자체를 멱등하게 만드는 방법, 배포 전 배치 실행 여부를 체크하는 방법 등을 MOA 적용편에서 다뤄보려 한다.
docker stop → docker run 방식을 deploy.sh 기반으로 교체하면서 기존 workflow를 어디까지 건드릴지.이런 부분들을 실제로 적용하면서 다음 글에 정리해보겠다.
claude + gemini이와 함께 공부하며 작성했습니다.