---
title: "이미지 저장소를 Cloudflare R2로 옮기고 썸네일 프록시를 걷어냈다"
date: 2026-09-18
tags: ["cloudflare", "infra", "backend"]
url: https://sub2n.github.io/posts/2026/ncs-to-cloudflare-r2/
---

# 이미지 저장소를 Cloudflare R2로 옮기고 썸네일 프록시를 걷어냈다

프론트엔드 개발자로 일하면서 저장소랑 CDN, DNS를 직접 만진 건 이번이 처음이다. 9월 14일부터 18일까지 5일 동안 했다.

## 이전 상황

- 이미지랑 영상은 네이버 클라우드 스토리지(NCS)에 올리고 CDN 도메인으로 서빙했다.
- 썸네일은 자체 이미지 프록시 서버가 만들었다. 리사이즈, 스마트 크롭, YuNet으로 얼굴 검출 크롭까지 전부 여기서 처리했다.
- 앱 45개가 이 구조를 같이 쓴다.

프록시 호스트 운영비랑 전송비가 매달 나갔고, 썸네일 바이트가 전부 인터넷 아웃바운드로 과금되고 있었다. 사용자의 80% 이상이 해외인데 원본도 썸네일도 전부 서울에서 나갔다.

확인해 보니 좀 아까운 상황도 있었다. 프록시 도메인이 Cloudflare DNS를 쓰면서도 프록시가 꺼져 있었다. 응답에 `immutable` 캐시 헤더까지 이미 보내고 있었는데 그걸 받아 줄 엣지가 없었다. 캐시하라고 다 말해 놓고 아무도 안 듣는 상태였다.

## 하려고 했던 것

- 신규 업로드부터 R2로 보내고, 기존 객체는 서비스 단위로 순차 이관.
- 썸네일은 Cloudflare 이미지 변환으로. 얼굴 크롭만 자체 프록시에 남길지는 결과를 대조해 보고 결정.
- 운영 DB의 이미지 URL을 건드려야 하니 그 단계는 특히 조심해서. 옛 도메인 제거는 다 옮기고 맨 마지막에.

## 내가 몰랐던 것

**비용 구조를 잘못 짐작하고 있었다.** 저장소를 옮기는 일이니 저장비가 관건인 줄 알았다. 실제로 재 보니 객체 106만 개에 451GB인데 R2 저장비가 월 $7 수준이었다. 너무 싸서 한 번 더 확인했다. **전환 판단을 좌우하는 건 저장비가 아니라 변환 건수**였다. Cloudflare 변환은 월 고유 조합 1,000건당 과금되고, 같은 달에 한 번이라도 다시 요청되면 또 센다. 캐시 버킷으로 하한은 알 수 있어도 상한은 못 재서, 결국 소형 앱 하나로 7일 파일럿을 돌려 실제 건수를 보기로 했다.

**같은 국내 서버인데 요금이 달랐다.** 이관 작업을 돌린 서버가 NCP 안이 아니라 이그레스 요금이 붙고 있었다. NCP VM에서 돌리면 같은 리전 내부 트래픽이라 무료다. 왜 다른지 한참 이해가 안 갔는데, 프론트엔드에서는 볼 일 없던 요금 구조였다.

**R2 커스텀 도메인은 객체 키를 경로 그대로 노출한다.** 코드가 키에 붙이는 접두를 URL에서 다시 만들어 주는 규칙이 없으면 업로드한 게 전부 404다.

## 과정과 시행착오

**확정했던 결정이 파일럿에서 뒤집혔다.** 14일 실험에서 `gravity=face`가 실사 얼굴을 잘 잡길래 그걸로 확정했다. 그런데 16일 운영 파일럿에서 선이 단순한 애니 얼굴을 아예 못 잡고 center 크롭으로 떨어졌다. 기존 YuNet은 잘만 잡던 이미지였다.

네 가지를 한 화면에 놓고 비교했다. 원본, 기존 프록시(YuNet), Cloudflare `gravity=face`, 그리고 서버에서 YuNet으로 좌표를 구해 넘기는 방식. 마지막 방식도 좌표를 그대로 주면 너무 잘렸다. 얼굴 박스로 크롭 창을 계산해서 `trim`으로 넘기고 얼굴 중심을 창 높이 40% 지점에 두니 기존 결과랑 똑같이 나왔다. 검출은 업로드 후 워커가 한 번만 하고, 트래픽은 그대로 Cloudflare가 받는다.

