CORS

브라우져에서 다른 출처에 있는 리소스를 사용할 수 있는 규칙이 교차 출처 리소스 공유, CORS다.

출처가 다른 리소스에 접근할 수 없는 상황에 맞닥뜨렸을 때 다소 당황했다. 장님 코끼리 코만지듯 문제를 해결했지만 마음의 꺼림칙함은 지울수 없었다.

한 가지 중요한 사실만 기억하자. 서버 자원을 보호하기 위한 보안 규칙. 이것만 붙들고 이 글을 따라가면 CORS 원리와 문제 상황, 해결 방식을 이해할 수 있을 것이다.

용어

몇 가지 용어 정리.

출처(origin). URL의 프로토콜, 호스트, 포트로 정의한다. 이 세 가지가 같으면 '출처가 같다', 하나라도 다르면 '출처가 다르다'라고 부른다.

크로스 오리진 요청(cross-origin request). 다른 출처의 자원을 사용하기 위한 요청이다. 브라우져는 실행 중인 어플리케이션의 출처가 아닌 다른 출처의 자원을 요청하면 차단한다. 허용하려면 서버와 특별한 약속을 지켜야하는 데 이것이 바로 CORS(Cross Origin Resource Sharing)다.

재현

재현해 보자.

노드로 웹 서버를 만든다.

const http = require("http")
const fs = require("fs")

// 다른 포트에도 서버를 띄우기 위해 포트 번호를 환경 변수로 받았다.
const port = process.env.PORT || 3000

const server = http.createServer((req, res) => {
  // 기본 index.html 파일을 응답한다.
  let filePath = "./public/index.html"
  let contentType = "text/html"

  // /json 요청일 경우 resource.json 파일을 응답한다.
  if (req.url === "/resource.json") {
    filePath = "./public/resource.json"
    contentType = "application/json"
  }

  // 해당 파일을 읽어 헤더와 함께 본문에 담아 응답한다.
  fs.readFile(filePath, (err, content) => {
    if (err) {
      res.writeHead(500)
      res.end("Error")
      return
    }

    res.writeHead(200, { "Content-Type": contentType })
    res.end(content)
  })
})

server.listen(port, () => {
  console.log(`server is running localhost:${port}`)
})
  • index.html을 기본 응답한다.
  • /resource.json 요청일 경우 resource.json 파일을 읽고 헤더를 설정해 응답한다.

기본 응답한 html에 인라인 자바스크립트가 있는데 resource.json을 요청하는 코드다.

<html>
  <body>
    <script>
      ;(async () => {
        // resource.json을 요청한다.
        const res = await fetch("/resource.json")
        // 서버가 응답한 파일 내용을 기록할 것이다.
        console.log(await res.json())
      })()
    </script>
  </body>
</html>
  • 브라우져는 html 문서를 렌더링 한다.
  • 자바스크립트 코드를 만나면 이를 실행한다.
  • 같은 출처의 서버로 GET https://localhost:3000/resource.json 자원을 요청한다.

서버는 해당 요청의 파일을 찾아 브라우져로 응답할 것이다. 브라우져 자바스크립트는 이 값을 콘솔에 기록할 것이다.

--

이제 교차 출처 요청을 만들어 보겠다.

브라우져에서 실행되는 어플리케이션은 3000번 포트(줄여서 서버 3000)에서 자원을 가져온다. 4000번 포트를 사용해 다른 출처로 사용할 서버(줄여서 서버 4000)를 하나 더 띄우자. 서버 3000에서 자원을 받아 동작하는 어플리케이션이 서버 4000으로 요청할 것이다. 바로 CORS다.

PORT=4000 node .

서버 3000이 제공한 자바스크립트 안에서 서버 4000으로 요청한다.

// 교차 출처 자원을 요청할 것이다.
const res = await fetch("http://localhost:4000/resource.json")

브라우져는 요청을 보내지 못하고 오류를 표시한다.

교차 출처 요청 차단: 동일 출처 정책으로 인해 http://localhost:4000/resource.json에 있는 원격
리소스를 차단했습니다. (원인: ‘Access-Control-Allow-Origin’ CORS 헤더가 없음). 상태 코드: 200.

서버 4000은 요청에 응답은 한다. 브라우져 네트웍 탭에 응답 데이터가 있다(파이어폭스 기준). 브라우져는 사용하기 전에 서버가 허용했는지를 먼저 확인한다.

응답 헤더 Access-Control-Allow-Origin에 내 출처가 있는지 확인한다. 서버 4000은 아무 말도 하지 않았다. 브라우져는 자원을 허용하지 않았다고 판단하고 네트웍 오류를 발생했다. 브라우져가 서버 자원을 보호한 셈이다.

서버 4000이 다른 출처에게 자원을 공유하려면 자원에 접근할 수 있게 허용해야 한다. 헤더에 '이 출처는 내 리소스를 사용할 수 있어'라는 의도를 밝힌다.

res.writeHead(200, {
  "Content-Type": contentType,
  // 자원을 허용할 출처를 지정한다.
  "Access-Control-Allow-Origin": "http://localhost:3000",
})

