CORS preflight는 왜 OPTIONS를 먼저 보내는가

seonwoo_jung·2일 전

1. 도입

프론트엔드에서 API를 붙이다 보면 실제 요청보다 먼저 OPTIONS 요청이 찍히는 순간이 있다. 특히 Authorization 헤더를 붙이거나 PUT, DELETE 같은 메서드를 쓰면 네트워크 탭에 preflight라는 이름으로 한 번 더 요청이 생긴다. 처음에는 서버가 같은 API를 두 번 받는 것처럼 보여서 헷갈렸는데, Fetch Standard §3.7의 CORS protocol을 따라가 보니 목적은 비교적 분명했다.

브라우저는 어떤 cross-origin 요청을 바로 보내도 되는지, 먼저 서버에 허락을 물어봐야 하는지 구분한다. preflight는 후자에 해당하는 요청을 보내기 전에 브라우저가 만드는 예비 요청이다. 서버가 "이 origin, 이 method, 이 header 조합을 허용한다"고 응답해야 실제 요청으로 넘어간다.

CORS preflight는 서버 리소스를 미리 변경하려는 요청이 아니라, 브라우저가 실제 요청을 노출해도 되는지 판단하기 위한 협상 단계다.

이 글에서는 preflight가 언제 발생하는지, 어떤 헤더로 협상하는지, 그리고 캐시가 왜 생각보다 중요해지는지까지 한 흐름으로 정리했다.

2. 핵심 개념

CORS는 Cross-Origin Resource Sharing의 줄임말이다. 브라우저의 기본 정책은 같은 origin에서 온 문서가 같은 origin의 리소스에 접근하는 것이다. 여기서 origin은 scheme, host, port의 조합이다. https://app.example.com에서 https://api.example.com으로 fetch()를 보내면 host가 다르므로 cross-origin 요청이 된다.

CORS는 cross-origin 요청을 무조건 막는 장치라기보다, 서버가 허용 범위를 HTTP 헤더로 선언하고 브라우저가 그 선언을 검증하는 프로토콜에 가깝다. 서버 응답에 Access-Control-Allow-Origin이 적절히 들어 있으면 브라우저는 응답을 자바스크립트 코드에 노출할 수 있다.

preflight는 그중에서도 실제 요청 전에 수행되는 확인 요청이다. Fetch Standard §3.7에서는 CORS-preflight request를 CORS request 중 하나로 다루며, 브라우저가 요청 메서드와 non-safelisted request-header names를 서버에 알려 확인받는 흐름을 설명한다.

preflight가 붙는 대표적인 상황은 다음과 같다.

상황예시
safelisted method가 아님PUT, PATCH, DELETE
safelisted header가 아님Authorization, X-Request-Id
safelisted content type이 아님application/json 요청 본문

반대로 GET, HEAD, 일부 POST처럼 조건을 만족하는 단순한 요청은 preflight 없이 바로 실제 요청이 나갈 수 있다. 다만 "simple request"라는 표현은 편의상 쓰는 말이고, 실제 판단은 Fetch Standard가 정의한 CORS-safelisted method, CORS-safelisted request-header 같은 조건을 따라간다고 보는 편이 정확하다.

3. 내부 동작

preflight가 필요한 요청을 브라우저가 만나면 실제 요청을 잠시 보류하고 같은 URL로 OPTIONS 요청을 보낸다. 이 요청에는 서버가 판단할 수 있도록 최소한의 의도를 담는다.

OPTIONS /orders/123 HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: authorization, content-type

여기서 Origin은 요청을 시작한 출처다. Access-Control-Request-Method는 곧 보낼 실제 요청의 메서드이고, Access-Control-Request-Headers는 실제 요청에 포함될 CORS non-safelisted 헤더 이름 목록이다. 브라우저가 DELETE를 이미 보낸 뒤 허락을 받는 것이 아니라, "이런 DELETE를 보내도 되는가"를 먼저 묻는 셈이다.

서버가 허용한다면 대략 다음과 같이 응답한다.

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, DELETE
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Max-Age: 600

브라우저는 이 응답을 보고 실제 요청을 보낼지 결정한다. Access-Control-Allow-Origin이 현재 origin과 맞지 않거나, DELETEAccess-Control-Allow-Methods에 없거나, 요청하려던 헤더가 Access-Control-Allow-Headers에 없다면 실제 요청은 진행되지 않는다. 서버 로그에는 OPTIONS만 남고 본 요청이 없는 상황이 생길 수 있다.

흐름을 간단히 그리면 다음과 같다.

브라우저
  |
  | 1. 실제 요청이 preflight 필요 조건인지 판단
  v
API 서버
  <- OPTIONS + Origin + Request-Method + Request-Headers
  -> Allow-Origin + Allow-Methods + Allow-Headers
  |
브라우저
  |
  | 2. 허용되면 실제 요청 전송
  v
API 서버
  <- DELETE /orders/123
  -> 실제 응답 + CORS 응답 헤더

여기서 자주 헷갈리는 지점은 두 가지다.

첫째, preflight 성공은 실제 요청의 비즈니스 성공을 의미하지 않는다. preflight는 "이런 종류의 cross-origin 요청을 받아도 되는가"에 대한 프로토콜 확인이다. 실제 DELETE /orders/123이 인증 실패나 권한 부족으로 401, 403을 받을 수는 있다.

