CORS란 무엇이고 프론트엔드에서는 어떻게 대응해야 할까

Frontend

프론트엔드 개발을 하다 보면 CORS는 거의 언젠가 반드시 만나게 됩니다.

  • 로컬에서 API를 붙이려는데 갑자기 요청이 막힐 때
  • 브라우저 콘솔에 Access-Control-Allow-Origin 에러가 찍힐 때
  • Postman에서는 되는데 브라우저에서만 안 될 때
  • 쿠키 기반 인증을 붙였더니 갑자기 더 까다로워질 때

이때 처음에는 이렇게 느끼기 쉽습니다.

  • "브라우저가 왜 멋대로 막지?"
  • "프론트에서 헤더 하나 넣으면 풀리지 않나?"
  • "백엔드가 안 열어줘서 그렇다는 말이 정확히 무슨 뜻이지?"

문제는 CORS가 단순 에러 메시지가 아니라, 브라우저 보안 모델의 일부라는 점입니다. 그래서 이 개념을 모르고 대응하면 자꾸 임시방편만 반복하게 됩니다.

이 글에서는 아래 흐름으로 정리해보겠습니다.

  1. CORS가 왜 생겼는지
  2. same-origin policy는 무엇인지
  3. 브라우저가 언제 막고, 언제 preflight를 보내는지
  4. 쿠키와 인증 요청에서는 왜 더 까다로운지
  5. 프론트엔드 실무에서 무엇을 할 수 있고 무엇은 서버가 해결해야 하는지

한눈에 보면

먼저 짧게 정리하면 이렇습니다.

  • CORS는 브라우저가 다른 origin으로 가는 요청 결과를 제한하는 보안 규칙입니다.
  • 배경에는 same-origin policy가 있습니다.
  • 서버가 적절한 Access-Control-Allow-* 헤더로 허용 의사를 명시해야 브라우저가 응답을 열어줍니다.
  • POST라고 해서 무조건 preflight가 가는 것은 아니고, 메서드/헤더/콘텐츠 타입 조건에 따라 달라집니다.
  • 프론트엔드는 CORS 자체를 직접 해제할 수는 없지만, 프록시, BFF, same-origin 배치, 요청 단순화 같은 방식으로 실무적으로 대응할 수 있습니다.

즉, CORS의 핵심은 "왜 브라우저가 막는가"와 "프론트엔드가 어디까지 바꿀 수 있는가"를 구분해서 보는 것입니다.

먼저 origin이 무엇인지부터 봐야 한다

CORS를 이해하려면 먼저 origin 개념이 필요합니다.

브라우저에서 origin은 보통 아래 세 가지 조합으로 봅니다.

  • 프로토콜
  • 호스트
  • 포트

예를 들어:

  • https://example.com
  • https://api.example.com
  • http://example.com
  • https://example.com:3000

는 서로 origin이 다를 수 있습니다.

즉, 같은 도메인처럼 보여도:

  • httphttps는 다르고
  • example.comapi.example.com도 다르고
  • 포트가 달라도 다릅니다

이 origin 개념이 same-origin policyCORS의 기준이 됩니다.

same-origin policy는 왜 있을까?

브라우저는 사용자를 대신해 여러 사이트에 로그인된 상태를 유지합니다.

예를 들어 사용자는:

  • 메일 서비스에 로그인해 있고
  • 은행 사이트에도 로그인해 있고
  • 관리자 페이지에도 로그인해 있을 수 있습니다

만약 아무 사이트에서나 다른 사이트 응답을 자유롭게 읽을 수 있다면 굉장히 위험해집니다.

악성 사이트가 열렸을 때 이런 일이 가능해질 수 있습니다.

  • 사용자가 로그인된 다른 서비스로 요청을 보내고
  • 그 응답 내용을 읽어오고
  • 민감한 데이터를 탈취하는 것

그래서 브라우저는 기본적으로 다른 origin의 응답을 함부로 읽지 못하게 제한합니다. 이것이 same-origin policy의 핵심입니다.

즉, CORS는 뜬금없이 추가된 번거로운 규칙이 아니라, 브라우저가 기본적으로 막고 있는 cross-origin 접근을 제한적으로 열어주는 방식이라고 보는 편이 맞습니다.

CORS는 무엇을 하는 걸까?

CORSCross-Origin Resource Sharing의 약자입니다.

이름만 보면 "서로 다른 origin끼리 자원을 공유하는 방식"처럼 보이는데, 실제 감각은 조금 다릅니다.

브라우저는 기본적으로 cross-origin 응답을 바로 열어주지 않습니다. 대신 서버가:

  • "이 origin은 읽어도 된다"
  • "이 메서드는 허용한다"
  • "이 헤더는 허용한다"