서버 4000을 재 실행하고 브라우져로 접근한다. 브라우져는 응답 헤더를 통해 서버 4000의 승인을 확인하고 자원을 사용한다.

단순 요청

이러한 교차 출처 요청을 단순 요청(Simple Request)이라고 부른다. 두 가지 조건이다.

  • GET, POST, HEAD 메소드 사용
  • 안전한 헤더 사용

방금은 GET을 사용한 단순 요청이다.

브라우져는 다른 출처의 자원을 요청할 때 Origin 헤더에 내 출처를 기록한다. 서버에게 자원을 사용해도 되는지 묻기 위해서다.

> Origin: http://localhost:3000

서버는 자원을 제공한다는 의미로 Access-Controll-Allow-Origin 헤더에 출처 정보를 기록해 응답했다. 모든 출처에 제공하려면 * 값을 사용한다.

< Access-Control-Allow-Origin: http://localhost:3000

브라우져는 Access-Control-Allow-Origin 헤더에 내 출처가 있는지를 보고 서버의 승인 여부를 판단한다.

헤더

교차 출처 요청에 사용할 수 있는 헤더는 다음과 같다.

  • Accept
  • Accept-Language
  • Content-Language
  • Content-Type

이를 안전한 헤더(CORS-safelisted request-header) 라고 부른다. 브라우져에서 이 외의 헤더를 사용하려면 출처를 확인한 것처럼 헤더 사용 여부도 서버에게 확인해야 한다.

const res = await fetch("http://localhost:4000/resource.json", {
  headers: {
    // 안전하지 않은 헤더를 사용한다.
    "X-Foo": "foo",
  },
})

안전하지 않은 헤더를 사용해 요청했다. 브라우져는 이 헤더를 사용할 수 있는지 서버에게 묻기 위해 헤더 이름을 전달한다.

> Access-Controll-Allow-Headers: X-Foo

서버가 이 헤더를 승인하지 않으면 브라우져는 요청할 수 없다.

교차 출처 요청 차단: 동일 출처 정책으로 인해 http://localhost:4000/resource.json에 있는 원격
리소스를 차단했습니다. (원인: CORS 사전 점검 응답의 헤더 ‘Access-Control-Allow-Headers’에
따라 헤더 ‘x-foo’가 허용되지 않음).
교차 출처 요청 차단: 동일 출처 정책으로 인해 http://localhost:4000/resource.json에 있는
원격 리소스를 차단했습니다. (원인: CORS 요청이 성공하지 못함). 상태 코드: (null).

안전하지 않은 헤더를 요청을 허용하려면 서버는 Access-Control-Allow-Header에 의사를 밝혀야한다.

res.writeHead(200, {
  "Content-Type": contentType,
  "Access-Control-Allow-Origin": "http://localhost:3000",
  // 교차 출처 요청의 X-Foo 헤더 사용을 허용한다.
  "Access-Control-Allow-Headers": "X-Foo",
})

다른 출처에서 X-Foo 헤더 요청을 사용할수 있다는 응답을 보냈다. 헤더는 쉼표로 구분해 여러 개 지정할 수 있다.

< Access-Control-Allow-Headers: X-Foo

브라우져는 이 값을 보고 X-Foo 헤더를 요청에 보낼 수 있다.

사전 전달 요청

단순 요청 조건은 GET, POST, HEAD 메소드를 사용해야 한다. 서버는 이 메소드 요청을 브라우져라고 판단해 자원을 허용한다. PUT, PATCH, DELETE 요청은 브라우져가 아니라고 판단하기 때문에 자원 접근을 차단한다.

이 경우 브라우져와 서버는 서로를 확인하기 위한 요청을 먼저 보낸다. 사전 요청(preflighted request)이라한다.

메소드

PUT 메소드로 사전 요청을 재현하자.

const res = await fetch("http://localhost:4000/resource.json", {
  method: "PUT",
})

브라우져는 오류를 출력할 것이다.

교차 출처 요청 차단: 동일 출처 정책으로 인해 http://localhost:4000/resource.json에 있는
원격 리소스를 차단했습니다. (원인: CORS 헤더 ‘Access-Control-Allow-Methods’의 메서드가 없음).

네트웍 탭에도 오류가 있다.

OPTIONS  localhost:4000  put  fetch        json  CORS Missing Allow Header
PUT      localhost:4000  put  /:11(fetch)        NS_ERROR_DOM_BAD_URI

사전 요청은 OPTIONS 메소드와 Access-Control-Request-Method 헤더를 사용한다. 여기에 PUT 메소드를 허용하는지 묻는다.

> Access-Control-Request-Method: PUT

서버는 이 메소드를 허용할지 판단한다. 다른 출처의 PUT 메소드 요청을 허용하려면 Access-Control-Allow-Methods 헤더에 표시한다.

res.writeHead(200, {
  "Access-Control-Allow-Origin": "http://localhost:3000",
  "Access-Control-Allow-Headers": "X-Foo",
  // 교차 출처의 PUT 요청을 허용한다.
  "Access-Control-Allow-Methods": "PUT",
})

