CORS 프리플라이트는 언제 생길까: OPTIONS 요청이 먼저 가는 조건
fetch()가 본 요청 전에 OPTIONS를 보내는 조건을 WHATWG Fetch 표준으로 정리했습니다. 안전 목록의 메서드와 헤더, application/json과 Authorization이 프리플라이트를 부르는 이유, Max-Age 기본값 5초도 다룹니다.
다른 출처의 API에 fetch()로 Content-Type: application/json인 JSON을 POST할 때 일치하는 CORS-preflight 캐시 항목이 없으면, 개발자 도구에 같은 URL로 가는 OPTIONS 요청이 하나 더 찍힙니다. 이것이 CORS 프리플라이트(preflight) 요청입니다. 본 요청을 보내기 전에 서버가 CORS 프로토콜을 이해하는지 브라우저가 확인하는 절차입니다.
이 글은 WHATWG Fetch 표준(2026년 9월 21일 갱신판)과 RFC 9110을 근거로 합니다. Fetch는 계속 고쳐지는 Living Standard라서 절 번호와 세부 단계가 바뀔 수 있습니다. 아래 내용은 표준이 정한 동작이고, 개별 브라우저의 구현은 따로 확인하지 않았습니다.
프리플라이트가 붙는 조건
같은 출처로 가는 요청은 CORS 절차를 거치지 않습니다. 다른 출처로 가는 fetch()와 XMLHttpRequest 요청에는 표준 내부의 unsafe-request 플래그가 켜집니다. 이런 요청은 다음 중 하나에 해당하면 프리플라이트 허가가 필요합니다. 다만 일치하는 CORS-preflight 캐시 항목이 있으면 브라우저는 새 OPTIONS 요청을 생략할 수 있습니다.
- 메서드가
GET,HEAD,POST가 아니다. - 안전 목록(CORS-safelisted)에 없는 요청 헤더가 있다.
- 본문으로
ReadableStream을 쓰거나XMLHttpRequestUpload에 이벤트 리스너를 등록했다.
안전 목록에 드는 요청 헤더는 Accept, Accept-Language, Content-Language, Content-Type이고, Range는 bytes=0-499처럼 단일 범위일 때만 해당합니다. 값이 128바이트를 넘으면 목록에서 빠집니다. Content-Type은 MIME 타입이 application/x-www-form-urlencoded, multipart/form-data, text/plain 중 하나여야 합니다. 이 헤더들의 값 길이를 합쳐 1024바이트를 넘어도 모두 안전하지 않은 헤더로 취급됩니다. Fetch 표준에는 ’단순 요청(simple request)’이라는 용어가 없고, 이 조건들만 있습니다.
| 요청 | 프리플라이트 필요 | 이유 |
|---|---|---|
fetch(url) |
없음 | GET이고 추가 헤더가 없다 |
fetch(url, { method: "POST", body: JSON.stringify(data) }) |
없음 | 문자열 본문에는 Content-Type: text/plain;charset=UTF-8이 붙는다 |
위 요청에 "Content-Type": "application/json" 추가 |
있음 | application/json은 안전 목록이 아니다 |
headers: { Authorization: "Bearer ..." } |
있음 | Authorization은 안전 목록이 아니다 |
method: "DELETE" |
있음 | 안전 목록 메서드가 아니다 |
두 번째 줄에서 Content-Type을 직접 지정하지 않으면 Request가 본문 종류에 맞는 값을 채웁니다. 그래서 서버는 JSON 문자열을 text/plain으로 받습니다.
프리플라이트가 없다고 CORS 검사가 없는 것은 아닙니다. 요청은 서버로 그대로 가고, 응답이 CORS 검사를 통과하지 못하면 브라우저가 스크립트에 네트워크 오류를 돌려줍니다. 서버는 이미 요청을 처리한 뒤일 수 있습니다.
요청과 통과하는 응답
JSON을 Authorization과 함께 POST하면 프리플라이트는 다음과 같습니다(주요 헤더만 적음).
OPTIONS /api/items HTTP/1.1
Origin: https://app.example
Accept: */*
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization,content-type
Access-Control-Request-Headers에는 안전 목록 밖의 헤더 이름이 소문자로 정렬되어 공백 없이 쉼표로 이어집니다.
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Max-Age: 600
통과하려면 상태 코드가 200~299여야 하고, Access-Control-Allow-Origin이 요청의 출처와 같거나 *여야 합니다. 쿠키 등을 함께 보내는 credentials 모드 "include"에서는 *가 통하지 않고 Access-Control-Allow-Credentials: true도 필요합니다. 메서드와 헤더의 *도 이 모드에서는 와일드카드로 쓰이지 않습니다. Authorization은 모드와 상관없이 Access-Control-Allow-Headers에 이름을 직접 적어야 합니다. 검사에 실패하면 본 요청은 보내지 않습니다.
프리플라이트 요청에는 쿠키나 Authorization 같은 자격 증명이 들어가지 않습니다. 인증 미들웨어가 OPTIONS에도 토큰을 요구해 401을 돌려주면 상태 코드 조건에 걸려 프리플라이트가 실패합니다.
결과의 캐시
Access-Control-Max-Age는 허용된 메서드와 헤더 정보를 몇 초 동안 캐시할지 정합니다. 헤더가 없으면 5초입니다. 브라우저는 상한을 둘 수 있는데, 그 값은 표준에 없고 이 글에서도 확인하지 않았습니다. 각 캐시 항목에는 네트워크 파티션 키, 직렬화된 출처, URL, max-age, credentials 여부, 그리고 메서드 또는 헤더 이름이 저장됩니다. max-age가 지나면 지워야 하고, 그 전에 지워질 수도 있습니다.
RFC 9110은 OPTIONS 응답을 캐시할 수 없다고 정합니다. 프리플라이트 결과는 HTTP 캐시가 아니라 Fetch 표준이 따로 둔 CORS-preflight 캐시에 남습니다.
401 응답 자체의 의미는 401과 403의 차이에서 다뤘습니다.
참고한 명세
- WHATWG Fetch Standard (Living Standard, 2026년 9월 21일 갱신): 2.2.1 Methods, 2.2.2 Headers, 2.2.3 Statuses, 2.2.5 Requests, 3.3.2–3.3.5 CORS protocol, 4.1 Main fetch, 4.4 HTTP fetch, 4.8 CORS-preflight fetch, 4.9 CORS-preflight cache, 4.10 CORS check, 5.2 BodyInit unions, 5.4 Request class
- RFC 9110 — HTTP Semantics: 9.3.7 OPTIONS
링크와 내용은 2026년 10월 5일에 확인했습니다.