밤 12시, 새로운 로드밸런서를 설정하는 작업을 진행했다. 레거시 클라이언트를 위해 이전 도메인 api.example.org로 오는 요청을 새 도메인 api.example.app으로 리다이렉트하도록 Google Cloud Load Balancer를 설정했다.
defaultUrlRedirect: redirectResponseCode: MOVED_PERMANENTLY_DEFAULT # 301 httpsRedirect: true hostRedirect: api.example.app
브라우저에서 테스트해보니 잘 리다이렉트되었다. 설정 완료.
이제 앱에서 직접 연결 확인을 위해 로그인 버튼을 눌렀는데, 로그인이 되지 않았다.
서버 로그를 확인해보니 이상한 내용이 찍혀 있었다.
[ERROR] 405 Method Not Allowed - GET /api/auth/login
클라이언트는 분명 POST 요청을 보내고 있는데, 서버에 도착한 요청은 GET이었다.
TestFlight의 네트워크 탭으로 네트워크 트래픽을 캡처해보고 나서야 무슨 일이 일어나고 있는지 알 수 있었다.
1. POST api.example.org/api/auth/login → 301 Moved Permanently → Location: api.example.app 2. GET api.example.app/api/auth/login → 405 Method Not Allowed
POST 요청이 301 리다이렉트를 거치면서 GET으로 바뀌고 있었다.
RFC 7231에는 이런 내용이 있다.
Note: For historical reasons, a user agent MAY change the request method from POST to GET for the subsequent request.
HTTP/1.0 시절 301의 원래 의도는 "같은 메서드로 새 위치에 요청하라"는 것이었다. 하지만 당시 브라우저들이 이를 잘못 구현해서 POST를 GET으로 바꿔버리는 동작이 널리 퍼졌고, 이 "버그"가 사실상 표준처럼 굳어져 버렸다.
결과적으로 301 리다이렉트를 받은 클라이언트가 POST를 유지할지 GET으로 바꿀지는 클라이언트 구현에 따라 다르다. iOS의 URLSession은 POST를 GET으로 바꾸고, Android의 OkHttp는 POST를 유지한다.
이 문제를 해결하기 위해 HTTP 308 (Permanent Redirect)이 존재한다. RFC 7538에 정의된 308은 301과 동일하게 영구 리다이렉트를 의미하지만, HTTP 메서드를 반드시 유지해야 한다.
URL Map 설정을 308로 변경했다.
defaultUrlRedirect: redirectResponseCode: PERMANENT_REDIRECT # 308 httpsRedirect: true hostRedirect: api.example.app
POST가 유지되는 것을 확인했다. 하지만 이번에는 401 Unauthorized 에러가 발생했다. Authorization 헤더가 사라져 있었다.
다시 Charles Proxy를 확인했다. 첫 번째 요청에는 Authorization 헤더가 있었지만, 리다이렉트 후 두 번째 요청에서는 사라져 있었다.
[첫 번째 요청]
POST api.example.org/api/auth/login Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
[두 번째 요청 - 리다이렉트 후]
POST api.example.app/api/auth/login (Authorization 헤더 없음)
이것은 버그가 아니라 의도된 보안 기능이다. 대부분의 HTTP 클라이언트는 호스트가 변경되는 리다이렉트에서 Authorization, Cookie 같은 민감한 헤더를 자동으로 제거한다.
이유는 간단하다. 악의적인 서버가 리다이렉트를 통해 사용자의 인증 토큰을 탈취하는 것을 방지하기 위함이다.
[보안 위협 시나리오]
example.org에서 example.app으로의 리다이렉트도 호스트가 변경되는 것이므로 Authorization 헤더가 제거된 것이다. 같은 조직이 소유한 도메인이라는 맥락을 HTTP 클라이언트는 알 수 없다.
308로 메서드 문제를 해결해도 인증 헤더 손실 문제는 해결할 수 없다. API 서버에 리다이렉트를 사용하는 것 자체가 잘못된 접근이었다.
최종 해결책은 리다이렉트를 제거하고, 두 도메인 모두 동일한 백엔드 서비스로 직접 라우팅하는 것이다.
hostRules: - hosts: - api.example.org - api.example.app pathMatcher: api-backend pathMatchers: - name: api-backend defaultService: projects/my-project/global/backendServices/api-service
이제 어떤 도메인으로 요청이 오든 리다이렉트 없이 바로 백엔드 서비스로 전달된다.
POST 요청을 리다이렉트해야 한다면 반드시 307이나 308을 사용해야 한다.
호스트가 변경되는 리다이렉트에서는 Authorization, Cookie 등의 민감한 헤더가 제거된다. 이는 보안을 위한 의도된 동작이며 우회할 방법이 없다.
웹 프론트엔드 서버에서는 301 리다이렉트가 잘 작동한다. 대부분 GET 요청이고 인증 헤더를 직접 보내는 경우도 드물기 때문이다. 하지만 API 서버는 다르다. 다양한 HTTP 메서드를 사용하고 거의 모든 요청에 인증 헤더가 포함된다.
API 서버에서 도메인 마이그레이션이 필요하다면 다음 방법을 고려해야 한다.
리다이렉트는 최후의 수단으로만 사용하고, 사용하더라도 307/308을 쓰며, 인증이 필요한 엔드포인트에서는 가급적 피해야 한다.