최근 부트캠프에서 백엔드 개발자가 되기 위한 공부를 진행하면서, 주특기인 Node.js + express 사용에 대해서 익숙해지는 과정을 겪었다. 이번주가 지나면 이제 본격적으로 react를 주특기로 선택했던 분들과 같은 조가 되어서 미니 프로젝트를 진행하게 되는데, 그 전에 궁금한 것이 생겼다.
지금 주특기 숙련 주간에는 단순히 내가 사용하는 플랫폼과 자바스크립트라는 언어에 익숙해지는 과정이었는데, 실제로 어떤 사이트 하나를 만들기 위해서 react의 데이터 요청들을 어떤식으로 node.js와 공유를 할 수 있는지 알 수 없었다.(실제로 이런 표현이 맞는지는 잘 모르겠으나, 지금 비전공자의 입장에서는 이런 표현이 적절하지 않을까 싶다.)
그래서 해당 내용을 미리 공부할 겸, react와 node.js의 URL을 통한 데이터 공유 요청에 대해 알아보던 중에 CORS의 존재에 대해 알게 됐다. 결론부터 말하자면, 두 플랫폼 간의 요청을 허용해주는 체제인 것인데, 오늘은 초보 프론트엔드, 백엔드 개발자들을 괴롭힌다고 악명(?)이 높은 CORS에 대해서 알아보도록 하자.
웹 기술 문서의 교과서라고 할 수 있는 mdn 문서를 보면 이렇게 적혀 있다.
CORS(Cross-Origin Resource Sharing, 교차 출처 리소스 공유)는 추가 HTTP헤더를 사용해 한 출처에서 실행중인 웹 애플리케이션이 다른 출처의 선택한 자원에 접근할 수 있는 권한을 부여하도록 브라우저에 알려주는 체제이다. 웹 애플리케이션은 리소스가 자신의 출처(도메인, 프로토콜, 포트)와 다를 때 교차 출처 HTTP 요청을 실행한다.
(mdn 문서에는 이 그림과 함께 나름 친절하게 설명이 되어있지만.. 그래도 어려운 것은 사실이다.)
위 내용을 조금 더 쉽게 알아보기 위해 천천히 살펴보도록 하자.
Cross라는 단어는 사실 우리가 생각하는 한국어의 의미와 조금 다르기 때문에, '교차 출처 리소스 공유'라는 번역된 단어로만 보면 그 의미가 쉽게 받아들여지지 않는다. '교차'라는 말 대신 '다른'이라고 바꾸면 '다른 출처 리소스 공유'라는 좀 더 쉬운 느낌으로 다가온다. 그럼 다른 출처라는것은 무엇이냐?라고 물어보기 전에 우선 출처(Origin)에 대해서 알아보도록 하자
우리가 어떤 사이트를 접속하던 간에, 인터넷 주소창에는 우리가 맨날 보는 형식의 영어들이 주루룩 써있다. 구글을 예로 들면, https://google.com과 같은 단어들이 써 있는데, 이를 URL이라고 한다. 이 URL은 하나의 문자열처럼 보이지만 사실은 여러 개의 구성 요소로 이루어져 있다.
(protocol이니 host니 하는 것들은 지금 포스트에서는 설명하기엔 너무 많은 내용들이니, 궁금하면 따로 찾아보도록 하자)
origin은 이러한 protocol과 host, 위의 예시에는 보이지 않은 :80 등과 같은 포트 번호까지 모두 결합된 것들의 문자열이다. 즉, 서버의 위치를 찾아가기 위해 필요한 가장 기본적인 것들을 합쳐 놓은 것이다.
(여기서 중요한 것은, https://google.com:443 등과 같이 포트 번호가 명시적으로 포함되어 있을 경우, 이 포트번호까지 모두 일치해야 같은 출처로서 인정이 된다. 하지만 이런 케이스는 표준으로 정해진 것은 아니며, 어떤 경우에는 같은 출처, 어떤 경우에는 다른 출처로 판단될 수도 있어서 너무 복잡하니 논외로 하자.)
(도로명 주소 처럼, 행정구역과 도로명건물번호, 상세주소와 같은 것들의 조합이라고 생각하면 된다.)
(출처 - https://www.juso.go.kr/CommonPageLink.do?link=/street/GuideBook)
이렇게 여러가지 구성요소로 이루어진 주소들이 Origin, 출처가 되는 것이다. 해당 서버의 어떤 포트에서, 어떤 내용들을 불러왔는지를 URL로써 보여주는 것이다.
이런 주소들을 통해 리소스 요청을 할 때, 웹에서는 다른 출처로의 리소스 요청을 제한하고 있고 이와 관련된 두 가지 정책이 존재하는데, 그 중의 하나가 CORS이고 다른 하나는 SOP이다.
SOP는 2011년 RFC 6454에서 처음 등장한 보안 정책으로, '같은 출처에서만 리소스를 공유할 수 있다.'라는 규칙을 가지고 있다.
(영어 울렁증이 없다면, 해당 링크로 가서 한번 천천히 읽어보도록 하자.)
하지만, 웹은 오픈 스페이스 환경이고 이러한 환경에서 다른 출처에 있는 리소스(자원)를 가져와 사용하는 일은 굉장히 흔한 일이라 무턱대고 막을 수는 없는 일이었다. 그래서 몇 가지 예외 조항을 두고 출처가 다른 리소스 요청이라도 이 조항에 해당할 경우에는 허용하기로 했는데 그 중 하나가 'CORS 정책을 지킨 리소스 요청'이다.
이를 보면, 다른 출처의 리소스를 사용하는 것을 제한하는 행위는 하나의 정책만으로는 결정된 사항이 아니며, SOP에서 정의도니 예외 조항과 CORS를 사용할 수 있는 케이스들이 딱 맞지 않는 경우에는 리소스 요청 조차 할 수 없는 경우들도 존재할 가능성이 있다.
왜 이런 조항들을 만들어서 초보 개발자들의 머리를 아프게 하는 것일까?
출처가 다른 두 어플리케이션이 마음대로 소통할 수 있는 환경은 사실 꽤 위험한 환경이다.
처음부터 클라이언트 어플리케이션, 특히 웹 환경에서 구동되는 클라이언트 어플리케이션은 사용자의 공격에 굉장히 취약하다. 크롬에서 F12를 눌러 DebTools를 열어서 DOM이 어떻게 작성되어있고, 어떤 서버와 통신하는지, 리소스의 출처가 어디인지와 같은 각종 정보를 확인할 수 있는 것만 봐도 이해가 될 것이다.
이런 상황에서 다른 출처의 어플리케이션이 서로 통신하는 것에 대한 제역이 존재하지 않으면, 나쁜 마음을 먹은 다른 사용자가 CSRF(Cross-Site Request Forgery)나 XSS(Cross-Site Scripting) 등의 방법을 이용해서 우리가 만든 어플리케이션에서 그들의 코드가 실행된 것 처럼 조작해 사용자들의 정보를 가로채기 매우 쉬워진다.
그렇다면, 앞에서 계속해서 이야기하는 같은 출처와 다른 출처는 어떻게 구분하는 걸까?
사실, 힌트는 Origin에 대한 내용을 보면 있다.
두 URL의 구성 요소 중에 Scheme, Host, Port 이 세 가지만 동일하면 된다. 깃허브를 예를 들면 이렇다.
| URL | 같은 출처 | 이유 |
|---|---|---|
https://github.com/about(기준) | O | Scheme, 호스트, 포트가 동일 |
https://github.com/about?q=basic | O | Scheme, 호스트, 포트가 동일 |
http://github.com | X | Scheme 다름 |
https://example.github.com | X | 호스트가 다름 |
중요한 것은, 출처를 비교하는 로직이 서버에 구현된 스펙이 아니라 브라우저에 구현되어 있는 스펙이라는 것이다.
만약에 CORS 정책을 위반하는 리소스 요청을 하더라도 '해당 서버가 같은 출처에서 보낸 요청만 받겠다'라는 로직을 가지고 있는 경우가 아니라면 서버는 정상적으로 응답을 할 것이지만, 이후 브라우저가 이 응답을 분석해 CORS 정책 위반이라고 판단하면 그 응답을 사용하지 않고 그냥 버린다.
(서버는 CORS를 위반하더라도 정상적으로 응답을 해주고, 응답의 파기 여부는 브라우저가 결정한다.)
(출처 : https://evan-moon.github.io/2020/05/21/about-cors/)
이 말은, CORS는 브라우저의 구현 스펙에 포함되는 정책이기 때문에, 브라우저가 아닌 서버 간 통신을 할 때는 이 정책이 적용되지 않는다. 또, CORS 정책을 위반하는 리소스 요청 때문에 에러가 발생해도, 서버 쪽 로그에는 정상 응답을 했다는 로그만 남기 때문에 이 정책이 돌아가는 방식을 정확히 모른다면 에러를 분석하는데 어려움을 겪을 수 밖에 없다.
웹 클라이언트 어플리케이션이 다른 출처의 리소스를 요청할 때는 HTTP프로토콜을 사용해 요청을 보내는데, 이때 브라우저는 요청 헤더에 Origin이라는 필드에 요청을 보내는 출처를 함께 담아서 보낸다.
이후에 서버가 이 요청에 대한 응답을 할 때 응답 헤더의 Access-Control-Allow-Origin이라는 값에 '이 리소스를 접근하는 것이 허용된 출처'를 내려주고, 이후 응답을 받은 브라우저는 자신이 보냈던 요청의 Origin과 서버가 보내준 응답의 Access-Control-Allow-Origin을 비교해 본 후 이 응답이 유효한 응답인지 아닌지를 결정한다.
프리플라이트(Preflight) 방식은 일반적으로 가장 많이 쓰이는 시나리오이다. 이 시나리오에 해당하는 상황일 경우에 브라우저는 요청을 예비 요청과 본 요청으로 나누어서 서버로 전송한다.
이때 브라우저가 본 요청을 보내기 전에 보내는 예비 요청을 Preflight라고 부르며, 이 예비 요청에는 HTTP 메소드 중 OPTIONS 메소드가 사용된다.
예비 요청의 역할은 본 요청을 보내기 전에 브라우저 스스로 이 요청을 보내는 것이 안전한지 확인하는 것이다.
(예비 요청에서 OK 신호가 떨어지면 본 요청이 들어가게 된다.)
(출처 : https://evan-moon.github.io/2020/05/21/about-cors/)
자바스크립트의 fetch API를 사용해 브라우저에게 리소스를 받아오라고 명령을 내리면, 브라우저는 서버에게 예비 요청을 먼저 보내고, 서버는 이 예비 요청에 대한 응답으로 현재 자신이 어떤 것들을 허용하고 어떤 것들을 금지하는지에 대한 정보를 응답 헤더에 담아서 브라우저에 다시 보내준다.
이후 브라우저는 자신이 보낸 예비 요청과 서버가 응답에 담아준 허용 정책을 비교한 후, 이 요청을 보내는 것이 안전하다고 판단되면 같은 엔드포인트로 다시 본 요청을 보내게 된다. 그리고 서버가 이 본 요청에 대한 응답을 하면 브라우저는 최종적으로 이 응답 데이터를 자바스크립트에게 넘겨준다.
실제로 브라우저가 보낸 요청에는 단순한 Origin에 대한 정보 뿐만 아니라, 자신이 예비 요청 이후에 보낼 본 요청에 대한 다른 정보들도 함께 포함되어 있다.
이 예비 요청에서 브라우저는 Access-Control-Request-Headers를 사용해 자신이 본 요청에서 Content-Type 헤더를 사용할 것을 알려주거나, Access-Control-Request-Method를 사용해 이후 GET 메소드를 사용할 것을 서버에 미리 알려주고 있는 것이다.
단순 요청은 예비 요청을 보내지 않고 바로 서버에 본 요청부터 보낸 후, 서버가 이에 대한 응답의 헤더에 Access-Control-Allow-Origin과 같은 값을 보내주면 그 때 브라우저가 CORS 정책 위반 여부를 검사하는 방식이다. 즉, preflight Request와 simple Request 시나리오는 전반적인 로직 자체는 같지만 예비 요청의 존재 유무만 다르다.
(예비 요청없이 바로 본 요청을 보내는 시나리오이다.)
(출처 : https://evan-moon.github.io/2020/05/21/about-cors/)
아무 때나 단순 요청을 사용할 수 있는 것은 아니다. 아래와 같은 특정 조건을 만족하는 경우에만 예비 요청을 생략할 수 있다. 게다가 이 조건이 조금 까다롭기 때문에 일반적인 방법으로 웹 어플리케이션 아키텍처를 설계하면 거의 충족시키기 어려운 조건들이다.
이 시나리오는 인증된 요청을 사용하는 방법이다. CORS의 기본적인 방식이라기 보다는 다른 출처 간 통신에서 보안을 좀 더 강화하고 싶을 때 사용하는 방법이다.
기본적으로 브라우저가 제공하는 비동기 리소스 요청 API인 XMLHttpRequest 객체나 fetch API는 별도의 옵션 없이 브라우저의 쿠키 정보나 인증과 관련된 헤더를 함부로 요청에 담지 않는다. 이때 요청에 인증과 관련된 정보를 담을 수 있게 해주는 옵션이 credentials 옵션이다.
이 옵션에는 3가지의 값을 사용할 수 있으며, 각 값들이 가지는 의미는 아래와 같다.
| 옵션 값 | 설명 |
|---|---|
| same-origin(기본값) | 같은 출처 간 요청에만 인증 정보를 담을 수 있다. |
| include | 모든 요청에 인증 정보를 담을 수 있다. |
| omit | 모든 요청에 인증 정보를 담지 않는다. |
참고 사이트
https://evan-moon.github.io/2020/05/21/about-cors/
https://lienkooky.tistory.com/64