CORS는 왜 우리를 괴롭힐까?

피트·2026년 5월 5일

프론트엔드

목록 보기
2/5

1. 들어가면서

"blocked by CORS policy"

사진의 에러 메세지는 그 악명 높은 CORS 에러인데, 프론트엔드 개발자라면 이 에러를 한 번 이상은 무조건 봤을 것이다.

이 글에서는 CORS 에러가 뭔지, 왜 발생하는지, 어떻게 해결하는지 알아보려고 한다.

2. CORS란?

우선 CORS 에러 메세지를 번역해보자.

"출처(origin)인 http://localhost:3000에서 https://joke-api-strict-cors.appspot.com/random_joke으로의 fetch 접근이 CORS 정책에 의해 차단되었습니다. 요청한 리소스에 'Access-Control-Allow-Origin' 헤더가 존재하지 않습니다. 만약 불투명한(opaque) 응답만으로도 충분하다면, 요청의 모드를 'no-cors'로 설정하여 CORS가 비활성화된 상태로 리소스를 가져오세요."

무슨 소리인지 모르겠다.. 우선 CORS가 어떤 정책인지 알아볼 필요성이 있는 것 같다.

2-1. CORS

CORSCross-Origin-Resource-Sharing 의 약자로, 정의 그대로 다른 출처자원공유한다는 뜻이다. 더 정확히 말하면 아래와 같다.

브라우저가 자신의 출처가 아닌 다른 출처로부터 자원을 가져오는 것을 허가해주는 매커니즘

다른 출처의 자원을 허용한다고? 그럼 원래는 금지된 것인가?

아래 웹 발전 과정을 살펴보면 알 수 있다.

과거에는 프론트엔드와 백엔드를 따로 구성하지 않았다. 하나의 서버에서 HTML도 만들고 데이터도 처리하고, 모든 리소스 요청이 같은 도메인 안에서 이뤄졌다.

그러다 보니 다른 출처로 요청을 보내는 행위 자체가 비정상적인 행위로 여겨졌고, 다른 출처로부터의 요청은 해킹 시도나 다름없었다.

그런데 웹이 발전하면서 프론트엔드와 백엔드가 물리적으로 분리되고 클라이언트에서 API를 직접 호출하는 방식이 당연해지게 되었고, 따라서 다른 출처라도 안전하게 리소스를 공유할 수 있도록 새로운 정책이 등장했다.

그게 바로 CORS!

2-2. Origin

동일 출처? 다른 출처? 우선 출처라는 말부터 알아보자. 출처(Origin)라는 게 뭘까?

출처는 프로토콜 + 호스트 + 포트, 이 세 가지를 합친 URL이다. 셋 중 하나라도 자신과 다르면 브라우저는 다른 출처로 판단한다.

위에 예시를 보자. 위 예시는 'https://www.example.com:8000'에 대해서 다른 url과의 비교한 표인데, 첫 번째와 두 번째 예시는 프로토콜, 호스트, 포트가 동일해서 동일한 출처이지만, 아래 세 경우는 다른 출처이다.

여담

2-3. SOP

뜬금없이 SOP? 이건 또 뭐지? SOP는 CORS와 뗄 수 없는 관계이다.

앞서 역사적 배경을 설명했을 때 엄격한 통제가 이루어졌다고 했는데, 그것이 바로 SOP, Same-Origin Policy, 번역하자면 동일 출처 정책 때문이다.

SOP는 브라우저기본 보안 정책으로, 출처가 같은 곳끼리만 리소스를 주고받을 수 있도록 엄격하게 제한한다.

결국 sop가 다른 출처의 리소스를 엄격하게 금지했기 때문에 CORS가 등장한 것이라고 볼 수 있다!

왜 엄격하게 제한하는가?

<section>
  <h1>당첨을 축하합니다! 클릭해서 상금을 받으세요!</h1>
  <button id="scam-button">상금 받기</button>
  
  <script>
    button.addEventListener("click", ()=> {
      fetch("https://bank.com/api/transfer", {
        method: 'POST',
    	body: JSON.stringify({
    	  to_account: "해커의_계좌",
    	  amout: 100000000,
    	})
      })
    })
  </script>
</section>

위의 코드를 보면서 끔찍한 상황을 하나 상상해 보자. 위 코드는 evil.com이라는 사이트의 상금 받기 버튼 코드이다.

우리가 은행 사이트(bank.com)에 로그인해서 인증 쿠키를 발급받은 상태라고 가정해 보자. 그런데 다른 탭에서 아무 생각 없이 evil.com이라는 사이트에 들어갔고, '상금 받기'라는 버튼을 클릭했다.

