현록

CORS 에러와 해결 기준

fetchaxios로 API를 호출하다 보면 CORS 에러를 자주 만난다.
콘솔에는 Cross-Origin Request Blocked 같은 문장이 나오고, 네트워크 탭에는 요청이 보이는데 코드에서는 응답을 읽지 못하는 식이다.

이때 먼저 분리해서 봐야 하는 기준이 있다.
CORS는 fetchaxios의 기능 차이 때문에 생기는 문제가 아니다.
브라우저의 보안 정책과 서버가 내려주는 CORS 응답 헤더가 맞지 않을 때 생기는 문제다.

그래서 클라이언트 코드만 바꿔서 모든 CORS 에러를 해결할 수는 없다.
대부분은 API 서버가 어떤 출처의 브라우저 코드에게 응답을 읽게 허용할지 명시해야 해결된다.

API 호출 도구 자체의 차이가 헷갈린다면 fetch와 axios 차이를 먼저 봐도 좋다.
이 글에서는 도구 선택이 아니라 브라우저가 응답을 막는 기준을 중심으로 정리한다.

CORS가 자주 보이는 상황

개발 중에는 프론트엔드와 API 서버 주소가 자주 다르다.
예를 들어 화면은 http://localhost:3000에서 열고, API는 http://localhost:8080으로 호출하는 식이다.

const response = await fetch('http://localhost:8080/api/users')
const users = await response.json()

이 요청이 서버까지 도착할 수는 있다.
하지만 브라우저는 응답을 JavaScript 코드에 넘겨주기 전에 CORS 규칙을 확인한다.

서버 응답에 필요한 CORS 헤더가 없으면 브라우저는 응답 본문을 코드에서 읽지 못하게 막는다.
그래서 서버 로그에는 요청이 찍혔는데 브라우저 코드에서는 실패처럼 보일 수 있다.

이 차이를 이해하는 것이 중요하다.
CORS 에러는 항상 “요청이 서버에 아예 가지 않았다”는 뜻이 아니다.
많은 경우에는 “브라우저가 응답을 JavaScript에 노출하지 않았다”에 더 가깝다.

Same-Origin Policy의 기준

브라우저는 기본적으로 Same-Origin Policy를 적용한다.
Same-Origin Policy는 한 출처에서 로드된 문서나 스크립트가 다른 출처의 리소스와 상호작용하는 방식을 제한하는 보안 정책이다.

origin은 scheme, host, port 조합으로 판단한다.
셋 중 하나라도 다르면 같은 origin이 아니다.

아래 두 주소는 host와 port가 같아도 scheme이 다르므로 다른 origin이다.

http://example.com
https://example.com

아래 두 주소는 scheme과 host가 같아도 port가 다르므로 다른 origin이다.

http://localhost:3000
http://localhost:8080

아래 두 주소는 scheme과 port가 같아도 host가 다르므로 다른 origin이다.

https://app.example.com
https://api.example.com

프론트엔드 개발 서버와 백엔드 서버를 따로 띄우면 포트가 달라지는 경우가 많다.
그래서 로컬 개발 환경에서도 CORS 에러를 쉽게 만난다.

CORS의 역할

CORS는 Cross-Origin Resource Sharing의 약자다.
이름 그대로 다른 origin 사이에서 리소스 공유를 허용하는 HTTP 헤더 기반 메커니즘이다.

브라우저는 보안을 위해 cross-origin HTTP 요청을 제한한다.
다만 서버가 “이 origin은 응답을 읽어도 된다”는 헤더를 내려주면 브라우저가 그 응답을 JavaScript 코드에 노출할 수 있다.

흐름은 단순하게 보면 아래와 같다.

브라우저 코드 -> API 서버 요청
API 서버 -> CORS 응답 헤더 포함
브라우저 -> 헤더 확인 후 응답 노출 여부 결정

여기서 판단하는 주체는 브라우저다.
허용 여부를 선언하는 주체는 서버다.

그래서 서버가 CORS 헤더를 내려주지 않으면 클라이언트 코드에서 응답을 읽을 방법이 없다.
이 점 때문에 CORS는 프론트엔드 개발자가 자주 만나지만, 실제 수정 지점은 서버 설정인 경우가 많다.

Access-Control-Allow-Origin

가장 핵심이 되는 응답 헤더는 Access-Control-Allow-Origin이다.
이 헤더는 어떤 origin의 요청 코드가 응답을 읽을 수 있는지 알려준다.

모든 origin에 공개해도 되는 리소스라면 아래처럼 *를 사용할 수 있다.

Access-Control-Allow-Origin: *

