반응형

브라우저 CORS와 preflight 요청 흐름을 표현한 일러스트

프론트엔드 개발을 하다 보면 한 번쯤은 CORS 오류를 만납니다. API 서버는 분명 정상인데 브라우저 콘솔에는 에러가 뜨고, Network 탭에서는 요청이 성공한 것처럼 보여 더 헷갈리기도 합니다.

이 글에서는 CORS를 “요청이 막히는 문제”로만 보지 않고, 브라우저가 응답을 JavaScript에 공개해도 되는지 판단하는 규칙으로 정리해보겠습니다. 함께 자주 등장하는 preflight 요청도 실제 요청 전에 무엇을 확인하는 절차인지 살펴봅니다.

CORS를 이해하려면 Same-Origin Policy부터 봐야 한다

브라우저 보안의 기본값은 Same-Origin Policy입니다. 같은 출처에서 온 스크립트만 응답을 자유롭게 읽을 수 있다는 규칙입니다.

여기서 출처는 보통 다음 세 가지 조합으로 판단합니다.

  • 프로토콜
  • 도메인
  • 포트

예를 들어 https://example.com에서 실행 중인 JavaScript가 https://api.example.com으로 요청을 보내면 도메인이 다르기 때문에 교차 출처 요청이 됩니다.

CORS는 이 제한을 풀어주는 예외 허가 장치입니다. 서버가 응답에 Access-Control-Allow-Origin 같은 헤더를 포함하면, 브라우저는 해당 응답을 특정 출처의 JavaScript에 공개할 수 있습니다.

중요한 점은 CORS가 서버의 요청 처리 자체를 항상 막는 것은 아니라는 점입니다. 요청은 서버까지 도달했지만, 브라우저가 응답을 JavaScript 코드에 넘겨주지 않는 상황이 생길 수 있습니다.

Preflight 요청은 무엇을 미리 확인할까?

preflight는 실제 요청 전에 브라우저가 서버에 먼저 보내는 사전 확인 요청입니다. 보통 OPTIONS 메서드로 전송됩니다.

브라우저는 다음과 같은 요청을 보내기 전에 preflight를 사용할 수 있습니다.

  • PUT, DELETE, PATCH 같은 메서드를 사용하는 경우
  • 커스텀 헤더를 포함하는 경우
  • Content-Type: application/json처럼 단순 요청 범위를 벗어나는 경우
  • 인증 정보나 특정 조건이 함께 얽힌 경우

예를 들어 다음 요청은 preflight가 발생할 가능성이 높습니다.

fetch("https://api.example.com/user/1", {
  method: "PUT",
  headers: {
    "Content-Type": "application/json",
    "X-Auth-Token": "abc123",
  },
  body: JSON.stringify({ name: "Kim" }),
});

브라우저는 곧바로 PUT 요청을 보내지 않고, 먼저 OPTIONS 요청으로 서버에 묻습니다.

“이 출처에서 PUT을 보내도 되나요?” “Content-Type과 X-Auth-Token 헤더를 포함해도 되나요?”

서버가 Access-Control-Allow-Methods, Access-Control-Allow-Headers 등으로 허용 범위를 응답하면, 브라우저는 그다음 실제 요청을 보냅니다.

Preflight는 응답 내용을 검사하는 요청이 아니다

preflight를 “응답을 미리 받아보는 요청”으로 이해하면 디버깅이 꼬이기 쉽습니다. preflight의 목적은 응답 데이터를 확인하는 것이 아니라, 이 형태의 요청을 보내도 되는지 사전에 승인받는 것입니다.

즉, preflight에서 통과해야 실제 요청이 나가고, 실제 요청의 응답을 JavaScript가 읽을 수 있는지는 다시 CORS 응답 헤더에 따라 결정됩니다.

그래서 Network 탭에서 요청이 보인다고 해서 JavaScript 코드에서 반드시 성공하는 것은 아닙니다. 서버는 응답했지만 브라우저가 “이 응답은 현재 페이지의 스크립트에 공개할 수 없다”고 판단하면 코드에서는 실패처럼 보입니다.

Credentials가 들어가면 더 엄격해진다

쿠키나 인증 정보를 포함하는 요청은 CORS 설정이 더 까다롭습니다.

예를 들어 fetch에서 다음처럼 인증 정보를 포함할 수 있습니다.

fetch("https://api.example.com/me", {
  credentials: "include",
});

이 경우 서버는 Access-Control-Allow-Origin: *를 사용할 수 없습니다. 모든 출처를 허용하는 와일드카드와 인증 정보 포함 요청은 함께 쓸 수 없기 때문입니다.

대신 서버는 다음처럼 정확한 출처를 명시해야 합니다.

Access-Control-Allow-Origin: https://frontend.example.com
Access-Control-Allow-Credentials: true

인증이 필요한 API라면 허용 출처를 명확하게 관리해야 합니다. 개발 환경에서는 편의상 넓게 열어두더라도, 운영 환경에서는 필요한 출처만 허용하는 방식이 안전합니다.

실무에서 CORS 오류를 디버깅하는 순서

CORS 문제가 발생했을 때는 서버가 죽었는지부터 보기보다, 브라우저가 응답 공개를 거부했는지 확인하는 편이 빠릅니다.

먼저 개발자 도구의 Network 탭에서 OPTIONS 요청이 있는지 확인합니다. preflight가 실패했다면 실제 요청이 보내지지 않았을 수 있습니다.

그다음 실제 요청의 응답 헤더를 확인합니다. 특히 아래 헤더를 봅니다.

  • Access-Control-Allow-Origin
  • Access-Control-Allow-Methods
  • Access-Control-Allow-Headers
  • Access-Control-Allow-Credentials

마지막으로 요청 쪽 설정도 함께 확인해야 합니다. 프론트엔드에서 credentials: "include"를 사용했는데 서버가 와일드카드 출처를 내려주고 있거나, 커스텀 헤더를 보냈는데 서버가 해당 헤더를 허용하지 않으면 CORS 오류가 발생할 수 있습니다.

정리

CORS는 단순히 “서버가 요청을 막는 문제”가 아닙니다. 브라우저가 교차 출처 응답을 JavaScript에 공개할지 판단하는 보안 모델입니다.

preflight는 실제 요청 전에 요청 메서드와 헤더가 허용되는지 확인하는 절차입니다. 응답 데이터를 미리 받아보는 과정이 아닙니다.

실무에서는 CORS를 다음 세 가지로 나눠 보면 훨씬 덜 헷갈립니다.

  1. 요청이 실제로 서버까지 갔는가?
  2. preflight가 필요한 요청인가, 그리고 통과했는가?
  3. 최종 응답에 JavaScript 공개를 허용하는 CORS 헤더가 있는가?

이 관점으로 보면 CORS 오류는 막연한 브라우저 문제가 아니라, 요청 형태와 서버 허용 정책이 맞지 않는 문제로 좁혀서 디버깅할 수 있습니다.

참고

반응형