본문으로 건너뛰기
버튼 하나 뒤에서

번외 · 장 번호 없음

배포 및 회고

서버를 한 번도 만져 본 적 없는 프론트엔드 개발자가, 이 사이트의 API를 AWS에 올리고 끊기지 않게 배포하기까지. 한 일을 순서대로, 친 명령 그대로, 그리고 왜 그렇게 했는지 적었어요.

  1. 1AWS에서 작은 컴퓨터 한 대를 빌려요. 돈이 새지 않게 알림부터 걸어요.

  2. 2문지기(Caddy)가 자물쇠(HTTPS)를 걸고, 모두에게 열린 문은 두 개(80/443)만 남겨요.

  3. 3주방을 둘(blue/green) 두고, 새 주방이 준비되면 손님 길만 바꿔요. 끊기지 않아요.

  4. 4매일 새벽 4시, 장부(DB)를 통째로 복사해 두고 진짜 되살아나는지도 확인해요.

1. 숫자로 본 결과

약 1분 10초
push 뒤 배포 끝까지 (Actions의 Deploy 전체)
이미지 굽기 + 서버 배포. API 코드가 바뀌면 2분 23초. 서버 배포 job은 135초 → 37~51초 (예전엔 job 하나로 전체 2분 18초)
0번
배포 중 실패한 요청
배포 3번 동안 0.2초마다 862 · 705 · 565번 요청
1028MB
배포 중 최고 메모리 (전체 1836MB)
두 색의 API 4개가 잠깐 같이 떠 있을 때. 가용 메모리 최저 808MB
약 640 req/s
서버 한 대가 낸 최대 처리량 (k6 부하 테스트, API 2개)
읽기 요청만, 12분 동안 10 → 800 VU. API 1개(약 620 req/s)와 처리량은 거의 같고, 800 VU의 p95가 7~10초 → 1.9초, 실패가 0.89% → 0%로 줄었어요. 이번엔 서버 CPU 전체(약 180% / 200%)가 꽉 찼어요
매일 04:00
DB 백업 (서버 + S3)
12K, 0.3초. 서버 7일 + S3 30일. 임시 DB에 복원해서 11개 테이블 행 수 일치
2026-10-21 측정. 배포 시간은 GitHub Actions Deploy 기록, 요청은 맥에서 https://<IP-대시>.sslip.io/health를 0.2초마다 부른 결과, 메모리는 서버에서 1초마다 free -m. 처리량은 맥에서 k6로 잰 값이라 서버 한 대 · 내 맥 한 대 조건의 작은 측정이고, "동시 사용자 몇 명"을 뜻하지는 않아요.

2. 그림으로 보는 구조

제가 만든 서버의 모습이에요. 화면은 Vercel, API · DB · 캐시는 AWS 서버 한 대에 있고, 새 버전을 올리는 동안에도 손님 요청은 끊기지 않아요.

브라우저화면VercelNext.js 화면GitHub Actions이미지GHCR2SSMAWS 서버 한 대 (EC2 t4g.small)API · HTTPS 4431Caddy (문지기)덜 바쁜 쪽으로 나눠 보냄deploy.shblue · 서비스 중api_blue_1api_blue_2green · 쉬는 중api_green ×2다음 배포 때 켜짐3MySQLRedisAPI · DB · Redis는서버 안쪽에서만 보여요매일 04:00 백업S3
  1. 손님 요청. 브라우저는 화면을 Vercel에서 받고, API는 서버의 Caddy로 와요. 밖에 열린 문은 443(HTTPS) 하나고, Caddy가 지금 서비스 중인 색의 API 2개 중 덜 바쁜 쪽으로 보내요.
  2. 배포. main에 push하면 GitHub Actions가 이미지를 GHCR에 올리고, 저장해 둔 키 없이 1시간짜리 임시 키로 서버에 deploy.sh를 실행시켜요.
  3. 끊기지 않는 교체. deploy.sh는 쉬던 green에 새 버전 2개를 띄우고, 둘 다 건강하면 Caddy만 green으로 돌린 뒤 blue를 꺼요. 다음 배포 땐 반대로 해요.
① 요청이 들어오는 길 자세히
브라우저 · Vercel 화면https://<IP-대시>.sslip.io로 API 부르기

sslip.io가 주소 안 숫자를 IP로 바꿔 줌 ↓

AWS 서버(EC2) · 보안 그룹: 80 열림 443 열림 22 SSH 개인 키 전용

Caddy (문지기)http는 https로 돌려보냄 · HTTPS 풀기 · upstream.caddy에 적힌 API 두 개에 나눠 전달

지금 서비스 중인 색의 API 두 개로 나눠서 ↓

blue · 지금 서비스 중api_blue_1api_blue_2덜 바쁜 쪽이 받아요4000 안쪽만
green · 쉬는 중api_green_1api_green_2다음 배포 때 둘 다 켜짐4000 안쪽만

Docker 내부 네트워크 ↓

Redis캐시6379 안쪽만
MySQL서버 안에서만 · 매일 04:00 백업3306 안쪽만

↑ 응답은 같은 길로 Caddy를 거쳐 브라우저로

손님 요청은 Caddy라는 문 하나(443)로만 들어오고, API · 캐시 · DB는 서버 안쪽에서만 서로 이야기해요.
포트와 길 자세히 보기

밖에서 열린 포트 (보안 그룹)

22 SSH
내 IP에서 내 개인 키로만. Workbench DB 터널 · 터미널 접속용
80 HTTP
Caddy. 인증서 발급, 그리고 HTTPS로 돌려보내기
443 HTTPS
Caddy. 서비스로 들어오는 유일한 입구

서버 안에서만 (밖에서 안 보임)

4000 API
Docker 내부망. 색마다 2개씩, api_blue_1 · api_blue_2 · api_green_1 · api_green_2
6379 Redis
Docker 내부망
3306 MySQL
127.0.0.1에만 (SSH 터널로만 접속)

배포는 SSM으로 들어와서, 배포 때문에 열어 둔 포트는 없어요.

②③ 배포가 들어오는 길 자세히
  1. GitHub Actionsmain에 push → CI 통과
  2. GHCRARM 러너가 이미지 굽기 → push (:SHA)
  3. OIDC 출입증신뢰 정책이 저장소 · 브랜치 확인
  4. AWS1시간짜리 임시 키 (저장된 비밀키 없음)
  5. SSMsend-command → 권한 정책이 이 서버 + AWS-RunShellScript만 허락
  6. 서버 · deploy.sh쉬던 색에 새 버전 띄우기
  7. 서버 · deploy.sh/health 200 → Caddy가 새 색으로
  8. 서버 · deploy.sh이전 색 끄기 → 결과를 Actions로
OIDC는 출입증 받기, SSM은 출입증 들고 서버에 일 시키기. 새 버전이 건강할 때만 문을 바꿔서 손님 요청은 안 끊겨요.
배포 단계 자세히 보기

출입증(OIDC) → 서버에 일 시키기(SSM)

  1. 출입증 받기main 브랜치의 Actions가 GitHub에서 "이 저장소 main" 증명서(OIDC 토큰)를 받아요
  2. AWS에 제출IAM이 신뢰 정책으로 저장소 · 브랜치를 확인해요 (repo:<오너>@<ID>/<저장소>@<ID>:ref:refs/heads/main)
  3. 임시 키 발급1시간짜리 임시 키를 줘요. GitHub에 저장해 둔 비밀키는 없어요
  4. 일 시키기임시 키로 SSM에 "deploy.sh 실행"을 요청해요. 권한 정책이 이 서버 + AWS-RunShellScript만 허락해요
  5. 서버에 전달SSM이 서버의 에이전트에 넘기고, deploy.sh가 blue-green을 시작해요
  6. 결과 돌려받기실행 결과와 로그를 Actions가 받아 와서 성공/실패를 표시해요

서버 안의 blue-green 교체 (blue가 서비스 중일 때의 예)

  1. 새 이미지 받기GitHub가 구워 둔 이번 버전을 받아요. 새로 구운 이미지면 19초, 그대로면 2~3초
  2. 쉬던 쪽에 켜기손님이 없는 api_green_1 · api_green_2에 새 버전을 함께 켜요. DB 구조 변경(마이그레이션)은 _1만 한 번 해요
  3. 건강한지 확인서버 안쪽에서 두 개 모두 /health가 200이 될 때까지 2초마다 물어봐요. 최대 2분
  4. 문 바꾸기Caddy가 보낼 곳을 api_green_1 · api_green_2로 바꾸고 설정만 다시 읽어요. 연결은 안 끊겨요
  5. 하던 일 마무리blue 두 개가 처리 중이던 요청이 끝나게 10초 기다려요
  6. 예전 쪽 끄기api_blue_1 · api_blue_2를 꺼요. 다음 배포 때는 이쪽이 "쉬던 쪽"이 돼요

3에서 둘 중 하나라도 200이 안 나오면 4로 넘어가지 않아요. 손님은 계속 blue 두 개를 쓰고, 새 쪽만 꺼져요. 배포 3번 동안 0.2초마다 찔러서 실패한 요청은 0번이었어요.

