CORS 내부에서 실제로 무슨 일이 일어나고 있나

발행: (2026년 5월 24일 AM 01:29 GMT+9)
12 분 소요
원문: Dev.to

Source: Dev.to

CORS는 모든 웹 개발자가 언젠가 마주하게 되는 일 중 하나입니다. 대부분은 해결 방법을 알고 있습니다 — 헤더를 추가하고, 설정을 바꾸고, 백엔드 담당자에게 “CORS에 대해 뭔가 해달라”고 요청하죠. 하지만 실제로 브라우저가 백그라운드에서 무엇을 하고 있는지, 왜 그렇게 하는지 이해하고 있는 사람은 얼마나 될까요?
오늘은 간단한 예시와 함께 천천히 살펴보겠습니다.

CORS에 들어가기 전에 먼저 짚고 넘어가야 할 단어가 하나 있습니다: origin. Origin은 URL을 구성하는 세 가지 요소, 즉 scheme(프로토콜), host(호스트), **port(포트)**가 합쳐진 것입니다.

  • https://example.comhttp://example.com 은 scheme이 다르기 때문에 서로 다른 origin입니다(https vs http).
  • https://example.comhttps://api.example.com 은 host가 다르기 때문에 서로 다른 origin입니다.
  • https://example.comhttps://example.com:8080 은 port가 다르기 때문에 서로 다른 origin입니다.

이 세 요소 중 하나라도 바뀌면 브라우저는 이를 별개의 origin으로 취급합니다.

이것이 중요한 이유는 브라우저가 각 origin을 작은 샌드박스처럼 다루기 때문입니다. 한 origin 안에서 일어나는 일은 그 origin 안에 머물러야 합니다.

기본적으로, 한 origin에서 실행되는 JavaScript는 다른 origin의 데이터를 읽을 수 없습니다. 이 규칙을 **동일 출처 정책(same‑origin policy)**이라고 부릅니다. 모든 최신 브라우저는 이 정책을 내장하고 있으며, 여기서 다루는 모든 내용은 이 정책을 기반으로 합니다.

동일 출처 정책이 없었다면 웹은 악몽이 되었을 것입니다. 예를 들어, 은행에 로그인한 상태에서 다른 탭에 임의의 사이트를 방문했다고 가정해 보세요. 동일 출처 정책이 없었다면 그 사이트는 백그라운드에서 다음과 같은 코드를 실행할 수 있었을 겁니다:

fetch('https://yourbank.com/api/balance')
  .then(res => res.json())
  .then(data => sendToAttacker(data))

브라우저는 요청을 기꺼이 보내고, 은행 도메인에 속한 쿠키를 자동으로 첨부합니다. 은행은 요청한 사람에게 잔액 정보를 반환하고, 임의 사이트의 JavaScript는 그 응답을 읽어 원하는 곳으로 전송할 수 있었습니다. 로그인한 모든 사이트가 언제든지 스크랩당할 수 있었겠죠.

동일 출처 정책은 이를 차단합니다. 브라우저는 요청을 보내지만, 임의 사이트의 JavaScript가 응답을 읽는 것을 거부합니다. 요청은 이루어지지만, 응답은 호출한 코드에 도달하지 못합니다.

CORSCross‑Origin Resource Sharing의 약자로, 서버가 “사실 괜찮다, 이 다른 origin이 내 응답을 읽어도 된다”라고 선언하는 메커니즘입니다. 즉, 동일 출처 정책을 완화하도록 옵트인(opt‑in) 하는 방식입니다.

가장 흔한 경우는 바로 여러분의 환경입니다. 프론트엔드가 app.example.com에, API가 api.example.com에 있다면 서로 다른 origin이므로 기본적으로 동일 출처 정책에 의해 프론트엔드의 호출이 차단됩니다. CORS는 API가 브라우저에 “예, app.example.com은 허용됩니다”라고 알려주는 방법이죠.

API는 어떻게 브라우저에 이를 알릴까요? 응답 헤더를 통해서입니다:

Access-Control-Allow-Origin: https://app.example.com

브라우저가 이 헤더를 응답에서 발견하면 JavaScript가 해당 응답을 읽을 수 있게 됩니다. 헤더가 없으면 응답은 차단되고 콘솔에 CORS 오류가 표시됩니다.

여기서 한 가지 주의할 점이 있습니다. 개발 단계에서 프론트엔드와 API가 같은 origin에 있다면(예: localhost:3000에서 Next.js rewrite를 사용) CORS가 전혀 작동하지 않습니다. 브라우저는 헤더를 검사조차 하지 않는데, 이는 교차 출처 요청이 전혀 발생하지 않기 때문입니다. CORS 문제는 보통 프론트엔드와 API가 서로 다른 도메인에 배포될 때만 나타납니다.

fetch가 시작부터 끝까지 어떻게 동작하는지 살펴보겠습니다