를 명시적으로 말해야 브라우저가 응답을 열어줍니다.

즉, CORS는 "무조건 허용"이 아니라 서버의 허용 의사를 브라우저가 확인하는 프로토콜에 가깝습니다.

흐름으로 보면 이렇게 된다

가장 단순화하면 브라우저의 판단 흐름은 이렇습니다.

flowchart TD
  A[브라우저에서 요청 발생] --> B{same origin 인가?}
  B -- 예 --> C[일반 요청 처리]
  B -- 아니오 --> D[CORS 규칙 확인]
  D --> E{preflight 필요한가?}
  E -- 아니오 --> F[실제 요청 전송]
  E -- 예 --> G[OPTIONS preflight 전송]
  G --> H{서버가 허용 헤더 반환했는가?}
  H -- 아니오 --> I[브라우저가 응답 차단]
  H -- 예 --> F
  F --> J{응답에 허용 헤더가 있는가?}
  J -- 아니오 --> I
  J -- 예 --> K[브라우저가 응답 열람 허용]

즉, 핵심은 요청이 네트워크에 갔느냐만이 아니라, 브라우저가 그 응답을 자바스크립트에 열어줄 것인가입니다.

왜 Postman에서는 되고 브라우저에서는 안 될까?

이 질문이 정말 자주 나옵니다.

답은 간단합니다. CORS는 브라우저 보안 정책이기 때문입니다.

즉:

  • Postman, curl, 서버 간 요청은 보통 브라우저의 same-origin policy를 따르지 않고
  • 브라우저에서 실행되는 자바스크립트 요청은 이 정책의 영향을 받습니다

그래서 API 자체는 정상 동작해도, 브라우저 환경에서는 CORS 때문에 막힐 수 있습니다.

즉, "브라우저에서 막힌다"는 것은 종종 API가 죽었다가 아니라 브라우저가 응답 접근을 막고 있다는 뜻일 수 있습니다.

preflight는 왜 보내는 걸까?

여기서 많이 헷갈립니다.

브라우저는 어떤 cross-origin 요청이 조금 더 민감하거나 복잡하다고 판단되면, 실제 요청 전에 먼저 OPTIONS 요청을 보냅니다. 이게 preflight입니다.

즉, 브라우저는 실제 요청을 바로 보내기 전에 먼저 이렇게 묻는 셈입니다.

  • "이 origin이 이 메서드로 요청해도 되나요?"
  • "이 헤더를 써도 되나요?"

서버가 허용 응답을 주면 그 다음 실제 요청이 나갑니다.

preflight가 자주 발생하는 경우

보통 아래 요소가 있으면 더 자주 보게 됩니다.

  • PUT, PATCH, DELETE 같은 메서드
  • 커스텀 헤더(Authorization, X-* 등)
  • 특정 Content-Type (application/json 포함)

즉, 많은 프론트엔드 요청이 JSONAuthorization 헤더를 같이 쓰기 때문에 preflight를 자주 보게 됩니다.

preflight 흐름을 보면 더 이해가 쉽다

sequenceDiagram
  participant FE as Frontend (Browser)
  participant API as API Server
 
  FE->>API: OPTIONS /users\nOrigin: https://app.example.com\nAccess-Control-Request-Method: POST\nAccess-Control-Request-Headers: authorization, content-type
  API-->>FE: 204 No Content\nAccess-Control-Allow-Origin: https://app.example.com\nAccess-Control-Allow-Methods: POST\nAccess-Control-Allow-Headers: authorization, content-type
  FE->>API: POST /users\nOrigin: https://app.example.com\nAuthorization: Bearer ...
  API-->>FE: 200 OK\nAccess-Control-Allow-Origin: https://app.example.com

즉, preflight는 에러가 아니라 실제 요청 전에 허용 여부를 확인하는 절차입니다.

"요청이 막혔다"는 말은 정확히 무슨 뜻일까?

실무에서는 이 부분을 구분하는 게 중요합니다.

CORS 에러가 났다고 해서 항상 요청이 서버까지 아예 안 간다는 뜻은 아닙니다.

상황에 따라:

  • preflight 단계에서 막힐 수도 있고
  • 실제 요청은 갔지만 응답을 브라우저가 읽지 못하게 막을 수도 있습니다

즉, 네트워크 탭을 보면 서버에는 흔적이 남는데 프론트에서는 에러처럼 보일 수 있습니다.