서버에서 실제로 하는 일 보기 (deploy.yml → deploy/deploy.sh)
  1. GitHub: 어떤 커밋인지 확인. 커밋 SHA를 40자로 풀고 git merge-base --is-ancestor <SHA> origin/main으로 main에 있는 커밋인지 본다. 아니면 여기서 멈춤.
  2. GitHub → AWS → 서버. OIDC로 1시간짜리 역할을 받아 aws ssm send-command(AWS-RunShellScript). 서버에서는 sudo -u ubuntu bash -lc 'git fetch origin main; git reset --hard origin/main; ./deploy/deploy.sh <SHA>'
  3. 지금 색 알아내기. deploy/caddy/upstream.caddy의 reverse_proxy api_blue_1:4000 api_blue_2:4000 줄을 읽고, 반대 색을 새로 띄울 쪽으로 정한다. (7장 전의 api_blue:4000 한 줄짜리도 읽는다)
  4. 이미지 받기. docker pull ghcr.io/pocodingwer/backend-visualized-api:<SHA> → backend-visualized-api:green으로 태그. 못 받으면 서버에 남은 같은 이미지, 그것도 없으면 비상용으로 서버에서 직접 빌드(FALLBACK_LOCAL_BUILD).
  5. 쉬던 색 띄우기. docker compose up -d --no-deps --force-recreate api_green_1 api_green_2 (MySQL · Redis는 그대로). 컨테이너 시작 스크립트 apps/api/docker/start.sh가 prisma migrate deploy를 하고(DB가 늦게 뜨면 다시 시도) 서버를 켠다. 마이그레이션은 _1만, _2는 RUN_MIGRATIONS=false로 건너뛴다. 마이그레이션은 앞으로만 가니까 "추가 먼저, 삭제는 다음 배포"(expand → contract).
  6. 건강 확인. Caddy 컨테이너 안에서 wget http://api_green_1:4000/health와 _2를 2초마다, 최대 60번(2분). 둘 다 200이어야 다음으로. 컨테이너가 죽으면(마이그레이션 실패, 환경 변수 오류) 바로 멈춘다.
  7. 문 바꾸기. upstream.caddy를 임시 파일에 쓰고 mv(한 번에 바뀜) → caddy reload. Caddy가 막 새로 만들어졌으면 관리 API가 늦게 떠서 1초 간격 15번까지 다시 시도. 이어서 curl --resolve로 HTTPS 443을 거친 /health도 확인.
  8. 마무리 → 끄기 → 정리. sleep 10 → docker compose stop api_blue_1 api_blue_2 → GHCR 이미지는 최근 3개만 남기고 docker image prune -f → DEPLOYED_SHA=… COLOR=green 출력. GitHub가 그 SHA가 맞는지, 바깥 https://…/health가 200인지 확인하면 끝.
