CORS 완전 정복하기

2023년 6월 27일

TL;DR:

CORS는 Cross Origin Resource Sharing의 약자로, 클라이언트와 서버가 교차 출처임에도 상호작용이 가능하도록 도와주는 브라우저의 정책이며, 서버가 HTTP 응답 헤더의 Access-Control-Allow-Origin 속성을 통해 제어합니다.

프론트엔드 개발을 하다 보면 브라우저 콘솔에서 한 번쯤은 마주쳤을 법한 에러 메시지가 있습니다. 바로 Access to fetch at '...' from origin '...' has been blocked by CORS policy 인데요, 이 글에서는 CORS가 무엇이며 어떻게 다뤄야 하는지에 대해 자세히 알아보겠습니다.


CORS란 무엇일까요?

CORS(Cross-Origin Resource Sharing)는 교차 출처 리소스 공유라는 뜻으로, 이름 그대로 클라이언트와 서버가 서로 다른 출처(Origin)를 가지고 있더라도 리소스를 주고받을 수 있도록 허용하는 브라우저의 정책이에요.

여기서 출처(Origin)는 Protocol, Host, Port를 모두 포함하는 개념입니다. 예컨대 https://asher.comhttps://api.asher.com은 호스트가 다르므로 다른 출처이고, http://localhost:3000http://localhost:8080은 포트가 다르므로 다른 출처인 셈이죠.

원래 브라우저는 보안상의 이유로 동일 출처 정책(Same-Origin Policy)에 따라 다른 출처의 리소스를 요청하고 사용하는 것을 제한해왔습니다. 하지만 웹 생태계가 발전하면서 서로 다른 출처끼리 상호작용해야 할 필요성이 커졌고, 이에 따라 CORS 정책이 등장하게 되었습니다. CORS는 2004년 Tellme Networks에서 처음 제안되어, 2014년에 W3C에서 공식 표준으로 채택되었습니다.


CORS는 어떻게 설정할까요?

CORS 정책의 주체는 브라우저이지만, 실제 제어는 서버 측에서 HTTP 응답 헤더의 Access-Control-Allow-Origin 속성을 통해 이루어집니다.

예컨대 Express.js 서버에서는 아래와 같이 Access-Control-Allow-Origin 속성에 허용할 출처를 명시해서 리소스 접근을 허용할 수 있어요.

const app = express();

app.get("/api/data", (_, res) => {
  res.set("Access-Control-Allow-Origin", "http://localhost:3000");

  res.status(200).json({ message: "success" });
});

CORS의 두 가지 요청 방식: Simple Request와 Preflight Request

CORS에는 두 가지 종류의 요청 방식이 있습니다. 바로 'Simple Request'과 'Preflight Request'입니다.

Simple Request

Simple Request는 특정 조건을 만족할 경우, 곧장 서버로 본 요청을 한 번만 보내는 방식입니다. 조건은 다음과 같습니다.

  • 메서드가 GET, POST, HEAD 중 하나여야 합니다.
  • POST 메서드의 경우, Content-Typeapplication/x-www-form-urlencoded, multipart/form-data, text/plain 중 하나여야 합니다.
  • 커스텀 헤더를 사용하지 않아야 합니다.

Simple Request의 경우, 브라우저는 요청을 보낸 뒤 서버의 응답 헤더에서 Access-Control-Allow-Origin을 확인합니다. 만약 현재 클라이언트의 출처가 허용 목록에 없다면, 브라우저는 서버의 응답을 파기하고 CORS 에러를 발생시킵니다. 중요한 점은 요청 자체는 서버로 전달되며, 응답을 브라우저가 차단한다는 것입니다.

Preflight Request

Simple Request의 조건을 만족하지 않는다면, 브라우저는 본 요청을 보내기 전에 Preflight Request을 먼저 보냅니다. 이 Preflight Request는 OPTIONS 메서드를 사용하여 서버로 전송되며, 본 요청이 안전한지 확인하는 역할을 합니다.

Preflight Request는 다음 헤더들을 통해 본 요청에 대한 정보를 서버에 알립니다.

  • Access-Control-Request-Method: 본 요청의 HTTP 메서드
  • Access-Control-Request-Headers: 본 요청에 담긴 Simple Request 허용 범위를 벗어난 헤더들 (예: Content-Type: application/json, Authorization, X-Custom-Header 등)
  • Origin: 요청을 보낸 클라이언트의 출처

