안녕하세요, codedbyjst입니다.
이번에는 스프링 부트에서 어떻게 하면 CORS를 설정할 수 있는지, 그리고 문제가 발생한다면 그 이유가 무엇인지 말씀드리려 합니다.
본 글은 스프링 부트에서의 트러블 슈팅에 집중하고 있으므로, CORS 자체의 이해를 위해선 아래의 글을 참조해주세요.
https://developer.mozilla.org/ko/docs/Web/HTTP/CORS
스프링 부트 CORS 설정법
스프링 부트에서 CORS를 설정하는 법은 사실 다양하지만, 보통 두 가지 경우로 나뉘게 됩니다.
1. @CrossOrigin
각 컨트롤러 별로 @CrossOrigin 어노테이션을 통해 설정하는 방식입니다.
@CrossOrigin(origins = "http://localhost:5173") // 추가된 부분
@RestController
@RequestMapping("/cors_test")
public class CorsTestController {
@GetMapping
public String test() {
return "CORS Test";
}
}
컨트롤러별로 세세하게 설정해줄 수 있다는 장점이 있지만, CORS를 적용하고자 하는 모든 컨트롤러에 직접 적용해줘야 해서 번거롭다는 단점이 있습니다.
2. WebMvcConfigurer
글로벌하게 CORS를 적용하는 방식입니다.
WebMvcConfigurer는 커스텀 설정을 위해 스프링에서 지정한 인터페이스이며, 해당 인터페이스를 구현한 클래스를 생성하고 빈으로 등록하면 해당 설정값들이 적용되어 동작하게 됩니다.
아래의 예시는 해당 설정을 등록하는 방법을 표현합니다.
(파일은 스프링이 스캔하는 곳 어디던지 괜찮습니다. 본 예시에서는 ohmycarset/src/main/java/com.softeer2nd.ohmycarset/config/WebConfig에 위치합니다.)
예를 들어 "http://ohmycarset.com:80"과 "https://ohmycarest.com:443"에 대해 CORS 정책을 허용하고 싶다면, 아래와 같이 작성하면 됩니다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**") // 모든 경로에 대해
.allowedOrigins(
"http://ohmycarset.com:80",
"https://ohmycarset.com:443"
) // 허용할 Origin 목록
.allowedMethods("*") // 허용할 HTTP 메소드 목록
.allowedHeaders("*"); // 허용할 헤더 목록
}
}
혹은, 모든 경로 요청에 대해 허용하고 싶다면 아래처럼 와일드카드를 이용해도 됩니다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**") // 모든 경로에 대해
.allowedOrigins(
"*"
) // 허용할 Origin 목록
.allowedMethods("*") // 허용할 HTTP 메소드 목록
.allowedHeaders("*"); // 허용할 헤더 목록
}
}
적용할 수 있는 옵션의 목록은 다음과 같습니다 :
설정 | 설명 | 예시 |
addMapping | 특정 URL 패턴에 대한 CORS 설정을 추가합니다. | .addMapping("/api/**") |
allowedOrigins | 허용된 오리진(도메인) 목록을 설정합니다. 와일드카드(*)나 특정 도메인을 지정할 수 있습니다. | .allowedOrigins("https://example.com") |
allowedMethods | 허용된 HTTP 메서드 목록을 설정합니다. | .allowedMethods("GET", "POST") |
allowedHeaders | 허용된 요청 헤더 목록을 설정합니다. | .allowedHeaders("Authorization") |
exposedHeaders | 브라우저가 액세스할 수 있는 응답 헤더 목록을 설정합니다. | .exposedHeaders("Custom-Header") |
allowCredentials | 자격증명(쿠키 및 인증 헤더)을 허용할지 여부를 설정합니다. | .allowCredentials(true) |
maxAge | 사전 패치 요청을 캐싱할 최대 시간을 설정합니다. | .maxAge(3600) |
설정도 복잡하지 않고, 쉽게 사용하면 되는 것처럼 보입니다.
그런데 둘 중 위의 예시를 실제로 작동시키고 브라우저에서 접근하면 아래와 같은 오류가 발생합니다.
CORS로 인해 데이터를 제공해줄 수 없다는 403 에러입니다.
Vary라는 키로 뭔가 여러 헤더값들이 설정되어 있긴 하지만, CORS 처리에 필요로 하는 'Access-Control-Allow-Origin' 헤더가 보이지 않습니다. 대체 왜일까요?
CORS의 동작 원리 파악
사실 이를 정확하게 이해하기 위해서는 CORS가 어떻게 동작하는지 이해해야 합니다.
예시로, ohmycarset.com이라는 도메인에서 api.ohmycarset.com에 요청을 보내는 상황을 가정해봅시다.
이 때 브라우저는 Origin이 같은지를 검증하게 됩니다. 이 때 Origin이 정확히 무엇일까요?
웹 콘텐츠의 출처(origin)는 접근할 때 사용하는 URL의 스킴(프로토콜), 호스트(도메인), 포트로 정의됩니다. 두 객체의 스킴, 호스트, 포트가 모두 일치하는 경우 같은 출처를 가졌다고 말합니다. - MDN Web Docs
위의 예시처럼, ohmycarset.com과 api.ohmycarset.com은 다른 호스트이므로 다른 Origin입니다.
따라서 브라우저는 api.ohmycarset.com에 요청할 때 아래처럼 Origin 헤더를 설정하여 보내주게 됩니다.
(물론 이러한 이유는 CORS가 '브라우저의 규약'이기 때문입니다. 헷갈리신다면 문서를 다시 읽어주세요.)
그러면 서버는 헤더에 포함된 Origin을 파악하고, CORS 설정에 허용된 Origin 목록에 포함되어 있다면 'Access-Control-Allow-Origin'에 다시 Origin을 담아 재전송해주게 됩니다.
스프링의 경우, 요청 Origin이 아까 위에서 설정한 .allowedOrigins의 목록에 포함된다면,
"Access-Control-Allow-Origin" : "http://ohmycarset.com" 이렇게 다시 설정해서 보내준다는 이야기입니다.
또, 이처럼 "Access-Control-Allow-Origin"의 값이 요청한 Origin에 따라 다르게 처리되는 경우(다시 말해, Access-Control-Allow-Origin의 값이 "*"가 아니라면),
해당 처리가 요청 Origin에 따라 다를 수 있다는 것을 명시하기 위해 "Vary" : "Origin" 헤더를 추가하여 보내줍니다.
Vary 헤더가 있던 이유가 이거였네요.
그런데 그렇다면 더더욱 이상합니다. 분명 위 설정에서 "http://ohmycarset.com:80"을 허용했고, "http://ohmycarset.com"에서 요청이 왔다면, 당연히 CORS가 허용되어야 했던 것 아닐까요?
CORS 규약과 스프링의 괴리
사실 이는 스프링에서 Origin 목록을 처리하는 방식이 브라우저에서 Origin을 처리하는 방식과 일부 차이가 있어 발생하는 문제입니다.
위의 상황을 다시 꼼꼼하게 되짚어봅시다. 무언가가 보일 겁니다.
요청 Origin : http://ohmycarset.com
허용 Origin : http://ohmycarset.com:80
자세히 보면 허용 Origin에는 포트번호까지 포함되어 있는 것을 볼 수 있습니다.
하지만 분명 위의 Origin의 정의에서는 'HTTP의 기본 포트는 80이므로 동일한 출처(Origin)'이라 명시되어 있었습니다. 이게 어떻게 된 일일까요?
결론적으로, 스프링에서는 CORS 검증을 위해 '기본 포트'를 고려하지 않습니다. 단순한 문자열 비교만으로 CORS 목록을 검증합니다.
따라서, "https://ohmycarset.com:443"과 "https://ohmycarset.com" 역시 다른 Origin으로 판단합니다. 따라서 CORS 허용 처리가 불가합니다.
Curl을 통한 검증
이를 어떻게 검증할 수 있을까요?
로컬에서 CORS를 검증하는 것은 상당히 어려운 일입니다만, 다행히 curl이나 Postman 등에서 헤더를 직접 수정하여 전송하는 것으로 테스트해 볼 수 있습니다.
curl \
--verbose \
--request OPTIONS \
'http://localhost:8080/trim' \
--header 'Origin: http://ohmycarset.com' \
--header 'Access-Control-Request-Headers: Origin, Accept, Content-Type' \
--header 'Access-Control-Request-Method: GET'
동일한 상황에서, 위 코드로 어떤 응답이 오는지 확인해 보도록 하겠습니다.
< HTTP/1.1 403
< Vary: Origin
< Vary: Access-Control-Request-Method
< Vary: Access-Control-Request-Headers
< Transfer-Encoding: chunked
< Date: Fri, 11 Aug 2023 16:42:43 GMT
<
* Connection #0 to host localhost left intact
Invalid CORS request%
응답은 다음과 같습니다. CORS 에러가 발생하게 되네요.
이번에는 Origin을 "http://ohmycarset.com:80"으로 수정해서 요청해보겠습니다.
curl \
--verbose \
--request OPTIONS \
'http://localhost:8080/trim' \
--header 'Origin: http://ohmycarset.com:80' \
--header 'Access-Control-Request-Headers: Origin, Accept, Content-Type' \
--header 'Access-Control-Request-Method: GET'
< HTTP/1.1 200
< Vary: Origin
< Vary: Access-Control-Request-Method
< Vary: Access-Control-Request-Headers
< Access-Control-Allow-Origin: http://ohmycarset.com:80
< Access-Control-Allow-Methods: GET
< Access-Control-Allow-Headers: Origin, Accept, Content-Type
< Access-Control-Max-Age: 1800
< Allow: GET, HEAD, POST, PUT, DELETE, OPTIONS, PATCH
< Content-Length: 0
< Date: Fri, 11 Aug 2023 16:44:43 GMT
<
* Connection #0 to host localhost left intact
정상적으로 처리되고,
"Access-Control-Allow-Origin"에는 요청된 Origin인 "http://ohmycarset.com:80"이 설정되어 있고,
"Vary" : "Origin" 헤더 역시 포함된 것을 확인할 수 있습니다.
스프링은 CORS 처리에 기본 포트를 고려하지 않는다
그렇다면 다시 처음으로 돌아와서 어떻게 설정을 진행하면 좋을지 생각해봅니다.
스프링은 기본 포트를 고려하지 않는다고 했으므로, 아래와 같이 작성하면 됩니다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**") // 모든 경로에 대해
.allowedOrigins(
"http://ohmycarset.com", // 포트 미포함
"http://ohmycarset.com:80", // 포트 포함
"https://ohmycarset.com", // 포트 미포함
"https://ohmycarset.com:443" // 포트 포함
) // 허용할 Origin 목록
.allowedMethods("*") // 허용할 HTTP 메소드 목록
.allowedHeaders("*"); // 허용할 헤더 목록
}
}
위처럼 포트를 포함한 경우와 미포함한 경우를 모두 작성해놓으면,
브라우저가 어떻게 Origin을 설정하여 보내주더라도 정상 처리를 기대할 수 있습니다.
정상적으로 동작하는지 확인하기 위해, 위와 같이 적용한 후 테스트를 진행해보았습니다.
curl \
--verbose \
--request OPTIONS \
'http://localhost:8080/trim' \
--header 'Origin: http://ohmycarset.com' \
--header 'Access-Control-Request-Headers: Origin, Accept, Content-Type' \
--header 'Access-Control-Request-Method: GET'
* Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> OPTIONS /trim HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/8.1.1
> Accept: */*
> Origin: http://ohmycarset.com
> Access-Control-Request-Headers: Origin, Accept, Content-Type
> Access-Control-Request-Method: GET
>
< HTTP/1.1 200
< Vary: Origin
< Vary: Access-Control-Request-Method
< Vary: Access-Control-Request-Headers
< Access-Control-Allow-Origin: http://ohmycarset.com
< Access-Control-Allow-Methods: GET
< Access-Control-Allow-Headers: Origin, Accept, Content-Type
< Access-Control-Max-Age: 1800
< Allow: GET, HEAD, POST, PUT, DELETE, OPTIONS, PATCH
< Content-Length: 0
< Date: Fri, 11 Aug 2023 16:50:33 GMT
<
* Connection #0 to host localhost left intact
이제 정상적으로 동작하는 것을 확인할 수 있네요.
이것으로 스프링에서 CORS를 다루는 법을 마치겠습니다. 개발에 도움이 되셨으면 좋겠습니다.
'개발팁' 카테고리의 다른 글
Nginx와 Docker Compose로 무중단 배포하기 (0) | 2023.08.20 |
---|---|
Certbot HTTPS용 SSL 인증서 발급 / Nginx로 적용하기 (2) | 2023.08.15 |
Spring Boot + Redis Cache 사용법 정리 (0) | 2023.08.11 |
[Fastapi]파라미터 올바르게 다루기 (0) | 2023.03.03 |
[Fastapi]sqlalchemy말고, pymysql로 데이터베이스(Mysql)와 통신하기 (4) | 2023.02.23 |