2026-10-21 Deploy 기록의 서버 로그 (deploy.sh 시작 → 끝)
경우이미지 받기건강해질 때까지마무리deploy.sh 전체
API 이미지 새로 구움 (#8)19초8초10초39초
이미지 그대로 (#9 ~ #11)2~3초4~5초10~11초19~21초
실패하면
새 색이 2분 안에 건강해지지 않으면 그 색의 로그 40줄을 남기고 그 색만 멈춘 뒤 실패로 끝난다. upstream.caddy를 건드리기 전이라 Caddy는 계속 예전 색으로 보낸다. GitHub에는 빨간 불이 켜진다.
롤백
GitHub Actions에서 Deploy를 수동 실행하고 ref에 예전 커밋 SHA를 넣는다 (이미지 굽기는 건너뜀). 서버에서 직접 하려면 ./deploy/deploy.sh <예전 SHA>. 순서는 위와 똑같은 blue-green이라 롤백도 안 끊긴다. 서버에 최근 이미지 3개가 남아 있고, 그보다 오래된 건 GHCR에서 다시 받는다. DB 마이그레이션은 되돌아가지 않는다.

코드: deploy/deploy.sh · .github/workflows/deploy.yml

3. 순서대로

가입부터 매일 백업까지 한 일이에요. 앞의 세 단계는 브라우저(AWS 콘솔)에서, 그다음부터는 대부분 터미널에서 했어요. 단계마다 "실행한 명령어 보기"를 열면 친 그대로 나와요. 비밀번호 · 토큰 · 서버 설정 파일의 값은 전부 <가림>으로 바꿨어요.

  1. AWS 가입

    가입하고 루트 계정에 MFA를 켰어요.

    실행한 명령어 보기
    브라우저 (AWS 콘솔)
    # 가입 → 루트 로그인 → MFA → 리전 서울
    aws.amazon.com → 계정 만들기 (이메일, 결제 카드, 전화 인증)
    "루트 사용자"로 로그인 → 보안 자격 증명 → MFA 할당
    리전 → 아시아 태평양(서울) ap-northeast-2
  2. 비용 알림 두 개

    1센트라도 나가면 오는 알림과 월 $30 알림을 걸어 뒀어요.

    실행한 명령어 보기
    브라우저 (AWS 콘솔)
    # 예산 알림 두 개 (메일)
    Billing and Cost Management → 예산(Budgets) → 예산 생성
      1) "제로 지출 예산"  → 1센트라도 청구되면
      2) "월별 비용 예산" → $30, 실제 85%·100% · 예상 100%

    결과 · t4g.small + 디스크 + 고정 IP를 합친 어림값이 월 $21 정도라 $30이면 넘을 일이 없어요.

  3. EC2(서버) 만들기왜 이렇게? →

    ARM(Graviton) t4g.small, 메모리 2GB, Ubuntu 24.04. 열쇠(키 페어)와 열 포트(보안 그룹)도 이때 정해요.

    실행한 명령어 보기
    브라우저 (AWS 콘솔)
    # 서버: ARM 2GB, Ubuntu 24.04, 디스크 20GB
    EC2 → 인스턴스 시작 (서울)
      AMI           Ubuntu Server 24.04 LTS (Arm)
      인스턴스 유형 t4g.small  (2 vCPU, 2 GiB, 시간당 $0.0208)
      키 페어       backend-visualized.pem (다시 못 받음)
      스토리지      gp3 20 GiB
    # 보안 그룹: 22는 내 IP만, 80·443만 전체 공개
      22  (SSH)     내 IP
      80  (HTTP)    0.0.0.0/0, ::/0   ← 인증서 발급 + https로 보내기
      443 (HTTPS)   0.0.0.0/0, ::/0
      3306(MySQL) · 6379(Redis) · 4000(API)은 열지 않음
  4. 내 맥에서 SSH로 접속

    내려받은 열쇠 파일로 서버에 들어가요. 열쇠 파일은 나만 읽을 수 있게 권한을 줄여야 SSH가 받아 줘요.

    실행한 명령어 보기
    내 맥
    # 1) 열쇠 파일은 나만 읽게 (아니면 ssh가 거절)
    mv ~/Downloads/backend-visualized.pem ~/.ssh/
    chmod 400 ~/.ssh/backend-visualized.pem
    # 2) 접속 (처음 받은 공인 IP, 나중에 고정 IP로 바뀜)
    ssh -i ~/.ssh/backend-visualized.pem ubuntu@<예전 IP>
    # 3) CPU 종류 · OS 버전 · 메모리 · 디스크 확인
    uname -m; lsb_release -ds; free -h; df -h /

    결과 · aarch64 · Ubuntu 24.04.4 LTS · 메모리 1.8Gi · 스왑 0B · 디스크 19G

  5. 서버 기본 설정: 업데이트 · 스왑 · 시간대 · 자동 보안 패치

    2GB는 빠듯해서 디스크 2GB를 비상용 메모리(스왑)로 붙였어요. 시계는 한국 시간으로, 보안 패치는 매일 알아서 깔리게 했어요.

    실행한 명령어 보기
    서버 (ubuntu@EC2)
    # 1) 패키지 목록 갱신 + 설치된 것 업데이트 (설정 파일은 기존 것 유지)
    sudo DEBIAN_FRONTEND=noninteractive apt-get update
    sudo DEBIAN_FRONTEND=noninteractive apt-get -y \
      -o Dpkg::Options::=--force-confdef \
      -o Dpkg::Options::=--force-confold \
      upgrade
    
    # 2) 비상용 메모리(스왑) 2GB 만들기 → 재부팅해도 유지(fstab)
    sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile
    sudo mkswap /swapfile && sudo swapon /swapfile
    echo "/swapfile none swap sw 0 0" | sudo tee -a /etc/fstab
    # 메모리가 정말 모자랄 때만 스왑 쓰기 (swappiness=10)
    echo "vm.swappiness=10" | sudo tee /etc/sysctl.d/99-swappiness.conf
    sudo sysctl -p /etc/sysctl.d/99-swappiness.conf
    
    # 3) 서버 시계를 한국 시간으로
    sudo timedatectl set-timezone Asia/Seoul
    
    # 4) 보안 패치 매일 자동 설치 (unattended-upgrades)
    sudo apt-get install -y unattended-upgrades
    printf '%s\n' \
      'APT::Periodic::Update-Package-Lists "1";' \
      'APT::Periodic::Unattended-Upgrade "1";' \
      | sudo tee /etc/apt/apt.conf.d/20auto-upgrades
    
    # 5) 확인
    swapon --show; free -h

    결과 · /swapfile 2G · swappiness 10 · Time zone: Asia/Seoul (KST)

  6. Docker 설치

    API, MySQL, Redis, Caddy를 전부 컨테이너(상자)로 띄워요. 우분투 기본 패키지는 오래돼서 Docker 공식 저장소에서 받았어요.

    실행한 명령어 보기
    서버 (ubuntu@EC2)
    # 1) Docker 공식 저장소의 서명 키 받기
    sudo apt-get install -y ca-certificates curl
    sudo install -m 0755 -d /etc/apt/keyrings
    sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
      -o /etc/apt/keyrings/docker.asc
    sudo chmod a+r /etc/apt/keyrings/docker.asc
    
    # 2) apt에 Docker 저장소 등록 (이 CPU · 이 우분투 버전용)
    echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc]" \
      "https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" \
      | sudo tee /etc/apt/sources.list.d/docker.list
    
    # 3) Docker + Compose 설치
    sudo apt-get update
    sudo apt-get install -y docker-ce docker-ce-cli containerd.io \
      docker-buildx-plugin docker-compose-plugin
    
    # 4) sudo 없이 docker 쓰기 (다시 로그인하면 적용) + 부팅 때 자동으로 켜기
    sudo usermod -aG docker ubuntu
    sudo systemctl enable --now docker containerd

    결과 · Docker 29.9.1 · Compose v5.6.0 · 부팅하면 Docker가 자동으로 켜짐

  7. 코드 받기: /opt/backend-visualized

    공개 저장소라 토큰 없이 받을 수 있어요. /opt는 따로 설치한 프로그램을 두는 리눅스 관례 자리예요.

    실행한 명령어 보기
    서버 (ubuntu@EC2)
    # 1) git 설치 + 코드 둘 폴더를 ubuntu 사용자 것으로
    sudo apt-get install -y git
    sudo mkdir -p /opt/backend-visualized   && sudo chown ubuntu:ubuntu /opt/backend-visualized
    # 2) 공개 저장소라 토큰 없이 받기 → 받은 커밋 확인
    git clone https://github.com/POcodingWER/backend-visualized.git /opt/backend-visualized
    cd /opt/backend-visualized && git log --oneline -1
  8. 비밀값 파일 만들기: deploy/production.env

    DB 비밀번호와 로그인 서명 키는 서버 안에서 바로 만들어 파일에만 적었어요. 깃에도, 채팅에도, 내 맥에도 남지 않아요. 아래 값 자리는 전부 가렸어요.

    실행한 명령어 보기
    서버 (ubuntu@EC2)
    cd /opt/backend-visualized
    # 1) 이제부터 만드는 파일은 나만 읽기
    umask 077
    # 2) 비밀값을 서버 안에서 무작위로 만들기 (화면 · 채팅 · 내 맥에 안 남음)
    MYSQL_ROOT_PASSWORD=$(openssl rand -hex 24)
    MYSQL_PASSWORD=$(openssl rand -hex 24)
    JWT_ACCESS_SECRET=$(openssl rand -base64 48)
    ADMIN_API_KEY=$(openssl rand -hex 32)
    # 3) 설정 파일 쓰기 (비밀값 자리는 위 변수. 이 화면에서는 가림)
    cat > deploy/production.env <<ENV
    COMPOSE_PROFILES=local-db
    API_DOMAIN=<예전 IP-대시>.sslip.io
    ACME_EMAIL=<GitHub noreply 메일>
    WEB_ORIGIN=http://localhost:3000
    VERCEL_ORIGIN=
    MYSQL_ROOT_PASSWORD=<서버에서 생성, 가림>
    MYSQL_DATABASE=backend_visualized
    MYSQL_USER=app
    MYSQL_PASSWORD=<서버에서 생성, 가림>
    DATABASE_URL=mysql://app:<가림>@mysql:3306/backend_visualized
    MIGRATION_MAX_ATTEMPTS=40
    REDIS_URL=redis://redis:6379/0
    JWT_ACCESS_SECRET=<서버에서 생성, 가림>
    ADMIN_API_KEY=<서버에서 생성, 가림>
    REFRESH_COOKIE_SAME_SITE=none
    AUTH_DEMO_ENDPOINTS=true
    SIMULATION_COOLDOWN_MS=3000
    ENV
    # 4) 권한 600 → 값 말고 이름만 확인 → git이 무시하는 파일(!!)인지 확인
    chmod 600 deploy/production.env
    grep -oE "^[A-Z_]+=" deploy/production.env | tr -d "="
    git status --short --ignored deploy/

    결과 · 실제 파일에서는 <가림> 자리에 openssl이 만든 값이 들어가요. 화면에는 "이름"만 찍어서 확인했어요.

  9. MySQL · Redis · API 띄우기 (docker compose)왜 이렇게? →

    처음에는 서버가 직접 이미지를 만들었어요(2분). DB와 캐시를 먼저 띄우고, API는 켜지면서 테이블을 만드는 작업(마이그레이션)을 알아서 해요.

    실행한 명령어 보기
    서버 (ubuntu@EC2)
    cd /opt/backend-visualized
    # 1) (처음에만) 서버에서 API 이미지 직접 빌드: real 1m59s. 지금은 GHCR에서 받음
    time docker build -f apps/api/Dockerfile -t backend-visualized-api:latest .
    # 2) DB와 캐시 먼저 → 살아났는지 (healthy, PONG)
    ./deploy/compose.sh up -d mysql redis
    docker inspect -f '{{.State.Health.Status}}' backend-visualized-mysql-1
    ./deploy/compose.sh exec -T redis redis-cli ping
    # 3) API 띄우기 → 켜지면서 마이그레이션(테이블 만들기)
    ./deploy/compose.sh up -d api
    ./deploy/compose.sh logs api | grep -iE 'migrat|config'
    # 4) DB · Redis · API 포트가 서버 밖으로 안 열렸는지
    sudo ss -tlnp | grep -E ':(3306|6379|4000)\b' || echo "밖에서 안 보임 OK"

    결과 · All migrations have been successfully applied · [config] mode=production … · DB·Redis·API 포트는 서버 밖으로 안 열림

  10. HTTPS: Caddy + sslip.io왜 sslip.io? →왜 Caddy? →

    인증서는 도메인에만 발급돼요. sslip.io는 1-2-3-4.sslip.io를 1.2.3.4로 알려주는 무료 주소라, 도메인 없이도 진짜 인증서를 받았어요. Caddy가 발급과 갱신을 알아서 해요.

    실행한 명령어 보기
    서버 (ubuntu@EC2)
    # Caddy 띄우기 → 인증서 자동 발급 로그 확인
    ./deploy/compose.sh up -d caddy
    ./deploy/compose.sh logs caddy | grep -iE 'certificate|acme'
    ./deploy/compose.sh ps
    내 맥
    # 1) https로 응답하는지
    curl -sS https://<예전 IP-대시>.sslip.io/health
    # 2) http로 오면 https로 보내는지 (308)
    curl -sSI http://<예전 IP-대시>.sslip.io/health | head -3
    # 3) 인증서 발급자 · 만료일
    curl -sSv https://<예전 IP-대시>.sslip.io/health 2>&1 \
      | grep -E 'subject:|issuer:|expire date'

    결과 · certificate obtained successfully · issuer Let’s Encrypt · {"status":"ok","db":{"status":"up"}}

  11. 꺼졌다 켜져도 살아나는지 (재부팅 테스트)

    AWS가 점검으로 서버를 재부팅해도 사람 손 없이 다시 떠야 해요. 진짜로 껐다 켜 봤어요.

    실행한 명령어 보기
    서버 (ubuntu@EC2)
    # 1) 컨테이너가 재부팅 뒤 다시 켜지게 돼 있는지 (restart 정책)
    docker inspect -f '{{.Name}} restart={{.HostConfig.RestartPolicy.Name}}' $(docker ps -q)
    # 2) Docker가 부팅 때 켜지는지
    systemctl is-enabled docker containerd
    # 3) 진짜로 재부팅
    sudo systemctl reboot
    내 맥
    # 5초마다 health → 언제 다시 살아나는지
    while true; do
      curl -sS -m 5 https://<예전 IP-대시>.sslip.io/health; echo
      sleep 5
    done

    결과 · 16:11:08 재부팅 → 16:12:04 다시 200. 약 1분. 넣어 둔 데이터도 그대로였어요.

  12. MySQL Workbench: 127.0.0.1에만 열고 SSH 터널로

    DB를 그림으로 보고 싶었지만 3306을 인터넷에 열면 위험해요. 서버 자기 자신(127.0.0.1)에만 열고, 이미 있는 SSH 길을 터널로 써서 들어가요.

    실행한 명령어 보기
    저장소 파일
    # docker-compose.prod.yml (mysql): 서버 자기 자신에만 열기
        ports:
          - '127.0.0.1:3306:3306'     # '3306:3306'이면 전 세계 공개라 금지
    서버 (ubuntu@EC2)
    # 바뀐 설정으로 mysql 다시 만들기 (볼륨은 그대로라 데이터 유지)
    ./deploy/compose.sh up -d mysql
    # LISTEN 127.0.0.1:3306 이면 OK
    sudo ss -ltnp | grep 3306
    내 맥
    # 1) SSH 터널: 내 맥의 13306 → 서버의 127.0.0.1:3306
    ssh -i ~/.ssh/backend-visualized.pem -N \
      -L 13306:127.0.0.1:3306 ubuntu@<IP>
    # 2) Workbench 접속 설정: Standard TCP/IP over SSH
    #   SSH Hostname <IP>:22 · SSH Username ubuntu
    #   SSH Key File ~/.ssh/backend-visualized.pem
    #   MySQL Hostname 127.0.0.1 · Port 3306 · Username app · 비밀번호는 키체인에만
    # 3) 인터넷에서 직접은 막혀 있어야 정상 (Operation timed out)
    nc -vz -G 5 <IP> 3306
  13. 고정 IP(Elastic IP)로 바꾸고 주소 한 줄 고치기왜 이렇게? →

    일반 공인 IP는 서버를 중지했다 켜면 바뀌어요. 그러면 주소도 인증서도 다 다시 해야 해서, 계정에 묶이는 고정 IP를 붙였어요.

    실행한 명령어 보기
    브라우저 (AWS 콘솔)
    # 고정 IP를 받아 서버에 붙이기 (예전 IP는 반납)
    EC2 → 탄력적 IP → 할당 → 연결 → backend-visualized
      <예전 IP> (반납) → <IP>
    서버 (ubuntu@EC2)
    # 1) API 주소를 새 IP의 sslip.io 이름으로
    sed -i 's/^API_DOMAIN=.*/API_DOMAIN=<IP-대시>.sslip.io/' deploy/production.env
    # 2) 다시 띄우기 → Caddy가 새 주소로 인증서를 다시 받음
    ./deploy/compose.sh up -d

    결과 · API 주소: https://<IP-대시>.sslip.io

  14. SSM Agent + 서버용 IAM 역할왜 이렇게? →

    자동 배포는 GitHub가 SSH로 들어오는 대신, 서버 안의 SSM Agent가 AWS에서 명령을 받아 실행하는 방식이에요. 서버가 AWS에 "나 맞아요"라고 증명하려면 역할(IAM Role)을 붙여야 해요.

    실행한 명령어 보기
    브라우저 (AWS 콘솔)
    # 1) 서버용 역할: SSM으로 명령 받는 권한만
    IAM → 역할 생성 → 신뢰할 엔터티: AWS 서비스 / EC2
      권한: AmazonSSMManagedInstanceCore (이것만)
      이름: backend-visualized-ec2-ssm
    # 2) 그 역할을 서버에 붙이기
    EC2 → 인스턴스 → 작업 → 보안 → IAM 역할 수정 → backend-visualized-ec2-ssm
    서버 (ubuntu@EC2)
    # 1) SSM Agent가 돌고 있는지 (Ubuntu AMI에 기본으로 깔려 있음)
    sudo snap services amazon-ssm-agent
    # 2) 역할이 붙었는지: 메타데이터에서 역할 "이름"만 (자격 증명 값은 조회하지 않음)
    TOKEN=$(curl -s -X PUT http://169.254.169.254/latest/api/token \
      -H "X-aws-ec2-metadata-token-ttl-seconds: 60")
    curl -s -H "X-aws-ec2-metadata-token: $TOKEN" \
      http://169.254.169.254/latest/meta-data/iam/security-credentials/
    # 3) 에이전트 재시작 → 로그에 Credentials ready면 성공
    sudo snap restart amazon-ssm-agent
    sudo tail -n 30 /var/log/amazon/ssm/amazon-ssm-agent.log | grep -E 'ERROR|Credentials'

    결과 · backend-visualized-ec2-ssm (HTTP 200) · [CredentialRefresher] Credentials ready

  15. GitHub OIDC + 배포용 IAM 역할왜 이렇게? →

    GitHub에 AWS 열쇠를 저장하지 않아요. 대신 GitHub가 "이건 POcodingWER/backend-visualized의 main이에요"라는 증명서(OIDC 토큰)를 내고, AWS가 그걸 확인한 뒤 1시간짜리 임시 열쇠를 줘요.

    실행한 명령어 보기
    브라우저 (AWS 콘솔)
    # 1) AWS가 GitHub의 증명서(OIDC)를 믿게 등록
    IAM → 자격 증명 공급자 → 추가 → OpenID Connect
      URL token.actions.githubusercontent.com · 대상(audience) sts.amazonaws.com
    # 2) GitHub가 빌려 쓸 배포 역할
    IAM → 역할 생성 → 웹 자격 증명 → 위 공급자 → 이름 backend-visualized-github-deploy
    브라우저 (AWS 콘솔)
    // 신뢰 정책: 누가 이 역할을 빌릴 수 있나 (권한은 아래 인라인 정책이 따로 정함)
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::<계정ID>:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      // 이 저장소(ID까지)의 main 브랜치에서 온 증명서만
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
          "token.actions.githubusercontent.com:sub":
            "repo:POcodingWER@<오너ID>/backend-visualized@<저장소ID>:ref:refs/heads/main"
        }
      }
    }
    브라우저 (AWS 콘솔)
    // 권한(인라인) 정책 ssm-deploy-only: 역할을 빌린 뒤 할 수 있는 일
    {
      "Version": "2012-10-17",
      "Statement": [
        // 1) 이 서버 하나에, AWS-RunShellScript 문서로만 명령 보내기
        {
          "Effect": "Allow",
          "Action": "ssm:SendCommand",
          "Resource": [
            "arn:aws:ec2:ap-northeast-2:<계정ID>:instance/<인스턴스ID>",
            "arn:aws:ssm:ap-northeast-2::document/AWS-RunShellScript"
          ]
        },
        // 2) 보낸 명령의 결과 조회
        {
          "Effect": "Allow",
          "Action": ["ssm:GetCommandInvocation", "ssm:ListCommandInvocations"],
          "Resource": "*"
        }
      ]
    }
    내 맥
    # GitHub가 실제로 만드는 sub 형식 확인 (읽기 전용 API)
    gh api repos/POcodingWER/backend-visualized/actions/oidc/customization/sub
    #   {"use_immutable_subject":true,"sub_claim_prefix":"repo:POcodingWER@<오너ID>/backend-visualized@<저장소ID>"}
  16. Vercel 화면이 API를 부를 수 있게 (CORS)

    브라우저는 다른 주소의 API 응답을, API가 "이 주소는 허락"이라고 해 줘야만 화면에 넘겨줘요. 서버 설정에 Vercel 주소 한 줄을 넣었어요.

    실행한 명령어 보기
    서버 (ubuntu@EC2)
    # 1) 허락할 화면 주소(Vercel)를 설정 파일에
    sed -i 's|^VERCEL_ORIGIN=.*|VERCEL_ORIGIN=https://backend-visualized.vercel.app|' \
      deploy/production.env
    # 2) API 다시 만들기 (그때는 API가 하나라 몇 초 끊김)
    ./deploy/compose.sh up -d api
    # 지금이라면: ./deploy/deploy.sh current (같은 이미지를 다른 색으로 띄워 무중단 반영)
    내 맥
    # 브라우저가 먼저 보내는 확인 요청(preflight)을 흉내 → 허락 헤더가 오는지
    curl -si -X OPTIONS https://<IP-대시>.sslip.io/payments \
      -H 'Origin: https://backend-visualized.vercel.app' \
      -H 'Access-Control-Request-Method: POST' \
      -H 'Access-Control-Request-Headers: content-type,idempotency-key'

    결과 · 204 · access-control-allow-origin: https://backend-visualized.vercel.app · access-control-allow-credentials: true

  17. 이미지는 GitHub에서 굽고, 서버는 받기만 + blue-green 무중단 교체왜 GHCR? →왜 blue-green? →

    서버가 직접 빌드하면 2분 동안 바빠지고, 바꿔 끼우는 순간 몇 초 끊겼어요. 그래서 GitHub의 ARM 컴퓨터가 이미지를 만들어 GHCR에 올리고, 서버는 받아서 쉬고 있는 쪽(blue/green)에 먼저 띄운 뒤 문만 바꿔 달아요.

    실행한 명령어 보기
    GitHub
    # .github/workflows/deploy.yml (요약): job 세 개가 차례로
    # 1) 배포할 커밋 정하기
    target  → 커밋 SHA 결정 (main에 있는 커밋인지 확인)
    # 2) ARM 러너에서 이미지 굽기 → GHCR에 올리기
    image   → runs-on: ubuntu-24.04-arm, permissions: packages: write
              docker/build-push-action
              → ghcr.io/pocodingwer/backend-visualized-api:<SHA>, :main
    # 3) OIDC로 AWS 역할 → SSM으로 서버에 명령 → 결과와 health 확인
    deploy  → permissions: id-token: write
              aws-actions/configure-aws-credentials (OIDC)
              aws ssm send-command --document-name AWS-RunShellScript \
                --instance-ids <인스턴스ID>
              → get-command-invocation 5초마다 확인
              → https://…/health 200 확인
    서버 (ubuntu@EC2)
    # SSM이 서버에서 실행하는 명령 (root로 와서 ubuntu로 바꿔 실행)
    sudo -u ubuntu bash -lc 'set -euo pipefail; cd /opt/backend-visualized;
      git fetch --prune origin main; git reset --hard origin/main;
      ./deploy/deploy.sh <커밋 SHA>'
    
    # deploy/deploy.sh가 하는 일 (blue가 서비스 중일 때)
    # 1) 이번 커밋의 이미지 받기
    docker pull -q ghcr.io/pocodingwer/backend-visualized-api:<SHA>
    # 2) 쉬던 색(green) 두 개를 새 이미지로 띄우기 (MySQL · Redis는 그대로)
    #    마이그레이션은 _1만 (_2는 RUN_MIGRATIONS=false)
    ./deploy/compose.sh up -d --no-deps --force-recreate api_green_1 api_green_2
    # 3) 두 개 모두 /health가 200이 될 때까지 서버 안쪽에서 확인 (_2도 같은 방법)
    ./deploy/compose.sh exec -T caddy \
      wget -q -O /dev/null http://api_green_1:4000/health
    # 4) Caddy가 보낼 곳을 green 두 개로 바꾸고 설정만 다시 읽기
    #    deploy/caddy/upstream.caddy → reverse_proxy api_green_1:4000 api_green_2:4000
    ./deploy/compose.sh exec -T caddy \
      caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
    # 5) 진행 중이던 요청이 끝나게 10초 기다렸다가 예전 색 끄기
    sleep 10 && ./deploy/compose.sh stop api_blue_1 api_blue_2
    내 맥
    # 배포 중 0.2초마다 health를 찔러서 200이 아닌 응답 세기
    while true; do
      curl -s -o /dev/null -w '%{http_code} %{time_total}\n' --max-time 3 https://<IP-대시>.sslip.io/health
      sleep 0.2
    done | awk '$1 != 200'

    결과 · 배포 3번 동안 862 · 705 · 565번 요청, 200이 아닌 응답 0번

  18. 매일 DB 백업 + S3에 한 부 더 + 진짜 복원해 보기

    DB가 같은 서버에 있어서 서버가 망가지면 데이터도 같이 사라져요. 매일 새벽 4시에 통째로 복사해서 서버와 S3에 한 부씩 두고, 그 복사본이 진짜 되살아나는지도 확인했어요.

    실행한 명령어 보기
    저장소 파일
    # deploy/cron/backend-visualized-backup → /etc/cron.d/ (deploy.sh가 배포 때마다 설치)
    # 매일 04:00(서버 시계 KST), ubuntu 사용자로 백업 스크립트 실행 → 로그는 backup.log
    0 4 * * * ubuntu /opt/backend-visualized/deploy/backup-mysql.sh >> /opt/backend-visualized/backups/backup.log 2>&1
    서버 (ubuntu@EC2)
    # backup-mysql.sh의 핵심
    # 1) 컨테이너 안에서 덤프: 비밀번호는 컨테이너의 환경 변수를 그 안에서만 씀
    #    --single-transaction = 테이블을 잠그지 않고 한 시점 그대로
    ./deploy/compose.sh exec -T mysql sh -c \
      'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec nice -n 19 mysqldump -uroot \
        --single-transaction --quick --routines --triggers --events \
        --no-tablespaces --set-gtid-purged=OFF backend_visualized' \
      | nice -n 19 ionice -c3 gzip -6 \
      > backups/backend_visualized-20261021-094109-KST.sql.gz.partial
    # 2) 끝까지 써졌는지 ("-- Dump completed"일 때만 .partial을 떼고 이름 바꾸기)
    gzip -t …partial && zcat …partial | tail -n 1
    # 3) 7일 지난 백업 지우기
    find backups -name 'backend_visualized-*-KST.sql.gz' -mtime +6 -delete
    
    # 4) cron 설치(몇 번 해도 같음) → 지금 한 번 백업 → 임시 DB에 복원해서 행 수 비교
    ./deploy/install-backup-cron.sh
    ./deploy/backup-mysql.sh
    ./deploy/restore-test.sh
    
    # 5) 서버 밖 한 부: production.env에 BACKUP_S3_BUCKET이 있으면 S3 업로드까지
    echo 'BACKUP_S3_BUCKET=backend-visualized-db-backups' >> deploy/production.env
    aws s3 cp backups/<파일> s3://backend-visualized-db-backups/mysql/<파일> \
      --only-show-errors
    # 읽기는 403이 정상 (서버 역할은 쓰기 전용)
    aws s3api head-object --bucket backend-visualized-db-backups --key mysql/<파일>
    브라우저 (AWS 콘솔)
    # 1) 백업 버킷: 공개 차단 · 버전 관리 · 암호화 · 오래된 것 자동 삭제
    S3 → 버킷 만들기 backend-visualized-db-backups (서울)
      퍼블릭 액세스 차단 켬 · 버전 관리 켬 · 기본 암호화 SSE-S3
      수명 주기 규칙 expire-mysql-backups (접두사 mysql/)
        현재 버전 30일 뒤 만료 · 이전 버전 7일 뒤 삭제 · 미완료 멀티파트 1일 뒤 삭제
    # 2) 서버 역할에 "mysql/ 폴더에 올리기만" 권한 추가
    IAM → 역할 backend-visualized-ec2-ssm → 인라인 정책 s3-backup-put-only
    {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Effect": "Allow",
          "Action": "s3:PutObject",
          "Resource": "arn:aws:s3:::backend-visualized-db-backups/mysql/*"
        }
      ]
    }

    결과 · 12K · 0.26초 · 메모리 28MB · RESTORE_TEST=PASS (11개 테이블 행 수 모두 일치) · S3 업로드 mysql/backend_visualized-20261021-095322-KST.sql.gz (10,883 bytes)

  19. 장애 알아채기: 요청 번호 로그 · Sentry · 15분마다 /health왜 이렇게? →

    지금까지는 서버가 죽어도 누가 알려 주지 않았어요. 15분마다 밖에서 살아 있는지 확인하고, 오류는 Sentry로 모으고, 요청마다 번호를 붙여서 "그 오류가 로그의 어느 줄인지" 바로 찾게 했어요.

    실행한 명령어 보기
    저장소 파일
    # .github/workflows/uptime.yml: 15분마다 /health, 3번 연속 200이 아니면 실패 → GitHub이 메일
    on:
      schedule:
        - cron: '*/15 * * * *'
    
    # apps/api/src/common/observability/
    #   logger.ts     pino 한 줄 JSON 로그 + x-request-id, 토큰·쿠키·비밀번호는 [가림]
    #   all-exceptions.filter.ts  오류 응답을 { statusCode, message, requestId } 로 통일
    #   sentry.ts     SENTRY_DSN이 있을 때만 켜짐 (없으면 아무것도 안 함)
    서버 (ubuntu@EC2)
    # Sentry를 켜려면 production.env에 한 줄 추가하고 다시 배포 (비워 두면 꺼짐)
    echo 'SENTRY_DSN=https://<키>@<호스트>/<번호>' >> deploy/production.env
    
    # 시작 로그에서 켜졌는지 확인: [config] … sentry=on
    ./deploy/compose.sh logs api_blue_1 | grep sentry=