그래서 CORS를 디버깅할 때는:

  1. OPTIONS가 나갔는지
  2. 실제 요청이 나갔는지
  3. 응답 헤더에 어떤 Access-Control-Allow-*가 있는지

를 같이 봐야 합니다.

쿠키 기반 인증에서는 왜 더 까다로울까?

인증이 쿠키와 연결되면 credentials 문제가 같이 따라옵니다.

브라우저는 cross-origin 요청에서 쿠키를 자동으로 포함하지 않을 수 있고, 포함시키려면 프론트와 서버 양쪽이 조건을 맞춰야 합니다.

프론트에서는 보통:

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

처럼 보내야 할 수 있습니다.

하지만 이걸로 끝나지 않습니다. 서버도:

  • Access-Control-Allow-Credentials: true
  • 정확한 Access-Control-Allow-Origin

를 내려야 합니다.

여기서 중요한 점은:

  • credentials를 허용할 때는 Access-Control-Allow-Origin: * 와 같이 와일드카드를 쓰면 안 된다는 점입니다

즉, 쿠키 기반 인증의 CORS는 단순히 origin만 여는 문제가 아니라, 브라우저가 인증 정보를 실어도 되는지까지 명시하는 문제가 됩니다.

프론트엔드가 할 수 있는 것과 없는 것

이 부분이 실무에서 가장 중요합니다.

프론트엔드가 직접 할 수 없는 것

  • 서버가 내려주는 Access-Control-Allow-Origin을 브라우저에서 대신 설정하는 것
  • 브라우저의 same-origin policy 자체를 해제하는 것
  • 서버 허용 없이 cross-origin 응답을 읽는 것

즉, CORS는 본질적으로 서버가 허용 의사를 보여줘야 해결되는 문제입니다.

프론트에서 헤더를 임의로 넣는다고 해결되지 않는 경우가 많은 이유가 여기 있습니다.

프론트엔드가 실무적으로 할 수 있는 것

반면 아래는 실제로 할 수 있습니다.

  • 개발 환경에서 프록시를 둔다
  • 배포 구조를 same-origin으로 바꾼다
  • BFF나 API route를 둔다
  • 요청을 불필요하게 복잡하게 만들지 않는다
  • credentials와 쿠키 전략을 정확히 맞춘다
  • 네트워크 탭으로 preflight와 응답 헤더를 확인한다

즉, 프론트엔드는 CORS를 직접 해제하지는 못하지만, 브라우저가 cross-origin으로 보지 않게 만들거나, 서버와 맞물린 구조를 설계하는 방식으로 대응할 수 있습니다.

프론트엔드 실무 대응 1. 개발 환경에서는 프록시를 둔다

로컬 개발에서 가장 흔한 해결책입니다.

예를 들어 프론트가 http://localhost:3000, API가 http://localhost:8080이면 origin이 다릅니다.

이럴 때 dev server proxy를 두면 브라우저는 같은 origin으로 요청하는 것처럼 볼 수 있습니다.

예를 들어 Vite라면:

// vite.config.ts
export default defineConfig({
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
      },
    },
  },
});

이제 프론트에서는:

fetch('/api/users');

처럼 호출할 수 있습니다.

즉, 브라우저 입장에서는 같은 origin으로 요청하고, 실제 백엔드 전달은 dev server가 대신 해줍니다.

이 방식은 특히 로컬 개발에서 가장 현실적이고 많이 쓰입니다.

프론트엔드 실무 대응 2. 배포 구조를 same-origin으로 맞춘다

운영 환경에서도 자주 쓰는 방법입니다.

예를 들어:

  • 프론트: https://app.example.com
  • API: https://api.example.com

처럼 완전히 분리하면 cross-origin 문제가 생깁니다.

대신 reverse proxy나 gateway를 둬서:

  • 브라우저는 https://app.example.com/api/*로 요청하고
  • 내부에서 API 서버로 전달하게 만들 수 있습니다

즉, 브라우저 기준으로는 같은 origin이 되므로 CORS 부담을 줄일 수 있습니다.

이 방식은 단순히 에러를 없애는 수준이 아니라, 인증 쿠키나 운영 정책까지 같이 정리하기 좋습니다.

프론트엔드 실무 대응 3. BFF나 API Route를 둔다

Next.js 같은 환경에서는 프론트 바로 옆에 서버 레이어를 둘 수 있습니다.

