기본 콘텐츠로 건너뛰기

CORS를 제대로 이해하기: 브라우저가 막는 것과 서버가 허용하는 것

CORS를 제대로 이해하기: 브라우저가 막는 것과 서버가 허용하는 것

빠른 답

  • CORS 오류는 서버 요청 자체가 실패했다기보다, 브라우저가 응답을 JavaScript에 노출하지 않았다는 뜻인 경우가 많습니다.
  • Access-Control-Allow-Origin은 클라이언트가 요청에 붙여 해결하는 헤더가 아니라 서버가 응답으로 내려줘야 하는 헤더입니다.
  • 쿠키나 인증 정보를 포함하는 요청은 Access-Control-Allow-Origin: *와 함께 쓸 수 없고, 클라이언트와 서버의 credentials 설정이 함께 맞아야 합니다.
  • 디버깅할 때는 콘솔 메시지뿐 아니라 OPTIONS preflight 응답과 실제 요청의 응답 헤더를 나눠서 확인해야 합니다.

흐름으로 보기

흐름 다이어그램
CORS를 제대로 이해하기: 브라우저가 막는 것과 서버가 허용하는 것 흐름 다이어그램

브라우저에서 요청이 발생하면, 브라우저는 현재 페이지의 출처와 요청 대상의 출처를 비교합니다. 출처는 프로토콜, 호스트, 포트의 조합입니다. https://app.example.comhttps://api.example.com은 호스트가 다르므로 다른 출처이고, http://localhost:3000http://localhost:8080은 포트가 다르므로 다른 출처입니다.

출처가 다르면 브라우저는 동일 출처 정책을 기준으로 응답을 JavaScript에 보여줘도 되는지 판단합니다. 요청 자체가 서버까지 도달할 수는 있지만, 서버 응답에 올바른 CORS 헤더가 없으면 브라우저가 응답 본문과 일부 헤더를 코드에 숨깁니다.

요청 메서드나 헤더가 단순 요청 범위를 벗어나면 실제 요청 전에 OPTIONS preflight 요청이 먼저 나갑니다. 예를 들어 Content-Type: application/json으로 POST를 보내거나 Authorization 헤더를 붙이면 브라우저는 서버가 그 조건을 허용하는지 먼저 확인합니다.

CORS가 필요한 배경

CORS를 이해하려면 동일 출처 정책부터 봐야 합니다. 브라우저는 한 출처에서 실행된 스크립트가 다른 출처의 응답을 마음대로 읽지 못하게 제한합니다. 사용자가 은행, 사내 도구, 관리자 페이지에 로그인한 상태에서 악성 페이지를 열었을 때, 그 페이지의 JavaScript가 민감한 응답을 읽어가는 상황을 줄이기 위한 기본 보안선입니다.

하지만 현대 웹 애플리케이션은 여러 출처를 함께 씁니다. 프론트엔드는 https://app.example.com, API는 https://api.example.com, 정적 파일은 CDN, 인증 서버는 별도 도메인에 둘 수 있습니다. 이런 정상적인 분리 구조까지 모두 막으면 웹 애플리케이션을 구성하기 어렵습니다.

CORS는 이때 서버가 “이 출처에서 온 브라우저 코드에는 내 응답을 읽게 해도 된다”고 알려주는 HTTP 헤더 기반의 허용 방식입니다. MDN의 CORS 가이드는 CORS를 서버가 브라우저에 허용 출처를 알려주는 HTTP 헤더 기반 메커니즘으로 설명합니다. 현재의 세부 동작은 Fetch Standard 흐름 안에서 함께 다뤄집니다.

CORS를 인증이나 방화벽처럼 이해하면 문제가 생깁니다. CORS는 브라우저의 응답 접근 제어에 가깝습니다. 서버 대 서버 요청, curl, Postman 같은 도구는 브라우저의 CORS 정책을 적용받지 않습니다. API를 보호하려면 CORS와 별개로 인증, 권한 확인, 요청 검증이 필요합니다.

실제로 막히는 지점