만약 SOP라는 보안 정책이 없다면 어떻게 될까? evil.com의 코드는 내 브라우저에 저장된 bank.com의 로그인 쿠키를 함께 실어서, 해커의 계좌로 1억 원을 송금하라는 API 요청을 몰래 보내게 된다.

서버 입장에서는 정상적인 로그인 쿠키가 함께 왔으니 정상 요청으로 판단하고 돈을 보내버린다. 이것이 바로 유명한 보안 취약점인 CSRF(Cross-Site Request Forgery) 공격이다.

브라우저는 이러한 악의적인 공격으로부터 사용자를 보호하기 위해, "애초에 출처가 다르면 요청을 보내거나 응답을 받는 것을 막아버리자!"라고 결정한 것이다.

하지만 모던 웹에서는 다른 출처에서 자원을 요청하는 경우가 흔하기 때문에, 무작정 모두 막아버릴 수도 없는 노릇이다. 그래서 다른 출처여도 자원을 안전하게 가져올 수 있도록 예외를 열어주는 것이 바로 'CORS'이다!!

3. CORS 시나리오

그렇다면 브라우저는 실제 어떤 방식으로 서버에게 "이 출처 허락해 줄 거야?"라고 물어볼까? CORS가 동작하는 방식은 크게 예비 요청, 단순 요청, 인증된 요청 총 3가지가 존재한다. 하나씩 자세히 살펴보자.

3-1. 예비 요청 (Preflight Request)

가장 먼저 알아볼 것은 실무에서 가장 흔하게 마주치는 예비 요청(Preflight Request)이다.

이름 그대로 본 요청을 보내기 전에 예비 요청을 먼저 보내서, 브라우저가 스스로 보내려는 요청이 안전한지 서버에 먼저 물어보는 방식이다.

동작 흐름은 다음과 같다.

  1. JS의 fetch() 등을 통해 리소스 요청을 시도한다.
  2. 브라우저가 본 요청을 보내기 전, 먼저 OPTIONS 메서드를 사용해 예비 요청을 서버로 날린다. 이때 출처와 사용할 메서드, 헤더 정보 등을 포함한다.
  3. 서버는 예비 요청에 대한 응답으로 허용되는 항목들을 브라우저에게 보낸다.
  4. 브라우저는 예비로 보낸 요청과 서버에서 받은 응답을 비교하여 허용된 출처인지, 안전한 요청인지 확인한다. 안전하다고 판단되면 그제야 본 요청을 서버로 다시 보낸다.
  5. 서버가 본 요청을 받은 후 응답하여 리소스를 JS에게 넘겨준다.

🤔 왜 굳이 두 번이나 요청을 보낼까?

결국 서버의 데이터를 안전하게 보호하기 위해서이다. 미리 요청을 보내 안전한지 판단한 후 본 요청을 보내는 방식이므로, 예상치 못한 파괴적인 요청으로부터 서버가 훼손되는 것을 미리 방지하여 안정성을 확보할 수 있다.

3-2. 단순 요청 (Simple Request)

두 번째는 예비 요청 과정을 쿨하게 생략하고 서버에 직행으로 본 요청을 보내는 단순 요청(Simple Request)이다.

본 요청을 먼저 보낸 후, 서버가 이에 대한 응답으로 보내준 Access-Control-Allow-Origin 헤더를 보고 브라우저가 CORS 위반 여부를 검사하는 방식이다.

예비 요청이 없으니 편해 보이지만, 단, 아래의 까다로운 조건들을 모두 만족해야만 사용 가능하다.

  1. 요청의 메서드는 GET, HEAD, POST 중 하나여야 한다.
  2. 헤더는 Accept, Accept-Language, Content-Language, Content-Type 등 제한된 헤더일 경우에만 적용된다.
  3. 무엇보다 중요한 것은 Content-Type이다. application/x-www-form-urlencoded, multipart/form-data, text/plain 셋 중 하나여야 한다.

우리가 프론트엔드에서 API 통신을 할 때 데이터를 주고받기 위해 보통 application/json을 사용한다. 따라서 모던 웹 개발에서는 위 조건들을 만족하기 매우 어렵기 때문에 단순 요청을 만날 일은 거의 없다고 보면 된다. 즉 단순 요청은 매우 한정적인 상황에서만 사용된다.

3-3. 인증된 요청 (Credentialed Request)

마지막 세 번째는 인증된 요청(Credentialed Request)이다.

