이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • curl은 성공하는데 브라우저만 실패할 때 무엇을 의심해야 하는가?
  • CORS, Cookie, SameSite, mixed content는 서버 로그와 어떻게 다르게 보이는가?
  • 브라우저 요청을 디버깅할 때 DevTools에서 어떤 항목을 봐야 하는가?

개요

브라우저는 단순 HTTP 클라이언트가 아니다. 서버에서 응답이 왔더라도 브라우저는 CORS, Cookie, SameSite, Secure, mixed content, cache, CSP 같은 정책을 적용해 JavaScript에 응답을 넘길지 결정한다.

그래서 curl은 성공하지만 브라우저는 실패하는 상황이 생긴다. 이때 백엔드 개발자가 “서버는 200을 줬는데 프론트 문제”라고만 말하면 원인이 늦게 잡힌다. 서버가 브라우저 정책에 필요한 헤더와 쿠키 속성을 올바르게 내려 주지 않았을 수도 있기 때문이다.

왜 백엔드 개발자가 알아야 하는가

프론트엔드와 백엔드가 도메인을 나누는 구조에서는 인증과 보안 문제가 브라우저 경계에서 자주 발생한다.

  • API 서버는 200을 반환했지만 브라우저가 CORS 정책으로 응답 접근을 막는다.
  • 로그인 응답에 Set-Cookie가 내려왔지만 브라우저가 SameSite/Secure 조건 때문에 저장하지 않는다.
  • HTTP 페이지에서 HTTPS API 또는 HTTPS 페이지에서 HTTP API를 섞어 mixed content 문제가 생긴다.
  • preflight OPTIONS 요청이 인증 필터나 WAF에서 막혀 실제 요청까지 가지 못한다.
  • redirect 후 cookie domain/path가 맞지 않아 로그인 상태가 유지되지 않는다.

이 문제들은 서버 로그만 보면 “정상 응답”처럼 보일 수 있다. 브라우저 DevTools와 서버 로그를 함께 봐야 한다.

요청 흐름에서의 위치

브라우저 정책은 두 지점에서 개입한다.

JavaScript fetch / form submit
→ 브라우저가 요청 전 정책 판단
→ 필요하면 preflight OPTIONS 전송
→ 실제 HTTP 요청 전송
→ 서버 응답 수신
→ 브라우저가 응답 노출, 쿠키 저장, cache 여부 판단
→ JavaScript에 응답 전달 또는 차단

CORS 오류는 서버가 아예 응답하지 않았다는 뜻이 아니다. 서버가 응답했지만 브라우저가 그 응답을 JavaScript에 넘기지 않았을 수 있다.

원리

브라우저 경계에서 자주 보는 정책은 다음과 같다.

정책적용 위치서버가 챙길 것실패 증상
CORScross-origin fetch 응답 노출Access-Control-Allow-Origin, method, header, credentialsConsole CORS error
Preflight비단순 요청 전 OPTIONSOPTIONS route, auth 예외, allow headers실제 요청 전 실패
Cookie요청 자동 첨부와 저장Domain, Path, Max-Age, HttpOnly, Secure쿠키 미전송, 로그인 풀림
SameSitecross-site 쿠키 제한OAuth, 프론트/백 분리 구조 고려callback 후 세션 없음
Mixed contentHTTPS 페이지의 HTTP 리소스 차단API와 asset 모두 HTTPS브라우저 차단
Cache브라우저 저장 응답 재사용Cache-Control, ETag수정했는데 이전 응답 표시
CSP리소스 로드 정책script/connect/img source 허용API나 script 차단

서버 간 호출에서는 CORS가 적용되지 않는다. CORS는 브라우저가 적용하는 보안 정책이다.

실무에서 자주 만나는 문제

  • curl로는 200인데 브라우저 Console에는 CORS 오류가 보인다.
    • curl은 브라우저 보안 정책을 적용하지 않는다.
    • Origin 헤더를 넣어 서버의 CORS 응답을 확인해야 한다.
  • Set-Cookie가 응답에 있는데 브라우저가 저장하지 않는다.
    • SameSite=None이면 Secure가 필요하다.
    • Domain, Path, Expires/Max-Age 조건도 확인한다.
  • preflight OPTIONS가 401/403을 받는다.
    • 실제 요청이 아니라 사전 확인 요청이 막힌 것이다.
    • 인증 필터, security config, WAF rule에서 OPTIONS를 어떻게 처리하는지 본다.
  • 로컬 개발에서는 되는데 배포 환경에서만 로그인 쿠키가 빠진다.
    • localhost, http/https, 실제 domain, reverse proxy termination 차이가 쿠키 조건을 바꿀 수 있다.

디버깅 방법

브라우저에서 실패할 때는 DevTools Network 탭에서 실패한 요청을 열고 아래를 본다.

위치확인할 것
HeadersRequest URL, Method, Origin, Cookie, Authorization
Response HeadersAccess-Control-Allow-Origin, Set-Cookie, Cache-Control
Cookiesblocked reason, SameSite, Secure, Domain, Path
Payload실제 body와 content type
Timingblocked, stalled, waiting, content download
ConsoleCORS, CSP, mixed content, cookie warning