둘째, CORS는 서버를 보호하는 인증 장치가 아니다. CORS 검증은 브라우저가 응답을 자바스크립트에 노출할지 결정하는 규칙이다. 서버는 여전히 인증, 인가, CSRF 방어를 별도로 해야 한다. 특히 쿠키 기반 인증을 쓰는 요청은 CORS 헤더와 별개로 서버까지 도달할 수 있다는 점을 분리해서 봐야 한다.

4. 예시 / 코드

다음 요청은 브라우저에서 preflight를 유발할 가능성이 높다. PUT 메서드와 Authorization, Content-Type: application/json 조합 때문이다.

await fetch("https://api.example.com/profile", {
  method: "PUT",
  headers: {
    "Authorization": "Bearer access-token",
    "Content-Type": "application/json"
  },
  body: JSON.stringify({ nickname: "new-name" })
});

이때 서버는 실제 PUT뿐 아니라 OPTIONS에도 응답해야 한다. 예를 들어 Node.js의 Express라면 개념적으로는 다음과 같은 응답이 필요하다.

app.options("/profile", (req, res) => {
  res.set("Access-Control-Allow-Origin", "https://app.example.com");
  res.set("Access-Control-Allow-Methods", "PUT");
  res.set("Access-Control-Allow-Headers", "authorization, content-type");
  res.set("Access-Control-Max-Age", "600");
  res.sendStatus(204);
});

실무에서는 직접 모든 라우트에 OPTIONS 핸들러를 붙이기보다 프레임워크의 CORS 미들웨어나 게이트웨이 설정으로 처리하는 경우가 많다. 그래도 문제를 추적할 때는 위 네 가지가 맞는지부터 보면 된다.

  1. 요청의 Origin이 서버 허용 목록에 있는가
  2. 실제 메서드가 Access-Control-Allow-Methods에 들어 있는가
  3. 실제 요청 헤더가 Access-Control-Allow-Headers에 들어 있는가
  4. credential을 쓴다면 Access-Control-Allow-Credentials와 origin 와일드카드 규칙을 혼동하지 않았는가

특히 Authorization 헤더를 붙였는데 서버가 Access-Control-Allow-Headers: content-type만 내려주면 브라우저는 실제 요청을 보내지 않는다. 이 경우 서버의 API 로직이 틀렸다기보다 preflight 협상 응답이 부족한 것이다.

5. 캐시와 성능

preflight는 네트워크 왕복을 하나 늘린다. 한두 번은 티가 덜 나지만, API 호출이 많은 화면에서는 지연과 서버 부하로 이어질 수 있다. 그래서 Fetch Standard에는 CORS-preflight cache가 정의되어 있다. 브라우저는 preflight 응답을 일정 조건으로 캐시해 같은 조합의 요청에서 OPTIONS를 반복하지 않을 수 있다.

서버는 Access-Control-Max-Age로 preflight 응답을 얼마나 재사용할 수 있는지 알려준다. 예를 들어 Access-Control-Max-Age: 600은 브라우저가 해당 preflight 결과를 600초 동안 재사용할 수 있음을 뜻한다. 다만 브라우저별 상한이나 구현 차이가 있을 수 있으므로, 아주 긴 값을 넣어도 항상 그대로 보장된다고 단정하기는 어렵다.

캐시 키는 단순히 URL 하나만으로 이해하면 부족하다. origin, 요청 URL, credentials mode, method, header name 같은 요소가 함께 영향을 준다고 보는 편이 안전하다. 그래서 같은 API라도 헤더 구성이 달라지면 preflight가 다시 발생할 수 있다.

성능을 줄이기 위해 무조건 preflight를 없애려고 하기보다는, 다음 순서로 보는 것이 좋았다.

접근판단 기준
필요한 cross-origin인지 확인같은 origin 프록시로 단순화할 수 있는가
헤더를 줄일 수 있는지 확인불필요한 커스텀 헤더가 있는가
preflight 캐시 설정Access-Control-Max-Age를 적절히 줄 수 있는가
서버 처리 경로 분리OPTIONS가 인증 미들웨어에서 막히지 않는가

예를 들어 OPTIONS 요청에도 JWT 인증을 강제하면 브라우저가 보낸 preflight에는 기대한 인증 헤더가 없어서 실패할 수 있다. preflight는 실제 요청의 권한 검사가 아니라 CORS 정책 확인이므로, 서버 라우팅과 보안 필터에서 별도로 다뤄야 한다.

6. 정리

CORS preflight는 브라우저가 cross-origin 요청을 보내기 전에 서버의 CORS 정책을 확인하는 OPTIONS 요청이다. PUT, DELETE, Authorization, application/json처럼 단순 요청 범위를 벗어나는 요소가 있으면 자주 등장한다.

핵심은 실제 요청을 디버깅하기 전에 preflight 협상이 먼저 성공해야 한다는 점이다. 서버는 Origin, Access-Control-Request-Method, Access-Control-Request-Headers를 보고 Access-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers를 정확히 내려줘야 한다. 반복 호출이 많다면 Access-Control-Max-Age로 브라우저의 preflight cache도 함께 고려한다.

다음에 더 파고들 만한 주제는 credentials: "include"를 쓸 때의 CORS 제약, 그리고 CORS와 CSRF가 정확히 어디서 갈라지는지다. 둘은 같이 언급되지만 해결하는 문제가 다르다.

참고 자료

0개의 댓글