클라이언트에서 서버에게 자격 인증 정보(Credential, 예: 쿠키나 JWT 토큰 등)를 실어 요청할 때 사용되는, 보안이 훨씬 강화된 시나리오이다. 인증 정보를 다루기 때문에 프론트엔드와 백엔드 사이의 더 엄격한 규칙이 적용된다. 양쪽 모두에서 다음과 같은 추가적인 설정이 필수적이다.

1) 클라이언트 (프론트엔드) 설정

첫 번째로 클라이언트에서는 인증 정보를 보내도록 명시적인 설정이 필요하다. 사용하는 통신 도구에 따라 차이가 있다.

// fetch 사용 시
fetch('https://example.com:8000/users/login', {
  method: 'POST',
  credentials: 'include', // 자격 증명 옵션
  body: JSON.stringify({ userId: 1 }),
});
// axios 사용 시
axios.post(
  'https://example.com:8000/users/login',
  { profile: { username: username, password: password } },
  { withCredentials: true } // 자격 증명 옵션
);

fetch와 axios에서의 credentials 문법이 미묘하게 다르다.

2) 서버 (백엔드) 설정

두 번째로 서버 헤더는 반드시 아래 조건을 맞춰주어야 한다.

  1. Access-Control-Allow-Credentials 항목을 반드시 로 설정해야 한다.
  2. Access-Control-Allow-Origin, Methods, Headers의 값에 와일드카드(*)를 사용할 수 없다. 반드시 명확한 출처를 지정해주어야 한다.

만약 1번을 어겼다면 다음과 같은 에러가,

만약 2번을 어겼다면 다음과 같은 에러가 발생한다.

인증 정보가 오가는 민감한 통신인 만큼, 무조건 전체 허용을 하는 것이 아니라 정확한 출처를 명시해야만 브라우저가 통과시켜준다는 점을 주의해야 한다.

4. CORS 해결 방법

앞서 CORS가 발생하는 시나리오들에 대해 알아보았다. 원인과 동작 방식을 알았으니, 이제 가장 중요한 "그래서 어떻게 해결하는데?"에 대해 다뤄볼 차례이다.

CORS 에러를 해결하는 방법은 크게 2가지로 나눌 수 있다. 프론트엔드 단에서 프록시(Proxy) 서버를 활용하는 방법과, 백엔드 단에서 응답 헤더를 세팅해 주는 방법이다.

4-1. 프론트엔드의 우회법: 프록시(Proxy) 서버

첫 번째는 프론트엔드 개발자가 로컬 환경에서 개발할 때 자주 사용하는 프록시 서버 활용법이다.

이 방법의 핵심은 "SOP는 브라우저의 정책이기 때문에, 서버와 서버 간의 통신에는 SOP가 적용되지 않는다"는 점을 이용하는 것이다. 브라우저가 타겟 서버(백엔드)로 직접 api를 요청하면 출처가 달라 SOP 정책에 걸리지만, 중간에 로컬 프록시 서버를 두면 이야기가 달라진다.

순서는 다음과 같다.

  1. 브라우저가 같은 출처인 로컬 프록시 서버(localhost:3000)로 API를 요청한다. (동일 출처이므로 SOP 통과)
  2. 로컬 프록시 서버가 주소를 변환하여 타겟 서버(example.com)로 대신 요청을 보낸다. (서버 간 통이므로 SOP 미적용)
  3. 타겟 서버가 프록시 서버로 응답을 주고, 프록시 서버가 다시 브라우저로 데이터를 전달한다.

로컬 개발 환경(React, Vite, Webpack 등)에서는 설정 파일에 몇 줄만 추가하면 이 프록시 기능을 아주 쉽게 켤 수 있다.

Vite 환경 프록시 설정 예시

// vite.config.js
export default defineConfig({
  server: {
    proxy: {
      // '/api'로 시작하는 요청이 오면 프록시 서버가 가로챔
      '/api': {
        target: 'https://api.example.com', // 실제 백엔드 타겟 서버 주소
        changeOrigin: true, // 출처를 target 주소로 변경
        rewrite: (path) => path.replace(/^\/api/, ''), // '/api' 경로 제거 후 요청
      },
    },
  },
});

React (CRA) 환경 프록시 설정 예시

// src/setupProxy.js (http-proxy-middleware 패키지 사용)
const { createProxyMiddleware } = require('http-proxy-middleware');

module.exports = function(app) {
  app.use(
    '/api',
    createProxyMiddleware({
      target: 'https://api.example.com',
      changeOrigin: true,
    })
  );
};

이렇게 설정해 두면 프론트엔드 코드에서는 fetch('/api/users')처럼 내 로컬 서버로 요청을 보내는 것처럼 코드를 작성해도, 알아서 백엔드 서버로 우회하여 통신하게 된다.