다른 출처에서 PUT 메소드를 사용할수 있다고 응답했다. 허용할 메소드는 쉼표로 구분해 여러 개 지정할 수 있다.

< Access-Control-Allow-Methods: PUT

브라우져는 서버가 PUT 메소드를 허용한다는 것을 확인하고 이어서 이 메소드로 본 요청을 보낸다.

캐시

매번 사전 요청을 보내는 것은 본 요청만 보내는 단순 요청에 비해 네트웍 비용이 더 크다.

서버 승인 하에 브라우져가 사전 요청을 생략할 수 있다. 브라우져가 이미 요청해 얻은 응답을 캐시해 그것을 사용하도록 하는 규칙이다. Access-Control-Max-Age 헤더를 사용한다.

res.writeHead(200, {
  "Access-Control-Allow-Origin": "http://localhost:3000",
  "Access-Control-Allow-Headers": "X-Foo",
  "Access-Control-Allow-Methods": "PUT",
  // 5초간 캐시를 허용한다.
  "Access-Control-Max-Age": "5",
})

5초간 사전 요청에 대한 응답을 캐시한하라고 보냈다.

< Access-Control-Max-Age: 5

브라우저는 최초 PUT http://localhost:4000/resource.json 요청을 보낸 뒤 이를 기억한다. 5초 안에 어플리케이션에서 같은 요청을 보내면 브라우져는 사전 요청을 생략하고 곧장 본 요청을 보낸다.

사례

CORS는 HTTP API 요청 뿐만 아니라 몇 가지 다른 구조에서도 발생한다. 내가 경험했던 것은 두 가지 였다.

출처가 다른 웹폰트 요청 시

css에서는 @font-face로 웹 폰트를 불러 올 경우 CORS가 동작한다.

@font-face {
  font-family: "MyFont";
  /* 다른 출처의 폰트 자원을 요청한다. */
  src: url("http://localhost:4000/myfont.otf");
}

어플리케이션이 동작하는 서버 3000이 아닌 서버 4000의 자원을 요청했다.

> GET localhost:4000 myfont.otf font. CORS Missing Allow Origin

서버 4000은 요청을 허용할 교차 출처를 지정해 주어야 한다.

이외에도 CORS를 사용하는 요청 종류는 다음을 참고하자.

팝업창 출처가 다른 경우

브라우져에서 open 함수를 사용하면 다른 창을 팝업으로 열 수 있다.

window.open(url, "_blank")

팝업에서는 opener 속성으로 현재 창을 열었던 창의 참조에 접근할 수 있다. 브라우져는 opener와 현재 창의 출처가 다르면 opner 속성에 접근하는 것을 막는다.

예제 코드를 보자.

index.html:

<script>
  window.foo = () => console.log("foo 실행되다.")
</script>
<body style="font-family: MyFont;">
  <!-- 다른 출처의 자원을 팝업으로 연다 -->
  <button
    onclick="window.open('http://localhost:4000/popup.html', '_blank', 'width=600,height=800')"
  >
    자식 윈도우 열기
  </button>
</body>

popup.html:

<body>
  <script>
    // 다른 출처인 opener 속성에 접근한다. CORS 실패가 발생할 것이다.
    opener.foo()
  </script>
</body>

팝업 창에서 opener 속성에 접근하면 오류가 나온다.

Uncaught DOMException: Permission denied to access property "foo" on
cross-origin object

서버 4000에서 CORS 관련 헤더를 변경해도 자원을 공유할 수 없었다. 나이스 서비스를 사용한 본인 인증 팝업에서 겪은 문제였다.

이런 경우엔 윈도우 간에 이벤트를 전달하는 방식으로 해결하는 모양이다.

결론

CORS 실패는 최근 인프라에서 자주 겪었다.

마이크로서비스 안에서 다른 도메인으로 구성된 각 도메인 API를 사용할 때 주로 발생한다. 하나인 웹서버를 프론트엔드 서버와 BFF로 분리하는 경우 어플리케이션 도메인과 BFF 도메인이 달라 CORS 실패를 겪었다.

디자인시스템 전문팀을 꾸리고 여기서 개발, 운영까지 담당한다. 라이브러리를 제공할 별도 도메인을 사용해 각 서비스 개발팀에 공유하는 방식이다. 각 어플리케이션에서는 다른 출처의 디자인시스템 폰트를 사용하기 때문에 CORS 실패를 겪었다.

어플리케이션 몸집이 커서 특정 페이지를 별도 도메인으로 분리하는 경우도 있었다. 분리된 도메인에서 자식 창으로 기존 URL을 팝업으로 열면 opener 속성을 사용할 때 CORS 실패가 발생한다. (최근 배민외식업광장의 하위 경로를 배민셀프서비스로 분리했다.)

CORS가 서버 자원을 보호하기 위한 HTTP 기반의 약속이라는 점을 기억하자. 이를 위해 서버와 브라우져가 함께 책임지고 행동하는 규칙이다.

참고