CLOUDFLARE · IMAGE TRANSFORMATIONS · POSTMORTEM
정상적인 이미지 URL이 404가 되기까지: Cloudflare Transformation 고정룰 문제 해결기
원본 파일은 분명 존재했습니다. 그런데 이미지 변환 실패를 원본으로 우회시키려던 고정 룰은 내부 요청을 재귀적으로 발생시켰고, 결국 정상적인 이미지 URL까지 404로 응답하게 만들었습니다. 이 글은 그 원인을 추적하고, Workers 도입 대신 룰과 Snippet의 역할을 재설계해 해결한 기록입니다.
한 줄 결론
문제는 이미지 변환 실패 자체가 아니라, fallback 표식인 ?image=fail이 Snippet의 서브리퀘스트 이전에 사라지면서 이미지 변환 룰이 다시 실행된 것이었습니다. 최종적으로 URL 쿼리는 외부 fallback 표식으로, 요청 헤더는 내부 서브리퀘스트 표식으로 분리해 재진입을 차단했습니다.
1. 이미지 변환 고정 룰의 목표
이번 룰의 목적은 서비스에서 제공하는 모든 JPG, JPEG, PNG 이미지 요청을 Cloudflare Image Transformations로 통일하고, 클라이언트 환경에 맞는 포맷과 품질로 전달하는 것이었습니다.
| 구분 | 설계 의도 | 적용 방식 |
|---|---|---|
| 대상 이미지 | JPG, JPEG, PNG 확장자를 가진 이미지 요청 | 이미지 변환 대상 경로에 대한 Rewrite 조건 구성 |
| 정상 요청 | 브라우저 환경에 맞는 이미지 포맷과 품질로 전달 | format=auto,quality=high |
| 변환 실패 | 오류 화면 대신 원본 이미지를 전달 | onerror=redirect |
| 핵심 제약 | fallback 요청이 다시 이미지 변환에 진입하면 안 됨 | 쿼리 표식과 내부 요청 헤더를 조합한 재진입 차단 |
변환 URL의 기본 구조는 아래와 같습니다. ${1}은 Rewrite 규칙에서 캡처한 원본 이미지 경로를 의미합니다.
/cdn-cgi/image/format=auto,quality=high,onerror=redirect/${1}
2. 최초 설계: 변환 실패 시 원본 이미지로 fallback
최초 설계는 변환 실패 시 리다이렉트된 요청에 ?image=fail을 붙이고, 해당 요청만 원본 이미지로 통과시키는 방식이었습니다. 이 표식이 있어야 fallback 요청을 일반 이미지 요청과 구분할 수 있습니다.
INITIAL FLOW · TRANSFORMATION FALLBACK
GET https://a.com/bbb.jpg
조건: 쿼리스트링에 image=fail이 없을 때
/cdn-cgi/image/format=auto,quality=high,onerror=redirect/bbb.jpg
Cloudflare가 format=auto, quality=high이 적용된 변환 이미지를 응답합니다.
onerror=redirect가 동작하며 원본 이미지 URL로 fallback합니다.
307 응답의 Location 헤더를 https://a.com/bbb.jpg?image=fail로 변조합니다.
GET https://a.com/bbb.jpg?image=fail
조건: 쿼리스트링에 image=fail이 있을 때. 동작: 쿼리스트링을 제거하고 원본 경로로 전달합니다.
https://a.com/bbb.jpg를 원본 이미지로 반환하는 것이 최초 설계의 목표였습니다.
3. 원본이 있는데도 404가 된 이유
기대와 달리 원본 파일이 존재하는 콘텐츠에서도 404가 다수 발생했습니다. 분석 결과, 실제 원인은 Snippet의 서브리퀘스트와 Rewrite 조건이 결합되면서 발생한 재귀 루프였습니다.
INCIDENT FLOW · RECURSIVE SUBREQUEST
GET /bbb.jpg?image=fail
fallback 상태를 나타내던 image=fail 표식이 사라집니다.
fetch() 서브리퀘스트 생성표식이 제거된 /bbb.jpg를 기준으로 내부 요청이 발생합니다.
조건상 image=fail이 없으므로 /bbb.jpg는 다시 변환 URL로 변경됩니다.
이미지 변환과 307 fallback이 재반복되고, 내부 request limit에 도달한 뒤 404가 반환됩니다.
즉, 이 사례의 404는 원본 파일이 없어 발생한 오류가 아니었습니다. fallback 상태가 유실된 요청이 이미지 변환 룰에 재진입하면서 발생한 내부 요청 한도 초과가 원인이었습니다.
4. Workers 대안은 왜 바로 채택하지 않았나
룰만으로는 상태 전달과 재진입 제어가 어려워 보였기 때문에 Workers 기반 이미지 변환도 검토했습니다. Workers는 URL, 요청 헤더, 변환 결과, fallback 응답을 하나의 코드 흐름에서 관리할 수 있다는 장점이 있습니다.
기술적으로 기대한 점
상태 전이와 fallback을 코드로 명시해 쿼리 제거 시점과 서브리퀘스트 재진입을 더 명확하게 제어할 수 있습니다.
검토 과정에서 확인한 제약
기존 Workers와의 라우팅 우선순위, service binding, 고객 코드 변경, 요청 수 기반 비용까지 함께 고려해야 했습니다.
| 검토 항목 | 확인된 문제 | 영향 |
|---|---|---|
| 기존 Workers Route | 기존 서비스가 /*와 같은 넓은 경로 방식의 Route를 이미 사용 | 상·하위 Worker 연결과 요청 전달 구조를 설계해야 함 |
| 확장자 기반 Route | 검토 당시 /*.jpg와 같은 확장자 방식 Route는 beta 기능이었음 | 광범위 Route와 이미지 Route의 선후 관계를 안정적으로 판단하기 어려움 |
| Service Binding | 상위 Worker가 동작한 뒤 하위 이미지 Worker로 넘기려면 binding 설정 필요 | 콘솔 설정뿐 아니라 고객 Worker 코드 수정까지 요구될 수 있음 |
| 요청 수 과금 | 계약과 플랜에 따라 Worker 요청 수가 비용으로 연결될 수 있음 | 모든 이미지 요청이 Worker를 통과하면 비용 규모가 커질 가능성 |
5. 최종 해결안: URL 표식과 내부 헤더 표식을 분리
최종 설계의 핵심은 fallback 상태를 쿼리스트링 하나에만 의존하지 않는 것입니다. 외부 요청을 구분하는 표식과 Snippet 서브리퀘스트를 구분하는 표식을 분리했습니다.
외부 fallback 표식
?image=fail은 307을 따라온 클라이언트 요청이 변환 실패 후의 fallback이라는 사실을 알립니다. 이 표식이 있으면 Rewrite 1은 이미지 변환을 다시 시작하지 않습니다.
내부 서브리퀘스트 표식
Snippet은 image-optimization: remove 요청 헤더를 추가한 뒤 쿼리스트링을 제거합니다. 이후 fetch() 요청도 이 헤더로 변환 룰의 재진입을 차단합니다.
FIXED FLOW · URL MARKER + HEADER GUARD
GET https://a.com/bbb.jpg
조건 A: image=fail이 없을 것. 조건 B: image-optimization: remove 헤더가 없을 것.
운영 규칙: 이미지 변환 Rewrite는 Rewrite 목록의 마지막에 배치합니다.
https://a.com/bbb.jpg?image=fail을 fallback URL로 반환합니다.
image=fail이 존재하므로 이미지 변환 경로로 다시 변경하지 않습니다.
image-optimization: remove 헤더를 먼저 추가하고, image=fail을 제거한 뒤 fetch()를 호출합니다.
쿼리스트링은 없어졌지만 image-optimization: remove 헤더가 남아 있어 변환 룰이 재실행되지 않습니다.
원본 파일이 존재하는 경우에만 정상 원본 이미지로 fallback하며, 재귀 루프는 발생하지 않습니다.
최종 룰의 정책을 의사 코드로 표현하면
아래 코드는 실제 Cloudflare Rules 언어가 아니라, 최종 구성의 판단 조건과 실행 순서를 설명하기 위한 의사 코드입니다.
// Rewrite 1: 이미지 변환 룰
// 반드시 Rewrite 목록의 마지막에 배치한다.
if (
query.image !== "fail" &&
request.headers["image-optimization"] !== "remove"
) {
rewrite(
"/cdn-cgi/image/format=auto,quality=high,onerror=redirect" +
request.path
);
}
// Response Header Transform: 변환 실패 후 307 응답에만 적용
if (response.status === 307) {
response.headers["Location"] =
"https://a.com/bbb.jpg?image=fail";
}
// Snippet: fallback 요청을 원본 요청으로 전환
if (query.image === "fail") {
request.headers["image-optimization"] = "remove";
removeQueryParameter("image");
return fetch(request);
}
운영 중 확인한 추가 규칙
- ✓ 이미지 변환 Rewrite는 상위에 두었을 때 기대대로 실행되지 않는 사례가 있었으므로, Rewrite 목록의 마지막 룰로 배치했습니다.
- ✓ Rewrite는 요청 생명 주기 동안 한 번만 실행된다고 가정하면 안 됩니다. 특히 Snippet 또는 Worker의
fetch()는 새로운 내부 요청 경로를 만들 수 있습니다. - ✓ fallback 요청을 식별하는 URL 표식과 내부 처리 단계를 식별하는 헤더 표식은 서로 다른 역할으로 분리하는 것이 안전합니다.
6. 검증과 운영 체크리스트
문제 해결 후에는 “404가 사라졌는가”만 확인해서는 부족합니다. 정상 변환, fallback, 실제 원본 누락, 서브리퀘스트 재진입까지 각각 분리해 검증해야 합니다.
| 테스트 시나리오 | 기대 결과 | 확인 포인트 |
|---|---|---|
| 정상 JPG/JPEG/PNG 요청 | 변환 이미지가 200으로 응답 | format=auto, quality=high 적용 여부 |
| 의도적으로 변환 실패를 유도한 요청 | 307 → fallback 요청 → 원본 이미지 200 | Location에 ?image=fail이 추가되는지 |
?image=fail을 가진 fallback 요청 | Rewrite 1을 건너뛰고 Snippet으로 진입 | 이미지 변환 경로가 다시 생성되지 않는지 |
Snippet의 fetch() 서브리퀘스트 | 원본 이미지 요청으로 종료 | image-optimization: remove 헤더로 Rewrite 1이 차단되는지 |
| 실제로 존재하지 않는 원본 이미지 | 정상적으로 404 | fallback이 없는 원본 파일을 복구하는 것처럼 보이지 않는지 |
| 원본 URL에 기존 쿼리스트링이 있는 경우 | 기존 쿼리를 보존한 채 fallback 표식 추가 | ?와 & 처리, 캐시 키 정책 |
마무리: 404는 파일이 없어서만 발생하지 않는다
이번 장애에서 원본 이미지 URL은 정상적이었습니다. 하지만 Edge에서 동작하는 여러 기능이 Rewrite → Snippet → subrequest → Rewrite 순서로 연결되면서, fallback 상태가 사라진 요청이 이미지 변환 룰에 재진입했습니다.
결국 해결의 핵심은 변환 기능을 바꾸는 것이 아니라, 요청이 어느 단계에 있는지 식별할 수 있는 상태를 명확하게 전달하는 것이었습니다. URL 쿼리는 외부 fallback 상태를, 요청 헤더는 내부 서브리퀘스트 상태를 맡게 했고, 이미지 변환 Rewrite를 마지막 룰로 배치해 재진입을 차단했습니다.
이미지 최적화는 겉으로 보기에는 단순한 URL 변환처럼 보이지만, fallback과 서브리퀘스트가 더해지는 순간 요청 흐름 자체를 설계해야 하는 문제로 바뀝니다. 정상 URL이 404가 되는 상황을 막기 위해서는, 규칙 하나보다 전체 Edge 요청 생명 주기를 먼저 그려보는 것이 중요합니다.
참고 자료
- Cloudflare Developers, Transform via URL —
onerror=redirect의 동작과 적용 제약을 확인할 수 있습니다. 공식 문서의 내용은 라이선스 준수를 위해 요약·재서술했습니다.