CORS 오류가 보이면 먼저 “요청이 서버에 도착했는지”와 “브라우저가 응답을 코드에 넘겼는지”를 분리해서 봐야 합니다.

preflight에서 막힌 경우에는 실제 POST, PUT, DELETE 요청이 아예 나가지 않을 수 있습니다. 반대로 단순 GET 요청이나 preflight를 통과한 실제 요청은 서버까지 도착하고 응답도 내려오지만, 브라우저가 JavaScript에 응답을 넘기지 않을 수 있습니다.

브라우저가 확인하는 조건은 대체로 다음과 같습니다.

  • 요청의 Origin과 서버의 Access-Control-Allow-Origin이 맞는가
  • preflight의 Access-Control-Request-Method가 서버의 Access-Control-Allow-Methods에 포함되는가
  • preflight의 Access-Control-Request-Headers가 서버의 Access-Control-Allow-Headers에 포함되는가
  • 쿠키 등 인증 정보를 포함했다면 Access-Control-Allow-Credentials: true가 있는가
  • 인증 정보를 포함한 요청에서 Access-Control-Allow-Origin: *를 쓰고 있지는 않은가
  • 동적으로 origin을 허용한다면 캐시 혼선을 막기 위해 Vary: Origin을 내려주는가

“응답이 200인데 CORS 오류가 난다”는 상황은 이 차이에서 나옵니다. 200은 서버 관점의 성공이고, CORS 오류는 브라우저 관점의 노출 실패입니다. 네트워크 탭에서 상태 코드만 보지 말고, 응답 헤더가 브라우저의 조건과 맞는지 확인해야 합니다.

서버 CORS 기준선

CORS 설정은 프레임워크마다 문법이 다르지만, 기준선은 HTTP 헤더입니다. 특정 프레임워크 설정부터 외우기보다 어떤 응답 헤더가 필요한지 먼저 정리하는 편이 오류를 줄이는 데 도움이 됩니다.

아래는 운영 설정 파일을 가정한 스택 중립 예시입니다. 실제 서버에서는 이 allowlist를 기준으로 요청의 Origin을 검사하고, 허용된 경우에만 해당 origin을 응답으로 돌려주는 방식이 흔합니다.

cors:
  allowedOrigins:
    - https://app.example.com
    - https://admin.example.com
  allowedMethods:
    - GET
    - POST
    - PATCH
    - DELETE
    - OPTIONS
  allowedHeaders:
    - Content-Type
    - Authorization
    - X-Request-Id
  exposedHeaders:
    - X-Request-Id
  allowCredentials: true
  maxAgeSeconds: 600

allowedOrigins는 공개 읽기 API가 아니라면 *보다 구체적인 목록으로 두는 편이 안전합니다. 인증 정보가 없고 누구나 읽어도 되는 리소스라면 wildcard를 선택할 수 있지만, 쿠키나 세션을 쓰는 API에서는 명시적인 origin을 내려야 합니다. MDN의 Access-Control-Allow-Origin 문서에서도 인증 정보를 포함한 요청과 wildcard origin은 함께 쓸 수 없다고 설명합니다.

preflight 응답과 실제 요청 응답은 역할이 조금 다릅니다. OPTIONS 응답은 “이 조건의 실제 요청을 받아도 되는가”에 대한 답이고, 실제 요청 응답은 “이 응답을 해당 origin의 JavaScript에 노출해도 되는가”에 대한 답입니다.

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization, X-Request-Id
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 600
Vary: Origin

HTTP/1.1 200 OK
Content-Type: application/json
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: X-Request-Id
Vary: Origin
X-Request-Id: req-7b42

{"id":123,"name":"sample"}

Access-Control-Expose-Headers도 자주 빠지는 항목입니다. 브라우저는 모든 응답 헤더를 JavaScript에 자동으로 노출하지 않습니다. 프론트엔드에서 X-Request-Id를 읽어 장애 신고나 추적에 쓰려면 서버가 이 헤더를 노출 대상으로 알려줘야 합니다. 이는 보안뿐 아니라 관측성 측면에서도 중요한 설정입니다.

