"CORS 에러"라는데 서버는 멀쩡했다
프런트 쪽에서 “Sentry로 보내는 요청에 CORS 에러가 난다”는 제보가 왔고, 이어서 “
baggage·sentry-trace헤더를 허용 목록에 넣어 달라”는 요청이 붙었다. 서버 로그를 48시간 치 뒤졌는데 서버는 멀쩡했다. 원인은 엉뚱한 데 있었다.ingest 외부 노출 편에서 이벤트 수집 경로만 따로 열어 둔 그 호스트의 후속 이야기다.
(사내 인프라 특성상 도메인·프로젝트 이름은 일반화했다.)
서버를 뒤졌다 — 전부 정상
“CORS를 고쳐 달라”는 말을 그대로 믿고 Sentry 쪽 CORS부터 봤다.
- 도달한 요청은 전부 성공: 프록시 로그 48시간 치에서 이벤트 수집 요청(
/envelope/) 2,930건이 모두 200이었다. - 수집 호스트의 사전 요청(preflight) 응답도 정상:
access-control-allow-origin: *, 허용 헤더 목록,max-age 3600까지 다 붙어 있었다. - Sentry가 출처(origin) 때문에 거부한 기록은 0건: Sentry의 수집 결과 기록에
invalid_origin이나cors사유가 하나도 없었다. - 프로젝트의 허용 도메인 설정도 비어 있음: 기본값이 “전부 허용”이라 여기서 막힌 것도 아니다.
- 504가 26건 있었지만 전부 관리 화면의 트레이싱 조회였고, 수집과는 무관했다.
단서는 하나였다. SDK가 보내는 클라이언트 보고에 network_error 사유로 버린 이벤트가 18건 있었다. SDK가 전송 자체에 실패한 것이다. 그런데 서버 로그에는 그 실패가 안 보인다. 서버까지 오지 못했거나, 서버가 아닌 곳에서 막혔다는 뜻이다.
403은 CORS 에러로 보인다
그 “서버가 아닌 곳”이 리버스 프록시였다. 수집 호스트는 수집 경로 두 개(/api/<id>/envelope/, /store/)만 열고 나머지는 403으로 막는다. 그리고 그 403 응답에는 Access-Control-Allow-Origin 헤더가 붙지 않는다.
브라우저 입장에서 보면, 다른 출처로 보낸 요청의 응답에 허용 헤더가 없으면 그 응답을 스크립트에 넘겨주지 않고 CORS 에러로 처리한다. 응답이 403이든 404든 상관없다. 그래서 콘솔에는 “CORS policy” 에러가 먼저 찍히고, 막힌 건지 설정이 틀린 건지는 구분되지 않는다.
실제 원인은 “경로나 IP가 막혔다”인데, 제보는 “CORS를 고쳐 달라”로 도착한다. 이번에 서버를 다 뒤진 이유가 이것이었다.
403에 헤더만 붙이면 될까 — 안 된다
처음 떠오르는 해법은 403 응답에도 CORS 헤더를 붙이는 것이다. 그러면 브라우저가 403을 그대로 보여 줄 테니까. 그런데 Sentry 요청에는 이게 거의 효과가 없다.
Sentry SDK는 Content-Type: application/x-sentry-envelope으로 보낸다. 이건 브라우저가 “단순 요청”으로 봐 주는 콘텐츠 타입이 아니라서, 본 요청 전에 항상 사전 요청(OPTIONS)이 먼저 나간다. 그리고 사전 요청은 응답 코드가 200·204가 아니면 헤더가 붙어 있어도 실패한다. 403에 헤더를 붙여 봐야 사전 요청 단계에서 이미 끝난다.
그래서 이렇게 나눴다. 사전 요청(OPTIONS)에는 204로 답하고, 본 요청에는 403을 그대로 준다. 그러면 브라우저가 본 요청까지 보내고, 콘솔에 403이 제대로 보인다.
고친 것
수집 호스트의 Advanced 설정에서, 막는 쪽 location / 블록만 바꿨다.
location / {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
add_header Access-Control-Allow-Headers "content-type, x-sentry-auth, sentry-trace, baggage" always;
add_header Access-Control-Max-Age 3600 always;
return 204;
}
add_header Access-Control-Allow-Origin "*" always;
return 403;
}
건드리면 안 되는 곳이 두 군데 있었다.
- 열어 둔 수집 경로의 location 블록. 여기는 Sentry가 이미 CORS 헤더를 붙여 응답한다. 프록시에서
add_header를 더하면Access-Control-Allow-Origin이 두 번 붙고, 브라우저는 그 응답을 거부한다. 멀쩡한 경로를 깨뜨리는 셈이다. - 관리 화면 호스트. Advanced가 비어 있는 호스트에
location /를 넣으면 Nginx Proxy Manager가 자동으로 만드는location /와 겹쳐서 호스트 전체가 죽는다. 관리 화면은 주소창으로 직접 여니까 403 화면이 원래 그대로 보인다. 손댈 이유도 없다.
보안상 달라지는 건 없다. 사전 요청은 프록시에서 끝나 Sentry까지 가지 않고, 본 요청은 여전히 403이며, 통과하는 경로도 여전히 두 개뿐이다.
검증
| 항목 | 전 | 후 |
|---|---|---|
| 막힌 경로의 사전 요청 | 403, CORS 헤더 없음 | 204 + CORS 헤더 4종 |
| 막힌 경로의 본 요청 | 403, CORS 헤더 없음 | 403 + 허용 헤더 (차단은 그대로) |
| 열린 경로의 사전 요청 | 200 + 허용 헤더 1개 | 200 + 허용 헤더 1개(중복 없음) |
| 열린 경로의 본 요청 | 401 | 401 (같음) |
| 관리 화면 헬스 체크 | 200 | 200 (영향 없음) |
이제 같은 상황이 오면 브라우저 콘솔에 “CORS 에러”가 아니라 403이 보인다.
아직 남은 것
정작 실패한 요청의 전체 주소는 아직 받지 못했다. 이게 있어야 원인이 둘 중 하나로 갈린다.
- 주소가 Sentry의
/envelope/라면, 웹 앱의 DSN 호스트를 수집 전용 서브도메인으로 바꾸면 해결된다. 7월에 수집 경로를 열 때 모바일 쪽에는 바뀐 주소를 안내했지만, 웹 쪽에 안내한 기록은 없다. 웹 앱이 아직 관리 화면 도메인으로 보내고 있다면, 사내망에서는 통과하고 외부망에서만 403(= CORS 에러)이 나는 지금 증상과 맞는다. - 주소가 사내 API 서버라면,
sentry-trace·baggage는 Sentry가 아니라 그 API 서버가 허용해야 하는 헤더다. Sentry SDK가 API 요청에 추적용 헤더를 붙이기 때문이다. 이 경우 Sentry와는 무관하다.
안내할 때의 원칙도 하나 정했다. 개발·스테이징·운영 모두 수집 주소는 수집 전용 서브도메인 하나로 통일한다. 이 호스트는 공개돼 있어서 사내망에서도 그대로 동작하니, 환경별로 나눌 이유가 없다.
마치며
진짜로 배운 것
- “CORS 에러”는 원인이 아니라 증상이다. 허용 헤더가 없는 응답이면 403도 404도 브라우저에서는 CORS 에러로 보인다. 제보를 받으면 실제 응답 코드부터 확인한다.
- 서버 로그에 없는 실패는 서버 앞에서 일어난 것이다. SDK는 실패를 셌는데 서버는 전부 성공이었다. 그 차이가 프록시를 가리켰다.
- 사전 요청을 따로 생각한다. 단순 요청이 아니면 OPTIONS가 먼저 나가고, 그건 200·204여야 통과한다. 그래서 403에 헤더만 붙여서는 소용이 없다.
- 이미 헤더를 붙이는 곳에는 더 붙이지 않는다. 허용 헤더가 두 번 붙으면 브라우저는 거부한다. 고칠 범위를 막힌 경로 하나로 좁혔다.
self-hosted Sentry 카테고리의 글
- 디스크가 곧 쿼터일 때 — 리플레이 보존 정책
- Sentry 화면이 30초씩 멈춘다 — 17번째에야 안 풀린 snuba-api
- "CORS 에러"라는데 서버는 멀쩡했다
- 리플레이 디스크를 GA4 3년치로 다시 쟀다
- Sentry Session Replay 용량 실측
- Keycloak 클라이언트 하나만 Google 로그인 전용으로
- Sentry ingest 엔드포인트만 외부에 여는 방법
- Sentry Admin은 왜 사라졌나
- self-hosted Sentry에 Slack 통합을 붙이며
- 리버스 프록시 뒤 Sentry에 Keycloak SSO를 붙이며 만난 CSRF 6단계