한줄요약: 앱이 보낸 419가 웹서버를 지나며 500으로 바뀌었고, 그걸 고친 JS는 Cloudflare 캐시 때문에 사용자에게 닿지 않았습니다. 공유 호스팅과 CDN 사이에서 두 번 헛걸음한 기록입니다.

문의 폼에 1회용 CSRF 토큰을 넣은 뒤, 토큰이 만료된 경우를 따로 처리하고 싶었습니다. 폼을 열어 둔 채 한참 있다가 제출하면 "페이지가 만료되었습니다. 새로고침 후 다시 시도해 주세요"라고 안내하는 것입니다.

그래서 만료된 토큰에는 419를 돌려주도록 했습니다. 웹 개발자들 사이에서 '페이지 만료'를 뜻하는 코드로 통용되는 숫자라 의미도 잘 맞는다고 생각했습니다. 로컬에서는 완벽하게 동작했습니다. 배포하자 사용자 화면에는 "서버 오류가 발생했습니다"가 떴습니다.

증상: 본문은 멀쩡한데 상태코드만 500

500이 떴으니 당연히 PHP 어딘가에서 예외가 났다고 생각했습니다. 그런데 확인할수록 이상했습니다.

확인한 것

결과

CI4 writable/logs

해당 시각 에러 없음

웹서버 에러 로그

해당 요청 관련 기록 없음

개발자도구 Network의 응답 본문

내가 만든 JSON 그대로 (code: CSRF.TOKEN.EXPIRED)

같은 응답의 상태줄

500 Internal Server Error

로컬(macOS + Apache)에서 같은 요청

419 정상

본문은 컨트롤러가 만든 그대로 왔습니다. 에러 화면도, 스택트레이스도 없었습니다. 바뀐 것은 상태줄 한 줄뿐이었습니다.

이게 헷갈린 이유입니다. 진짜 500이었다면 본문이 깨져 있거나 비어 있었을 것입니다. 본문이 정상이니 "코드는 제대로 돌았다"는 증거였고, 그래서 한동안 코드에서 원인을 찾는 데 시간을 썼습니다.

프런트 JS는 response.ok가 거짓이고 상태가 5xx면 일괄로 "서버 오류" 문구를 띄우게 되어 있었습니다. 본문에 정확한 에러 코드가 있었는데도 읽지 않고 상태코드만 보고 판단한 것입니다.

원인: 419는 표준이 아니다

419는 HTTP 표준에 없는 코드입니다. IANA 상태코드 목록에도 없고, Laravel이 "Page Expired" 용도로 쓰면서 널리 알려졌을 뿐입니다. CodeIgniter 4는 CSRF 검증 실패에 기본으로 403을 씁니다.

문제는 PHP와 브라우저 사이에 있는 계층입니다. PHP는 상태코드를 숫자로 넘기지만, 실제 상태줄(HTTP/1.1 419 ...)을 쓰는 것은 웹서버입니다. 웹서버는 자기가 아는 코드표에서 이유 문구를 찾습니다. 버전과 구성에 따라 표에 없는 코드를 만나면 500으로 바꿔 버리는 경우가 있습니다. 본문은 건드리지 않습니다.

공유 호스팅에서는 이 계층을 내가 고를 수 없습니다. 웹서버 종류, 버전, 앞단의 리버스 프록시나 보안 모듈이 무엇인지 알 수도 없습니다. 로컬과 운영의 결과가 달랐던 이유가 여기 있었습니다.

확인한 방법

추측만으로는 호스팅에 문의할 수도 없어서, 상태코드만 바꿔 돌려주는 테스트 라우트를 임시로 올렸습니다.

// app/Config/Routes.php (확인 후 삭제)
$routes->get('_status/(:num)', static function (int $code) {
    return service('response')
        ->setStatusCode($code, 'Test')
        ->setJSON(['sent' => $code]);
});
for c in 403 409 418 419 420 422 429; do
  printf '%s -> ' $c
  curl -s -o /dev/null -w '%{http_code}\n' https://example.com/_status/$c
done

표준 코드는 모두 그대로 나왔고, 419만 500으로 바뀌었습니다. 본문에는 여전히 "sent":419가 들어 있었습니다. 앱이 아니라 그 앞단이 바꾼다는 것이 이것으로 확정됐습니다.

해결: 상태코드는 표준으로, 세부 사유는 본문으로

