← 모두의 툴

Preflight가 도는 요청 vs 안 도는 요청 — CORS 에러의 진짜 원인

가이드 · 2026.08.21 최종 확인

"CORS 에러가 났는데 서버 헤더는 분명히 맞게 넣었다"는 상황의 상당수는 프리플라이트(preflight) 요청 자체가 왜 도는지, 혹은 왜 안 도는지를 오해한 데서 시작합니다. 모든 크로스오리진 요청에 OPTIONS 프리플라이트가 자동으로 붙는 게 아닙니다. 브라우저는 "단순 요청(simple request)"이라는 특정 조건을 만족하는 요청은 프리플라이트 없이 곧장 보내고, 그 조건을 벗어나는 순간부터 반드시 프리플라이트를 먼저 보냅니다. 이 경계선을 모르면 헤더를 아무리 손봐도 원인을 못 찾습니다.

1. 단순 요청의 3가지 조건

브라우저가 프리플라이트를 생략하는 "단순 요청"으로 인정받으려면 세 가지 조건을 동시에 만족해야 합니다. 첫째, 메서드가 GET·HEAD·POST 중 하나여야 합니다. 둘째, 브라우저가 자동으로 붙이는 헤더 외에 사람이 추가로 지정할 수 있는 헤더가 Accept, Accept-Language, Content-Language, Content-Type 등 제한된 목록 안에 있어야 합니다(Authorization 헤더가 붙으면 이 조건을 벗어납니다). 셋째, Content-Type이 `application/x-www-form-urlencoded`, `multipart/form-data`, `text/plain` 셋 중 하나여야 합니다. 세 조건을 모두 만족해야만 프리플라이트 없이 바로 전송됩니다.

2. JSON을 보내는 순간 왜 항상 프리플라이트가 도는가

웹 개발에서 가장 흔한 API 호출 패턴인 `fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(data)})`는 메서드는 POST라서 조건 1은 통과하지만, Content-Type이 `application/json`이라 조건 3을 벗어납니다. 그 결과 무조건 비단순 요청으로 분류되어 브라우저가 실제 POST를 보내기 전에 먼저 OPTIONS 메서드로 프리플라이트 요청을 보냅니다. 서버가 이 OPTIONS 요청에 올바른 `Access-Control-Allow-Methods`, `Access-Control-Allow-Headers` 값으로 응답하지 않으면, 정작 실제 POST 요청은 서버에 도달하기도 전에 브라우저가 차단해버립니다. "서버 로그에는 POST 요청이 안 찍히는데 콘솔에는 CORS 에러가 뜬다"는 흔한 증상이 바로 이 지점에서 나옵니다.

예시: `fetch('/api/users', {method:'GET'})` → 단순 요청, 프리플라이트 없이 바로 전송. `fetch('/api/users', {method:'POST', headers:{'Content-Type':'application/json'}, body:'{}'})` → 비단순 요청, OPTIONS 프리플라이트가 먼저 전송되고 여기서 Allow-Methods·Allow-Headers 응답이 통과해야 실제 POST가 나갑니다.
요청 특성프리플라이트비고
GET (기본 헤더만)안 뜸가장 흔한 단순 요청
POST, Content-Type: application/x-www-form-urlencoded안 뜸전통적 폼 전송 방식
POST, Content-Type: application/json대부분의 REST API 호출
PUT / DELETE / PATCH메서드 자체가 조건 1 위반
커스텀 헤더(Authorization 등) 포함메서드와 무관하게 조건 2 위반

3. 모두의 툴 CORS 헤더 검사기는 이 구분을 어떻게 판정하는가

