"CORS는 인증이나 서버 쪽 권한 검사를 대신하지 않아. 서버가 선언한 교차 출처 응답 공유 정책을 브라우저가 집행하는 장치야. 이 경계를 잡으면 요청이 서버에 도착했는지, 브라우저가 응답을 스크립트에 공개했는지부터 분리해서 볼 수 있어."
CORS를 푸는 두 가지 관점
CORS 문제를 볼 때는 다음 두 사실을 함께 기억해:
- CORS는 브라우저가 집행해. curl, Python httpx, 서버 간 HTTP 호출은 브라우저의 동일 출처 정책을 따르지 않아. 따라서 CORS 응답 헤더가 없어도 요청하고 응답을 읽을 수 있어.
- 정책은 서버가 선언해. 서버는 허용할 출처와 메서드, 요청 헤더를
Access-Control-Allow-*응답 헤더로 알리고, 브라우저는 그 선언을 바탕으로 응답을 JavaScript에 노출할지 판단해.
그래서 CORS 오류가 보이면 먼저 서버 도달 여부와 응답 헤더를 나눠 확인해야 해. 단순 요청은 서버에서 처리된 뒤에도 응답 노출만 차단될 수 있고, 프리플라이트가 실패한 요청은 실제 요청 자체가 전송되지 않을 수 있어.
출처는 무엇으로 결정될까
출처는 (scheme, host, port)의 조합이야. 기본 포트는 정규화되므로 https://example.com과 https://example.com:443은 같은 출처지만, http://example.com은 스킴이 달라 다른 출처야. 호스트나 유효 포트가 달라도 교차 출처가 돼.
동일 출처 정책(SOP)은 한 출처의 JavaScript가 다른 출처의 응답을 마음대로 읽지 못하게 하는 브라우저의 기본 방어야. CORS는 리소스 서버가 특정 교차 출처에 응답 읽기를 허용하도록 이 제한을 안전하게 완화해. 다만 요청 전송 자체를 모두 막는 CSRF 방어는 아니므로 쿠키 인증 API에는 별도의 CSRF 대책이 필요해.
단순 요청과 프리플라이트 요청
브라우저는 교차 출처 요청을 크게 두 가지로 처리해.
단순 요청은 다음 조건을 모두 만족해 프리플라이트가 필요하지 않은 요청이야:
- 메서드가 GET, HEAD, POST 가운데 하나야.
- 요청 헤더가 Accept, Accept-Language, Content-Language와 일부 Content-Type처럼 CORS 안전 목록에 들어가.
- Content-Type이
application/x-www-form-urlencoded,multipart/form-data,text/plain가운데 허용된 형태야.
브라우저는 단순 요청을 먼저 보내고, 응답의 Access-Control-Allow-Origin: matching-origin을 확인해. 값이 맞으면 응답을 JavaScript에 노출하고, 없거나 맞지 않으면 스크립트가 응답을 읽지 못하게 해. 서버에서 이미 일어난 부작용까지 되돌려 주지는 않아.
프리플라이트가 필요한 요청에는 Content-Type: application/json인 POST나 Authorization 같은 안전 목록 밖의 헤더를 쓰는 요청이 포함돼:
- 브라우저가 실제 요청 전에 같은 URL로
Origin,Access-Control-Request-Method,Access-Control-Request-Headers를 담은OPTIONS요청을 보내. - 서버는 허용 범위를
Access-Control-Allow-Origin/Methods/Headers로 응답해. - 브라우저는 요청하려는 출처·메서드·헤더가 허용 범위에 들어가는지 비교해. 맞지 않으면 실제 요청을 보내지 않아.
- 검사를 통과하면 실제 요청을 보내고, 실제 응답의
Access-Control-Allow-Origin도 다시 확인한 뒤 스크립트에 노출해.
프리플라이트는 왕복을 하나 추가하지만, 서버는 Access-Control-Max-Age로 결과의 캐시 시간을 제안할 수 있어. 실제 캐시 상한은 브라우저마다 다를 수 있으므로 무한정 유지된다고 가정하면 안 돼.
핵심 응답 헤더 다섯 가지
Access-Control-Allow-Origin— 응답을 읽도록 허용할 구체적인 출처나*와일드카드야. 요청의 Origin을 그대로 반사하지 말고 먼저 허용 목록과 정확히 대조해. 출처별로 값을 바꿔 반환하고 공유 캐시를 쓴다면 Vary: Origin도 필요해.Access-Control-Allow-Credentials— 쿠키처럼 브라우저가 관리하는 자격 증명을 포함한 요청의 응답을 노출하려면true로 설정해. 클라이언트도 credentials 모드를 알맞게 설정해야 해. 명시적 Authorization 헤더는 별도로 프리플라이트의 허용 헤더 검사도 통과해야 해. 자격 증명 모드에서는Access-Control-Allow-Origin: *를 사용할 수 없어.Access-Control-Allow-Methods— 프리플라이트 응답에서 실제 요청에 허용할 메서드를 알려줘.Access-Control-Allow-Headers— 프리플라이트 응답에서 실제 요청에 허용할 헤더를 알려줘.Access-Control-Max-Age— 브라우저가 프리플라이트 결과를 캐시할 시간을 초 단위로 제안해.
와일드카드와 자격 증명의 함정
자주 만나는 CORS 오류는 두 가지야:
1. Access-Control-Allow-Origin: * + 쿠키. 자격 증명 모드의 요청에 와일드카드를 반환하면 브라우저가 응답을 스크립트에 노출하지 않아. 쿠키가 전송되지 않았다고 가정해서도 안 돼. 허용 목록에서 Origin을 검증한 뒤 구체적인 출처를 반환하고 Vary: Origin을 설정해.
2. 프리플라이트는 성공 상태인데 CORS 헤더가 없어. OPTIONS 응답이 200이나 204여도 Access-Control-Allow-Origin과 필요한 허용 헤더가 빠지면 브라우저는 실패로 처리해. 실제 응답뿐 아니라 프리플라이트 응답에도 올바른 CORS 헤더가 있어야 해.
cwkPippa의 CORS 구성
backend/main.py의 _allowed_origins에 http://localhost:5173, http://127.0.0.1:5173, 허용한 Tailscale 출처처럼 구체적인 값을 등록해. 쿠키를 포함한 자격 증명 요청을 지원해야 하므로 무조건적인 와일드카드를 쓰지 않아. FastAPI의 CORSMiddleware가 프리플라이트와 실제 응답 헤더를 처리하므로 각 엔드포인트에 OPTIONS 처리기를 반복해서 만들 필요는 없어. 새 접속 출처를 추가할 때는 정확한 스킴·호스트·포트를 허용 목록에 반영하고 애플리케이션을 다시 시작해야 해. 연결 오류를 조사할 때도 실행 중인 설정과 요청의 Origin을 먼저 비교하면 돼.