요청 코드에서 확인할 부분

프론트엔드에서 fetch를 사용할 때 CORS 자체를 켜는 설정은 보통 따로 두지 않습니다. 브라우저는 cross-origin 요청이면 CORS 흐름을 적용합니다. 다만 쿠키를 보낼지, 어떤 헤더를 붙일지에 따라 preflight 여부와 서버가 맞춰야 할 헤더가 달라집니다.

인증 정보가 필요 없는 공개 API라면 단순한 요청으로 충분할 수 있습니다. 세션 쿠키를 보내야 한다면 credentials: "include"가 필요하고, 서버도 Access-Control-Allow-Credentials: true와 명시적인 Access-Control-Allow-Origin을 내려줘야 합니다.

const publicResponse = await fetch("https://api.example.com/articles");
const articles = await publicResponse.json();

const meResponse = await fetch("https://api.example.com/me", {
  method: "GET",
  credentials: "include",
});

const me = await meResponse.json();

const orderResponse = await fetch("https://api.example.com/orders", {
  method: "POST",
  credentials: "include",
  headers: {
    "Content-Type": "application/json",
    "Authorization": "Bearer eyJhbGciOi...",
    "X-Request-Id": "web-20260413-001",
  },
  body: JSON.stringify({
    productId: 42,
    quantity: 1,
  }),
});

if (!orderResponse.ok) {
  throw new Error(`request failed: ${orderResponse.status}`);
}

const order = await orderResponse.json();

POST 요청은 Content-Type: application/jsonAuthorization 헤더를 사용하므로 preflight가 발생할 가능성이 높습니다. 이때 프론트엔드 코드만 고쳐서는 문제가 해결되지 않습니다. 서버가 OPTIONS 요청에 대해 POST, Content-Type, Authorization, X-Request-Id를 허용한다고 응답해야 실제 요청으로 넘어갈 수 있습니다.

쿠키 기반 인증을 쓴다면 쿠키 설정도 함께 확인해야 합니다. cross-site 쿠키가 필요한 구조에서는 쿠키의 SameSite=NoneSecure 조건이 맞아야 하며, 서버 응답에는 wildcard가 아닌 정확한 origin이 들어가야 합니다.

디버깅과 운영 검증

브라우저 콘솔의 CORS 오류는 출발점으로는 좋지만, 원인을 충분히 말해주지는 않습니다. 보안상 JavaScript에는 상세 실패 이유가 제한적으로 전달되기 때문에, 네트워크 탭과 CLI로 preflight 응답을 직접 확인하는 습관이 필요합니다.

다음은 preflight를 CLI에서 재현하는 예시입니다. 브라우저가 보낼 법한 Origin, Access-Control-Request-Method, Access-Control-Request-Headers를 직접 넣어 서버 응답 헤더를 확인합니다.

$ curl -i -X OPTIONS "https://api.example.com/orders" \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: content-type,authorization,x-request-id"

HTTP/2 204
access-control-allow-origin: https://app.example.com
access-control-allow-methods: GET,POST,PATCH,DELETE,OPTIONS
access-control-allow-headers: Content-Type,Authorization,X-Request-Id
access-control-allow-credentials: true
access-control-max-age: 600
vary: Origin

이 출력에서 볼 부분은 상태 코드보다 헤더의 정합성입니다. 요청에서 authorization을 물었는데 응답의 Access-Control-Allow-HeadersAuthorization이 빠져 있으면 preflight가 실패합니다. 요청 origin이 https://app.example.com인데 응답이 https://admin.example.com만 허용해도 실패합니다.

브라우저 콘솔에는 다음과 비슷한 메시지가 남을 수 있습니다.

  • No 'Access-Control-Allow-Origin' header is present on the requested resource.
  • The value of the 'Access-Control-Allow-Origin' header must not be the wildcard '*' when the request's credentials mode is 'include'.
  • Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response.