전체 설명은 저장소의 docs/deploy.md, 스크립트는 deploy/deploy.sh · deploy/backup-mysql.sh · .github/workflows/deploy.yml

4. 왜 이렇게 했나

왜 SSH가 아니라 SSM으로 배포하나요?

SSH로 배포하려면 서버의 SSH 포트를 밖에 열어 둬야 해요. GitHub Actions 러너는 IP가 매번 바뀌어서 사실상 넓게 열어야 하고, 서버 개인키도 GitHub에 맡겨야 해요. SSM은 서버의 에이전트가 AWS 쪽으로 먼저 연결해서 명령을 받아 오는 방식이라 배포 때문에 열어 둘 포트가 없어요. 권한은 OIDC로 받는 임시 권한이라 저장해 둔 키도 없고, "이 서버 한 대에 AWS-RunShellScript로만"으로 좁혀 두었고, 누가 언제 무슨 명령을 보냈는지 기록도 남아요. 솔직히 덧붙이면 SSH 포트는 하나 열려 있어요. Workbench로 DB를 보려고 쓰는 터널인데, 제 IP에서 제 키로만 들어오는 개인용 통로이고 자동 배포에는 SSH가 아예 끼지 않아요.

대신 떠안은 것 IAM 역할 두 개와 정책을 직접 이해해야 했어요. 처음 설정이 SSH보다 훨씬 오래 걸렸어요.

