GCS에서 Cloudflare R2로: 이미지 서비스까지 전부 옮긴 이야기


GCS egress 비용이 불안해지기 시작했습니다
Phase 104에서 GCP 서버리스를 VM 한 대로 합치면서 월 비용을 30만원대에서 10만원대로 줄였습니다. 그런데 한 가지 계속 걸리는 게 있었는데요. GCS(Google Cloud Storage) egress 비용입니다.
지금은 월 1만원 정도지만 GCS egress는 GB당 120원씩 나갑니다. 트래픽이 10배 늘면 비용도 10배. 교육 플랫폼이라 이미지가 많고, 수강생이 늘면 페이지뷰도 같이 올라가니까 언제 터질지 모르는 비용 구조였습니다.
VM 안에서는 Node.js/Sharp 기반 image-service가 Docker 컨테이너로 돌고 있었는데, 메모리 512MB를 차지하면서 하는 일이라곤 프로필 이미지 32x32 리사이징 정도였거든요. 16GB RAM에서 512MB면 3%인데, 그걸 위해 Node.js 코드를 따로 관리하고 있다는 게 좀 아까웠습니다.
전환 전 이미지 요청 흐름 ┌──────────┐ ┌───────────────┐ ┌─────────────┐ ┌──────┐ │ 브라우저 │────▶│ VM Nginx │────▶│ image-service│────▶│ GCS │ │ │ │ (프록시) │ │ (Node/Sharp) │ │ │ │ │◀────│ │◀────│ 512MB 메모리 │◀────│ │ └──────────┘ └───────────────┘ └─────────────┘ └──────┘ ↑ 매번 원본 다운로드 ↑ LRU 캐시 있지만 재시작하면 초기화
정리하면 이런 상황이었습니다:
| 문제 | 영향 |
|---|---|
| GCS egress 비용 | 트래픽에 비례해서 증가 |
| image-service 유지보수 | 512MB 메모리 + Node.js 코드 관리 |
| DDoS 방어 없음 | VM IP가 직접 노출 |
Cloudflare를 선택한 이유
파일 스토리지를 옮기려면 선택지가 몇 개 있습니다.
┌────────────────┬──────────────┬────────────────┬──────────────────────┐ │ 스토리지 │ egress 비용 │ CDN │ 비고 │ ├────────────────┼──────────────┼────────────────┼──────────────────────┤ │ AWS S3 │ GB당 ~130원 │ CloudFront 별도│ GCS랑 비슷한 구조 │ │ Cloudflare R2 │ 0원 │ 무료 CDN 포함 │ S3 호환 API │ │ Backblaze B2 │ 0원(CF 연동) │ CF 필요 │ 설정이 복잡 │ │ MinIO (자체) │ 0원 │ 없음 │ VM 리소스 사용 │ └────────────────┴──────────────┴────────────────┴──────────────────────┘
R2를 고른 이유는 단순합니다. egress가 0원이고, Cloudflare DNS/CDN/Worker를 같이 쓸 수 있고, S3 호환 API라서 AWS SDK를 그대로 쓸 수 있거든요. 백엔드 코드에서 GcsStorageService를 R2StorageService로 바꾸면 끝입니다.
거기다 Cloudflare 무료 플랜에 DNS, CDN, DDoS 방어, SSL이 전부 포함되어 있습니다. GCP Cloud DNS(월 1천원)와 Let's Encrypt(certbot 관리)를 따로 쓰고 있었는데, 그것도 같이 정리가 됩니다.
전환 후 아키텍처
전환 후 전체 구조 ┌─────────────────────────────────────────┐ │ Cloudflare Edge (무료) │ 사용자 ────▶ DNS ──▶│ CDN + DDoS 방어 + SSL 자동 │ │ │ │ www/api ──(프록시)──▶ VM Origin │ │ storage ──(R2 커스텀 도메인)──▶ R2 버킷 │ │ image ──(Worker)──▶ R2 버킷 │ └──────────────────┬──────────────────────┘ │ 프록시 ▼ ┌──────────────────────────────────────────┐ │ VM (34.64.156.69) │ │ ┌────────┐ ┌──────────┐ ┌───────────┐ │ │ │ Nginx │ │ Backend │ │ Frontend │ │ │ │ (443) │▶│ (8080) │ │ (3000) │ │ │ └────────┘ └──────────┘ └───────────┘ │ │ ┌───────────┐ │ │ │ MySQL │ ← image-service 삭제 │ │ │ (3306) │ │ │ └───────────┘ │ └──────────────────────────────────────────┘
달라진 점은, 우선 VM IP가 Cloudflare 프록시 뒤로 숨었습니다. image-service 컨테이너는 삭제하고 Cloudflare Worker가 그 역할을 대신합니다. GCS 의존도 R2로 완전히 이전했고요 (VOD만 GCS에 남겨뒀습니다).
비용 변화
이번 전환으로 줄어든 금액 자체는 Phase 104(30만→10만)에 비하면 크지 않습니다. 대신 구조가 바뀐 게 더 의미 있습니다.
비용 비교 (월 기준) ┌──────────────────────────┬──────────┬──────────┐ │ 항목 │ 전환 전 │ 전환 후 │ ├──────────────────────────┼──────────┼──────────┤ │ VM (e2-highmem-2, 약정) │ 7만원 │ 7만원 │ │ GCS 저장소 + egress │ 1만원 │ → 0원 │ │ GCP Cloud DNS │ 1천원 │ → 0원 │ │ Cloudflare R2 │ - │ 200원 │ │ Cloudflare Worker/DNS/CDN │ - │ 0원 │ │ GCP 잔여 (VOD 등) │ 2만원 │ 2만원 │ ├──────────────────────────┼──────────┼──────────┤ │ 합계 │ ~10만원 │ ~9만원 │ │ 추가 혜택 │ - │ CDN,DDoS │ └──────────────────────────┴──────────┴──────────┘ 절감: ~1만원/월 + CDN/DDoS 방어/SSL 자동 관리 (이건 돈으로 환산하기 어려움)
핵심은 트래픽이 늘어나도 비용이 안 늘어난다는 겁니다. GCS egress는 트래픽 비례인데, R2 egress는 무제한 무료거든요. 수강생이 10배 늘어도 스토리지 비용은 저장 용량만큼만 올라갑니다.
| 이미지 수 | GCS egress 예상 | R2 비용 |
|---|---|---|
| 1,000장 | ~1만원 | ~100원 |
| 5,000장 | ~5만원 | ~200원 |
| 10,000장 | ~10만원 | ~300원 |
Cloudflare Worker: 100줄짜리 image-service
기존 image-service는 Node.js + Express + Sharp로 600줄짜리 코드에 Docker 이미지까지 관리해야 했습니다. 새로 만든 Worker는 120줄 정도고요.
하는 일은 단순합니다. 프리셋 이름을 받아서 R2에서 이미지를 꺼내 서빙하는 게 전부입니다.
Worker 처리 흐름 GET /profile-md/users/123/avatar.jpg │ ▼ ┌─────────────────┐ │ preset 파싱 │ "profile-md" → { width:96, height:96, fit:'cover' } │ path 추출 │ "users/123/avatar.jpg" └────────┬────────┘ │ ▼ ┌─────────────────┐ │ R2 binding │ env.UPLOADS_BUCKET.get("users/123/avatar.jpg") │ (내부 네트워크) │ ← egress 0원 └────────┬────────┘ │ ▼ ┌─────────────────┐ │ 응답 헤더 설정 │ Cache-Control: public, max-age=31536000 │ ETag/304 지원 │ CDN 글로벌 캐싱 └─────────────────┘
Worker 배포도 npx wrangler deploy 한 줄입니다. Docker 빌드, 이미지 푸시, 컨테이너 재시작... 이런 과정이 전부 없어집니다.
현재 Free 플랜이라 이미지 리사이징은 못 쓰고 원본을 그대로 서빙하고 있습니다. Pro 플랜($20/월)으로 올리면 cf.image 옵션으로 엣지에서 리사이징이 되는데, 아직 그 정도 트래픽이 아니라서 나중으로 미뤘습니다. 코드는 주석으로 준비만 해뒀고요.
DB URL 마이그레이션: COMMIT을 빼먹으면 생기는 일
파일만 R2로 옮기면 끝이 아닙니다. DB에 저장된 이미지 URL도 전부 바꿔야 하거든요.
-- 16개 테이블, 22개 컬럼을 변환 UPDATE unified_file SET url = REPLACE(url, 'https://storage.fullstackfamily.com/', 'https://storage.fullstackfamily.com/') WHERE url LIKE '%storage.googleapis.com/fullstackfamily-uploads/%';
이걸 16개 테이블에 대해 반복합니다. 직접 URL 컬럼 11개, 마크다운 본문 안의 인라인 이미지 URL 5개. START TRANSACTION으로 시작해서 검증 쿼리를 돌려본 후 COMMIT하는 구조인데요.
여기서 실수를 했습니다. SQL 파일에 COMMIT;이 주석 처리되어 있었거든요.
-- 모든 remaining이 0이면 COMMIT, 아니면 ROLLBACK -- COMMIT; ← 이게 주석... -- ROLLBACK;
트랜잭션 안에서 검증 쿼리를 돌리면 "remaining: 0"이 나옵니다. 잘 된 것처럼 보이죠. 그런데 COMMIT 없이 연결이 끊기면 MySQL이 자동으로 ROLLBACK합니다. 다음 세션에서 다시 확인했더니 GCS URL이 그대로 남아 있었습니다.
마이그레이션 SQL은 COMMIT을 실행 스크립트에 넣어두는 게 맞습니다. "수동으로 확인 후 커밋"은 빼먹기 딱 좋습니다.
또 하나 놓친 게 있었는데, fullstackfamily-uploads 버킷만 rclone으로 복사하고 fullstackfamily-dev-uploads 버킷을 깜빡한 겁니다. 개발 환경에서 올린 파일 29개가 빠져서 특정 블로그 글의 이미지가 깨졌습니다. 버킷이 여러 개면 전부 확인하는 게 기본인데, 이런 기본을 놓치더라고요.
파일 다운로드 CORS 문제와 해결
R2로 전환하면서 파일 다운로드 방식도 바꿨습니다. 기존에는 백엔드가 파일을 직접 프록시 스트리밍했는데, R2에서는 Presigned URL로 302 리다이렉트하는 방식으로 바꿨거든요.
그런데 이게 CORS 에러를 일으켰습니다.
문제가 된 흐름 프론트엔드 fetch() ─── GET /api/files/123/download ──▶ 백엔드 │ 302 Redirect │ 프론트엔드 fetch() ◀── Location: https://xxx.r2.cloudflarestorage.com/... │ ▼ fetch()가 자동으로 리다이렉트를 따라감 │ ▼ R2 엔드포인트에 cross-origin 요청 │ ▼ CORS 헤더 없음 → 브라우저 차단!
fetch()는 302를 자동으로 따라갑니다. 그래서 R2의 S3 API 엔드포인트로 cross-origin 요청이 가는데, 거기에 CORS 헤더가 없으니 브라우저가 차단합니다.
해결은 생각보다 간단했습니다. 302 리다이렉트 대신 JSON으로 URL을 반환하고, 프론트엔드에서 window.location.href로 이동하면 됩니다.
// 변경 전: 302 리다이렉트 (fetch가 따라가서 CORS 에러) return ResponseEntity.status(HttpStatus.FOUND) .location(URI.create(signedUrl)).build(); // 변경 후: JSON 응답 (CORS 문제 없음) return ApiResponse.success(Map.of("downloadUrl", signedUrl));
// 프론트엔드: JSON에서 URL 꺼내서 브라우저 네비게이션 const result = await response.json() window.location.href = result.data.downloadUrl // → 브라우저 직접 이동이라 CORS 제한 없음
window.location.href는 fetch()와 달리 브라우저 네비게이션이라 CORS 제한을 안 받습니다. Presigned URL에 Content-Disposition: attachment 헤더가 들어 있으니 파일명도 알아서 지정되고요.
GitHub Actions CI/CD 파이프라인
프론트엔드와 백엔드가 별도 GitHub 레포에 있고, 각각 독립적인 배포 워크플로를 가지고 있습니다. main 브랜치에 push하면 자동으로 빌드 → 이미지 푸시 → VM 배포가 진행됩니다.
배포 파이프라인 흐름 git push origin main │ ▼ ┌─────────────────────────────────────────┐ │ GitHub Actions Runner (ubuntu-latest) │ │ │ │ 1. 코드 체크아웃 │ │ 2. (백엔드만) ./gradlew test │ │ 3. GCP 인증 (Workload Identity) │ │ 4. Docker 이미지 빌드 │ │ 5. Artifact Registry에 이미지 push │ │ 6. SSH로 VM 접속 → 컨테이너 교체 │ └─────────────────────────────────────────┘
백엔드 워크플로를 예시로 보면 이렇습니다:
name: Deploy Backend to VM on: push: branches: [main] # main에 push할 때만 실행 jobs: test: # 먼저 테스트 통과 확인 runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 with: java-version: '21' distribution: 'temurin' cache: 'gradle' # Gradle 의존성 캐시로 빌드 시간 단축 - run: ./gradlew test --no-daemon deploy: needs: test # 테스트 성공해야 배포 진행 runs-on: ubuntu-latest permissions: contents: read id-token: write # OIDC 토큰 발급 권한 (GCP 인증용)
needs: test로 테스트가 실패하면 배포가 아예 실행되지 않습니다. 프론트엔드는 별도 테스트 job 없이 바로 배포하고 있는데, TypeScript 빌드 자체가 타입 체크 역할을 해주니까요.
GCP 인증: Workload Identity Federation
배포 과정에서 GCP Artifact Registry에 Docker 이미지를 push해야 합니다. 예전에는 서비스 계정 JSON 키를 GitHub Secrets에 넣어서 썼는데, 키가 유출되면 GCP 계정이 털리는 구조라 위험합니다.
Workload Identity Federation은 GitHub Actions의 OIDC 토큰으로 GCP에 직접 인증하는 방식입니다. JSON 키 파일 없이 "GitHub의 이 레포에서 실행되는 워크플로만 허용"이라는 조건으로 권한을 부여합니다.
- name: Google Auth uses: google-github-actions/auth@v2 with: workload_identity_provider: projects/{PROJECT_NUM}/locations/global/... service_account: github-actions@{PROJECT_ID}.iam.gserviceaccount.com # ↑ JSON 키 파일 대신 OIDC 토큰으로 인증 # GitHub Secrets에 키를 저장할 필요 없음
GCP 콘솔에서 Workload Identity Pool을 만들고 GitHub provider를 등록하는 과정인데, 처음 설정이 좀 번거롭습니다. 대신 한번 해두면 키 순환 걱정이 사라지고요.
Docker 이미지 빌드와 태깅
- name: Build and Push run: | # 커밋 SHA로 태그 → 정확히 어떤 커밋이 배포됐는지 추적 가능 docker build -t {REGISTRY}/backend:${{ github.sha }} . # latest 태그도 함께 → VM에서 pull할 때 사용 docker tag {REGISTRY}/backend:${{ github.sha }} \ {REGISTRY}/backend:latest docker push {REGISTRY}/backend:${{ github.sha }} docker push {REGISTRY}/backend:latest
이미지 태그를 두 개 만드는 이유가 있는데요. latest는 VM에서 docker compose pull할 때 쓰고, 커밋 SHA 태그는 "지금 프로덕션에 어떤 버전이 올라가 있지?" 확인하거나 롤백할 때 씁니다.
프론트엔드는 빌드 시점에 환경변수를 넣어줘야 합니다:
docker build \ --build-arg NEXT_PUBLIC_API_URL=https://api.fullstackfamily.com \ --build-arg NEXT_PUBLIC_SITE_URL=https://www.fullstackfamily.com \ -t {REGISTRY}/frontend:${{ github.sha }} .
Next.js는 빌드 타임에 NEXT_PUBLIC_* 변수가 코드에 인라인되기 때문에, 런타임이 아니라 빌드 시점에 넣어줘야 합니다.
SSH로 VM에 배포
이미지를 Registry에 올린 다음, SSH로 VM에 접속해서 컨테이너를 교체합니다:
- name: Deploy to VM uses: appleboy/ssh-action@v1 with: host: ${{ secrets.VM_HOST }} # VM IP (GitHub Secrets에 저장) username: ${{ secrets.VM_USER }} key: ${{ secrets.VM_SSH_KEY }} # SSH 개인키 (GitHub Secrets에 저장) script: | cd /opt/fullstackfamily docker compose pull backend # 새 이미지 다운로드 docker compose up -d --no-deps backend # 컨테이너 교체 docker compose restart nginx # nginx 재시작 (IP 갱신)
appleboy/ssh-action이 SSH 접속 → 스크립트 실행을 해줍니다. 민감 정보(IP, SSH 키)는 전부 GitHub Secrets에 넣어두고 워크플로에서 참조만 합니다. 레포 코드에는 서버 접속 정보가 남지 않습니다.
--no-deps가 중요한 이유
프론트엔드, 백엔드, MySQL이 한 VM의 docker-compose.yml에 같이 있습니다. 여기서 --no-deps를 빼면 큰일 납니다.
docker compose up -d backend
이렇게 실행하면 docker-compose.yml의 depends_on을 따라가서 backend가 의존하는 mysql까지 같이 재시작됩니다. MySQL이 재시작되면 연결이 끊기고, 진행 중인 트랜잭션이 날아가고, 프론트엔드에서도 에러가 나기 시작하죠.
docker compose up -d backend ← mysql도 재시작 (위험) docker compose up -d --no-deps backend ← backend만 교체 (안전)
| 옵션 | 의미 |
|---|---|
up -d | 컨테이너를 백그라운드로 실행 (detached) |
--no-deps | 의존하는 서비스를 건드리지 않음 |
pull | Registry에서 최신 이미지를 다운로드 |
restart | 컨테이너를 중지했다 다시 시작 (이미지 변경 없음) |
프론트엔드와 백엔드를 동시에 push해도 괜찮은 이유가 여기 있습니다. 두 워크플로가 동시에 SSH로 들어와서 각각 --no-deps frontend, --no-deps backend를 실행해도 서로 안 건드리거든요.
배포 직후 502 사고: nginx의 DNS 캐싱
여기서 한 가지 함정에 빠졌습니다. --no-deps로 backend나 frontend를 교체하면 컨테이너가 새로 만들어지면서 Docker 내부 IP가 바뀌거든요.
배포 전: ff-backend = 172.18.0.4, ff-frontend = 172.18.0.5 └─ nginx가 시작할 때 이 IP를 resolve해서 캐싱 배포 후: ff-backend = 172.18.0.6 (새 컨테이너, 새 IP) └─ nginx는 여전히 172.18.0.4로 연결 시도 → 502!
nginx는 upstream 호스트명(ff-backend, ff-frontend)을 시작할 때 한 번만 DNS resolve하고 그 뒤로는 캐싱된 IP를 씁니다. 컨테이너가 교체되어 IP가 바뀌었는지 nginx는 모르는 거죠.
이번 마이그레이션 중에 정확히 이걸 겪었습니다. GitHub Actions 배포는 성공이라고 나오는데 사이트에 접속하면 "서버 점검 중"이 뜹니다. nginx 에러 로그를 열어보니:
connect() failed (113: Host is unreachable) while connecting to upstream, upstream: "http://172.18.0.5:3000/"
이미 없는 IP로 연결을 시도하고 있었습니다. docker compose restart nginx로 재시작해서 새 IP를 resolve하게 하니 바로 해결됐고요.
이후 배포 스크립트 마지막에 docker compose restart nginx를 넣었습니다. restart는 이미지를 새로 받지 않고 컨테이너를 재시작만 하니까 nginx 설정이 안 바뀌는 한 안전합니다.
docker compose pull backend # 1. 새 이미지 다운로드 docker compose up -d --no-deps backend # 2. backend 컨테이너만 교체 docker compose restart nginx # 3. nginx가 새 IP를 인식하도록 재시작
이걸 안 넣었다가 배포 후 사이트가 먹통이 됐으니, 단일 VM에 Docker Compose로 운영하고 계시다면 한번 확인해 보시는 게 좋겠습니다.
서버 2대로 확장한다면
지금은 VM 1대에 전부 올려놨지만, 트래픽이 늘어서 서버를 추가해야 한다면 어떻게 될까요. Cloudflare가 이미 앞에 있으니까 생각보다 단순합니다.
서버 2대 구성 ┌──────────────────────────┐ │ Cloudflare (LB 역할) │ │ Load Balancing Rules │ └─────┬──────────┬──────────┘ │ │ ┌──────▼───┐ ┌───▼──────┐ │ VM-1 │ │ VM-2 │ │ (앱서버) │ │ (앱서버) │ │ Nginx │ │ Nginx │ │ Backend │ │ Backend │ │ Frontend │ │ Frontend │ └────┬─────┘ └────┬─────┘ │ │ ┌────▼─────────────▼────┐ │ DB 서버 (별도) │ │ MySQL (Primary) │ └───────────────────────┘
Cloudflare Load Balancing으로 두 VM에 트래픽을 분산하면 됩니다. 프록시가 이미 앞에 있으니 Origin 서버를 2개로 늘리는 거죠.
여기서 제일 큰 작업은 DB를 앱서버에서 분리하는 겁니다. 지금은 docker-compose 안에 MySQL이 같이 있는데, 별도 VM이나 관리형 DB로 빼야 합니다. Nginx는 각 VM에 그대로 두면 되고, 세션은 지금 JWT를 쓰고 있어서 변경할 게 없습니다. 파일 업로드도 R2에 직접 올리니까 어느 서버에서 올려도 상관없고요. 배포는 GitHub Actions에서 두 VM에 순차적으로 하면 됩니다 (Rolling).
Cloudflare Load Balancing은 유료($5/월~)인데, 헬스체크와 자동 페일오버가 포함되어 있어서 LB 서버를 따로 관리하는 것보다 편합니다.
더 저렴한 호스팅으로 옮긴다면
GCP VM은 안정적이긴 한데, 솔직히 저렴하진 않습니다. 한국의 스마일서브, iwinv, 카페24 같은 호스팅에서는 비슷한 사양을 월 3~5만원에 줍니다.
호스팅 비용 비교 (2vCPU, 16GB RAM 기준) ┌──────────────────┬──────────┬────────────────────┐ │ 호스팅 │ 월 비용 │ 비고 │ ├──────────────────┼──────────┼────────────────────┤ │ GCP VM (약정) │ ~7만원 │ 서울 리전, SLA 99.5% │ │ 스마일서브 VPS │ ~3만원 │ 한국 IDC, SLA 99.9% │ │ iwinv VPS │ ~4만원 │ 한국 IDC │ │ 카페24 서버호스팅 │ ~3만원 │ 한국 IDC │ │ Vultr 서울 │ ~7만원 │ 서울 PoP │ └──────────────────┴──────────┴────────────────────┘
Cloudflare가 앞에 있기 때문에 서버를 옮기는 작업 자체는 간단합니다. 바꿔야 하는 게 몇 개 안 됩니다:
1. Cloudflare DNS의 Origin IP 변경
# Cloudflare 대시보드 또는 API A www → 새 서버 IP (Proxied) A api → 새 서버 IP (Proxied)
이게 전부입니다. DNS 레코드의 IP만 바꾸면 트래픽이 새 서버로 갑니다. Cloudflare 프록시가 앞에 있으니 사용자는 아무것도 모릅니다.
2. 새 서버에 Docker Compose 환경 구축
# 새 서버에서 docker compose -f docker-compose.yml up -d # → nginx, backend, frontend, mysql 전부 올라감
docker-compose.yml이랑 .env 파일만 복사하면 됩니다. GCP에 종속된 건 VOD 관련뿐이고, 그건 GCP에 남겨두면 됩니다.
3. GitHub Actions 배포 타겟 변경
# secrets에서 VM_HOST만 바꾸면 됨 - name: Deploy to VM uses: appleboy/ssh-action@v1 with: host: ${{ secrets.VM_HOST }} # ← 새 서버 IP username: ${{ secrets.VM_USER }} key: ${{ secrets.VM_SSH_KEY }} # ← 새 서버 SSH 키
4. GCP 종속 서비스 처리
| 서비스 | 처리 |
|---|---|
| MySQL 데이터 | mysqldump로 백업 → 새 서버 MySQL에 복원 |
| R2 스토리지 | 변경 없음 (Cloudflare에 있으므로) |
| VOD 파이프라인 | GCP에 유지 (Transcoder API는 GCP 전용) |
| Docker Registry | GCP Artifact Registry → Docker Hub나 GitHub GHCR로 변경 |
R2와 Worker는 Cloudflare에 있으니 서버가 어디에 있든 상관없습니다. 스토리지를 Cloudflare로 옮긴 덕분에 호스팅 종속이 사라진 셈이죠.
Docker Registry 이전이 좀 번거로울 수 있는데, GitHub GHCR(무료)을 쓰면 됩니다. GitHub Actions에서 빌드한 이미지를 GHCR에 올리고 새 서버에서 pull 받도록 바꾸면, GCP 의존이 완전히 사라집니다.
스마일서브 같은 곳으로 옮기면 VM 비용이 7만원에서 3만원으로 줄어서, 전체 월 비용이 5~6만원 수준이 됩니다. 처음 30만원에서 여기까지 왔으면 80% 절감입니다.
마이그레이션 체크리스트
실제로 이번에 진행한 순서입니다.
1. Cloudflare 계정 생성, 도메인 네임서버 변경 2. R2 버킷 생성, 커스텀 도메인(storage.xxx.com) 연결 3. rclone으로 GCS → R2 파일 복사 (모든 버킷 확인!) 4. 백엔드 R2StorageService 구현 (S3 호환 SDK) 5. Cloudflare Worker 작성 및 배포 (wrangler deploy) 6. DNS 레코드에서 image 도메인 프록시 활성화 (주황 구름) 7. DB URL 마이그레이션 (COMMIT 잊지 말기!) 8. Docker Compose에서 image-service 컨테이너 제거 9. GitHub Actions 배포 스크립트에 nginx restart 추가 10. 서비스 재시작 및 검증
전체 작업 시간은 반나절 정도였습니다. 가장 오래 걸린 건 rclone 파일 복사(50초)가 아니라, CORS 디버깅과 COMMIT 누락 추적이었습니다.
정리
Phase 104에서 서버리스를 VM으로 합치고, Phase 105에서 GCS를 R2로 옮기면서 인프라가 꽤 단순해졌습니다.
Phase 시리즈 비용 변화 ┌─────────────────────────────────┬──────────┐ │ Phase 104 전 (서버리스) │ 30만원/월 │ │ Phase 104 후 (VM 단일) │ 10만원/월 │ │ Phase 105 후 (VM + Cloudflare) │ 9만원/월 │ │ 스마일서브 이전 시 (예상) │ 5만원/월 │ └─────────────────────────────────┴──────────┘
비용도 비용이지만, 관리 포인트가 줄어든 게 개인적으로 더 큽니다. image-service 코드 관리 안 해도 되고, Let's Encrypt 갱신 신경 안 써도 되고, DDoS 방어도 Cloudflare가 알아서 해주니까요.
그리고 서버 호스팅 종속이 거의 없어졌습니다. 스토리지는 R2, CDN과 DNS도 Cloudflare. 서버에는 Docker Compose 하나만 올리면 됩니다. 내일 당장 GCP에서 스마일서브로 옮겨도 DNS IP만 바꾸면 끝입니다.
처음에 GCP 서버리스로 시작했을 때는 "관리할 서버가 없다"가 장점이라고 생각했는데, 결국 월 30만원짜리 "관리 안 해도 되는" 인프라보다 월 9만원짜리 VM 한 대 + Cloudflare가 더 실용적이었습니다. 동시 접속 수백 명 수준까지는 이 구조로 충분하고요.






댓글
댓글을 작성하려면 이 필요합니다.