도메인 하나 옮기다 만난 301·302 와 CORS — curl 은 200 인데 브라우저는 403 이었다
오늘 서비스 하나의 공개 도메인을 eln.lemuel.co.kr 에서 asat.lemuel.co.kr 로 옮겼다.
옮기는 것 자체는 십 분이면 끝나는 일이었고, 실제로도 그랬다. 문제는 그 다음에 두 번 걸려 넘어졌다는 것이다.
한 번은 리다이렉트 상태 코드를 무엇으로 할 것인가에서, 또 한 번은 같은 도메인끼리 부르는 요청이 CORS 로 막히는 데에서.
두 번째는 특히 고약했다. curl 로 찌르면 200 이 나오는데 브라우저에서는 403 이 났다.
같은 URL, 같은 메서드, 같은 본문인데 결과가 달랐다. 이 글은 그 두 지점의 기록이다.
1. 옛 도메인을 어떻게 할 것인가
새 주소로 옮긴다고 옛 주소가 곧바로 죽어도 되는 건 아니다. 이 서비스는 회원가입 인증 메일에 절대 URL 을 박아서 보낸다. 즉 이미 누군가의 메일함에 옛 도메인이 적힌 링크가 들어 있다. 그 링크의 유효기간이 지나기 전에 옛 도메인을 내리면, 그 사람은 가입을 못 끝낸다.
그래서 옛 도메인은 당분간 살려 두되 새 주소로 넘겨주는 표지판으로 만들기로 했다. Cloudflare 의 Redirect Rule 하나면 되는 일이다. 그런데 폼에서 상태 코드를 고르라고 한다. 301 인가 302 인가.
301 과 302 의 진짜 차이는 “캐시”다
흔히 “301 은 영구, 302 는 임시” 라고 외우는데, 그 문장만으로는 운영상 무엇이 달라지는지 알 수 없다. 실무에서 갈리는 지점은 브라우저와 중간 캐시가 그 응답을 저장해도 되는가이다. RFC 9110 이 이걸 명시한다.
A 301 response is heuristically cacheable; i.e., unless otherwise indicated by the method definition or explicit cache controls
302 절(§15.4.3)에는 이 문장이 없다. 우연이 아니라 명시적인 구분이고, 같은 문서의 상태 코드 개요가 목록으로 못 박아 둔다.
Responses with status codes that are defined as heuristically cacheable (e.g., 200, 203, 204, 206, 300, 301, 308, 404, 405, 410, 414, and 501 in this specification) can be reused by a cache with heuristic expiration (…) all other status codes are not heuristically cacheable.
— RFC 9110 §15 (강조는 인용자)
목록에 301 과 308 은 있고 302 와 307 은 없다. 그래서 실제로 벌어지는 일은 이렇다.
- 301 을 걸면 — 브라우저가 그 전달을 캐시에 적어 둔다. 나중에 규칙을 지워도 그 브라우저는 서버에 다시 묻지 않고 새 주소로 간다. 되돌리려면 방문자 각자가 캐시를 비워야 한다. 서버 쪽에서 되돌릴 방법이 없다.
- 302 를 걸면 — 캐시되지 않으므로 매번 서버에 묻는다. 규칙을 지우는 순간 원래대로 돌아온다.
“당분간” 이라는 말이 붙은 이전에 301 을 쓰면 안 되는 이유가 이것이다. 되돌릴 계획이 있는 변경에 되돌릴 수 없는 도구를 쓰는 셈이다. 나는 302 를 골랐다.
덤으로 알게 된 함정 — POST 는 리다이렉트를 견디지 못한다
이 규칙은 호스트만 보고 걸리니 /api/* 를 포함한 모든 경로에 적용된다. 그러면 옛 도메인으로 오는 POST 요청은 어떻게 되나.
RFC 9110 은 301 과 302 양쪽 절에 같은 각주를 달아 두었다.
Note: For historical reasons, a user agent MAY change the request method from POST to GET for the subsequent request. If this behavior is undesired, the 307 (Temporary Redirect) status code can be used instead.
메서드가 바뀔 수도 있다는 것이다. 반대로 307 은 “user agent MUST NOT change the request method” 라고 못 박는다. 307·308 이 따로 존재하는 이유가 이 한 줄이다.
궁금해서 직접 재봤다. 옛 도메인의 API 경로로 POST 를 던지고 리다이렉트를 따라가게 했더니 —
$ curl -L -X POST "https://eln.lemuel.co.kr/api/v1/auth/demo-login" \
-H 'Content-Type: application/json' -d '{"role":"ADMIN"}'
{"message":"내부 서버 오류가 발생했습니다","code":"G001"}
final=https://asat.lemuel.co.kr/api/v1/auth/demo-login code=500
새 주소까지는 잘 도착했는데 500 이 났다. -v 로 두 번째 요청을 뜯어보니 메서드는 POST 그대로인데 Content-Length 가 아예 없다 — 본문이 사라진 것이다.
확인 삼아 새 주소에 본문 없는 POST 를 직접 던져봤더니 같은 500 · 같은 G001 이 나왔다. 같은 고장이 맞다.
결론은 명확하다. 이 리다이렉트는 사람이 브라우저 주소창으로 들어오는 경로를 구제하는 장치이지, API 호출을 옮기는 장치가 아니다. API 를 부르는 쪽은 코드에서 새 주소로 고쳐야 한다.
그리고 체크박스 하나
Cloudflare 의 Redirect Rule 폼에는 Preserve query string 이라는 체크박스가 있다. 공식 문서의 설명은 이렇다.
Preserve query string: Choose whether to keep the query string from the original request.
한 줄짜리 옵션인데, 껐다면 이 이전 작업의 목적 자체가 무너진다.
표적이 https://.../verify-email?token=... 인 인증 메일 링크였기 때문이다. 쿼리를 안 지키면 ?token=... 이 통째로 날아가고, 링크는 살아 있는데 인증은 실패한다. 200 은 뜨는데 기능은 죽는, 제일 알아채기 어려운 형태의 고장이다.
배포 후에 실측했다.
$ curl -sSI "https://eln.lemuel.co.kr/verify-email?token=abc123&x=2"
HTTP/2 302
location: https://asat.lemuel.co.kr/verify-email?token=abc123&x=2
토큰이 그대로 넘어간다. 여기까지가 1교시였다.
2. curl 은 200, 브라우저는 403
이전을 끝내고 조금 뒤 “데모 로그인이 안 된다” 는 얘기가 왔다. 화면에는 앱이 만든 문구가 떠 있었다 — 데모 로그인에 실패했습니다.
가장 먼저 한 일은 API 를 직접 찌르는 것이었다.
$ curl -X POST "https://asat.lemuel.co.kr/api/v1/auth/demo-login" \
-H 'Content-Type: application/json' -d '{"role":"ADMIN"}'
{"accessToken":"eyJ...","tokenType":"Bearer"} → HTTP 200
200 이다. 백엔드는 멀쩡하다. 그럼 프론트 문제인가? 브라우저를 열어 실제로 버튼을 누르고 네트워크 탭을 봤다.
POST https://asat.lemuel.co.kr/api/v1/auth/demo-login → 403
같은 요청인데 403. 여기가 이 사건의 핵심이다.
curl 과 브라우저가 다르게 취급되는 요소가 무엇인지 찾으면 답이 나온다. 그리고 그건 Origin 헤더였다.
브라우저는 같은 출처의 POST 에도 Origin 을 붙인다
Origin 헤더를 크로스 오리진 요청에만 붙는 것으로 아는 경우가 많은데, 사실이 아니다.
Fetch Standard 의 “append a request Origin header” 알고리즘은 이렇게 갈라진다.
If request’s response tainting is “cors” or request’s mode is either “websocket” or “webtransport”, then append (
Origin, serializedOrigin) to request’s header list.Otherwise, if request’s method is neither
GETnorHEAD, then: (…) Append (Origin, serializedOrigin) to request’s header list.
두 번째 갈래에 주목하자. CORS 요청이 아니어도, 메서드가 GET·HEAD 가 아니면 Origin 이 붙는다.
즉 같은 도메인 안에서 부르는 POST 에도 브라우저는 Origin 을 실어 보낸다. curl 은 시키지 않으면 안 붙인다. 이것이 두 도구의 결과가 갈린 이유의 전부다.
가설을 세웠으면 재현해야 한다. 헤더 하나만 더 붙여봤다.
$ curl -X POST "https://asat.lemuel.co.kr/api/v1/auth/demo-login" \
-H 'Content-Type: application/json' \
-H 'Origin: https://asat.lemuel.co.kr' \
-d '{"role":"ADMIN"}'
Invalid CORS request → HTTP 403
재현됐다. 그리고 응답 본문 Invalid CORS request 는 검색할 것도 없이 출처가 분명한 문자열이다.
왜 “같은 출처” 인데 백엔드는 크로스 오리진이라고 했나
여기서 한 겹이 더 있다. 브라우저 입장에서 이건 분명히 same-origin 호출이다. 페이지도 https://asat.lemuel.co.kr 이고 요청도 /api/v1/... 상대 경로다. 그런데 왜 백엔드가 CORS 로 판정했나.
구조 때문이다. 이 서비스의 프론트는 Next.js 이고, /api/* 로 들어온 요청을 catch-all 라우트 핸들러가 받아 백엔드로 넘긴다. 브라우저 → Next.js → Spring Boot 의 2단 구성이다.
그리고 그 프록시는 Origin 을 지우지 않고 그대로 넘긴다 — 지울 이유가 없으니까.
그래서 Spring Boot 가 실제로 받는 요청은 이렇게 생겼다.
| 항목 | 값 |
|---|---|
| 요청이 도착한 곳 (자기 자신) | http://asat-app:8080 (클러스터 내부 서비스 주소) |
Origin 헤더 |
https://asat.lemuel.co.kr |
Spring 이 CORS 여부를 판정하는 코드는 이 둘을 비교한다.
public static boolean isCorsRequest(HttpServletRequest request) {
String origin = request.getHeader(HttpHeaders.ORIGIN);
if (origin == null) {
return false;
}
UriComponents originUrl = UriComponentsBuilder.fromUriString(origin).build();
String scheme = request.getScheme();
String host = request.getServerName();
int port = request.getServerPort();
return !(ObjectUtils.nullSafeEquals(scheme, originUrl.getScheme()) &&
ObjectUtils.nullSafeEquals(host, originUrl.getHost()) &&
getPort(scheme, port) == getPort(originUrl.getScheme(), originUrl.getPort()));
}
— Spring Framework v7.0.9, CorsUtils.java
request.getScheme() · getServerName() · getServerPort() — 백엔드가 자기 자신을 보는 관점이다. 프록시를 한 번 거쳤으니 그건 http / asat-app / 8080 이고, Origin 의 https / asat.lemuel.co.kr / 443 과 전부 다르다. 셋 다 다르니 크로스 오리진.
크로스 오리진으로 분류되면 허용 목록 검사로 넘어가고, 통과 못 하면 여기로 떨어진다.
protected void rejectRequest(ServerHttpResponse response) throws IOException {
response.setStatusCode(HttpStatus.FORBIDDEN);
response.getBody().write("Invalid CORS request".getBytes(StandardCharsets.UTF_8));
response.flush();
}
— Spring Framework v7.0.9, DefaultCorsProcessor.java
403 + Invalid CORS request. 화면에서 본 그것이다.
정리하면 이렇게 된다.
브라우저 (https://새도메인) ← 자기 기준: same-origin
│ POST /api/v1/auth/demo-login
│ Origin: https://새도메인 ← GET·HEAD 가 아니라서 붙음 (Fetch Standard)
▼
Next.js 프록시 ← Origin 을 그대로 전달
▼
Spring Boot (http://asat-app:8080) ← 자기 기준: cross-origin (셋 다 불일치)
└─ 허용 목록에 없음 → 403 Invalid CORS request
브라우저는 same-origin 이라고 믿고, 백엔드는 cross-origin 이라고 판단한다. 프록시 한 겹이 “출처” 의 정의를 갈라놓은 것이다.
진짜 원인은 한 줄
그럼 허용 목록에는 뭐가 있었나.
CORS_ALLOWED_ORIGINS=https://eln.lemuel.co.kr,https://eln.lmshi.site
옛 도메인만 있고 새 도메인이 없다. 새 도메인을 추가하고 앱을 재시작하니 끝났다. 브라우저에서 다시 눌러 200 → 대시보드 진입까지 확인했다.
주의할 것은, 이게 오늘의 도메인 이전이 만든 회귀가 아니라는 점이다. 새 도메인은 애초부터 이 목록에 없었다. 다만 그동안은 아무도 그 주소로 안 들어왔으니 드러나지 않았을 뿐이다. 주 도메인이 되면서 그제야 보인 것이다.
남는 것
이 사건에서 건질 것은 세 가지다.
첫째, “도메인을 옮긴다” 는 원자적 작업이 아니다. 도메인 문자열은 코드 한 곳에 있지 않았다. 인증 메일에 박는 절대 URL, 백엔드의 CORS 허용 목록, 엣지의 터널 라우팅 규칙 — 최소 세 군데에 흩어져 있었고 각각 다른 곳에서 관리됐다. 그중 CORS 목록은 하필 git 밖의 수동 Secret 안에 있었다. 비밀도 아닌 공개 도메인 목록인데 git 에 없으니, 도메인을 바꿔도 아무도 같이 고쳐주지 않았다. 이전을 하기 전에 “이 도메인 문자열이 어디어디에 적혀 있는가” 를 먼저 세는 게 순서였다.
둘째, curl 로 재현이 안 되면 Origin 을 붙여봐라.
curl 200 · 브라우저 403 은 다음부터는 곧바로 CORS 를 의심할 신호로 삼는다. 브라우저는 GET·HEAD 가 아닌 모든 요청에 Origin 을 붙이고 curl 은 안 붙인다 — 그 차이 하나가 전부였다. -H 'Origin: <페이지 주소>' 를 붙이는 데는 3초가 걸린다.
셋째, 되돌릴 계획이 있는 변경에는 되돌릴 수 있는 도구를 쓴다. 301 과 302 의 선택은 취향 문제가 아니다. 301 은 캐시에 박히고, 박힌 캐시는 서버가 회수할 수 없다. “당분간” 이라고 말하는 순간 답은 302 로 정해진다. 반대로 정말 영구 이전이고 POST 까지 살려야 한다면 308 이다.
마지막 하나. 이번엔 브라우저를 실제로 열어서 눌러본 덕에 5분 만에 잡았다. curl 만 봤다면 “백엔드는 200 인데요” 에서 한참 헤맸을 것이다.
재현 환경이 사용자의 환경과 다르면, 재현에 성공했다는 사실 자체가 오답이 된다.
References
- R. Fielding, M. Nottingham, J. Reschke (eds.), HTTP Semantics, RFC 9110, IETF, June 2022. — §15 Status Codes (heuristically cacheable 목록), §15.4.2 301 Moved Permanently, §15.4.3 302 Found, §15.4.8 307 Temporary Redirect, §15.4.9 308 Permanent Redirect
- WHATWG, Fetch Standard, Living Standard. — append a request
Originheader - Cloudflare Docs, Create a redirect rule in the dashboard (Rules — Single Redirects). — https://developers.cloudflare.com/rules/url-forwarding/single-redirects/create-dashboard/
- Spring Framework
v7.0.9소스. —CorsUtils.java,DefaultCorsProcessor.java
글에 나오는 상태 코드·헤더 값은 모두 실제로 요청을 보내 받은 응답이다. 내부 주소는 노드·서비스 이름으로 바꿔 적었다.