프론트엔드(https://app.example.com)가 다음과 같이 호출합니다:

fetch('https://api.example.com/users')

실제로 일어나는 일은 다음과 같습니다:

  1. 요청 생성
    브라우저는 요청을 만들면서 자동으로 Origin: https://app.example.com 헤더를 추가합니다. 이는 “이 요청은 app.example.com에서 왔습니다”라는 의미입니다.

  2. 요청 전송
    브라우저는 api.example.com에 요청을 보냅니다. 서버는 일반적인 요청처럼 이를 받아 데이터베이스를 조회하거나 비즈니스 로직을 실행하고, 결과를 반환합니다.

  3. 응답 전송
    서버는 사용자 목록을 JSON 형태로 응답합니다.

  4. 응답 검증
    브라우저는 응답을 받으면 Access-Control-Allow-Origin 헤더를 확인합니다. 헤더가 없거나 값이 https://app.example.com(또는 *)과 일치하지 않으면 브라우저는 응답을 차단합니다. 이 경우 fetch를 호출한 JavaScript는 CORS 오류를 보게 되고, 응답 본문은 코드에 전달되지 않습니다.

  5. 성공 시
    헤더가 존재하고 값이 일치하면 브라우저는 응답을 JavaScript에 전달하고, 코드는 정상적으로 진행됩니다.

대부분의 CORS 설명에서 빠뜨리는 부분: 서버가 요청을 거부하는 것이 아니라 브라우저가 거부한다는 점입니다. 서버는 정상적인 응답을 반환했지만, 브라우저는 “이 origin은 허용되지 않는다”는 헤더가 없으므로 JavaScript에 전달하지 않은 겁니다. 그래서 curl이나 Postman, 서버‑투‑서버 호출에서는 전혀 문제가 없고, CORS는 오직 브라우저에서만 작동합니다.

프리플라이트(preflight) 요청

  • 단순(simple) 요청: 주로 GET과 몇몇 POST(안전한 콘텐츠 타입)인 경우, 브라우저는 실제 요청을 바로 보내고 응답 헤더만 검사합니다.

  • 복잡한 요청: PUT, DELETE, 커스텀 헤더(Authorization 등) 혹은 Content-Type: application/json 같은 경우, 브라우저는 먼저 프리플라이트 요청을 보냅니다. 프리플라이트는 실제 요청 전에 같은 URL로 OPTIONS 메서드를 사용해 전송됩니다.

프리플라이트 예시:

OPTIONS /users HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: Authorization, Content-Type

브라우저는 서버에 “DELETE와 이 헤더들을 포함한 요청을 보내려고 하는데, 괜찮은가요?”라고 묻는 겁니다.

서버는 허용 가능한 항목을 다음과 같이 응답합니다:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type

브라우저가 이 프리플라이트 응답에 만족하면 실제 요청을 보냅니다. 프리플라이트가 실패하면 실제 요청은 전혀 전송되지 않습니다. 그래서 네트워크 탭에 두 개의 요청이 보이는 경우가 있습니다. 첫 번째가 프리플라이트, 두 번째가 실제 호출입니다.

인증 정보와 와일드카드

프론트엔드가 쿠키, Authorization 헤더 등 인증 정보를 포함해서 요청을 보낼 때는 다음과 같이 합니다:

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

이때 Access-Control-Allow-Origin: * 와일드카드만으로는 충분하지 않습니다. 브라우저는 응답을 전달하지 않으며, 서버는 신뢰하는 특정 origin을 명시하고 추가 헤더를 보내야 합니다:

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

그 이유는 보안 때문입니다. 와일드카드와 인증 정보를 동시에 허용한다면, 악의적인 사이트가 사용자의 인증 쿠키를 이용해 API에 요청을 보낼 수 있습니다. 브라우저는 쿠키를 자동으로 첨부하고, 서버는 “모든 origin을 허용한다”는 응답을 반환하므로, 악성 사이트의 JavaScript이 응답을 읽어버릴 수 있습니다. 결국 모든 인증된 API가 한 번의 fetch만으로 스크랩당할 위험에 처합니다.

따라서 규칙은 다음과 같습니다:

  • 공개 API처럼 호출 주체가 중요하지 않은 경우에는 *를 사용할 수 있습니다.
  • 인증이 필요한 경우, 서버는 허용할 origin을 명시하고 Access-Control-Allow-Credentials: true를 추가해야 합니다.

요약

  • 브라우저는 기본적으로 동일 출처 정책에 따라 교차 출처 읽기를 차단합니다.
  • CORS는 서버가 Access-Control-Allow-Origin 같은 응답 헤더를 통해 이 차단을 해제하도록 허용하는 메커니즘입니다.

CORS 오류를 마

0 조회
Back to Blog

관련 글

더 보기 »

내 스킬

프로젝트를 위한 AI 지시문을 만들고, 설치하고, 관리하세요 — 코딩이 필요 없습니다. CREATE 이름을 정하고, 카테고리를 선택하고, 원하는 것을 설명하세요 — 마법사가 자동으로 구성합니다.