예를 들어:

  • 브라우저 -> 우리 앱 서버(/api/*)
  • 앱 서버 -> 외부 API

흐름으로 가져가면 브라우저는 같은 origin으로만 통신하게 됩니다.

간단히 말해:

  • 브라우저에서 직접 외부 API를 때리지 않고
  • 우리 서버가 중간에서 받아 대신 호출하는 구조

입니다.

이건 특히:

  • 외부 API 키를 숨겨야 할 때
  • 쿠키 인증과 조합할 때
  • CORS 제어권이 없는 외부 API를 붙여야 할 때

유용합니다.

즉, 프론트엔드 실무에서 진짜 자주 쓰는 해결책은 브라우저에서 억지로 뚫는 것이 아니라, 요청 경로 자체를 재설계하는 것입니다.

프론트엔드 실무 대응 4. 요청을 불필요하게 복잡하게 만들지 않는다

이건 preflight 최적화와 연결됩니다.

예를 들어:

  • 꼭 필요하지 않은 커스텀 헤더를 제거하고
  • 인증이 필요 없는 요청에는 Authorization을 붙이지 않고
  • 정말 필요한지 확인한 뒤 application/json을 사용하는 것

만으로도 일부 요청의 preflight를 줄일 수 있습니다.

물론 무조건 preflight를 피하는 게 목표는 아닙니다. 하지만 불필요하게 복잡한 요청을 습관처럼 만들고 있지는 않은지 보는 것은 의미가 있습니다.

즉, CORS 대응은 서버 설정만이 아니라, 프론트 요청 설계 자체를 단순하게 가져가는 습관과도 연결됩니다.

프론트엔드 실무 대응 5. no-cors로 도망가지 않는다

이것도 정말 자주 보입니다.

처음 fetch를 만지다 보면:

fetch('https://api.example.com/data', {
  mode: 'no-cors',
});

를 시도하게 될 수 있습니다.

하지만 이건 대부분 원하는 해결책이 아닙니다.

no-cors는 보통:

  • 응답 본문을 제대로 읽지 못하고
  • opaque response가 되어
  • 실제 애플리케이션 로직에 쓰기 어려운 경우가 많습니다

즉, no-cors는 CORS를 정상적으로 해결하는 방법이라기보다, 응답 접근을 포기하는 방향에 더 가깝습니다.

실무에서는 보통 피하는 편이 낫습니다.

디버깅할 때 이렇게 보면 좋다

CORS 문제를 만나면 아래 순서로 보면 도움이 됩니다.

  1. 프론트와 API의 origin이 정확히 어떻게 다른가
  2. OPTIONS preflight가 나가는가
  3. 응답 헤더에 Access-Control-Allow-Origin이 있는가
  4. 쿠키 요청이면 credentials: 'include'Access-Control-Allow-Credentials가 맞는가
  5. Access-Control-Allow-Origin*인데 쿠키를 같이 쓰고 있지는 않은가
  6. dev proxy나 same-origin 배치로 바꿀 수 있는가

즉, CORS는 감으로 고치기보다 origin, preflight, credentials, response headers를 체크리스트처럼 보는 편이 훨씬 빠릅니다.

자주 하는 오해

정리하면 아래 오해가 정말 많습니다.

1. 프론트에서 헤더 하나 넣으면 해결된다

대부분 아닙니다. 핵심 허용 헤더는 서버가 응답으로 내려줘야 합니다.

2. CORS는 API 서버 장애다

아닙니다. 서버는 정상인데 브라우저가 응답 접근을 막고 있을 수 있습니다.

3. Postman이 되니 브라우저도 돼야 한다

아닙니다. CORS는 브라우저 정책입니다.

4. no-cors면 해결된다

대부분 아닙니다. 응답을 읽지 못하는 방향이라 실제 앱 로직에 도움이 안 될 때가 많습니다.

5. 프론트엔드만으로 완전히 해결할 수 있다

아닙니다. 본질적으로는 서버 허용 정책이 필요합니다. 프론트는 구조적으로 우회하거나 맞춰갈 수 있을 뿐입니다.

같이 보면 좋은 글

결론

CORS는 브라우저가 괜히 개발자를 괴롭히기 위해 만든 규칙이 아니라, 다른 origin의 응답을 아무 페이지에서나 읽지 못하게 하기 위한 보안 모델 위에 있습니다.

짧게 정리하면:

  • same-origin policy가 기본적으로 막고 있고
  • CORS는 서버가 허용 의사를 명시할 때만 제한적으로 열어주며
  • 프론트엔드는 이를 직접 해제할 수는 없지만 프록시, BFF, same-origin 구조, 요청 단순화 같은 방식으로 실무적으로 대응할 수 있습니다

결국 CORS를 잘 이해한다는 것은 에러 문구를 외우는 것이 아니라, 브라우저가 왜 막는지와 프론트가 어디까지 바꿀 수 있는지를 분리해서 보는 감각에 더 가깝습니다.