curl로 브라우저 요청을 흉내 내면 서버 응답을 분리해서 볼 수 있다.

curl -i https://api.example.com/me \
  -H 'Origin: https://www.example.com' \
  -H 'Cookie: SESSION=abc'
 
curl -i -X OPTIONS https://api.example.com/orders \
  -H 'Origin: https://www.example.com' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: content-type,authorization'

첫 번째는 실제 요청 응답의 CORS/Cookie 헤더를 본다. 두 번째는 preflight가 허용되는지 본다.

코드로 확인하기

브라우저에서 쿠키 기반 인증 요청을 보낼 때는 credentials 설정이 필요하다.

await fetch("https://api.example.com/me", {
  method: "GET",
  credentials: "include",
  headers: {
    "X-Request-ID": crypto.randomUUID()
  }
});

서버도 credential 요청에 맞게 응답해야 한다.

Access-Control-Allow-Origin: https://www.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
Set-Cookie: SESSION=abc; Path=/; HttpOnly; Secure; SameSite=None

여기서 Access-Control-Allow-Origin: *와 credential 요청은 함께 쓸 수 없다. 또한 여러 origin을 동적으로 허용한다면 Vary: Origin을 고려해야 cache가 다른 origin의 응답을 잘못 재사용하지 않는다.

주니어가 자주 하는 오해

  • CORS를 서버 간 통신 문제로 이해한다.
    • CORS는 브라우저가 JavaScript의 cross-origin 접근을 제한하는 정책이다.
  • Set-Cookie가 내려왔으면 쿠키가 저장됐다고 생각한다.
    • 브라우저는 SameSite, Secure, Domain, Path, third-party cookie 정책으로 저장을 거부할 수 있다.
  • OPTIONS 요청을 이상한 공격 요청으로 본다.
    • preflight OPTIONS는 브라우저가 실제 요청 전에 보내는 정상 요청이다.
  • DevTools Console만 보고 Network 탭을 보지 않는다.
    • Console은 결과 메시지이고, Network에는 실제 요청/응답 헤더와 blocked reason이 있다.

시니어의 설계 판단 기준

  • 프론트와 API의 origin 구성을 먼저 정하고 CORS 허용 목록을 좁게 관리한다.
  • 쿠키 기반 인증을 쓸지 Authorization header 기반 인증을 쓸지 브라우저 정책까지 포함해 결정한다.
  • OAuth redirect, embedded browser, mobile WebView에서는 SameSite와 third-party cookie 정책을 별도로 검증한다.
  • CORS 정책은 운영 설정이므로 환경별 origin 목록과 배포 절차에 포함한다.
  • 인증 실패, CORS 실패, 쿠키 미전송, CSRF 차단을 서로 다른 로그와 응답으로 구분한다.

인프라 협업 포인트

  • TLS termination 위치가 바뀌면 앱이 요청을 http로 인식해 Secure cookie나 redirect가 깨질 수 있다.
  • CDN이나 proxy가 Origin, Access-Control-*, Set-Cookie, Vary 헤더를 제거하지 않는지 확인한다.
  • WAF가 OPTIONS 요청이나 특정 header를 차단하면 실제 API 요청 전에 실패한다.
  • API domain과 frontend domain이 바뀌면 쿠키 Domain과 CORS origin도 같이 바뀌어야 한다.

실전 팁

  • 브라우저 문제는 “curl 성공 여부”보다 “curl에 Origin을 넣었을 때 서버가 어떤 CORS 헤더를 주는가”가 더 중요하다.
  • 로그인 장애는 Network 탭에서 request cookie와 response set-cookie를 동시에 본다.
  • SameSite=None; Secure는 로컬 HTTP 개발 환경에서 그대로 재현하기 어렵다. 로컬 HTTPS나 개발 전용 설정을 둔다.
  • CORS 허용 origin을 *로 열어 문제를 덮으면 인증/보안 문제가 나중에 더 커진다.

위험 신호!

  • CORS 설정에 *와 credentials를 동시에 넣으려 한다.
  • 운영 origin 목록이 코드와 인프라 설정에 흩어져 있다.
  • OPTIONS 요청이 인증 필터에서 401을 반환한다.
  • Set-Cookie는 내려오는데 DevTools에 cookie rejected warning이 있다.
  • API는 HTTPS인데 redirect Location이 HTTP로 내려온다.

확인 질문

확인 질문

  • CORS 오류가 보이면 서버가 응답하지 않았다는 뜻인가?
    • 아니다. 서버가 응답했지만 브라우저가 JavaScript에 응답을 노출하지 않은 것일 수 있다.
  • 쿠키 기반 cross-site 요청에서 client와 server가 각각 챙길 것은 무엇인가?
    • client는 credentials: "include"를 설정하고, server는 명시적 origin, credentials 허용, 적절한 Set-Cookie 속성을 내려야 한다.
  • preflight OPTIONS가 401이면 무엇을 의심하는가?
    • 실제 API가 아니라 사전 확인 요청이 인증 필터, CORS 설정, WAF에서 막히는지 확인한다.

참고 문서