이번 글에서는 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