↑ 14단계로 돌아가기

왜 Ubuntu 24.04인가요?

자료와 문서가 제일 많아서 막혔을 때 답을 찾기 쉬워요. 처음 서버를 만지는 저한테는 이게 제일 중요했어요. LTS(장기 지원) 버전이라 2029년까지 보안 업데이트가 나오고, OS 라이선스 비용도 없어요(EC2 사용료는 따로예요). Docker 공식 설치 문서가 그대로 맞고, SSM Agent도 AMI에 이미 깔려 있어요.

대신 떠안은 것 저장소 문서는 원래 Amazon Linux 기준이라 설치 명령을 우분투에 맞게 다시 써야 했어요.

↑ 3단계로 돌아가기

왜 RDS 대신 같은 EC2의 Docker MySQL인가요?

돈과 공부 두 가지예요. t4g.small + MySQL 컨테이너는 월 $21 정도인데, RDS db.t4g.micro를 붙이면 $41 정도로 두 배가 돼요. 그리고 RDS가 대신 해 주는 일(백업, 복원, 패치)을 한 번은 제 손으로 해 보고 싶었어요.

대신 떠안은 것 백업과 패치는 제 몫이에요. 서버 한 대가 죽으면 API와 DB가 같이 죽어요(단일 장애점). 그래서 매일 백업(서버 + S3)과 복원 테스트를 만들었고, 진짜 서비스라면 RDS로 옮길 거예요.

↑ 9단계로 돌아가기

왜 sslip.io인가요?