운영 환경에서는 CORS 실패를 애플리케이션 로그와 연결할 수 있어야 합니다. preflight 요청에도 요청 ID를 남기거나, 적어도 Origin, 메서드, 요청 경로, 허용 여부를 구조화 로그로 남기면 원인 파악이 빨라집니다. 단, Authorization 값이나 쿠키 원문을 로그에 남기면 보안 문제가 되므로 헤더 이름과 허용 여부 중심으로 기록하는 편이 안전합니다.

품질 기준으로 보는 점검 순서

CORS는 한 번 설정하고 끝나는 값이라기보다 배포 환경, 도메인, 인증 방식, 프록시 구성에 따라 깨지기 쉬운 품질 기준에 가깝습니다. 개발 환경에서는 잘 되는데 운영에서만 실패하는 경우도 많습니다. 로컬 origin, 스테이징 origin, 운영 origin이 다르고, CDN이나 API 게이트웨이가 응답 헤더를 덮어쓸 수 있기 때문입니다.

점검 순서는 다음처럼 가져가면 원인을 좁히기 쉽습니다.

  1. 브라우저 네트워크 탭에서 실패한 요청이 OPTIONS인지 실제 요청인지 확인합니다.
  2. 요청 헤더의 Origin 값을 확인합니다.
  3. preflight라면 Access-Control-Request-MethodAccess-Control-Request-Headers를 확인합니다.
  4. 서버 응답의 Access-Control-Allow-Origin이 요청 origin과 정확히 맞는지 확인합니다.
  5. 쿠키가 필요하다면 클라이언트의 credentials와 서버의 Access-Control-Allow-Credentials를 함께 확인합니다.
  6. 프록시, CDN, API 게이트웨이가 CORS 헤더를 제거하거나 중복으로 붙이지 않는지 확인합니다.

테스트는 계층을 나누면 부담을 줄일 수 있습니다. 단위 테스트는 origin allowlist 함수처럼 순수한 판단 로직을 검증하는 데 맞습니다. 통합 테스트는 실제 HTTP 서버를 띄워 OPTIONS와 실제 요청의 헤더를 확인하는 데 맞습니다. E2E 테스트는 브라우저에서 로그인 후 API 응답 본문까지 읽히는지 확인하는 데 사용합니다. 스모크 테스트는 배포 직후 운영 origin에서 대표 API 하나를 preflight와 실제 요청으로 확인하는 정도가 현실적입니다.

CI 품질 게이트는 최소한 “허용되지 않은 origin이 열리지 않는다”와 “운영 origin의 credentials 요청이 wildcard로 응답하지 않는다”를 막아야 합니다. 실패 증거로는 요청 URL, 요청 origin, preflight 응답 헤더, 실제 응답 헤더, 브라우저 콘솔 메시지를 남기면 재현이 쉬워집니다.

name: cors-smoke

on:
  workflow_dispatch:
  push:
    branches: [main]

jobs:
  verify-cors:
    runs-on: ubuntu-latest
    steps:
      - name: Check preflight headers
        run: |
          curl -i -X OPTIONS "https://api.example.com/orders" \
            -H "Origin: https://app.example.com" \
            -H "Access-Control-Request-Method: POST" \
            -H "Access-Control-Request-Headers: content-type,authorization" \
            | tee cors-preflight.txt

          grep -i "access-control-allow-origin: https://app.example.com" cors-preflight.txt
          grep -i "access-control-allow-credentials: true" cors-preflight.txt

      - name: Reject wildcard with credentials
        run: |
          if grep -i "access-control-allow-origin: \*" cors-preflight.txt; then
            echo "Wildcard origin must not be used for credentialed requests"
            exit 1
          fi

이런 검증은 보안 기본기와 운영 안정성 모두에 영향을 줍니다. 너무 넓은 origin 허용은 의도하지 않은 브라우저 접근을 열 수 있고, 너무 좁거나 환경별로 누락된 설정은 정상 사용자를 막습니다. CORS 설정은 배포 체크리스트, 통합 테스트, 운영 스모크 테스트 중 최소 한 곳에는 들어가는 편이 좋습니다.

CORS와 CSRF의 차이

