안녕하세요 오늘은 웹 개발자라면 한번쯤 경험했을 법한 CORS 정책에 대해 알아보겠습니다 👨💻
프론트앤드 개발자들과 웹 서비스 구현 프로젝트를 진행중에 게시글 추천 수 조회
API 에 대한 요청이 CORS
에러가 뜬다는 피드백을 받았습니다. ( 프로젝트 정보 )
당시 저는 Postman을 통해 제가 만든 모든 API 테스트 과정을 마무리 했기 때문에 배포 환경에서 에러가 발생했다는 소식에 굉장히 당황했습니다.
이뿐만 아니라, 제가 처음들어보는 에러였기에 CORS에 대해 알아보고 정리해야 겠다는 생각을 했고 이번 포스팅을 진행하게 되었습니다 🤔
CORS는 Cross-Origin Resource Sharing 을 뜻합니다.
여기서 Origin
이란 출처
를 의미하며 한국어로 직역하자면 교차 출처 리소스 공유
라고 해석할 수 있습니다.
여기서 교차
라는 말은 다른
이라고 이해하시면 됩니다. 즉 정리하자면 다른 출처 리소스 공유
라고 직역할 수 있겠습니다.
CORS 를 완벽하게 이해하기 위해서 선행되어야 하는 개념들이 있습니다. 이에 대해 먼저 알아보겠습니다 💪
모든 웹 리소스는 자신만의 주소를 가지고 있습니다. 이를 URL 이라고 합니다.
이때 출처(Origin)
은 Protocol
과 host
그리고 포트
를 모두 함친 것을 의미합니다.
3가지 요소는 웹 리소스를 찾기 위해 필요한 가장 기본이 되는 요소들입니다.
여기서 출처가 같고 다르다라는 뜻을 이해하는 것이 중요합니다. 앞에서 배운 것처럼 출처
는 3가지 요소를 의미합니다.
따라서 출처가 서로 같다라고 판단하는 로직 자체는 굉장히 간단한데, 3가지 요소만 동일하다면 URL 의 다른 요소가 달라도 같은 출처로 인정이 됩니다.
SOP 는 이름에서 알 수 있듯이 같은 출처에서만 리소스를 공유할 수 있다
라는 규칙을 가진 정책을 의미합니다.
해당 정책은 지난 2011년, RFC 6454 에서 처음 등장한 보안 정책입니다.
그러나 실제 웹 상에서 이와 같은 정책만을 이용하는데에는 무리가 있습니다.
실제 서로 다른 출처에서 리소스를 공유하는 경우가 더 빈번하기 때문입니다. 이때문에 어느정도 출처가 다르더라도 리소스 공유를 허용하는데 이중 하나가 CORS 정책입니다.
쉽게 말하자면 SOP 정책을 기반으로 해서 예외 사항을 둔 정책을 CORS 라고 생각하면 됩니다 👍
만약 다른 출처로 리소스를 요청한다면 SOP 정책을 위반한 것이 되고 SOP 정책을 기반으로 예외적으로 다른 출처의 리소스 요청을 허용하는 CORS 마저 위반한다면 리소스 공유를 하지 못하게 됩니다 🚫
CORS 에 대해서 이해했다면 이런 생각을 할 수 있습니다 🤔
"오픈스페이스인 웹 상에서 서로 다른 출처의 리소스를 요청하고 응답하는 일은 매우 당연한 일이고 빈번한 일인데 왜 굳이 이런 정책을 만듬으로써 개발자를 괴롭히는 것일까요?"
제가 이번 포스팅을 진행하게 된 계기도 바로 이런 발상의 시작이였습니다.
하지만 잘 생각해보면 출처가 이렇게 다른 두 어플리케이션이 마음대로 소통할 수 있는 환경은 매우 위험합니다.
보안 기술이 나날히 발전해가고 있는 현대 사화에서 해킹 기술 또한 이에 맞춰 발전해가고 있습니다.
이는 개발자에게 보안 기술 구현 능력을 요구하게 되는 결과를 초래하게 되고 특히, 많은 이용자가 존재하는 웹상
에서 보안 기술은 필수적인 요소입니다.
지금 눈 앞에 떠있는 브라우저 창만 조금만 분석해봐도 보안에 매우 취약하다는 것을 알 수 있습니다.
개발자 도구를 열어서 특정 웹 페이지의 DOM 구조를 알아볼까요? 이 작업은 브라우저 시스템을 조금이라도 아는 누구든지에게 매우 쉬운 작업입니다.
이뿐만 아니라 소스코드에는 날 것 그대로의 중요한 정보가 노출되어 담겨져있기도 합니다.
이런 상황에서 다른 출처의 어플리케이션이 서로 통신하는 것에 대해 아무런 제약도 존재하지 않는다면, CSRF
혹은 XSS
와 같은 방법을 사용하여 사용자의 중요 정보를 탈취하는 것이 매우 쉬워집니다 👿
웹 개발자를 괴롭히던 빨간 CORS 관련 에러 메세지는 사실 브라우저의 SOP 정책에 따라 다른 출처의 리소스를 발생한 에러이며, CORS 는 다른 출처의 리소르를 얻기위한 해결방안이였습니다.
정리하면, 클라이언트가 다른 출처로 리소스를 요청한다면 SOP
을 위반한 것이 되고, 거기다가 SOP
의 예외 조항인 CORS
정책까지 지키지 않는다면 아예 다른 출처의 리소스를 사용할 수 없습니다 🚫
자 그럼 이제 본격적으로 어떤 원리를 이용해서 CORS 정책을 구현할 수 있는지 알아보겠습니다 💪
기본적으로 사용자 대신 웹 요청을 보내는 웹 브라우저(클라이언트 어플리케이션)은 HTTP
라는 프로토콜을 이용해서 웹 서버와 통신을 합니다.
이때 요청과 함께 요청 메세지
라는 것도 같이 보내게 됩니다. [요청 메세지(Request Message)에 대한 참고 자료]
요청 메세지는 요청 해더
라는 요소를 가지는데 여기에는 요청에 대한 각종 정보와 응답으로 받아야 하는 데이터 정보를 담고 있습니다. Key-Value 로 구성되어 있는 요청 헤더
에는 Origin
이라는 헤더가 있습니다.
이 Origin
헤더에는 요청을 보내는 출처를 담아 보냅니다 👨💻
Origin: Http://velog.io/write?id=60325059-02dc-4236-8e1d-bcf1840fae82 [요청을 보내는 출처]
이후 서버는 해당 요청에 대한 응답을 할 때 요청과 비슷한 원리로 응답 메세지
를 생성해서 보냅니다.
이때 응답 메세지
에는 응답 헤더
라는 것이 존재하는데 Access-Control-Allow-Origin
이라는헤더에 해당 서버의 리소스를 접근하는 것이 허용되는 출처 를 내려줍니다.
이를 받은 브라우저는 자신의 출처와 Access-Control-Allow-Origin
의 헤더값을 비교한 후 응답의 유효성을 결정합니다.
만약 브라우저가 판단한 결과가 유효하지 않다면 프로젝트를 진행하면서 마주쳤던 CORS 에러
를 마주치게 될 것입니다 😰
사실 CORS 동작 방식에는 3가지 시나리오
가 존재합니다.
하나씩 살펴보겠습니다.
Simple Request 는 밑에서 살펴볼 Preflight Request(예비 요청) 없이 바로 본요청을 보내고 브라우저가 응답 헤더인 Access-Control-Allow-Origin
와 Origin
헤더의 출처를 비교하여 요청에 대한 응답을 차단할지 말지를 결정합니다.
다만, 심플한 만큼 특정 조건을 만족하는 경우에만 사용할 수 있습니다.
1. 요청의 메소드가 GET
,HEAD
,POST
중 하나여야 한다.
2. Accept
,Accept-Language
,Content-Language
,Conent-Type
,DPR
,Downlink
등의 헤더인 경우만 적용된다.
3. Content-Type 헤더가 application/x-www-form-urlencoded
,multipart/form-data
,text/plain
중 하나여야 한다.
따라서, 위에 조건을 모두 만족되는 경우가 아니라면 밑에서 살펴볼 Preflight Request 가 이루어집니다.
이처럼 다소 까다로운 조건들이 많기 때문에, 위 조건을 모두 만족되어 Simple Request가 일어나는 상황은 드뭅니다.
그 이유는 대부분의 HTTP API
요청은 text/xml
이나 application/json
으로 통신하기 때문에 3번째 조건에 위반되기 때문입니다.
앞에서 살펴본 CORS 동작 원리는 기본적인 동작 흐름이였습니다.
실제로 CORS 가 동작하는 방식은 3가지 시나리오에 따라 변경됩니다.
그 중 가장 많이 마주치는 시나이로인 Preflight Request
에 대해 알아보겠습니다.
Preflight Request
의 가장 큰 특징은 예비 요청
과 본 요청
으로 나누어져있다는 것입니다.
해당 방식은 Simple Request
의 제약조건에 위반하는 경우 브라우저가 자동적으로 Preflight Request
을 보냅니다.
Preflight Request
플로우 차트를 통해 깊게 이해해 보겠습니다 👨💻
웹 요청을 보내는 과정을 떠올려볼까요?
사용자는 자바스크립트의 fetch API 를 통해 브라우저에게 웹 서버로부터 리소스를 받아오라는 명령을 내립니다.
명령을 받은 브라우저는 서버에게 OPTIONS
메소드를 통해 예비 요청
을 보내게 됩니다.
웹 서버는 예비 요청
에 대한 응답으로 현재 웹 서버의 출처가 어떤 출처를 허용하고 금지하는지에 대한 정보를 응답 헤어데 담아서 브라우저에게 보냅니다.
여기서 예비 요청
과정이 우리가 앞에서 살펴보았던 CORS 의 기본 동작 과정입니다 👨💻
이후 브라우저는 응답을 받고 자신이 보낸 예비 요청
과 서버로 부터 받은 허용 정책
을 비교한 후, 안전하다고 판단되면 비로서 본 요청
을 엔드포인트로 다시 보냅니다. 이를 통해 서버로 부터 원하는 리소스를 받게 됩니다.
제가 이번 포스팅을 작성하면서 가장 헷갈렸던 부분은 " Preflight Request
방식에서 예비 요청
을 보낼 때 요청 과정에서 에러가 발생하면 이는 CORS
위반일까? "였습니다.
이에 대한 답은 "CORS 정책 위반과 예비 요청의 성공 여부는 상관 없다" 입니다.
물론 예비 요청
과정에서 브라우저가 요청에 대한 유효성을 따지기 전에 실패할 때도 CORS 정책 위반으로 취급할 수 있습니다.
하지만 여기서 가장 중요한 것은 브라우저가 CORS 정책 위반 여부를 판단하는 시점은 예비 요청
에 대한 응답을 받은 이후입니다.
따라서, 만약 브라우저가 예비 요청
이 실패해서 200
이 아닌 상태 코드를 받아도 응답 헤더에 자신의 출처가 허용된 것을 확인 할 수 있다면 CORS 정책 위반이 아닙니다.
앞에서 살펴보았듯이, OPTIONS 메소드로 예비 요청을 보내면 특히 보안적으로 큰 장점을 가집니다.
그 이유는 본 요청에 담기게 될 다양한 API 요청 데이터 없이 Origin 헤더에 클라이언트의 출처를 싣어 해당 서버에게 리소스 요청이 가능한지 여부를 판단할 수 있으니까요 ❗️
하지만, 실제 요청에 걸리는 시간이 늘어나게 되어 애플리케이션 성능에 영향을 미치는 큰 단점을 가집니다.
특히 수행하는 API 호출 수가 많으면 많을 수록 Preflight Request 로 인해 서버 요청을 배로 보내게 되니 비용적인 측면에서 폐가 될 수 있습니다.
하지만, 브라우저 캐시(Cache)
을 이용하여 이러한 단점을 보완할 수 있습니다.
서버의 API 호출에 대한 응답 헤더에 Access-Control-Max-Age
헤더에 캐시될 시간을 명시에 주면, Preflight 요청을 브라우저에 캐싱 시켜 최적화 시켜줄 수 있습니다 ❗️
Preflight Request 브라우저 캐싱은 다른 캐싱 매커니즘과 유사하게 동작합니다.
1. 브라우저는 Preflight Request 을 할 때마다, 먼저 Preflight 캐시를 확인하여 해당 요청에 대한 응답이 있는지 체크한다.
2. 해당 요청에 대한 응답이 존재한다면, 서버에 Preflight Request 을 보내지 않고 캐시에서 요청 결과를 가져온다.
3. 만일 응답이 캐싱 되어 있지 않다면, 서버에 예비 요청을 보내 인증 절차를 거친다.
4. 예비 요청을 보낸 후 서버로부터 Access-Control-Max-Age
응답 헤더를 받는다면 그 기간 동안 브라우저 캐시에 요청 결과를 저장한다.
Credentialed Request 는 인증된 요청입니다.
즉 클라이언트에서 서버에게 자격 인증 정보
(Credential) 을 실어 요청할때 사용되는 요청입니다.
여기서 자격 인증 정보
란 세션 ID가 저장되어있는 쿠키
혹은 Authorization
헤더에 설정되어 있는 토큰
을 일컫습니다.
이렇듯, 클라이언트에서 일반적인 JSON 데이터 외에도 쿠키 같은 인증 정보를 포함해서 다른 출처의 서버로 전달할때 Credentialed Request 로 동작하며, 앞서 살펴본 Simple Request, Preflight Request 와 다른 형태로 통신하게 됩니다.
기본적으로 브라우저가 제공하는 비동기 리소스 요청 API
인 XMLHttpRequest
객체나 fetch
API 는 별도의 옵션 없이 브라우저의 쿠키 정보나 인증과 관련된 헤더를 함부로 요청에 담지 않습니다.
하지만 요청에 자격 인증 정보가 필요한 경우가 있는데,
이때 요청에 자격 인증 정보를 담을 수 있게 해주는 옵션이 바로 credential
옵션입니다.
해당 옵션에는 3가지 값을 사용할 수 있습니다.
옵션 값 | 설명 |
---|---|
same-origin(기본값) | 같은 출처 간 요청에만 인증 정보를 담을 수 있다 |
include | 모든 요청에 인증 정보를 담을 수 있다 |
omit | 모든 요청에 인증 정보를 담지 않는다 |
이렇게 3가지 옵션이 존재하며 일반적인 브라우저의 기본 옵션 값은 same-origin
입니다.
서버도 마찬가지로 Credentialed Request 에 대해 일반적인 CORS 요청과는 다르게 대응해줘야 합니다.
1. 응답 헤더의 Access-Control-Allow-Credentials
항목을 true 로 설정해야 한다.
2. 응답 헤더의 Access-Control-Allow-Origin
값에 와일드카드 문자 "*"
사용할 수 없다.
3. 응답 헤더의 Access-Control-Allow-Headers
값에 와일드카드 문자 "*"
사용할 수 없다.
4. 응답 헤더의 Access-Control-Allow-Methods
값에 와일드카드 문자 "*"
사용할 수 없다.
즉, 응답에서 허용하는 Origin을 분명히 설정해야 하는 등 별도의 작업을 요구합니다.
이러한 이유는 요청 데이터에 포함된 자격 인증 정보는 민감한 정보이기 때문입니다.
@CrossOrigin(origins = "http://localhost:8080") // 서버가 허용할 "요청을 보내는 출처"
@RestController
public class TestController {
@GetMapping("/test")
public String test() {
return "test";
}
}
@CressOrigin
어노테이션을 이용하면 쉽게 CORS 설정을 할 수 있습니다.
해당 어노테이션의 origins
속성을 통해 도메인을 설정하면 해당 출처에 대한 요청을 모두 허용하게 됩니다.
특정 도메인으로 오는 요청에는 모드 CORS 적용하고 싶다면 WebConfig
를 설정하면 됩니다.
@Configuration
public class WebConfig implements WebMvcConfiguerer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**)
.allowedOrigins("http://localhost:8080");
}
}
WebMvcConfigurer
을 상속 받아서 addCorsMappings
를 Override 하면 CORS 관련 설정이 가능합니다.
프로젝트를 진행하면서 마주쳤던 CORS 관련 에러는 결국 이번 포스팅에 대한 공부를 하고 나서야 해결할 수 있었습니다. 해결과정에서 가장 번거로웠던 점은 문제를 겪는 사람과 해결해야하는 사람이 다르다는 점이였습니다.
실제 에러를 마주친 부분은 프론트앤드 쪽이였지만 이애 대해 공부하고 해결하는 쪽은 제가 진행했던 백엔드 쪽이였습니다.
이를 위해 원활한 소통이 필요했습니다 💬