Vercel 화면은 https라서 API도 https여야 브라우저가 막지 않고, 로그인 쿠키(Secure)도 https에서만 돼요. 그런데 Let’s Encrypt 인증서는 도메인 이름이 있어야 받을 수 있어요. sslip.io는 IP를 이름으로 바꿔 주는 무료 주소라, 도메인을 사지 않고도 진짜 인증서를 받을 수 있었어요.

대신 떠안은 것 sslip.io는 남의 무료 서비스라 멈추면 API 주소도 멈춰요. 제 도메인이 생기면 API_DOMAIN 한 줄만 바꾸면 돼요.

↑ 10단계로 돌아가기

왜 Caddy인가요?

설정이 짧아요. Caddyfile은 열 줄 남짓이고, Nginx처럼 server 블록 · SSL · http→https 리다이렉트를 따로 쓸 필요가 없어요. 주소만 적으면 Let’s Encrypt 인증서 발급과 갱신도 알아서 해요. 무중단 배포도 쉬워요. upstream.caddy 한 줄을 반대 색으로 바꾸고 caddy reload만 하면, 접속을 끊지 않고 바로 새 API로 넘어가요. 같은 서버에 컨테이너 하나라 추가 비용이 없고, 메모리 2GB 서버라 가벼운 게 중요했어요.

다른 선택지와 비교
Nginx + Certbot
가장 흔하고 자료가 많아요. 대신 인증서 갱신을 직접 챙겨야 해요.
Traefik
인증서는 자동인데, 라벨 · 설정이 Caddy보다 복잡해요.
ALB + ACM
인증서는 무료지만 ALB에 월 고정비가 나가고, 도메인도 있어야 해요.
Cloudflare 프록시
무료지만 도메인이 있어야 해요.

도메인 없음 · 서버 한 대 · 추가 비용 0이라는 조건에서, 가장 적은 설정으로 HTTPS와 무중단 전환이 되는 게 Caddy였어요.

대신 떠안은 것 자료와 실무 사례는 Nginx가 훨씬 많아요. 그리고 도메인을 사도 Caddy는 그대로 필요해요. 바뀌는 건 주소뿐이에요.

↑ 10단계로 돌아가기

왜 Elastic IP(고정 IP)를 붙였나요?

일반 공인 IP는 서버를 중지했다 켜면 바뀌어요. 주소가 바뀌면 sslip.io 주소, 인증서, Vercel 설정, Workbench 접속까지 다 고쳐야 해요. 실제로 한 번 바꿔 보고 나서 고정했어요.

대신 떠안은 것 공인 IPv4는 고정이든 아니든 시간당 $0.005(월 약 $3.7)가 나가요.

↑ 13단계로 돌아가기

왜 GitHub OIDC인가요?

AWS 열쇠(Access Key)를 GitHub Secret에 넣어 두면 언젠가 새거나 잊힌 채로 남아요. OIDC는 배포할 때마다 GitHub가 증명서를 내고 AWS가 1시간짜리 임시 열쇠를 줘요. 저장해 둔 비밀값이 아예 없어요.

대신 떠안은 것 정책 설정이 생각보다 까다로웠어요. 신뢰 정책에 적은 조건이 GitHub이 실제로 보내는 값(숫자 ID가 붙은 형식, repo:<오너>@<숫자ID>/<저장소>@<숫자ID>:ref:refs/heads/main)과 달라서 거절됐고, 그걸 고치니 이번엔 권한 정책이 빠져 있어서 또 막혔어요. 두 정책이 하는 일이 다르다는 걸 그때 확실히 알게 됐어요.

↑ 15단계로 돌아가기

왜 이미지를 GHCR(GitHub 컨테이너 저장소)에 두나요?

서버가 빌드하지 않고 "받기만" 하려면 구워 둔 이미지를 둘 곳이 필요해요. GHCR은 공개 저장소면 무료이고, Actions가 따로 비밀값 없이 GITHUB_TOKEN으로 바로 올려요. Docker Hub처럼 계정을 하나 더 만들거나 익명 pull 횟수 제한을 신경 쓸 일도 없어요. 이미지에 org.opencontainers.image.source 라벨을 붙여 이 공개 저장소에 연결하니 패키지도 Public이 돼서, 서버는 로그인 없이 받아요. 태그가 커밋 SHA라 롤백은 "예전 SHA로 다시 배포"면 끝나요.

# .github/workflows/deploy.yml 맨 위: 이미지 이름은 여기 한 번만
env:
  IMAGE: ghcr.io/pocodingwer/backend-visualized-api
# push할 태그와 "로그인 없이 받을 수 있나" 확인이 같은 이름을 씀 → 안 어긋남
# GHCR 이름은 소문자만 → POcodingWER 대신 pocodingwer
# 태그: <IMAGE>:<커밋 SHA> (배포·롤백용), <IMAGE>:main (지금 main 표시용)

대신 떠안은 것 이미지 이름이 deploy.yml의 IMAGE와 서버의 deploy/deploy.sh 두 곳에 적혀 있어서, 바꿀 땐 둘 다 고쳐야 해요.

↑ 17단계로 돌아가기

왜 blue-green으로 바꿔 끼우나요?

예전엔 컨테이너를 그 자리에서 다시 만들어서, 바꿔 끼우는 순간 몇 초 끊겼어요. 이제는 쉬고 있던 쪽(blue/green)에 새 버전을 먼저 띄우고, /health가 200이면 문(Caddy)만 돌려요. 실패하면 문을 안 돌려서 예전 쪽이 계속 서비스해요. 배포 3번 동안 0.2초마다 862 · 705 · 565번 찔러서 실패는 0번이었고, 두 쪽 API 4개가 잠깐 같이 떠도 메모리는 최고 1028MB / 1836MB였어요. 서버 배포 job은 서버 빌드가 빠지면서 135초에서 37~51초로 줄었어요.

대신 떠안은 것 두 버전이 몇십 초 같이 돌아서, DB 구조를 바꿀 때는 "추가 먼저, 삭제는 다음 배포"(expand → contract) 순서를 지켜야 해요.

↑ 17단계로 돌아가기

서버가 죽거나 오류가 나면 어떻게 아나요?

세 겹으로 봐요. ① GitHub Actions가 15분마다 /health를 찔러 보고, 3번 연속 200이 아니면 실패 메일이 와요. ② 오류(500)는 Sentry로 보내서 어떤 코드에서 났는지 바로 봐요. SENTRY_DSN이 없으면 그냥 꺼져 있고 서버는 그대로 떠요. ③ 요청마다 x-request-id 번호를 붙여 응답 헤더와 서버 로그에 같이 남겨요. 화면에서 오류가 나면 그 번호 하나로 서버 로그의 그 줄을 찾을 수 있어요.

# 응답에 붙는 번호 (들어온 x-request-id가 있으면 그대로 이어 받음)
curl -si https://<IP-대시>.sslip.io/nope | grep -i request-id
x-request-id: 2f0c…
{"statusCode":404,"message":"Cannot GET /nope","requestId":"2f0c…"}

# 그 번호로 서버 로그 찾기 (로그는 한 줄 JSON)
./deploy/compose.sh logs api_blue_1 api_blue_2 | grep 2f0c

대신 떠안은 것 15분 간격이라 짧게 죽었다 살아나면 모를 수 있어요. GitHub 예약 실행은 몇 분 늦기도 해요. 지금은 메일 한 통이 전부라, 진짜 서비스라면 UptimeRobot 같은 1분 단위 외부 감시나 메신저 알림을 붙일 거예요.

↑ 19단계로 돌아가기

5. DB 백업은 언제, 어떻게

언제
매일 04:00 (한국 시간). 서버 시계가 Asia/Seoul이라 cron의 4시가 곧 새벽 4시예요.
무엇을
backend_visualized DB 전체를 mysqldump로. 테이블을 잠그지 않아서 백업 중에도 사이트는 그대로예요.
어디에
서버의 /opt/backend-visualized/backups/, 파일 이름에 한국 시간. 나만 읽을 수 있게(chmod 600) 저장하고 7일 지난 건 지워요.
서버 밖에도
S3 버킷 backend-visualized-db-backups의 mysql/ 폴더에도 매일 한 부씩 올라가요. 버전 관리를 켜 두고 30일 뒤 지워요. 서버 역할은 올리기만 할 수 있어서, 서버가 털려도 S3의 백업을 읽거나 지울 수 없어요.
복원 테스트: 가장 최근 백업을 임시 DB에 되살려서 운영 DB와 테이블마다 행 수를 셌어요 (2026-10-21 09:41). 다 센 뒤 임시 DB는 지웠어요.
테이블운영 DB복원한 DB
Coupon44
CouponIssue763763
Event33
EventDailyStat11
MenuBoard11
Payment55
PointEntry00
PointWallet00
RefreshToken1414
User22
_prisma_migrations55

11개 테이블 모두 행 수가 같았어요. 백업은 "있다"가 아니라 "되살아난다"까지 봐야 해요.

