맥북 서버 탈출기

[맥북 서버 탈출기 8편·완] 배포는 한 줄, 검사는 자동, 비용은 0원: deploy.sh와 CI, 그리고 영수증

TerryDev 2026. 10. 16. 00:01

이 글 요약

  • ./deploy.sh 한 줄로 테스트 → 빌드 → 업로드 → 재시작 → 헬스체크. 실패하면 직전 버전으로 알아서 되돌린다
  • GitHub Actions로 세 저장소에 CI를 붙였다. 붙이는 과정에서 "통과한 줄 알았던 테스트"가 사실은 돌지도 않았다는 걸 알게 됐다
  • 오라클과 Cloudflare 청구 화면으로 0원을 확인하고, 0원을 지키는 안전장치 세 겹을 정리한다. 시리즈를 마무리한다

7편까지로 이사는 끝났다. 서버는 돌고, 데이터는 옮겨졌고, 백업은 매일 새벽 R2로 간다.
남은 건 이사 후의 생활이다. 코드를 고치면 서버에 어떻게 올리는지, 고친 코드가 망가지지 않았는지 누가 확인하는지, 그리고 이 모든 게 정말 공짜인지. 마지막 편은 이 세 가지다.


1. 배포: 명령어 한 줄로

맥북 시절의 배포

맥북 시절의 배포 순서는 이랬다.

  1. 터미널에서 빌드한다.
  2. launchctl로 앱을 내렸다 올린다.
  3. 앱을 열어서 되는지 본다.

테스트는 생각날 때 돌렸다. 배포한 버전이 무엇인지 기록하지 않았다. 새 버전이 안 뜨면 이전 버전으로 돌아갈 방법도 없었다. 이전 jar 파일은 빌드할 때 덮어써졌다.
서버로 옮기면서 이걸 스크립트 하나로 묶었다.

deploy.sh가 하는 일

./deploy.sh
  ├─ 1. 커밋 안 된 변경 확인        (있으면 물어본다)
  ├─ 2. 테스트 + jar 빌드           (테스트 실패하면 여기서 멈춘다)
  ├─ 3. 서버로 jar 업로드
  ├─ 4. 지금 돌던 jar를 app.jar.prev로 보관
  ├─ 5. 새 jar로 교체 + 재시작
  ├─ 6. 헬스체크 (최대 90초)
  │     ├─ 200 → ✅ 배포 완료, 버전 기록
  │     └─ 실패 → 로그 30줄 출력 + app.jar.prev로 자동 복구
  └─ (따로) ./deploy.sh --rollback   언제든 직전 버전으로

실제로 돌리면 이렇게 나온다. 테스트부터 헬스체크까지 51초 걸렸다.

▶ 테스트 + 빌드
▶ 업로드 ( 67M)
▶ 교체 + 재시작
✅ 배포 완료: 7b96749 2026-10-15 01:19:59

핵심 부분 설명

1) 커밋 안 된 변경이 있으면 멈춘다

if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
  echo "⚠️  커밋 안 된 변경이 있음 — 배포본과 git 기록이 어긋난다."
  read -r -p "그래도 배포할까요? [y/N] " ans; [ "$ans" = y ] || exit 1
fi

커밋하지 않은 코드로 배포하면, 서버에서 돌고 있는 코드가 git 어디에도 없다. 문제가 생겨도 그 코드를 다시 찾을 수 없다. 그래서 묻는다.

--untracked-files=no에는 사연이 있다. 처음 만든 스크립트를 처음 돌렸더니 "커밋 안 된 변경이 있다"며 멈췄다. 그 커밋 안 된 파일은 deploy.sh 자기 자신이었다. 아직 git에 추가하지 않은 새 파일까지 변경으로 세고 있었던 것이다. 새 파일은 빼고 "수정된 파일"만 보게 고쳤다.

2) 어떤 버전이 돌고 있는지 서버에 적어 둔다

VERSION="$(git rev-parse --short HEAD) $(date '+%F %T')"
echo "$VERSION" | sudo tee /opt/powerup/VERSION

서버에서 cat /opt/powerup/VERSION 하면 지금 어떤 커밋이 언제 배포됐는지 바로 나온다. "지금 서버에 뭐가 돌고 있지?"라는 질문에 기억이 아니라 파일이 답한다.