CORS와 CSRF는 함께 언급되지만 같은 문제를 막는 장치는 아닙니다. CORS는 브라우저가 cross-origin 응답을 JavaScript에 노출할지 결정하는 정책입니다. 반면 CSRF는 사용자가 로그인한 브라우저를 이용해 공격자가 원치 않는 상태 변경 요청을 보내게 만드는 공격입니다.

차이는 “요청 전송”과 “응답 읽기”에 있습니다. CORS가 실패해도 특정 조건에서는 요청이 서버에 도착할 수 있습니다. 특히 과거의 HTML 폼 제출과 유사한 요청은 preflight 없이 서버로 갈 수 있습니다. 서버가 CSRF 방어 없이 쿠키만 믿고 상태 변경을 처리한다면, CORS 설정이 엄격하더라도 CSRF 위험은 남습니다.

OWASP의 CSRF Prevention Cheat Sheet는 CSRF 방어로 토큰, SameSite 쿠키, Origin 또는 Referer 검증, Fetch Metadata 헤더 같은 여러 방식을 설명합니다. 즉, CORS는 CSRF 방어의 대체재가 아닙니다.

인증 API에서는 다음 기준을 나눠 보는 편이 안전합니다.

  • CORS: 허용된 웹 앱 origin만 응답을 읽을 수 있게 제한합니다.
  • 인증: 요청자가 누구인지 확인합니다.
  • 권한: 그 사용자가 해당 리소스를 조작할 수 있는지 확인합니다.
  • CSRF 방어: 사용자의 브라우저가 속아서 보낸 상태 변경 요청인지 검증합니다.
  • 로깅: origin, 요청 ID, 차단 사유를 남겨 운영 중 원인을 추적합니다.

이 구분이 잡히면 Access-Control-Allow-Origin: *로 모든 문제가 해결될 것 같은 착각을 피할 수 있습니다. CORS 오류를 없애는 것과 API를 안전하게 여는 것은 다른 일입니다. 허용 범위를 줄이고, 필요한 요청만 통과시키며, 실패했을 때 증거를 남기는 방식으로 설계해야 합니다.

원문 참고

https://www.maeil-mail.kr/question/78

댓글

이 블로그의 인기 게시물

아이콘 폰트 (icomoon 사용법)

 장난감 프로젝트를 만들다 보면, 아이콘이 필요한 경우가 있다. 간단하게 아이콘을 인터넷에서 검색하여, 이미지로 넣어두고 이미지 태그를 이용하여, 사용하는 경우가 일반적이였지만...  요즘에는 대부분 폰트를 이용하여 아이콘을 노출 한다. 나 같은 경우에도 기본적으로  https://material.io/resources/icons 를 참고하여 아이콘 폰트를 이용할 수 있도록 처리하고, 추가적으로 필요한 아이콘이고, 일상적으로 사용 되지 않는 아이콘의 경우에는  https://icomoon.io 에서 제작하여, 아이콘 폰트로 이용 하곤 한다.  그래서 이번에는 아이콘  https://icomoon.io 의 사용법을 간단히 공유하고자 한다.   들어가자 마자 위의 icoMoonApp버튼을 누르면 아래와 같은 화면이 나타난다.  icomoon에서 무료로 제공하는 아이콘들이 보이면 위에 파란색으로 표시 되어있는 집 모양 세가지를 선택한 후, 아래의 빨간색으로 표시되어있는 Generate Font를 눌러보자.  그리고 나서 바로 다운로드를 요청해보자. icomoon.zip이 다운로드가 될텐데, 압축을 해제해 보면, 아래의 폴더 및 파일들이 있다. 아래에서 중요한 것은 font 폴더와 style.css이다. demo-files fonts demo.html Read Me.txt selection.json style.css <!doctype html > <html> <head> <link rel ="stylesheet" href ="style.css" ></head> </head> <body> <span class ="icon-home" ></span> <span class ="icon-home2" ></span> <span class ="icon-hom...

Chart js와 amchart 비교