6. 막혔던 것들

  1. Not authorized to perform sts:AssumeRoleWithWebIdentity

    왜
    정책 설정이 생각보다 까다로웠어요. 신뢰 정책에 적은 sub 조건이 GitHub이 실제로 보내는 값과 달랐어요. 이 저장소는 immutable subject 형식이라 계정·저장소 숫자 ID가 붙어서 와요(repo:<오너>@<숫자ID>/<저장소>@<숫자ID>:ref:refs/heads/main).
    어떻게
    gh api …/oidc/customization/sub로 실제 형식을 확인하고, GitHub 설정을 되돌리는 대신(그게 덜 안전해요) AWS 신뢰 정책의 sub를 새 형식으로 고쳤어요.
  2. OIDC 로그인은 통과했는데 AccessDenied: ssm:SendCommand

    왜
    신뢰 정책을 고치니 이번엔 권한 정책이 빠져 있었어요. 두 정책은 하는 일이 달라요. 신뢰 정책은 "누가 이 역할을 받을 수 있나", 권한 정책은 "받고 나서 무엇을 할 수 있나"예요. 역할은 받았는데 할 수 있는 게 없었던 거예요.
    어떻게
    배포 역할에 권한(인라인) 정책 ssm-deploy-only를 붙였어요. SendCommand는 이 인스턴스 하나와 AWS-RunShellScript 문서로만 좁히고, 결과 조회(GetCommandInvocation)만 전체로 열었어요.
  3. OIDC와 SSM을 한 덩어리로 생각함

    처음엔 "GitHub이 AWS로 배포하는 기능" 하나로 봤어요. 실제로는 OIDC는 출입증 받기, SSM은 그 출입증을 들고 서버에 명령 보내기로 단계가 달랐어요.

    자세히

    OIDC: GitHub이 "이 저장소의 main"이라고 증명하고, AWS가 1시간짜리 임시 키를 줘요. SSM: 그 키로 send-command를 보내면 서버의 SSM Agent가 받아서 실행해요.

    SSM Agent는 Ubuntu EC2 이미지에 기본으로 깔려 있어서, 서버 쪽은 EC2에 역할(AmazonSSMManagedInstanceCore)만 붙이면 됐어요.

  4. SSM에 서버가 안 보이고, 메타데이터가 404

    왜
    EC2에 인스턴스 역할이 안 붙어 있었어요. 169.254.169.254의 iam/security-credentials/가 404였고, SSM Agent 로그에 no EC2 instance role found, 30분마다 다시 시도한다고 나왔어요.
    어떻게
    콘솔에서 backend-visualized-ec2-ssm 역할을 붙이고 sudo snap restart amazon-ssm-agent → 30분을 안 기다리고 바로 Credentials ready.
  5. 서버가 main을 지켜보다가 알아서 배포하는 줄 앎

    처음엔 서버가 GitHub을 보고 있다가 새 커밋을 당겨 가는 줄 알았어요. 실제로는 반대로, GitHub Actions가 시작해서 SSM으로 deploy.sh <SHA>를 보내요. 서버는 명령을 기다리기만 해요.

    자세히

    그래서 서버에는 주기적으로 GitHub을 확인하는 작업이 없어요. 어떤 커밋을 배포할지도 Actions가 정해서 SHA로 넘기고, 배포 기록은 Actions 실행 목록에 모여요.

  6. 이미지와 컨테이너, Compose 명령이 처음엔 헷갈림

    예전엔 이미지만 빌드해서 매니지드 플랫폼(카카오클라우드 PlayMCP)에 올리면 끝이었어요. 이번엔 서버를 직접 운영하니까 이미지(틀)와 컨테이너(틀로 찍어낸 것)를 나눠서 생각해야 했어요.

    자세히

    처음엔 "이미지를 올리면 돌아간다"로만 알았어요. 실제로는 같은 이미지 하나로 컨테이너를 여러 개 띄울 수 있고, 이 서버도 같은 이미지 하나로 색마다 두 개씩, api_blue_1 · api_blue_2 · api_green_1 · api_green_2를 띄워요.

    Compose는 여러 컨테이너를 한 파일(docker-compose.prod.yml)로 다뤄요. up은 만들기 · 바꾸기(이미지나 설정이 바뀌면 다시 만듦), stop · start는 전원만 끄고 켜기, down은 지우기예요. 그래서 배포는 새 색을 up -d --no-deps --force-recreate로 만들고, 예전 색은 stop만 해요.

  7. API는 4000번에서 도는데 밖에서 :4000으로는 안 열림

    왜
    API 컨테이너는 Docker 안쪽 네트워크에만 4000을 열고, 보안 그룹도 4000을 막아요. 밖에서 API로 들어오는 길은 Caddy의 443 하나예요.
    어떻게
    일부러 그렇게 뒀어요. Caddy가 HTTPS를 받아서 안쪽 api_blue_1:4000 · api_blue_2:4000으로 나눠 넘겨 줘요(리버스 프록시). API는 인증서를 몰라도 되고, 밖에서 X-Forwarded-For를 꾸며 바로 들어올 길도 없어요.
  8. Caddy는 api_green_1:4000을 어떻게 찾을까?

    처음엔 컨테이너 IP를 어딘가 적어 줘야 하는 줄 알았어요. 실제로는 Compose 서비스 이름이 Docker 내부망의 DNS 이름이 돼요.

    자세히

    Docker에 내장된 DNS가 api_green_1을 그 컨테이너의 내부 IP로 바꿔 줘요. docker ps에 보이는 backend-visualized-api_green_1-1은 Compose가 붙인 표시용 이름이에요.

    4000은 DNS가 알려 주는 값이 아니라 upstream.caddy에 제가 직접 적은 포트예요. 꺼져 있는 색은 DNS에 안 잡혀서, getent hosts api_green_1로 지금 떠 있는지 확인할 수 있었어요.

  9. RedisService에 get · set이 없는데 어디서 Redis로 가지?

    처음엔 Redis로 가는 코드가 안 보였어요. 실제로는 RedisService가 ioredis의 Redis를 상속(extends Redis)해서, get · set은 라이브러리에서 그대로 물려받은 메서드였어요.

    자세히

    생성자의 super(redisUrl, { lazyConnect: true, … })가 연결을 만들고, 첫 명령 때 실제로 접속해요. 그래서 Redis가 꺼져 있어도 API는 떠요.

    Redis 자체 설정은 코드가 아니라 docker-compose.prod.yml의 redis command에 있어요. 메모리 64mb, 넘치면 allkeys-lru로 오래 안 쓴 키부터 버리고, 디스크에는 저장하지 않아요. 코드는 거기에 접속하는 클라이언트일 뿐이에요.

    6장 캐시는 cache-aside예요. GET → 없으면 DB → SET … EX 5.

  10. 고정 IP가 있는데 왜 https://<IP>가 아니라 sslip.io?

    왜
    Vercel 화면은 https라서 API를 http로 부르면 브라우저가 막아요(mixed content). 그런데 Caddy가 기본으로 받는 Let’s Encrypt 인증서는 도메인 이름이 있어야 해요.
    어떻게
    1-2-3-4.sslip.io처럼(IP 1.2.3.4의 점을 -로) IP를 이름으로 바꿔 주는 무료 DNS를 쓰고, Caddy가 그 이름으로 인증서를 받아요. 제 도메인이 생기면 API_DOMAIN 한 줄만 바꾸면 돼요.
  11. 화면(vercel.app)과 API(sslip.io)가 다른 사이트: CORS와 로그인 쿠키

    왜
    API가 허락한 주소가 아니면 브라우저가 응답을 화면에 안 넘겨요(CORS). 로그인을 이어 주는 리프레시 쿠키도 다른 사이트로 보내려면 SameSite=None; Secure가 있어야 하고, Safari는 원래 제3자 쿠키를 막아서(ITP) 새로고침하면 풀릴까 걱정했어요.
    어떻게
    VERCEL_ORIGIN에 Vercel 주소를 넣고 preflight(OPTIONS)로 허락 헤더를 확인했어요. 쿠키는 SameSite=None; Secure; Partitioned. 실제 Safari에서 새로고침해도 로그인이 유지됐어요(Partitioned 덕분으로 보여요). "모든 쿠키 차단" 같은 엄격한 설정이나 오래된 버전에서는 여전히 막힐 수 있어서, 제 도메인으로 같은 사이트를 만들고 SameSite=Lax로 바꾸는 게 가장 튼튼해요.
  12. Elastic IP로 바꾼 뒤 Workbench가 갑자기 접속 실패 (API도 잠깐 503)

    왜
    Workbench의 SSH Hostname이 아직 예전 IP를 가리키고 있었어요. API는 새 주소로 다시 뜨는 동안 Caddy가 잠깐 503을 돌려줬어요.
    어떻게
    Workbench의 SSH Hostname을 새 IP로 바꿨고, 503은 API가 healthy가 되자 사라졌어요. IP를 바꾸면 sslip.io 주소, Vercel, Workbench, ssh 명령을 한 번에 같이 고쳐야 해요.
  13. MySQL Workbench가 서버 MySQL에 접속을 못 함

    왜
    MySQL 컨테이너는 Docker 안쪽 네트워크에만 열려 있었어요. 보안 그룹도 3306을 막고 있어서 바깥에서는 길이 아예 없었어요(그게 맞는 상태였어요).
    어떻게
    3306을 인터넷에 여는 대신 127.0.0.1:3306:3306으로 서버 자기 자신에만 열고, Workbench는 Standard TCP/IP over SSH로 SSH 터널을 타고 들어가요.
  14. caddy reload: connection refused (첫 blue-green 배포 실패)

    왜
    그 배포에서만 Caddy 컨테이너가 새로 만들어졌는데, 관리 API(:2019)가 뜨기 1~2초 전에 reload를 불러서 거절당했어요. 서비스는 이미 새 쪽을 보고 있었고, 끊긴 건 Caddy가 다시 뜨는 순간의 요청 4번(약 0.8초)이었어요.
    어떻게
    deploy.sh가 reload를 1초 간격으로 최대 15번 다시 시도하게 고쳤어요. 그 뒤 배포는 전부 통과했어요.
  15. deploy/caddy/upstream.caddy가 저장소에 없음

    왜
    이 파일은 "지금 어느 색이 서비스 중인가"라는 서버의 실행 상태예요. git에 넣어 두면 배포 때마다 하는 git reset --hard origin/main이 지금 색을 커밋된 값으로 되돌려 버려요.
    어떻게
    .gitignore에 넣고 deploy.sh가 배포 때마다 만들어요(임시 파일에 쓰고 mv로 한 번에 바꿈). 파일이 없으면 첫 배포로 보고 blue부터 띄워요.
  16. 서버가 재부팅되면 다시 배포해야 하나?

    처음엔 재부팅하면 손으로 다시 띄워야 하는 줄 알았어요. 실제로는 모든 컨테이너에 restart: unless-stopped가 있어서, Docker가 부팅 때 켜지면서 돌던 컨테이너를 다시 띄워요.

    자세히

    배포 때 stop으로 끈 반대 색은 "직접 끈 것"이라 꺼진 채로 남아요. upstream.caddy도 디스크에 남아 있어서 Caddy는 원래 보던 색으로 보내요.

    MySQL · Redis는 deploy.sh가 --no-deps로 건드리지 않고, 재시작 정책에 맡겨요.

  17. Actions 목록엔 1분 12초인데 페이지엔 37~51초?

    왜
    Actions 목록의 시간은 워크플로 전체(커밋 확인 + 이미지 굽기 + 서버 배포)이고, 37~51초는 그중 서버 배포 job만이에요. 처음엔 이 둘을 섞어 적어서 읽는 사람이 헷갈렸어요.
    어떻게
    숫자를 나눠 적었어요: 전체 약 1분 10초(API 코드가 바뀌면 2분 23초), 서버 배포 job 135초 → 37~51초.
  18. force-static이면 빌드가 막아 줄 줄 알았음

    API가 필요 없는 메인 · About · 배포 페이지를 SSG로 고정하려고 처음엔 force-static을 넣었어요. 실제로는 요청 시점 코드(cookies(), headers())가 들어와도 빌드를 실패시키지 않고 빈 값으로 굳혀요.

    자세히

    빌드에서 바로 잡으려면 dynamic = 'error'여야 해서 바꿨어요. 임시로 headers()를 넣어 보니 빌드가 /about 에러로 멈추는 걸 확인하고 지웠어요.

  19. PG가 빨리 답하면 결제 정리 잡은 필요 없지 않나?

    처음엔 PG가 빨리 답하면 정리할 게 없고, 정리 잡은 따로 도는 스케줄 서버인 줄 알았어요. 실제로는 API 컨테이너 안에서 @nestjs/schedule의 @Cron으로 1분마다 도는 기능이에요. 그래서 PG 속도는 기준 시간만 바꾸고, 정리 잡 자체는 있어야 해요.

    자세히

    결제를 받은 컨테이너가 PG 답을 기다리다 죽으면, "PG가 승인했다"(paid)를 DB에 적을 주체가 사라져요. 그 행은 processing으로 계속 남아요.

    컨테이너는 restart: unless-stopped로 다시 떠요 (green이 죽으면 green이 다시 뜨고, 꺼 둔 blue가 대신 뜨지는 않아요). 다시 뜬 컨테이너 안의 정리 잡이 5분 넘은 processing을 failed로 닫아요. 5분은 PG 타임아웃에 여유를 더한 값이라, PG가 느리면 이 숫자만 늘리면 돼요.

    배포 중 blue · green이 잠깐 같이 떠서 정리 잡이 두 번 돌아도 괜찮아요. UPDATE … WHERE status = 'processing' AND createdAt < 기준 한 문장이라 먼저 바꾼 쪽만 1행, 다른 쪽은 0행이에요.

    데모의 가짜 PG는 승인 기록이 없으니 바로 failed로 닫아요. 진짜 PG라면 닫기 전에 결제사 조회 API로 승인됐는지 물어보고 paid / failed를 정해야 해요.

  20. k6로 800 VU까지 몰면 서버가 죽을 줄 알았음

    처음엔 사람이 많이 몰리면 메모리가 터져서 컨테이너가 죽을 줄 알았어요. 실제로는 죽지 않고 느려졌어요. 처리량은 약 620 req/s에서 더 오르지 않고, 그 뒤로 온 요청은 줄을 서서 p95가 0.12초 → 10초까지 늘었어요. 그래서 다음 단계는 메모리가 아니라 CPU 코어를 더 쓰는 쪽이에요.

    자세히

    docker stats에서 API 컨테이너 CPU가 100~120%에 머물렀어요. t4g.small은 2코어라 최대 200%인데, Node는 요청을 이벤트 루프 하나(코어 하나)로 처리해서 한 코어가 꽉 차면 거기서 멈춰요. 메모리는 여유가 있었고 재시작 0번, OOMKilled 없음, MySQL 최대 연결 14개, 느린 쿼리 0개였어요.

    언제부터 느려졌나: 50 VU까지 p95 약 0.12초였고, 100 VU에서 처리량이 약 620 req/s로 멈추면서 p95가 0.46초로 올랐어요. 200 VU 1.3초, 400 VU 3.1초, 800 VU에서는 10초 시간 초과가 4~6% 나왔어요. 실패는 전부 시간 초과였고 500은 0번이었어요.

    그래서 7장에서 색마다 API를 2개 띄워 Caddy가 나눠 보내게 했어요(blue-green 구조에 복제본만 추가). 쿠폰 · 포인트 실험의 "한 번에 하나" 잠금은 프로세스 메모리에서 Redis로 옮겼어요. 2개로 늘린 뒤 같은 조건으로 다시 재 보니 처리량은 약 640 req/s로 거의 그대로였고, 800 VU의 p95가 7~10초 → 1.9초, 시간 초과가 0%로 줄었어요. 이번엔 컨테이너 CPU 합이 약 180% / 200%였어요(API 둘이 각각 50~65%, Caddy 35~50%, MySQL 25~40%). 다음 병목은 Caddy · DB와 나눠 쓰는 서버 CPU예요. 자주 읽는 /events/stats의 Redis 캐시는 아직이에요.

    조건: 읽기 요청만(/health + /events/stats), 내 맥 한 대에서, VU는 쉬지 않고(0.05초 간격) 보내서 VU 1명 ≠ 사람 1명이에요. 이 서버 한 대의 작은 측정이지, "동시 사용자 몇 명까지 된다"는 뜻은 아니에요. 스크립트는 load/breakpoint.js.