3) 직전 버전을 남겨 둔다

sudo cp /opt/powerup/app.jar /opt/powerup/app.jar.prev

새 jar로 바꾸기 전에 지금 돌던 jar를 복사해 둔다. 이 파일 하나가 롤백의 전부다.
4) 헬스체크: 진짜로 떴는지 확인한다

for i in $(seq 1 30); do
  [ "$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8080/api/v1/health)" = 200 ] && exit 0
  sleep 3
done; exit 1

systemctl restart는 "재시작 명령을 받았다"는 뜻이지 "앱이 정상으로 떴다"는 뜻이 아니다. Spring Boot는 뜨는 데 10초 넘게 걸리고, 설정이 틀리면 뜨다가 죽는다. 그래서 3초마다 헬스체크 주소를 찔러 보고, 90초 안에 200이 나오면 성공으로 본다.

5) 실패하면 알아서 되돌린다 헬스체크가 끝내 실패하면 스크립트가 서버 로그 마지막 30줄을 보여 주고, app.jar.prev로 되돌린 뒤 다시 헬스체크를 한다. 배포가 실패해도 앱은 몇십 초 안에 원래 버전으로 돌아온다. 내가 터미널 앞에서 당황하는 시간과 상관없이.

./deploy.sh --rollback도 실제로 돌려서 직전 버전으로 돌아가는 걸 확인했다. 롤백은 쓸 일이 없기를 바라는 기능이지만, 쓸 일이 생겼을 때 처음 돌려 보는 기능이어서는 안 된다.

러브투데이에도 같은 스크립트를 포트와 헬스체크 주소만 바꿔서 넣었다.


2. CI: 코드를 올릴 때마다 자동 검사

CI가 뭔가

CI(Continuous Integration)는 코드를 GitHub에 올릴 때마다 남의 컴퓨터에서 테스트를 자동으로 돌려 주는 것이다. GitHub Actions는 공개 저장소는 무료이고, 개인(비공개) 저장소도 매달 일정 시간까지는 무료다. 개인 프로젝트 몇 개는 그 안에 넉넉히 들어간다.
"내 컴퓨터에서는 되는데"를 막는 장치다. 내 맥에만 깔려 있는 무언가에 기대고 있던 코드는 깨끗한 CI 서버에서 바로 드러난다.

세 저장소에 붙인 것

저장소 검사 내용
PowerUp 백엔드 (Spring Boot) mvn test (테스트 184개, H2 인메모리 DB)
PowerUp 앱 (Expo, TypeScript) tsc --noEmit 타입 검사 + jest 테스트 900여 개
LoveToday (백엔드 + 앱) 백엔드 빌드·테스트 + 앱 타입 검사

백엔드 워크플로는 이게 전부다.

# .github/workflows/ci.yml
name: CI
on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: '21'
          cache: maven
      - run: ./mvnw -B -q test
줄 뜻
on: push / pull_request main에 올리거나 PR을 열 때마다 실행
runs-on: ubuntu-latest GitHub이 빌려주는 깨끗한 리눅스에서 실행
setup-java ... cache: maven Java 21 설치, 라이브러리는 캐시해서 다음부터 빠르게
./mvnw -B -q test 테스트 실행. 하나라도 실패하면 빨간불

테스트에 외부 DB가 필요 없도록 H2 인메모리 DB를 쓰는 테스트 설정이 이미 있어서, CI 서버에 MySQL을 띄울 필요가 없었다.

붙이다가 알게 된 것 1: 테스트는 돌지도 않고 있었다

CI를 붙이기 전에 맥에서 테스트를 돌려 봤다. 통과했다. 그런데 출력을 자세히 보니 이상한 줄이 있었다.

오류: 기본 클래스 org.codehaus.plexus.classworlds.launcher.Launcher을(를) 찾거나 로드할 수 없습니다.

Maven 실행 도구(Maven wrapper)의 캐시가 깨져 있어서 테스트가 아예 실행되지 않고 있었다. 그런데 예전에 돌렸던 테스트 결과 파일이 target 폴더에 남아 있어서, 결과만 보면 통과한 것처럼 보였다.