Chart js 특징은 위의 그림으로 대체 할 수 있을 듯 하다. 오픈 소스이고, 기본으로 제공하는 차트 종류가 8가지 Canv a s를 이용해서 차트를 그리고, 반응형을 지원한다. amchart amchart는 기본적으로 유료이며, 기본으로 제공하는 차트 종류가 기본적인 차트 + 주식 처럼 보이는 차트 + 지도에 관련된 차트(?) 까지 하면, 기본 제공 하는 종류가 20개 내외 이려나, 일일이 세기에는 양이 좀 많아 보인다. 렌더링은 svg를 통하여 그려지고, 당연 반응형도 지원이 된다. 그러면, 이 둘중에 어떤것이 내 프로젝트에 적합 하냐는 것이 문제이다. 일단, 주식 처럼 보이는 차트나 지도에 관련된 차트(?)가 필요하면, amchart를 선택해야 되는 것은 맞다. 그건 당연한 것이니 빼고 얘기 해보자! 여러 종류의 차트가 필요하다면, 일단은 amchart를 염두해 두는 것이 좋다. 돈 낸 만큼은 하는 듯 하다. 하지만, 기본적인 막대 그래프, 도넛 차트 등, 아주 기본적인 차트들인데, Chart js도 amchart도 그러한 차트가 없을 때가 문제가 된다. 그렇다면, 조금이라도 커스텀이 용이한 것을 찾는 것이 좋을 것이다.  일단 amchart에서 custom이라고 검색 하였을 때, 검색 결과가 61가지가 나온다. 차트의 종류도 많고, 각 차트마다 들어가는 속성이 매우 많기 때문에, 웬만한 내용들은 속성 값을 어떻게 주느냐에 따라서 변경이 가능 하게 된다. 커스텀의 예를 들면, 기본적으로 도넛 파이의 형태를 띄면서, 화살표로 목표를 표시해주는 차트가 필요하다고 생각 해보자. 이것은 amchart로 만든 그래프이고 이것은 chart js로 만든 그래프이다. 모양이 살짝 다르긴 하지만, 완벽하게 똑같이 구현 할 수도 있다. amchart로 만든 그래프의 경우, 저것은 도넛그래프가 아닌 guage 그래프이다. 원래 게이지 그래프는 이와 같...

javascript 압축 파일 다운로드

이번에는 전 게시글의 응용판? 이라고 해야하나....? 어쨋든! 우리는 각각의 파일들을 다운로드 해보았다. 그런데 생각보다 귀찮음?을 느꼇을 것이다. 파일을 각각 다운 받아야 한다는 현실때문에! 그래 파일 두개야 뭐 그렇다 치지... 하지만, 개발자도 사용자도 게으름뱅이이다. 자 결국, 우리가 해야 하는 것은 파일을 한 번에 둘다! 다운 받는 것이다. 물론, 클릭 한번에 여러개의 함수를 엮어서 다운받게 하면 되지만! 크롬에서 자주 봤듯이, 여러개의 파일을 다운로드를 시도하면 <- 여러개의 파일을 다운로드 합니다. 허용 합니까? 하고 물어보는 것을 볼 수 있다. 게다가 다운로드 한 파일들을 찾기도 귀찮다는 것. 자 해결책을 제시해보자면, https://github.com/Stuk/jszip 클라이언트 단에서 파일을 zip파일로 압축을 할 수가 있다! 필요한 작업은 아래와 같다. 0. 데이터 준비 1. BLOB(binary large object)를 만든다. 2. Blob을 URL.createObjectURL을 사용하여, 해당 binary의 주소를 생성. 3. 다운로드가 필요한 파일들을 Zip 객체에 셋팅! 4. a태그를 이용하여, 해당 url 셋팅 하고, 다운로드. 전 게시물과 별로 달라진게 없네... 자 그럼 샘플! 샘플을 보자! http://embed.plnkr.co/NMprnRxqYG0fkHa2J55D/ var util = {} function fixBinary(bin) { //binary to arrayBuffer var length = bin.length var buf = new ArrayBuffer(length) var arr = new Uint8Array(buf) for (var i = 0; i < length; i++) { arr[i] = bin.charCodeAt(i) } return buf } window.onload = function() { ...