특정 프론트엔드 주소만 허용하려면 명시적인 origin을 내려준다.

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

로그인 쿠키나 인증 정보를 포함하는 요청에서는 더 조심해야 한다.
credentials 요청과 Access-Control-Allow-Origin: *는 같이 쓸 수 없다.

이런 경우 서버는 요청의 Origin을 확인하고, 허용 목록에 있는 origin일 때만 같은 값을 응답해야 한다.

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

여러 origin을 지원하면서 명시적인 origin을 내려주는 서버라면 Vary: Origin도 중요하다.
캐시가 origin마다 달라지는 응답을 같은 응답처럼 재사용하지 않게 돕기 때문이다.

preflight 요청

브라우저는 어떤 cross-origin 요청을 보내기 전에 OPTIONS 요청을 먼저 보낼 수 있다.
이 사전 확인 요청을 preflight 요청이라고 부른다.

예를 들어 커스텀 헤더를 보내거나, 단순 요청 범위를 벗어나는 method와 content type을 쓰면 preflight가 발생할 수 있다.

await fetch('https://api.example.com/users', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: 'Bearer <access-token>',
  },
  body: JSON.stringify({ name: 'sample-user' }),
})

브라우저는 실제 POST 요청 전에 서버에 이런 요청이 허용되는지 물어본다.

OPTIONS /users HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization

서버는 허용할 origin, method, header를 응답해야 한다.

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization

서버가 OPTIONS 요청을 처리하지 못하면 실제 API 요청 전에 막힐 수 있다.
그래서 API 서버의 라우트는 실제 요청뿐 아니라 preflight 응답도 함께 고려해야 한다.

클라이언트에서 해결할 수 없는 이유

CORS를 처음 만나면 프론트엔드 코드에서 헤더를 추가하려고 할 수 있다.
하지만 핵심 CORS 허용 헤더는 요청 헤더가 아니라 응답 헤더다.

아래처럼 클라이언트 요청에 Access-Control-Allow-Origin을 넣어도 해결되지 않는다.

await fetch('https://api.example.com/users', {
  headers: {
    'Access-Control-Allow-Origin': '*',
  },
})

이 헤더는 서버가 응답으로 내려줘야 의미가 있다.
브라우저는 서버 응답의 CORS 헤더를 보고 응답 노출 여부를 결정한다.

클라이언트에서 할 수 있는 일은 제한적이다.
요청 method, header, credentials 옵션이 서버가 허용한 범위와 맞는지 확인할 수는 있다.
하지만 서버가 허용하지 않는 origin을 클라이언트에서 강제로 허용시킬 수는 없다.

서버에서 확인할 항목

CORS 에러를 만났다면 먼저 브라우저 개발자 도구의 Console과 Network 탭을 같이 본다.
Console에는 CORS 실패 이유가 비교적 직접적으로 나온다.
Network 탭에서는 preflight OPTIONS 요청이 실패했는지, 실제 요청 응답에 CORS 헤더가 있는지 확인할 수 있다.

서버에서는 아래 항목을 우선 확인한다.

  • 요청한 프론트엔드 origin이 허용 목록에 있는지
  • Access-Control-Allow-Origin이 실제 응답에 포함되는지
  • credentials 요청이라면 Access-Control-Allow-Credentials: true가 필요한지
  • credentials 요청에서 wildcard *를 쓰고 있지 않은지
  • preflight 요청에 필요한 Access-Control-Allow-Methods가 있는지
  • preflight 요청에 필요한 Access-Control-Allow-Headers가 있는지
  • 에러 응답에도 CORS 헤더가 붙는지

마지막 항목도 중요하다.
정상 응답에는 CORS 헤더가 붙지만 401, 403, 500 같은 에러 응답에는 헤더가 빠지는 서버가 있다.
이 경우 실제 문제는 인증 실패나 서버 에러인데, 브라우저에서는 CORS 에러처럼 보일 수 있다.

no-cors의 오해

fetch에는 mode: 'no-cors' 옵션이 있다.
이 이름 때문에 CORS를 끄는 해결책처럼 보일 수 있다.
하지만 보통 API 응답을 읽어야 하는 상황에서는 해결책이 아니다.

await fetch('https://api.example.com/log', {
  method: 'POST',
  mode: 'no-cors',
})

no-cors 모드에서는 응답이 opaque response가 된다.
opaque response는 JavaScript에서 status, header, body를 제대로 읽을 수 없다.

그래서 데이터를 받아 화면에 보여줘야 하는 API 호출에는 맞지 않는다.
analytics beacon처럼 응답 내용을 읽지 않아도 되는 일부 전송에는 쓸 수 있지만, 일반적인 JSON API 호출 문제를 해결해주지는 않는다.