**앱별 토글을 만들었다가 사흘 만에 지웠다.** 15일에 어드민에서 앱마다 저장소랑 변환을 켜고 끄는 토글을 만들었다. 문제 생기면 앱 하나만 되돌리려고. 17일엔 45개를 하나씩 켜기가 귀찮아서 서버 기본값을 두는 3상태로 바꿨고, 18일엔 전부 켜고 나니 설정이 남아 있을 이유가 없어서 통째로 지웠다.

**Cloudflare 문서와 실측이 달랐다.** 이미지를 지우면 원본 URL은 2초 안에 엣지에서 사라지는데 변환본은 계속 남았다. 문서에는 "원본을 purge하면 최적화본도 같이 지워진다"고 적혀 있었는데 실측은 그렇지 않았다. 변환 URL 자체는 purge 대상이 아니라는 문장도 같은 문서에 있었다. 결국 변환본 Edge TTL을 1일로 낮추는 규칙으로 대응하고 purge 코드는 뺐다.

**구 워커가 새 이미지 게시글을 블라인드했다.** R2 URL을 외부 이미지로 판정하는 옛 코드가 어딘가에서 계속 돌고 있었다. 원인 찾느라 며칠 걸렸는데, 한 번은 다른 머신의 test 워커, 한 번은 내 맥에 떠 있던 옛 개발 서버 프로세스였다. 둘 다 dev DB를 공유하고 있었다. 내 로컬 프로세스가 dev 데이터를 건드리고 있을 거라고는 생각도 못 했다.

**API도 같이 Cloudflare 뒤로 넣었다.** HAR을 분석했더니 서울에서는 엣지가 오리진 직결보다 느렸다. 한국 트래픽이 홍콩으로 간다. 그래도 해외가 80%라 진행했는데, 그 전에 오리진부터 손봤다. API 응답 압축이 아예 없어서 10.7KB JSON이 비압축으로 태평양을 건너다니고 있었다. 압축을 넣으니 424B가 됐다. 엣지 캐시 헤더는 익명 요청에만 붙였다. 이 코드베이스의 "공개" 엔드포인트는 토큰이 오면 응답이 달라져서, 잘못 캐시하면 개인화된 응답이 남한테 나간다. 여기가 제일 무서웠다.

## 결과

- 신규 업로드는 R2로 간다. 클라이언트가 presigned PUT으로 직접 올린다.
- 썸네일은 우리 CDN 원본이면 항상 Cloudflare 변환을 탄다. 외부 호스트 이미지는 변환 없이 원본 그대로.
- 얼굴 크롭은 서버 검출 1회 + `trim`. 박스가 없으면 `gravity=face`로 폴백.
- 클라이언트는 이제 자체 프록시 URL을 만들지 않는다. 역파서만 남겼다. 옛 공유 링크랑 구버전 앱이 만든 URL을 풀어야 해서 이건 지울 수가 없다.
- 프록시 서버는 호출이 0이 되면 내린다. NCS는 DB 치환이 끝난 뒤에.

## 배운 것

- **숫자를 먼저 재면 계획이 바뀐다.** 저장비가 관건인 줄 알고 시작했는데 실측하고 나서야 변환 건수가 진짜 변수라는 걸 알았다. 그 뒤로는 계획을 더 짜기 전에 재는 걸 먼저 한다.
- **확정한 결정도 파일럿에서 뒤집힌다.** 실험 샘플에 없던 종류의 이미지가 운영에는 있었다. 파일럿을 건너뛰었으면 전 앱에 깨진 썸네일이 나갔을 거다.
- **전환기에만 필요한 스위치는 수명을 정해 두고 만든다.** 토글을 만들 때는 이게 오래 갈 설정인 줄 알았는데 사흘짜리였다. 만들 때부터 언제 지울지 정해 놨으면 중간에 3상태로 복잡하게 만들 일도 없었다.
- **문서와 실측이 다르면 실측이 맞다.** 공식 문서 한 문단 안에서 서로 어긋나는 설명을 봤다. 지우고 다시 요청해 보는 데 10분이면 됐다.