이번 글에서는 SockJS를 사용한 웹소켓 연결 시 발생했던 CORS 이슈에 대해 글을 작성해보려 합니다.
발생 이슈
SockJS를 사용한 웹소켓 연결 시 CORS와 403 오류가 발생했습니다.
Access to XMLHttpRequest at 'https://fittrip.site/stomp/ws-stomp/info?t=1717573508675'
from origin 'http://localhost:3000/' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
useWebSocketStore.js:10
GET https://fittrip.site/stomp/ws-stomp/info?t=1717573508675
net::ERR_FAILED 403 (Forbidden)
현재 상황
리액트에서 채팅 서버로 웹소켓 연결 요청 시 흐름은 nginx → api gateway → chat-service입니다.
맨 처음에는 애플리케이션단에서 CORS에 관한 설정을 했습니다. 하지만 서비스가 스케일 아웃될 때마다 CORS에 대한 설정이 중복돼서 Nginx 쪽에서 공통적으로 처리하는 게 좋다고 생각했습니다. 그래서 수정 전 코드와 같이 Nginx쪽에서 웹소켓 연결에 대한 CORS 설정을 했으나 여전히 CORS와 403 오류가 여전히 발생했습니다. 원래대로 설정했던 수정 후 코드와 같이 nginx 쪽에서 CORS에 관한 설정은 주석 처리하고 StompConfig에서 CORS에 관한 설정을 추가하니 CORS와 403 오류가 더 이상 뜨지 않았습니다.
수정 전
nginx
location /stomp {
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' $allowed_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, DELETE, PATCH, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'Content-Type';
add_header 'Access-Control-Allow-Credentials' 'true';
return 204;
}
proxy_pass http://localhost:8000;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
StompConfig
@Configuration
@RequiredArgsConstructor
@EnableWebSocketMessageBroker
public class StompConfig implements WebSocketMessageBrokerConfigurer {
private final WebSocketConnectionHandler webSocketConnectionHandler;
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic", "/queue");
config.setApplicationDestinationPrefixes("/ws/api/chat");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws-stomp")
.withSockJS();
}
...
}
수정 후
nginx
location /stomp {
# if ($request_method = 'OPTIONS') {
# add_header 'Access-Control-Allow-Origin' $allowed_origin always;
# add_header 'Access-Control-Allow-Methods' 'GET, POST, DELETE, PATCH, OPTIONS';
# add_header 'Access-Control-Allow-Headers' 'Content-Type';
# add_header 'Access-Control-Allow-Credentials' 'true';
# return 204;
# }
proxy_pass http://localhost:8000;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
StompConfig
@Configuration
@RequiredArgsConstructor
@EnableWebSocketMessageBroker
public class StompConfig implements WebSocketMessageBrokerConfigurer {
private final WebSocketConnectionHandler webSocketConnectionHandler;
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/topic", "/queue");
config.setApplicationDestinationPrefixes("/ws/api/chat");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws-stomp")
.setAllowedOriginPatterns("http://localhost:3000")
.withSockJS();
}
...
}
위 상황에서 드는 의문점
- nginx쪽에서 CORS에 관한 설정을 해줬는데 왜 적용이 안 됐는가
- api gateway쪽에서 CORS에 관한 설정을 하지 않았는데 어째서 웹소켓 연결 요청 시 nginx를 지나고 api gateway에 도달했을 때 CORS 문제가 발생하지 않았는가
브라우저는 웹소켓 요청을 할 때 CORS preflight 요청을 수행하지 않습니다.
또한 웹소켓 요청을 할 때 브라우저는 Access-Control 헤더에 지정된 제한 사항을 준수하지 않습니다.
하지만, 웹소켓 요청을 보낼 때 브라우저는 Origin 헤더를 포함합니다.
따라서 애플리케이션은 이 Origin 헤더를 검증하여 예상된 출처에서 오는 웹소켓 요청만 허용하도록 구성해야 합니다. 예를 들어, 서버가 "https://server.com"에 호스팅되고 클라이언트가 "https://client.com"에 호스팅 되고 있다면, "https://client.com"을 웹소켓의 허용된 출처 목록(AllowedOrigins)에 추가하여 이를 검증해야 합니다.
즉 브라우저는 웹소켓 연결 요청 시 preflight 요청을 하지 않습니다. 따라서 nginx 설정 중 $request_method = 'OPTIONS' 조건은 맞지 않아 CORS에 대한 설정이 되지 않으므로 1번 의문은 해결되었습니다.
그리고 CORS는 브라우저 단에서 확인을 하는 거지 서버단에서 확인하는 게 아니라 api gateway에 도달했을 때 CORS 문제가 발생한다는 말은 잘못된 말이라 2번 의문은 처음부터 전제가 잘못됐습니다.
그리고 또 다른 의문이 발생했는데 그전에 SockJs에 대해 간단히 설명하겠습니다.
SockJS 란
클라이언트-서버 WebSocket 통신이 순탄하게만 진행될 수 없다 아래와 같이 예외 상황들이 발생할 수 있다.
- 모든 클라이언트의 브라우저에서 WebSocket을 지원한다는 보장이 없다.
- 클라이언트/서버 중간에 위치한 프록시가 Upgrade 헤더를 해석하지 못해 서버에 전달하지 못할 수 있다.
- 클라이언트/서버 중간에 위치한 프록시가 유휴 상태에서 도중에 커넥션 종료시킬 수도 있다.
이러한 문제는 WebSocket Emulation을 통해서 해결이 가능하다.
WebSocket Emulation 이란, WebSocket을 첫 번째로 시도하고 WebSocket 연결이 실패한 경우에는 HTTP-Streaming, HTTP Long Polling 같은 HTTP 기반의 다른 기술로 전환해 다시 연결을 시도하는 것을 말한다.
즉 WebSocket Emulation을 통해서, 위와 같이 WebSocket 연결을 할 수 없는 경우에는 다른 HTTP 기반의 기술을 시도하는 방법이다.
이러한, WebSocket Emulation을 지원하는 것이 바로 SockJS 프로토콜이다. Spring Framework는 Servlet 스택 위에서 서버/클라이언트 용도의 SockJS 프로토콜을 모두 지원하고 있다.
SockJS의 목표는 "애플리케이션이 우선적으로 WebSocket API를 사용하도록 하지만, 사용할 수 없는 경우에는 런타임 시점에 코드 변경 없이 WebSocket 이외의 대안으로 대체"하도록 하는 것이다.
SockJs 내부 코드
다시 돌아와서 웹소켓을 지원하지 않는 브라우저인 경우 SockJs 사용 시 HTTP-Streaming, HTTP Long Polling 같은 HTTP 기반의 다른 기술로 전환을 해준다고 위에서 설명했습니다. 그렇다면 CORS에 대해 origin 뿐만 아니라 header나 method 들도 설정을 해줘야 되는데 어째서 StompEndpointRegistry에는 .setAllowedOriginPatterns("http://localhost:3000/") 과 같이 origin 부분에 대해서만 설정이 있는지 의문이 생겼습니다.
그래서 StompEndpointRegistry에 대해 좀 더 자세히 알아보고자 StompEndpointRegistry의 코드 구현부로 찾아가니WebMvcStompEndpointRegistry 클래스가 나왔습니다. 이 클래스는 StompEndpointRegistry를 구현한 클래스입니다.
해당 클래스에서 정의된 함수에 대해 살펴보다가 getHandlerMapping()이라는 함수에 대해 알게 되었고 이 함수는 간단하게 설명하면 STOMP 웹 소켓 엔드포인트의 URL을 핸들러에 매핑하는 데 사용되는 함수입니다.
public AbstractHandlerMapping getHandlerMapping() {
Map<String, Object> urlMap = new LinkedHashMap<>();
for (WebMvcStompWebSocketEndpointRegistration registration : this.registrations) {
MultiValueMap<HttpRequestHandler, String> mappings = registration.getMappings();
mappings.forEach((httpHandler, patterns) -> {
for (String pattern : patterns) {
urlMap.put(pattern, httpHandler);
}
});
}
WebSocketHandlerMapping hm = new WebSocketHandlerMapping();
hm.setUrlMap(urlMap);
hm.setOrder(this.order);
if (this.urlPathHelper != null) {
hm.setUrlPathHelper(this.urlPathHelper);
}
return hm;
}
위 함수에서 사용된 함수들에 대해 살펴보다가 getMappings() 메서드가 정의된 곳을 찾아가니 WebMvcStompWebSocketEndpointRegistration 클래스에서 아래와 같이 정의가 되어 있었고 해당 메서드 중 getSockJsService()가 눈에 띄었습니다. 우리는 현재 SockJS를 사용할 경우 어째서 origin만 설정하면 되는 건지에 대한 원인을 찾고 있어서 바로 해당 함수가 정의된 곳으로 찾아갔습니다.
public final MultiValueMap<HttpRequestHandler, String> getMappings() {
MultiValueMap<HttpRequestHandler, String> mappings = new LinkedMultiValueMap<>();
if (this.registration != null) {
SockJsService sockJsService = this.registration.getSockJsService();
for (String path : this.paths) {
String pattern = (path.endsWith("/") ? path + "**" : path + "/**");
SockJsHttpRequestHandler handler = new SockJsHttpRequestHandler(sockJsService, this.webSocketHandler);
mappings.add(handler, pattern);
}
}
...
}
SockJsServiceRegistration 클래스에서 getSockJsService() 함수가 정의되어 있었고 해당 함수에서 createSockJsService() 함수를 사용하고 있었습니다. 그래서 createSockJsService() 함수가 정의된 곳으로 찾아가니 아래와 같이 정의되어 있었습니다.
private TransportHandlingSockJsService createSockJsService() {
Assert.state(this.scheduler != null, "No TaskScheduler available");
Assert.state(this.transportHandlers.isEmpty() || this.transportHandlerOverrides.isEmpty(),
"Specify either TransportHandlers or TransportHandler overrides, not both");
return (!this.transportHandlers.isEmpty() ?
new TransportHandlingSockJsService(this.scheduler, this.transportHandlers) :
new DefaultSockJsService(this.scheduler, this.transportHandlerOverrides));
}
이 함수는 설정에 따라 특정한 SockJS 서비스 또는 기본 SockJS 서비스를 반환하는 함수입니다.
그래서 TransportHandlingSockJsService와 DefaultSockJsService가 SockJs와 관련된 클래스라는 걸 파악했고 우선 TransportHandlingSockJsService 클래스를 찾아갔습니다.
TransportHandlingSockJsService 생성자에서 상위 클래스인 AbstractSockJsService의 생성자를 호출하고 있어 AbstractSockJsService 클래스로 찾아가니 의문을 해결할 답을 찾을 수 있었습니다.
AbstractSockJsService 생성자에는 CORS(Cross-Origin Resource Sharing) 구성을 초기화하기 위해 initCorsConfiguration() 메서드를 호출하고 있음을 확인할 수 있었습니다.
initCorsConfiguration() 메서드에서 초기화하고 있는 작업을 보면 모든 HTTP 메서드를 허용하고 허용된 출처(origin) 및 출처 패턴(origin pattern)을 빈 리스트로 설정하고 자격 증명(credential)을 허용하고 모든 헤더를 허용하도록 설정하고 있는 것을 확인할 수 있습니다. 이를 통해 SockJs를 사용하는 경우 기본적으로 CORS 설정에 대해 초기화 작업을 한다는 걸 확인하여 3번째 의문을 해결할 수 있었습니다.
public AbstractSockJsService(TaskScheduler scheduler) {
Assert.notNull(scheduler, "TaskScheduler must not be null");
this.taskScheduler = scheduler;
this.corsConfiguration = initCorsConfiguration();
}
private static CorsConfiguration initCorsConfiguration() {
CorsConfiguration config = new CorsConfiguration();
config.addAllowedMethod("*");
config.setAllowedOrigins(Collections.emptyList());
config.setAllowedOriginPatterns(Collections.emptyList());
config.setAllowCredentials(true);
config.setMaxAge(ONE_YEAR);
config.addAllowedHeader("*");
return config;
}
아쉬운 점
websocket 연결 시 구체적으로 CORS가 어떻게 동작하는지 그리고 SockJs가 구체적으로 어떻게 동작하는지 몰라서 발생한 이슈였습니다. 다시 한번 기능이 내부적으로 어떻게 구성되어 있고 어떻게 동작하는지 이해하는 게 중요하다는 걸 깨달았습니다.
Nginx 쪽에서 CORS 설정을 안 하다 보니 서버단에서 설정하여 스케일 아웃 시 중복되는 부분이 있어 Nginx쪽에서 처리하는 방법을 찾아보려 합니다.
참고
https://learn.microsoft.com/en-us/aspnet/core/fundamentals/websockets?view=aspnetcore-8.0
https://stackoverflow.com/questions/22644392/chrome-websockets-cors-policy
https://docs.spring.io/spring-framework/docs/4.2.3.RELEASE/spring-framework-reference/html/websocket.html#websocket-server-allowed-origins
'프로젝트 > FitTrip' 카테고리의 다른 글
개발 기록 - 유저의 마지막 채널 위치 기억 기능 (0) | 2024.07.13 |
---|---|
개발 기록 - 분산 시스템에서 데이터를 전달하는 효율적인 방법 (0) | 2024.07.12 |
개발 기록 - WebSocket & STOMP 개발 이슈 (0) | 2024.07.05 |
개발 기록 - MongoDB auto-incremented sequence 적용 (0) | 2024.07.03 |
트러블 슈팅 - IN 연산자를 활용하여 채팅 목록 조회 92% 성능 최적화 (0) | 2024.07.02 |