깨진 캐시를 지우고 다시 받으니 테스트가 진짜로 돌았고, 다행히 진짜로 통과했다. 언제부터 테스트가 안 돌고 있었는지는 모른다. CI가 처음부터 있었다면 그날 바로 알았을 것이다. 깨끗한 서버에는 남아 있는 옛 결과 파일이 없으니까.
6편의 "0개 테이블 일치"와 같은 종류의 함정이다. 초록불을 볼 때는 무엇이 실행됐는지를 같이 봐야 한다.

붙이다가 알게 된 것 2: CI 서버는 내 맥보다 느리다

앱 저장소의 첫 CI는 빨간불이었다. 실패한 테스트 2개를 보니 버그가 아니라 시간 초과였다. 화면 전체를 그려 보는 무거운 테스트가 jest 기본 제한(5초)을 넘긴 것이다. GitHub의 무료 CI 서버는 M 시리즈 맥보다 느리다.

# 러너가 느려 화면을 통째로 그리는 테스트가 기본 5초 제한을 넘는다 — CI에서만 여유를 준다
- run: npx jest --maxWorkers=2 --testTimeout=30000

로컬 설정은 그대로 두고 CI에서만 제한을 30초로 늘렸다. 로컬에서는 느려진 테스트를 계속 빨리 알아챌 수 있게.

붙이다가 알게 된 것 3: 워크플로 파일은 권한이 따로 있다

워크플로 파일을 GitHub에 올리려는데 이런 오류가 났다.

refusing to allow an OAuth App to create or update workflow `.github/workflows/ci.yml` without `workflow` scope

GitHub는 CI 설정 파일을 올리는 권한을 일반 코드 권한과 따로 관리한다. CI 파일은 GitHub 서버에서 코드를 실행시키는 파일이라서다. 터미널에서 권한을 추가로 승인하면 된다. 브라우저 승인이 필요해서 이건 내가 했다.

gh auth refresh -h github.com -s workflow

CI가 실제로 잡은 것

CI를 붙인 다음 날, 앱 저장소에 다른 기능을 올렸더니 빨간불이 켜졌다.

FAIL app/__tests__/workoutPrevBasis.test.tsx
Tests:       1 failed, 892 passed, 893 total

893개 중 1개. CI가 없었다면 모르고 지나갔을 수도 있는 1개다. 다음 커밋에서 고쳐졌고 초록불로 돌아왔다. CI는 붙인 날이 아니라 이런 날을 위해 있다.


3. 비용: 정말 0원인가

이사의 첫 번째 목표는 "월 0원"이었다. 말로 확인하지 않고 청구 화면으로 확인했다.

오라클: 비용 분석

Billing & Cost Management → Cost Analysis에서 이번 달 비용을 서비스별로 본다.

오라클 Cost Analysis: 10월 비용 없음

"There are no costs associated..." 이번 달에 청구된 비용이 없다.

Cloudflare R2: 사용량

R2 사용량: 청구 $0.00, 저장 10MB대

청구 사용량 $0.00. 이 화면을 찍은 날 기준 저장 공간은 10MB 남짓, 쓰기 작업 91번, 읽기 작업 51번이다. 무료 한도는 저장 10GB, 쓰기 월 100만 번, 읽기 월 1,000만 번이다.

사용량 대 무료 한도

오라클 A1 서버2 OCPU / 12GB2 OCPU / 12GB한도에 딱 맞춤
오라클 디스크100GB200GB절반
오라클 공인 IP임시(Ephemeral) 1개무료 
오라클 외부 전송량월 수 GB월 10TB여유 많음
Cloudflare Tunnel, DNS사용무료 
R2 저장 공간약 67MB (사진까지)10GB1% 미만
R2 요청하루 백업 1번쓰기 100만 / 읽기 1,000만 (월)여유 많음
GitHub Actions푸시할 때마다비공개 저장소도 월 일정 시간 무료 

0원을 지키는 안전장치 세 겹

지금 0원인 것과 앞으로도 0원인 것은 다르다. 실수 한 번이면 유료 자원이 생긴다. 그래서 세 겹으로 막았다.

1오라클 쿼터 정책 (4편)무료 범위를 넘는 자원은 아예 만들어지지 않는다
2오라클 예산 알림 $11달러라도 청구되면 메일이 온다
3R2 예산 알림 $1R2도 1달러를 넘으면 메일이 온다

오라클 Budgets: free-guard, 월 $1

