CORS preflight 요청이 발생하는 조건

CORS preflight 요청이 발생하는 조건

한눈에 보기

단순 요청의 메서드와 헤더 범위를 벗어나거나 특정 Content-Type을 사용하면 preflight가 발생한다. 서버는 허용 origin, method, header를 응답해야 한다.

목차

왜 이 문제가 생기는가

프론트엔드에서 API로 POST 요청을 한 번 보냈는데 서버 로그에는 먼저 OPTIONS가 기록될 때가 있다. 개발 중에는 잘 동작하던 요청이 운영의 게이트웨이나 인증 미들웨어를 거치면서 OPTIONS 401로 실패하기도 한다. 이때 본 요청인 POST는 아예 서버에 도달하지 않는다.

preflight는 불필요한 중복 요청이 아니라, 브라우저가 다른 origin에 실제 요청을 보내기 전에 허용 여부를 묻는 과정이다. 중요한 것은 “왜 OPTIONS가 생겼을까”를 외우는 것보다 preflight와 실제 요청이 서로 다른 두 번의 HTTP 교환이라는 사실을 이해하는 것이다.

이 글의 예제에 관하여

app.example.testapi.example.test는 CORS 흐름을 설명하기 위한 가상 주소다. 서버 코드는 특정 프로젝트의 실제 구현이 아니다.

CORS는 무엇을 보호하는가

동일 출처 정책은 한 origin에서 실행된 JavaScript가 다른 origin의 응답을 자유롭게 읽지 못하도록 제한한다. 여기서 origin은 scheme, host, port의 조합이다.

https://app.example.test  →  https://api.example.test
          origin A                    origin B

host가 다르므로 cross-origin 요청이다. 서버가 CORS 응답 헤더로 app.example.test를 허용하면 브라우저는 프론트엔드 코드가 응답을 읽도록 허용할 수 있다.

CORS는 서버 간 요청을 막는 장치가 아니다

curl, 서버 백엔드, 악성 클라이언트는 브라우저의 동일 출처 정책을 따르지 않는다. 따라서 CORS를 인증이나 API 접근 제어로 사용해서는 안 된다. 서버는 별도로 인증과 권한을 검사해야 한다.

단순 요청이 되기 위한 조건

일부 cross-origin 요청은 별도의 preflight 없이 바로 전송될 수 있다. 흔히 CORS의 단순 요청 조건이라고 부른다. 대략 다음 제한 안에 있어야 한다.

다음 폼 전송은 preflight 없이 전송될 가능성이 있다.

await fetch("https://api.example.test/feedback", {
  method: "POST",
  headers: {
    "Content-Type": "application/x-www-form-urlencoded",
  },
  body: new URLSearchParams({ message: "좋은 글이었습니다." }),
});

반면 애플리케이션에서 흔히 사용하는 JSON과 사용자 정의 인증 헤더는 단순 요청 범위를 벗어난다.

await fetch("https://api.example.test/feedback", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-Client-Version": "web-42",
  },
  body: JSON.stringify({ message: "좋은 글이었습니다." }),
});

application/json, 사용자 정의 헤더, PUT, PATCH, DELETE 같은 메서드는 preflight가 발생하는 대표적인 이유다. “POST인데 왜 OPTIONS가 생겼지?”가 아니라 헤더와 content type까지 함께 봐야 한다.

preflight와 실제 요청의 순서

브라우저는 실제 요청을 보내기 전에 사용할 메서드와 헤더를 서버에 알린다.

sequenceDiagram
    participant B as Browser
    participant A as api.example.test

    B->>A: OPTIONS /feedback
    Note right of B: Origin
Access-Control-Request-Method
Access-Control-Request-Headers A-->>B: 204 + 허용 정책 alt 정책이 요청을 허용함 B->>A: POST /feedback + JSON A-->>B: 201 + CORS 응답 헤더 else 정책이 요청을 허용하지 않음 Note over B,A: 실제 POST는 보내지 않음 end

실제 preflight 요청은 다음과 비슷하다.

OPTIONS /feedback HTTP/1.1
Host: api.example.test
Origin: https://app.example.test
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type,x-client-version

서버가 허용한다면 다음처럼 응답할 수 있다.

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.test
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: Content-Type, X-Client-Version
Access-Control-Max-Age: 600
Vary: Origin

이 응답은 실제 API 성공 결과가 아니다. 브라우저에 “이 origin이 이 메서드와 헤더로 요청해도 된다”고 알리는 정책 응답이다.

서버는 무엇을 응답해야 할까

서버는 요청 정보와 자신의 허용 정책을 비교해야 한다.

const allowedOrigins = new Set([
  "https://app.example.test",
  "https://admin.example.test",
]);