고친 방향은 두 가지였습니다.

  1. 상태코드는 표준 코드만 쓴다. 토큰 만료는 CI4 기본값과 같은 403으로 바꿨습니다. 어떤 호스팅, 어떤 프록시를 지나도 바뀌지 않는 코드입니다.
  2. 세부 사유는 본문의 에러 코드로 구분한다. 403은 "권한 없음" 전체를 뜻하니, 만료인지 다른 거부인지는 본문의 code로 가립니다. 이미 쓰고 있던 RFC 9457 기반 에러 규약(api-error-contract) 그대로입니다.
throw new ApiException(
    status: 403,
    code: 'CONTACT.CSRF.EXPIRED',
    title: '페이지가 만료되었습니다',
);

프런트도 바꿨습니다. 상태코드로 대분류만 하고, 화면 문구는 본문의 code로 정합니다. 본문을 못 읽을 때만 상태코드 기준 기본 문구로 떨어집니다.

async function submitContact(form) {
  const res = await fetch(form.action, { method: 'POST', body: new FormData(form) });
  if (res.ok) return showDone();

  let err = null;
  try { err = await res.json(); } catch (_) { /* 본문이 JSON이 아님 */ }

  if (err?.code === 'CONTACT.CSRF.EXPIRED') {
    await refreshCsrfToken(form);   // 새 토큰 받아 hidden 필드 교체
    return showNotice('페이지가 만료되어 새로 고쳤습니다. 다시 제출해 주세요.');
  }
  return showError(err?.title ?? (res.status >= 500 ? '서버 오류가 발생했습니다.' : '요청을 처리하지 못했습니다.'));
}

이 구조라면 앞단이 상태코드를 또 바꾸더라도 본문이 살아 있는 한 사용자에게는 맞는 안내가 나갑니다. 상태코드는 중간 장비가 손댈 수 있지만 본문은 앱이 끝까지 책임질 수 있는 영역입니다.

2라운드: JS를 고쳤는데 그대로다

서버 코드를 403으로 바꾸고 contact.js도 고쳐서 배포했습니다. 다시 테스트하니 여전히 "서버 오류가 발생했습니다"가 떴습니다. 이번에는 서버가 403을 정확히 돌려주고 있었습니다.

개발자도구에서 contact.js를 열어 보니 예전 코드였습니다. 서버에 올라간 파일은 분명 새 버전인데, 브라우저가 받은 것은 이전 버전이었습니다.

응답 헤더에 답이 있었습니다.

cf-cache-status: HIT
age: 5321
cache-control: max-age=14400

사이트 앞에 Cloudflare가 있었습니다. Cloudflare는 HTML은 기본적으로 캐시하지 않지만, .js, .css, 이미지 같은 정적 파일은 확장자만 보고 엣지에 캐시합니다. 내가 파일을 바꿔도 엣지에 남은 사본이 만료될 때까지는 옛 파일이 나갑니다. 브라우저도 max-age 동안은 자기 캐시를 씁니다.

결과적으로 "새 서버 + 옛 JS" 조합이 만들어졌습니다. 서버는 403과 새 에러 코드를 보내는데, 옛 JS는 그 코드를 모르니 결국 기본 오류 문구를 띄운 것입니다. 첫 번째 문제를 고친 것이 맞는지조차 확인할 수 없는 상태였습니다.

덧붙이면, 이 시기에 jblove.net 속도 진단도 했습니다. 그때 HTML은 cf-cache-status: DYNAMIC으로 전혀 캐시되지 않으면서 정적 파일만 캐시되는 구조를 봤습니다. "HTML은 바로 바뀌고 JS는 안 바뀐다"는 이 비대칭이 바로 이번 함정의 정체였습니다.

해결: ?v=N으로 캐시 키를 바꾼다

당장은 Cloudflare 대시보드에서 해당 파일을 퍼지해서 확인했습니다. 그러자 비로소 "페이지가 만료되었습니다" 안내가 정상으로 떴습니다. 하지만 배포할 때마다 퍼지를 기억하는 방식은 언젠가 빠뜨립니다.

그래서 정적 파일 URL에 버전 쿼리를 붙였습니다.

 

Cloudflare의 기본 캐시 설정은 쿼리스트링까지 캐시 키에 포함합니다. ?v=7이 ?v=8로 바뀌면 엣지 입장에서는 처음 보는 URL이라 오리진에서 새로 받아 갑니다. 브라우저도 마찬가지입니다. 파일을 바꾸지 않은 배포에서는 URL도 그대로이니 캐시 효과는 그대로 누립니다.