정리

CORS는 fetchaxios 중 무엇을 쓰느냐의 문제가 아니다.
브라우저가 다른 origin의 응답을 JavaScript에 노출할지 결정하는 보안 정책의 문제다.

origin은 scheme, host, port 조합으로 판단한다.
로컬 개발 환경에서도 포트가 다르면 다른 origin이다.

서버는 Access-Control-Allow-Origin을 비롯한 CORS 응답 헤더를 정확히 내려줘야 한다.
preflight가 발생하는 요청이라면 OPTIONS 요청에 대한 응답도 준비해야 한다.

클라이언트에서 Access-Control-Allow-Origin 요청 헤더를 추가하는 방식은 해결책이 아니다.
mode: 'no-cors'도 응답을 읽어야 하는 API 호출의 해결책으로 보면 안 된다.

CORS 에러를 만나면 먼저 Console의 메시지와 Network의 응답 헤더를 같이 확인한다.
그다음 서버가 해당 origin, method, header, credentials 조건을 실제로 허용하는지 점검하는 편이 가장 빠르다.

참고 자료

관련 포스트
fetch와 axios 차이 thumbnail
fetch와 axios 차이
fetch와 axios의 차이를 초보자 기준으로 정리합니다. 설치 여부, JSON 처리, HTTP 에러 처리, timeout, interceptor, 언제 어떤 방식을 쓰면 좋은지 함께 봅니다.
Vue 3 입문기 thumbnail
Vue 3 입문기
Vue의 메인 버전이 3가 된지 꽤 됐다. 작성 당시에는 Nuxt 3가 RC 단계였기 때문에 실서비스에 바로 적용하기에는 조심스러웠다. 그래서 궁금하고 심심하던 참에 간단한 Todo App을 만들어봤다.
marked renderer custom 하기 thumbnail
marked renderer custom 하기
현록을 개발하면서 티스토리에 있던 포스트들을 markdown으로 변환하여 올리고 있는데, 간혹 이미지 가로 사이즈가 viewport width보다 크면 overflow되는 현상이 있었습니다. 사실 css로 해결하였지만, styled-components 환경에서 nesting하는 것을 꺼리기도 하고, marked에서 직접 inline style을 넣는 방법이 궁금하기도 해서 찾아보았습니다.
가독성 있게 상수 넣기 thumbnail
가독성 있게 상수 넣기
오늘 회사 동료의 PR 리뷰 과정에서 좋은 기능을 공유해주셔서 TIL로 남겨봅니다. 아래와 같이 상수에 언더바(_)로 콤마처럼 구분을 시켜줄 수 있습니다. 앞으로 깔끔하고 좋은 코드를 작성하기 위해 자주 사용해야겠습니다 :)
TS에서 generic optional 하게 설정하기 thumbnail
TS에서 generic optional 하게 설정하기
오늘 next에서 `getStaticProps`와 `getLayout` 패턴을 함께 사용할 때, typescript generic을 넘겨주는 작업을 하고 있었는데, 기본값이 없다보니 기존 코드에 에러가 발생했었다. 이를 해결하기 위해 찾아보니 단순히 아래 예시처럼 `= {}`을 추가해주면 해결된다고 한다.
Backend에서 API Response가 snake_case인 경우엔? thumbnail
Backend에서 API Response가 snake_case인 경우엔?
안녕하세요. 프론트엔드 개발자의 경우, 가끔 백엔드의 API Response 값이 snake_case일 경우 어떻게 관리할지에 대해 고민에 빠지게 됩니다. 저도 오늘 같은 상황을 겪게 되었는데,  이번엔 네이밍 컨벤션을 맞춰주기로 했습니다. 컨벤션을 맞추는 데에는 여러가지 방법이 있겠지만, 고민 끝에 저는 axios의 interceptors를 통해 해결을 해보았습니다.
Promise 다루기 (feat. 병렬실행, 순차실행) thumbnail
Promise 다루기 (feat. 병렬실행, 순차실행)
오늘은 Promise를 통해 구문을 동기 처리 할 때, 여러 Promise들을 다루는 법을 소개해보겠습니다. Javscript를 작성하다 보면, 가끔 여러 Promise들을 다룰 때가 있습니다. 필자도 Nodejs 서버에서 동시에 여러 쿼리를 실행할 때 자주 마주쳤었는데요. 오늘은 어떻게 하면 Promise들을 유연하게 다룰 수 있는지 알아보겠습니다. 시작하기 앞서, 네 가지 Promise를 선언하고, 그들을 하나의 Array에 묶어보겠습니다.