function applyCors(request, response) {
  const origin = request.headers.origin;

  if (!origin || !allowedOrigins.has(origin)) return;

  response.setHeader("Access-Control-Allow-Origin", origin);
  response.setHeader("Vary", "Origin");
  response.setHeader("Access-Control-Allow-Methods", "GET,POST,PATCH");
  response.setHeader(
    "Access-Control-Allow-Headers",
    "Content-Type, X-Client-Version",
  );
}

app.options("/feedback", (request, response) => {
  applyCors(request, response);
  response.status(204).end();
});

요청의 Origin을 아무 검증 없이 그대로 Access-Control-Allow-Origin으로 되돌려 주면 사실상 모든 origin을 허용하는 구현이 된다. 명시적인 허용 목록과 비교한다.

여러 origin에 따라 응답 값이 달라진다면 Vary: Origin도 중요하다. 중간 캐시가 한 origin에 대한 허용 응답을 다른 origin 요청에 잘못 재사용하지 않도록 캐시 키에 영향을 주기 때문이다.

credentials가 있을 때 주의할 점

쿠키를 포함하는 요청은 프론트엔드와 서버 양쪽의 명시적인 설정이 필요하다.

const response = await fetch("https://api.example.test/profile", {
  credentials: "include",
});
Access-Control-Allow-Origin: https://app.example.test
Access-Control-Allow-Credentials: true

credentials가 있는 응답에서 허용 origin을 *로 처리할 수 있다고 기대하면 안 된다. 구체적인 origin을 응답하고, 허용 목록을 검증한다. 또한 CORS가 허용되더라도 쿠키의 SameSite, Secure, Domain, Path 조건에 따라 쿠키가 빠질 수 있다. 이는 SameSite 쿠키가 로그인 흐름에 미치는 영향에서 다룬 쿠키 전송 문제와 구분해야 한다.

preflight를 줄여야 할까

preflight는 네트워크 왕복을 추가하므로 지연 시간에 영향을 줄 수 있다. 그러나 JSON API를 억지로 폼 content type으로 바꾸거나 인증 헤더를 제거해 단순 요청으로 만드는 것이 항상 좋은 최적화는 아니다. API 의미와 보안을 흐리면서 몇 번의 OPTIONS만 줄일 수 있기 때문이다.

먼저 다음을 고려한다.

  1. 같은 origin 아래 reverse proxy를 두어 브라우저 관점의 cross-origin을 없앨 수 있는가?
  2. Access-Control-Max-Age로 성공한 preflight 결과를 합리적인 시간 캐시할 수 있는가?
  3. API 호출을 지나치게 잘게 나누지 않았는가?
  4. 실제 지연의 병목이 preflight인지 네트워크 기록으로 확인했는가?

preflight 캐시는 일반 HTTP 응답 캐시와 동일하게 생각하면 안 되며, 브라우저별 상한과 정책 차이를 전제로 측정해야 한다.

자주 만나는 실패 사례

증상 가능한 원인 확인할 위치
OPTIONS 401 인증 미들웨어가 preflight까지 세션을 요구함 서버 middleware 순서
허용 헤더 오류 요청의 사용자 정의 헤더가 allow list에 없음 요청·응답 헤더 비교
개발만 성공 운영 proxy가 OPTIONS를 전달하지 않음 gateway 라우팅과 로그
응답은 200인데 브라우저 오류 실제 응답에 CORS 헤더가 빠짐 POST 응답 헤더
쿠키가 없음 credentials 또는 쿠키 속성 문제 fetch 옵션과 Cookie 헤더

특히 OPTIONS만 허용하고 실제 응답에 Access-Control-Allow-Origin을 넣지 않는 실수가 잦다. preflight 성공은 실제 응답을 JavaScript가 읽을 수 있다는 보장이 아니다.

디버깅 순서

브라우저 네트워크 탭에서

  1. 실패한 요청이 OPTIONS인지 실제 메서드인지 구분한다.
  2. Origin, 요청 메서드, 요청 헤더 목록을 확인한다.
  3. preflight 응답의 allow origin·method·headers를 하나씩 비교한다.
  4. 실제 응답에도 올바른 CORS 헤더가 있는지 본다.
  5. proxy, CDN, 인증 middleware 중 어느 계층이 응답했는지 추적한다.

단순 요청의 메서드와 헤더 범위를 벗어나거나 특정 Content-Type을 사용하면 preflight가 발생한다. 서버는 허용 origin, method, header를 응답해야 한다.

결론

preflight는 브라우저가 실제 cross-origin 요청 전에 메서드와 헤더의 허용 여부를 묻는 별도의 HTTP 교환이다. OPTIONS와 실제 응답 모두에 필요한 정책을 적용하고, origin은 명시적인 허용 목록과 비교해야 한다. preflight를 무조건 제거하기보다 네트워크 기록으로 비용을 확인하고 캐시, proxy, API 호출 구조 중 적절한 지점을 개선하는 편이 낫다.

관련 노트