7. 회고

저는 그동안 프론트엔드만 했어요. 서버는 "API 주소"로만 알고 있었고, 배포는 버튼을 누르면 되는 일이었어요. 이번에 처음으로 그 버튼 뒤를 직접 만들었어요.

어려웠던 것

  • 화면에서는 버튼을 누르면 바로 결과가 보이는데, 서버는 실패해도 아무 말이 없었어요. OIDC가 거절됐을 때도, SSM에 서버가 안 보일 때도 로그 한 줄을 찾기 전까지는 뭐가 틀렸는지 몰랐어요.
  • AWS 콘솔은 메뉴가 너무 많아서, "어디서 고치는지" 찾는 데 실제로 고치는 시간보다 더 오래 걸렸어요.
  • 권한을 넓게 주면 금방 되는 걸 알면서도 좁게 주려니까 정책 JSON을 한 줄씩 이해해야 했어요.

프론트엔드 개발자로서 배운 것

  • 프론트에서 쓰던 "배포 버튼"이 실제로는 빌드, 이미지 저장, 권한 확인, 명령 전달, 건강 확인, 문 바꿔 달기로 쪼개진다는 걸 직접 만들어 보고 알았어요.
  • "돌아간다"와 "운영할 수 있다"는 달랐어요. 재부팅 테스트, 0.2초마다 /health 찌르기, 복원 테스트처럼 일부러 깨 보고 숫자로 확인해야 마음이 놓였어요.
  • 비밀값은 처음부터 서버 안에서 만들고, 화면에는 이름만 찍는 습관이 생겼어요.
  • CORS, SameSite 쿠키처럼 프론트에서 "서버가 막아요"라고 넘기던 것들이 서버 쪽에서는 한 줄 설정이라는 것, 그리고 그 한 줄의 의미를 이제는 설명할 수 있어요.

다음에 할 것

  • 제 도메인 사기: 화면과 API를 같은 사이트로 묶어 엄격한 쿠키 설정에서도 로그인이 유지되게 하고, sslip.io 의존도 없애기
  • 롤백 연습: 일부러 예전 커밋으로 되돌려 보고 몇 초 걸리는지 재기
  • 모니터링과 알림: 서버가 죽거나 디스크·메모리가 차면 메일이 오게 (CloudWatch 경보)
  • GitHub Actions의 Node 20 경고 정리: checkout, build-push, login, setup-buildx 액션 버전 올리기
  • S3에서 실제로 내려받아 복원해 보기: 지금은 서버 안의 백업으로만 복원 테스트를 했어요

— 임철순