4-2. 백엔드의 근본적 해결: 올바른 응답 헤더 세팅

프록시는 어디까지나 로컬 개발을 편하게 하기 위한 꼼수일 뿐, 실제 서비스 배포를 위해서는 서버 측에서 정석대로 응답 헤더를 세팅해 주어야 한다.

서버에서 직접 헤더를 설정하는 방식은 크게 세 가지로 나눌 수 있다.

1) 직접 헤더에 명시하기

// Node.js (Express) 예시
app.get('/api/data', (req, res) => {
  // 특정 출처(프론트엔드 도메인) 허용
  res.setHeader('Access-Control-Allow-Origin', 'https://my-front-end.com');
  // 여러 메서드 허용
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, PATCH, DELETE');
  // 인증 정보 주고받기 허용
  res.setHeader('Access-Control-Allow-Credentials', 'true');
  
  res.json({ message: "CORS 에러 없이 잘 도착했습니다!" });
});

가장 원초적인 방법으로, HTTP 응답 객체의 헤더에 Access-Control-Allow-Origin을 직접 적어주는 방식이다.

2) CORS 미들웨어 사용하기 (가장 추천)

// Node.js (Express) cors 라이브러리 사용
const express = require('express');
const cors = require('cors');
const app = express();

const corsOptions = {
    origin: 'https://my-front-end.com', // 허용할 프론트엔드 출처 명시
    credentials: true, // 인증된 요청(쿠키 등) 허용
    optionsSuccessStatus: 200
};

// 미들웨어 등록: 모든 요청에 대해 CORS 정책 적용
app.use(cors(corsOptions));

매 라우터마다 헤더를 적어주는 것은 매우 비효율적이다. Node.js의 Express나 Java Spring 같은 프레임워크들은 이를 한 번에 처리해 주는 미들웨어를 제공한다. 실무에서 가장 많이 쓰이는 방식이다.

3) DB를 활용한 동적 출처 허용 처리

// 허용 도메인 목록을 DB에서 조회해 왔다고 가정
const allowedOriginsFromDB = [
  'https://front.com',
  'https://my-blog.com',
  'https://partner-shop.com'
]; //

const corsOptions = {
  origin: function (requestOrigin, callback) {
    // 요청을 보낸 출처가 DB 목록에 존재하는지 확인
    if (allowedOriginsFromDB.includes(requestOrigin) || !requestOrigin) {
      callback(null, true); // 존재하면 허용
    } else {
      callback(new Error('허용되지 않은 출처의 접근입니다.')); // 존재하지 않으면 CORS 에러 발생
    }
  } //
};

app.use(cors(corsOptions));

만약 우리 API를 사용해야 하는 파트너사나 클라이언트 도메인이 수십 개고 계속 추가된다면 어떻게 할까? 코드를 매번 수정할 수 없으니, 허용할 출처 목록을 데이터베이스에 저장해 두고 동적으로 검사하는 방식을 쓸 수 있다.

5. 마치며

지금까지 우리를 괴롭히던 CORS의 정체부터 해결 방법까지 모두 알아보았다. 마지막으로 내용을 간략하게 정리해 보자.

Q. CORS란?

브라우저가 다른 출처로부터 자원을 가져오는 것을 서버가 허가해 주는 메커니즘이다.

Q. CORS 에러는 왜 발생하는가?

브라우저의 기본 보안 정책인 SOP 정책으로 인해 다른 출처끼리 리소스 공유를 할 수 없게 막혀있는데, 서버가 이를 허용하는 헤더를 주지 않았기 때문이다.

Q. CORS 동작 시나리오는?

OPTIONS 메서드로 찔러보는 예비 요청, 직행하는 단순 요청, 쿠키 등을 담아 보내는 인증된 요청 3가지가 있다.

Q. CORS 해결 방법은?

프론트엔드에서는 프록시 서버로 우회하거나, 백엔드 서버에서 응답 헤더를 설정해 주면 된다.

결국 가장 확실하고 빠른 CORS 해결책은 '프론트엔드와 백엔드 개발자 간의 원활한 소통'이다.

프론트엔드 개발자는 배포할 도메인을 백엔드에게 정확히 전달하고, 백엔드 개발자는 그 도메인을 서버의 허용 목록에 잘 등록해 준다면 CORS를 잘 해결해나갈 수 있을 것이다.


테코톡 PPT 자료

https://www.canva.com/design/DAHGiMeP8Qs/sxpqH9iyB0YE7BQCvwl_Bg/edit?ui=e30

https://www.youtube.com/watch?v=0jLxWJUlZLs&feature=youtu.be

profile
저는 개발노동자입니다.

0개의 댓글