실제 코드를 열어보면 CORS 헤더 검사기의 `analyzeCors()` 함수는 `const needsPreflight = ['PUT', 'DELETE', 'PATCH'].includes(method);`로 판정합니다. 즉 요청 메서드 드롭다운에서 PUT·DELETE·PATCH를 고르면 프리플라이트가 필요하다고 판단해 `Access-Control-Allow-Methods` 값을 반드시 확인하고, GET·POST·OPTIONS를 고르면 단순 요청으로 간주해 Allow-Methods 값과 무관하게 `Access-Control-Allow-Origin`(그리고 Credentials 충돌 여부)만으로 성공 여부를 판정합니다. 코드 주석에도 "단순 요청에서는 Allow-Methods가 브라우저의 최종 판정에 전혀 영향을 주지 않는다"는 원칙이 명시돼 있습니다. 다만 이 도구는 메서드만으로 단순/비단순을 가르는 단순화된 모델이라, 실제 스펙처럼 "POST + application/json"이라서 프리플라이트가 필요해지는 경우까지는 별도 Content-Type 입력란이 없어 자동으로 반영하지 않습니다. 그런 경우라면 도구에서 메서드를 PUT 등으로 바꿔 프리플라이트 조건으로 시뮬레이션해서 확인하는 우회가 필요합니다.

4. 프리플라이트 응답에서 실제로 검사되는 항목

프리플라이트(OPTIONS) 응답과 실제 요청 응답은 브라우저가 서로 다른 기준으로 확인합니다. 프리플라이트 단계에서는 Access-Control-Allow-Methods(요청 메서드가 포함되는지), Access-Control-Allow-Headers(요청에 실린 커스텀 헤더가 모두 허용되는지), Access-Control-Allow-Origin을 확인합니다. 이 세 값이 모두 통과해야 브라우저가 실제 요청을 내보냅니다. 반면 단순 요청은 애초에 프리플라이트 단계 자체가 없으므로, 실제 응답의 Access-Control-Allow-Origin(및 자격 증명 요청이라면 Allow-Credentials)만 확인합니다. 헤더 값 자체를 붙여넣어 정확히 어떤 항목이 어떤 기준으로 통과·차단되는지 진단하려면 HTTP 헤더 검사기와 함께 사용하는 것이 좋습니다.

5. 자주 놓치는 함정: Allow-Origin: * 와 Credentials

프리플라이트 여부와 별개로 자주 걸리는 실수가 하나 더 있습니다. `Access-Control-Allow-Origin: *`와 `Access-Control-Allow-Credentials: true`는 절대 함께 쓸 수 없습니다. 쿠키나 Authorization 헤더 같은 자격 증명을 포함하는 요청(`credentials: 'include'`)에는 서버가 반드시 명시적인 출처(origin)를 지정해야 하며, 와일드카드는 브라우저가 무조건 거부합니다. 이 조합은 단순 요청·비단순 요청 여부와 무관하게 항상 적용되는 별도 규칙이라, 프리플라이트 문제를 다 해결했는데도 여전히 차단된다면 이 조합부터 의심해야 합니다. API 서버 자체를 curl로 직접 찔러 헤더를 확인하고 싶다면 curl 명령어 생성기가 유용합니다.

자주 묻는 질문

Q. GET 요청은 절대 프리플라이트가 안 도나요?

기본적으로는 안 돕니다. 다만 Authorization 헤더 등 단순 요청 허용 목록을 벗어나는 커스텀 헤더를 추가하면 GET이라도 프리플라이트가 발생합니다. 메서드뿐 아니라 헤더 구성도 함께 확인해야 합니다.

Q. POST인데 Content-Type을 안 정해주면 프리플라이트가 안 뜨나요?

Content-Type을 아예 지정하지 않으면 기본값이 `text/plain`으로 처리돼 단순 요청 조건을 만족할 수 있습니다. 반대로 명시적으로 `application/json`을 지정하면 조건을 벗어나 프리플라이트가 발생합니다.

Q. 프리플라이트를 매 요청마다 다시 보내나요?

아닙니다. 서버가 `Access-Control-Max-Age` 헤더로 캐시 시간을 지정하면, 그 시간 동안은 동일한 출처·메서드·헤더 조합에 대해 브라우저가 프리플라이트 결과를 캐시해 재사용합니다.

Q. 모두의 툴 CORS 헤더 검사기는 Content-Type 기준으로도 판정하나요?

아닙니다. 실제 코드를 확인한 결과 이 도구는 요청 메서드(PUT·DELETE·PATCH 여부)만으로 프리플라이트 필요 여부를 판정하며, Content-Type을 별도로 반영하지 않습니다. POST + JSON처럼 Content-Type 때문에 프리플라이트가 필요한 경우를 시뮬레이션하려면 메서드를 PUT 등으로 바꿔 테스트하는 우회가 필요합니다.