번호를 손으로 올리는 것도 결국 잊으니, 파일 수정 시각으로 자동으로 붙이는 헬퍼를 만들었습니다.

// app/Helpers/asset_helper.php
function asset_v(string $path): string
{
    $file = FCPATH . ltrim($path, '/');
    $v    = is_file($file) ? filemtime($file) : '0';

    return base_url($path) . '?v=' . $v;
}
 

파일이 바뀌면 수정 시각이 바뀌고, URL이 바뀌고, 캐시가 자연스럽게 갈립니다. 배포 과정에서 손댈 것이 없어집니다.

주의할 점

  • 캐시 키 설정 확인: Cloudflare에서 캐시 레벨을 "쿼리스트링 무시"로 바꿨다면 ?v=는 효과가 없습니다. 그때는 파일명에 해시를 넣는 방식(contact.3f2a9c.js)이 맞습니다.
  • HTML 쪽 캐시: ?v=가 바뀐 HTML 자체가 캐시돼 있으면 소용없습니다. 페이지 HTML은 캐시하지 않거나 짧게 두어야 합니다.
  • 여러 서버 배포: 서버마다 filemtime이 다르면 같은 파일이 다른 URL을 갖습니다. 그때는 배포 시 빌드 번호나 Git 커밋 해시를 버전으로 씁니다.

공유 호스팅 + CDN 점검 체크리스트

이후 새 사이트를 올릴 때마다 아래 항목을 먼저 확인합니다.

  • API가 쓰는 상태코드가 모두 IANA 표준 목록에 있는가 (418, 419, 420 같은 관용 코드 금지)
  • 에러 세부 사유가 상태코드가 아니라 본문의 code에 들어 있는가
  • 프런트가 상태코드보다 본문 code를 먼저 보고 문구를 정하는가
  • 운영 환경에서 상태코드 테스트 라우트로 주요 코드가 그대로 나오는지 확인했는가 (확인 후 라우트 삭제)
  • 정적 파일 URL에 ?v= 또는 해시 파일명이 붙는가
  • Cloudflare 캐시 레벨이 쿼리스트링을 캐시 키에 포함하는가
  • 페이지 HTML이 CDN에 오래 캐시되지 않는가
  • "배포했는데 안 바뀐다" 싶으면 응답 헤더의 cf-cache-status와 age부터 보는가

마지막 항목이 가장 많은 시간을 아껴 줍니다. 코드를 의심하기 전에 지금 보고 있는 응답이 어디서 왔는지부터 확인합니다.

돌아보며

  1. 내 코드와 사용자 사이에는 생각보다 많은 계층이 있다. PHP → 웹서버 → 호스팅 프록시 → CDN → 브라우저 캐시. 이 중 내가 통제하는 것은 첫 번째뿐인 경우가 많습니다. 특히 공유 호스팅에서는 나머지를 볼 수도 없습니다.
  2. 표준은 중간 장비와의 약속이다. 419는 앱과 프런트 사이에서는 잘 통했지만, 그 사이의 장비들은 그 약속을 모릅니다. 사람만 읽는 의미는 본문에 담고, 장비가 읽는 상태코드는 표준을 지킵니다.
  3. 본문이 멀쩡하면 앞단을 의심한다. 500인데 본문이 정상이라면 코드가 아니라 경로의 문제일 가능성이 큽니다. 이번 일로 얻은 가장 쓸모 있는 감각입니다.
  4. 캐시는 수정의 확인까지 막는다. 첫 번째 문제를 고치고도 확인할 수 없었던 것은 두 번째 문제 때문이었습니다. 두 버그가 겹치면 하나를 고쳐도 증상이 그대로라 "수정이 틀렸다"는 잘못된 결론으로 가기 쉽습니다.
  5. 로컬에서 되는 것은 아무것도 증명하지 않는다. 로컬에는 CDN도, 호스팅 프록시도 없습니다. 운영 경로를 그대로 통과하는 짧은 확인 절차(상태코드 테스트, cf-cache-status 확인)를 배포 체크리스트에 넣어 두었습니다.

두 문제 모두 몇 분이면 고칠 수 있는 것이었지만, 찾는 데는 반나절이 걸렸습니다. 원인이 코드 바깥에 있었기 때문입니다. 다음에 "분명히 고쳤는데 안 된다"는 순간이 오면, 코드를 다시 열기 전에 응답 헤더부터 열어 보시길 권합니다.