예산 알림은 기본값이 크게 잡혀 있을 수 있다. 내가 만들 때 R2 예산 알림 창의 기본값은 10달러였다. 0원을 지키려는 사람에게 10달러 알림은 너무 늦다. 1달러로 바꿨다.

종량제(PAYG)와 유휴 회수

오라클 무료 계정은 7일 동안 CPU, 네트워크, 메모리 사용률이 계속 낮으면 인스턴스를 회수할 수 있다. 개인 앱 서버는 대부분의 시간에 한가하니 대상이 될 수 있다.
종량제(PAYG) 계정은 이 회수 대상에서 빠진다. 내 계정은 이미 종량제였다. PAYG로 바꿔도 Always Free 범위 안의 자원은 계속 무료다. 대신 유료 자원도 만들 수 있게 되니, 위의 쿼터 정책과 예산 알림이 필요한 것이다.
이 부분에서 에이전트가 한 번 틀렸다. 유휴 회수를 막으려면 PAYG로 전환하라며 방법을 설명했는데, 나는 "지금 종량제일걸?"이라고 답했다. 확인해 보니 맞았다. 에이전트는 수긍했고, 대화는 거기서 끝났다.


시리즈를 마치며

이사 전과 후

  맥북 시절 지금
서버 책상 위 맥북 에어 오라클 ARM 서버 (2코어, 12GB)
재부팅하면 앱이 죽는다 systemd가 자동으로 다시 띄운다
외부 연결 설정 파일 없이 메모리로 버티던 터널 대시보드 관리 터널, 열린 포트는 SSH 하나
백업 없음 매일 암호화 백업, 서버 밖 R2, 복원 테스트 완료
배포 손으로 빌드하고 재시작 ./deploy.sh, 실패하면 자동 복구
검사 생각날 때 (사실은 안 돌고 있었음) 푸시할 때마다 CI
비용 0원 (전기세와 맥북 수명 별도) 0원, 안전장치 세 겹
앱 중단 시간   이사하는 동안 앱당 3~4분

남은 숙제

  • DB 변경 관리: 운영 DB가 아직 JPA의 ddl-auto: update로 테이블을 고친다. 컬럼 이름을 바꾸는 날 사고가 난다. 그 전에 Flyway 같은 마이그레이션 도구를 넣어야 한다.
  • 백업 실패 알림: 백업이 실패하면 지금은 서버 로그에만 남는다. 실패했을 때 메일이나 알림이 오게 해야 한다. 아무도 보지 않는 로그는 없는 로그와 같다.
  • 맥북 정리: 맥에 남은 옛 터널과 서비스들. 이제는 정말 지워도 된다.

에이전트와 일한다는 것

이 시리즈의 작업 대부분은 Claude Code가 했다. 서버 명령을 실행하고, 클라우드 콘솔을 클릭하고, 스크립트를 쓰고, 이 글의 초안까지 썼다. 8편 동안 정리해 보니 역할은 이렇게 나뉘었다.

오라클 콘솔 클릭, 서버 명령, 스크립트 작성로그인
오류 원인 추적 (쿼터 정책, 0개 일치, 덤프 실패)"진행해줘"
되돌리는 방법을 먼저 설명하고 확인 요청확인
결제 버튼 앞에서 멈추기결제 버튼 누르기
비밀번호를 서버 밖에도 보관하라고 반복하기암호 앱에 저장하기
시키지 않은 복원 테스트결과 보고 안심하기

에이전트가 일을 잘했다고 해서 내가 할 일이 없어지지는 않았다. 무엇을 할지 정하는 것, 되돌릴 수 없는 버튼을 누르는 것, 돈을 내는 것은 끝까지 내 몫이었다. 오히려 에이전트가 그 경계를 정확히 지켰기 때문에 맡길 수 있었다. 사람이 하든 에이전트가 하든 사고는 난다. 차이는 사고가 났을 때 되돌릴 길이 있느냐다. 이번 이사에서 가장 많이 한 일은 결국 되돌릴 길을 먼저 만드는 일이었다.


오늘의 교훈 0원짜리 서버의 진짜 비용은 운영이다. 배포, 검사, 백업, 감시를 자동으로 만들어 두지 않으면 그 비용은 내 시간으로 청구된다.

맥북은 이제 노트북으로 돌아갔다. 덮개를 닫아도 아무 일도 일어나지 않는다. 몇 달 만에 처음이다.