서버는 이 Preflight Request을 받고, Access-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers 등의 헤더를 담아 응답합니다. 브라우저는 이 응답을 보고 본 요청을 보낼지 말지를 결정합니다. 이처럼 수정이나 삭제와 같이 데이터에 영향을 줄 수 있는 위험한 요청을 서버가 처리하기 전에 유효성을 먼저 검증하기 위해 Preflight Request가 존재합니다.


프론트엔드에서 CORS 에러 우회하는 방법

로컬 환경에서 프론트엔드 개발을 할 때에도 브라우저와 API 서버의 출처는 다를 수밖에 없는데요, 이때에는 프론트엔드 자체적으로 프록시를 설정해서 CORS 에러를 우회할 수 있습니다. (단, 운영 환경에서는 API 서버가 교차 출처 요청을 명시적으로 허용하도록 설정하는 것이 올바른 방법입니다.)

프록시가 동작하는 원리는 다음과 같습니다.

  1. 브라우저는 API 서버가 아닌 프론트엔드 서버(프록시 서버)로 요청을 보냅니다. (동일 출처)
  2. 프록시 서버는 그 요청을 받아 API 서버로 대신 전달합니다. (서버와 서버 간 통신에는 CORS 정책이 적용되지 않음)
  3. API 서버는 응답을 프록시 서버로 보냅니다.
  4. 프록시 서버는 다시 브라우저로 응답을 전달합니다.

이렇게 하면 브라우저 입장에서는 동일 출처에 요청을 보내는 것이므로 CORS 에러가 발생하지 않게 되는 것입니다.

이젠 실제로 어떻게 적용할 수 있는지, 아래 단계를 통해 함께 알아볼까요?

✅ 0단계: 환경변수 설정하기

먼저 .env.local 파일을 주목해 봅시다.

로컬 환경에서는 API 서버가 아니라 프론트엔드 서버로 요청을 보내야 하므로 API_BASE_URL에 빈 값을 넣어줍니다. (브라우저와 프론트엔드 서버는 동일 출처이기 때문입니다. 물론 명시적으로 프론트엔드 서버의 주소를 넣어 주어도 상관없습니다.) API_PROXY_TARGET에는 로컬 환경에서 찌를 API 서버의 주소를 넣어줍니다.

그다음 .env.development 파일과 .env.production 파일에는 각 운영 환경의 API 서버의 주소를 넣어줍니다.

🔹 Vite 사용 시

// .env.local
VITE_API_BASE_URL=
VITE_API_PROXY_TARGET=https://api.asher-dev.com

// .env.development
VITE_API_BASE_URL=https://api.asher-dev.com

// .env.production
VITE_API_BASE_URL=https://api.asher.com

🔹 Next.js 사용 시

// .env.local
NEXT_PUBLIC_API_BASE_URL=
NEXT_PUBLIC_API_PROXY_TARGET=https://api.asher-dev.com

// .env.development
NEXT_PUBLIC_API_BASE_URL=https://api.asher-dev.com

// .env.production
NEXT_PUBLIC_API_BASE_URL=https://api.asher.com

✅ 1단계: 프론트엔드 서버에 프록시 설정하기

🔹 Vite 사용 시

// vite.config.js
import react from "@vitejs/plugin-react";
import { defineConfig, loadEnv } from "vite";

export default ({ mode }) => {
  const env = loadEnv(mode, process.cwd());

  const proxyTarget = env.VITE_API_PROXY_TARGET;

  return defineConfig({
    plugins: [react()],
    server: proxyTarget
      ? {
          proxy: {
            "/api": {
              target: proxyTarget,
              changeOrigin: true,
            },
          },
        }
      : {},
  });
};

🔹 Next.js 사용 시

// next.config.js
/** @type {import('next').NextConfig} */
const proxyTarget = process.env.NEXT_PUBLIC_API_PROXY_TARGET;

const nextConfig = {
  async rewrites() {
    if (!proxyTarget) return [];

    return [
      {
        source: "/api/:path*",
        destination: `${proxyTarget}/api/:path*`,
      },
    ];
  },
};

module.exports = nextConfig;

✅ 2단계: 클라이언트 코드에서 프록시 사용하기

🔹 Vite 사용 시

// fetch 사용 시
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/users`);
const users = await response.json();

// axios 사용 시
const users = await axios.get(`${import.meta.env.VITE_API_BASE_URL}/api/users`);

🔹 Next.js 사용 시

// fetch 사용 시
const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/users`);
const users = await response.json();

// axios 사용 시
const users = await axios.get(`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/users`);