반응형
04-20 10:01
Today
Total
«   2024/04   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
관리 메뉴

개발하는 고라니

[Spring Boot] WebSocket과 채팅 (2) - SockJS 본문

Framework/Spring Boot

[Spring Boot] WebSocket과 채팅 (2) - SockJS

조용한고라니 2021. 4. 4. 04:24
반응형
 

[Spring Boot] WebSocket과 채팅 (1)

일전에 WebSocket(웹소켓)과 SockJS를 사용해 Spring 프레임워크 환경에서 간단한 하나의 채팅방을 구현해본 적이 있다. [Spring MVC] Web Socket(웹 소켓)과 Chatting(채팅)  기존 공부 용도의 게시판(?)에 여러.

dev-gorany.tistory.com

이전 게시글에 이어 업로드되는 글 입니다.

 

저번에 순수 WebSocket만 가지고 간단한 채팅을 구현해보았다. Firefox, Chrome, Edge, Whale에서는 동작을 확인하였다. 하지만 모바일 크롬 브라우저와 IE에서는 WebSocket이 동작하지 않았다.

이처럼 기껏 채팅을 하려고 만들었더니 브라우저에서 지원을 안해준다. 조금 정리하자면,

 

1. 모든 클라이언트의 브라우저에서 WebSocket을 지원한다는 보장이 없다.

2. 또한, Server/Client 중간에 위치한 Proxy가 Upgrade헤더를 해석하지 못해 서버에 전달하지 못할 수 있다. 마지막으로

3. Server/Client 중간에 위치한 Proxy가 유휴 상태에서 도중에 Connection 종료시킬 수도 있다.

 

그럼 어떻게 해야할까? 방법이 있다. 바로 WebSocket Emulation을 이용하는 것이다.

이것은 우선 WebSocket을 시도하고, 실패할 경우 HTTP Streaming, Long-Polling 같은 HTTP 기반의 다른 기술로 전환해 다시 연결을 시도하는 것을 말한다.

 

node.js를 사용한다면 Socket.io를 이용하는 것이 일반적이고,

Spring을 사용한다면 SockJS를 이용하는 것이 일반적이다.

 

Spring 프레임워크는 Servlet 스택 위에서 Server/Client 용도의 SockJS 프로토콜을 모두 지원한다.

 

나는 Spring을 사용하기 때문에 SockJS를 사용해서 이를 해결해보고자 한다.

 

Web on Servlet Stack

Spring Web MVC is the original web framework built on the Servlet API and has been included in the Spring Framework from the very beginning. The formal name, “Spring Web MVC,” comes from the name of its source module (spring-webmvc), but it is more com

docs.spring.io

 

sockjs/sockjs-client

WebSocket emulation - Javascript client. Contribute to sockjs/sockjs-client development by creating an account on GitHub.

github.com

SockJS

SockJS는 어플리케이션이 WebSocket API를 사용하도록 허용하지만 브라우저에서 WebSocket을 지원하지 않는 경우에 대안으로 어플리케이션의 코드를 변경할 필요 없이 런타임에 필요할 때 대체하는 것이다.

 

SockJS의 구성

  • SockJS Protocol
  • SockJS Javascript Client - 브라우저에서 사용되는 클라이언트 라이브러리
  • SockJS Server 구현 - Spring-websocket 모듈을 통해 제공
  • SockJS Java Client - Spring-websocket 모듈을 통해 제공 (Spring ver.4.1 ~ )

SockJS는 다양한 기술을 이용해 웹소켓을 지원하지 않는 브라우저에서 정상적으로 동작하도록 해준다. 전송 타입은 크게 다음의 3가지로 분류된다.

  1. WebSocket
  2. HTTP Streaming
  3. HTTP Long Polling

 

WebSocket Emulation Process

 

SockJS Client는 서버의 기본 정보를 얻기 위해 "GET /info"를 호출하는데, 이는 서버가 WebSocket을 지원하는지,  전송 과정에서 Cookies 지원이 필요한지 여부 그리고 CORS를 위한 Origin 정보 등의 정보를 응답으로 전달받는다. 그 이후 SockJS는 어떤 전송 타입을 사용할 지 결정한다. 위의 순서대로 사용하려고 시도한다.

 

모든 전송 요청은 다음의 URL 구조를 갖는다

https://host:port/myApp/myEndpoint/{server-id}/{session-id}/{transport}
  • server-id : 클러스터에서 요청을 라우팅하는데 사용하나 이외에는 의미 없음
  • session-id : SockJS session에 소속하는 HTTP 요청과 연관성 있음
  • transport : 전송 타입 (예 : websocket, xhr-streaming, xhr-polling )

WebSocket 전송은 WebSocket Handshaking을 위한 하나의 HTTP 요청을 필요로 한다. 모든 메세지들은 그 이후 사용했던 Socket을 통해 교환된다.

 

HTTP 전송은 보다 더 많은 요청을 필요로 한다.

{

> Ajax/XHR Streaming은 서버 -> 클라이언트로의 메세지들을 위해 하나의 Long-running 요청이 있고, 추가적인 HTTP POST 요청은 클라이언트 -> 서버로의 메세지를 위해 사용된다.

 

> Long Polling은 서버 -> 클라이언트로의 응답 후 현재의 요청을 끝내는 것을 제외하고는 XHR Streaming과 유사하다.

}

 

SockJS는 메세지 Frame의 크기를 최소화하기 위해 노력한다. 예를 들어, 서버는

"o" (open frame)을 초기에 전송하고, 메세지는 ["msg1", "msg2"]와 같은 JSON-Encoded 배열로서 전달되며,

"h" (heartbeat frame)는 기본적으로 25초간 메세지 흐름이 없는 경우에 전송하고

"c" (close frame)는 해당 세션을 종료한다.

SockJS Enabling

Java Configuration을 통해 SockJS를 가능하게 할 수 있다. 이전에 WebSocketConfig를 작성했었는데, 거기에 몇 글자만 덧붙이면 된다.

import lombok.RequiredArgsConstructor;
import org.gorany.community.handler.ChatHandler;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;

@Configuration
@RequiredArgsConstructor
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {

    private final ChatHandler chatHandler;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(chatHandler, "/ws/chat")
        .setAllowedOrigins("http://localhost:8080")
        .withSockJS();
    }
    //.withSockJS() 추가
    //setAllowedOrigins("*")에서 *라는 와일드 카드를 사용하면
    //보안상의 문제로 전체를 허용하는 것보다 직접 하나씩 지정해주어야 한다고 한다.
}

 

허용된 Origins

 Origin? 
Origin Protocol, Host, Port 3개 부분으로 구성된다.
http://localhost:8080/

> protocol : http
> host : localhost
> port : 8080
3개 부분이 모두 동일한 경우 동일한 Origin이라고 말한다.

Springframework 4.1.5를 기준으로 WebSocket 및 SockJS의 기본 동작은 동일한 Origin요청만 수락하는 것이다. 오리진의 모든 목록이나 특정 목록을 허용하는 것도 가능하다. 다음의 3가지 행동을 취할 수 있다.

 

  • 동일한 오리진 요청만 허용 (default)
    • 이 모드에서는  SockJS가 활성화되면 iframe HTTP 응답 헤더 X-Frame-Options가 'SameOrigin'으로 설정되며, JSONP 전송은 요청의 오리진 확인이 불가능하므로 비활성화된다. 따라서 이 모드가 활성화된 경우 IE 6, 7은 지원되지 않는다.
  • 지정된 Origin목록 허용
    • 이 모드에서는 지정된 Origin은 반드시 http:// or https://로 시작해야한다. 이 모드에서 SockJS가 활성화되면 iframe 전송이 비활성화되므로 IE 6 ~ 9까지는 지원되지 않는다.
    • 위의 코드는 지정된 Origin 목록 허용을 한 것이다.
  • 모든 Origin 허용
    • 이 모드를 사용하면 허가된 오리진 값으로써 '*'를 사용해야 한다. 이 모드에서는 모든 전송(Send)를 사용할 수 없다.

 

여담이지만 Spring WebSocket이 Spring MVC 외에도 사용할 수 있도록 제공되듯이, SockJS도 그러하다. 이는 

SockJSHttpRequestHandler를 통하여 제공된다. 클라이언트는 sockjs-client를 사용할 수 있다.

Internet Explorer 8 & 9

SockJS는 Ajax/XHR Streaming을 Microsoft의 'XDomainRequest' (xdr)를 통해 지원하고 있다. 이는 서로 다른 도메인 간에도 동작하지만, Cookie 전송을 지원하지 않는다. Cookie는 Java 어플리케이션에서 필수적이지만 SockJS 클라이언트는 Java만을 위한 것이 아닌 많은 서버 타입을 위해 사용되도록 고안되었기 때문에 Cookie를 중요하게 다룰지 여부를 알려주어야 한다. SockJS 클라이언트는 Ajax/XHR Streaming을 선택하거나, iframe기반의 technique를 사용한다.

 

10버전 부터는 XMLHttpRequest(xhr) 사용을 권장하여 XDomainRequest를 제거했다. XDR, XHR 모두 CORS를 지원하기 위한 도구이다.

 

XDomainRequest는 비록 CORS 도구로서 잘 동작하지만, Cookies 전송을 지원하지 않는다.

Cookies는 종종 Java 어플리케이션에서 필수적이나, SockJS 클라이언트는 여러 유형의 서버와 함께 사용될 수 있기 때문에 그다지 문제되지 않는다.

 

따라서 서버 측 Cookies 필요 여부에 따라 HTTP Streaming, HTTP Long Polling에서 사용하는 기술이 달라진다.

  • Cookies X : XDomainRequest (xdr) 사용
  • Cookies O : iframe 기반의 기술 사용

위에서 "/info"를 GET 요청한다고 했는데, 거기서 'cookie_needed'라는 속성을 볼 수 있다. 의미는 Cookies 정보가 필요한지인데, WebSocketConfig에서 변경할 수 있으며 메소드는 'setSessionCookieNeeded'이고 값은 true/false이다.

자바 어플리케이션에서는 'JSESSIONID' 쿠키를 많이 사용하기 때문에 기본값은 'true'이다.

 

IE 10이상부터는 xdr을 제거했다고 했으니, Cookies 사용을 허가하고 iframe 기반의 기술을 사용하도록 해보자.

그전에 [X-Frame-Options] 응답 헤더를 알아야 한다.

X-Frame-Options

해당 페이지를 <frame> 또는 <iframe>, <object>에서 렌더링할 수 있는지 여부를 나타내는데 사용되고, 사이트 내 컨텐츠들이 다른 사이트에 포함되지 않도록해 'clickjacking' 공격을 막아내기 위해 사용된다.

 

X-Frame-Options: deny
X-Frame-Options: sameorigin
X-Frame-Options: allow-from https://example.com/
  • deny : 어떤 사이트에서도 frame 상에서 보여질 수 없다.
  • sameorigin : 동일한 사이트의 frame에서만 보여진다.
  • allow-from uri : 지정된 특정 uri의 frame에서만 보여진다.

 

이를 바꾸기 위해 Spring Security 설정 파일인 SecurityConfig에서 변경을 주었다. 스프링 부트의 설정파일인 properties에서도 변경이 가능하다.

//application.properties 파일

security.headers.frame = false
@Configuration
@Log4j2
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    ...

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http.headers().frameOptions().sameOrigin();
        
        ...
    }
    ...
}

 

만약 iframe 기반의 Transport를 사용하고 X-Frame-Options 응답 헤더를 포함하려면 반드시 sameorigin이거나 allow-from <origin>에 SockJS 클라이언트 도메인을 지정해야 한다. 즉, iframe으로부터 load되기 위해 스프링 서버의 SockJS가 클라이언트의 위치를 알고있어야 한다는 것이다.

 

따라서 스프링은 sameorigin을 지원하기 위해 SockJS-Client 접근 경로를 설정할 수 있도록 다음과 같이 제공한다.

@Configuration
@RequiredArgsConstructor
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {

    private final ChatHandler chatHandler;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(chatHandler, "/ws/chat")
                .setAllowedOriginPatterns("http://*:8080", "http://*.*.*.*:8080")
                .withSockJS()
                .setClientLibraryUrl("http://localhost:8080/myapp/js/sock-client.js");
                //.setClientLibarayUrl은 그냥 sockjs CDN 주소를 입력해도 무관하다.
                //https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.1.2/sockjs.js
                
                /*
                Spring Boot에서 CORS 설정 시, .allowCredentials(true)와
                .allowedOrigins("*")는 동시 설정을 못하도록 업데이트 되었다고 한다.
                모든 주소를 허용하는 대신 특정 패턴만 허용하는 것으로 적용해야한다고 변동됨.
                
                .allowedOrigins("*") 대신 .allowedOriginPatterns("*")를 사용하면 에러는 해결이
                된다고 한다.
                
                나는 이처럼 하지 않고, http://localhost:8080 또는, IP 주소로 접속하기 때문에
                위에 설정처럼 하였다.
                */
    }

}

여기서 잠시 진행 상황을 정리하고자 한다.

우선 위의 코드를 모두 적용했을 때 Chrome/Firefox/Edge에서는 문제없이 정상적으로 잘 동작하였다.

 

그런데 IE 10 부터는 WebSocket을 지원하는 걸로 알고 있는데 동작하지 않는 것이다. IE 8, 9는 그렇다 쳐도 IE 11, 10은 왜 동작하지 않는거지? 하다가 Javascript쪽에서 문제가 발견되었다. 그것은 다름아닌 IE는 자바스크립트의 화살표 함수(Arrow Function)를 지원하지 않는다는 것. 그래서 화살표 함수를 기존의 자바스크립트 함수 방식으로 변경해주었다.

 

IE 11, 10 : WebSocket으로 동작

 

 

IE 8, 9 : iframe-htmlfile로 동작 (출처: github.com/sockjs/sockjs-client#supported-transports-by-browser-html-served-from-file)

 

 

그리고 위에서 X-Frame-Option을 'sameorigin'으로 변경했었는데, 이에 대해서 두 가지 방법으로 시도해보았다. 이는 IE 8, 9버전에 영향을 미친다. (나머지 크롬, 엣지, 파이어폭스 등은 이와 무관)

 

1) X-Frame-Option을 'sameOrigin'으로 설정

sameorigin으로 설정하고, javascript에서 SockJS 객체를 생성할 때 인자를 다음과 같이 줄 수 있는데 모두 동작은 하지만, 1번 처럼 주었을 때와 2번 처럼 주었을 때의 동작 방식이 다른 것 같다.

1.
var sockJs = new SockJS("http://localhost:8080/ws/chat");

2.
var sockJs = new SockJS("http://localhost:8080/ws/chat", null, {transports: ["websocket", "xhr-streaming", "xhr-polling"]});

 

2) X-Frame-Option을 기본값인 'deny'로 설정

deny로 설정하고 1번 처럼 SockJS 객체를 생성하면 동작하지 않는다. 반면 2번 처럼 생성하면 잘 동작한다. 이에 대해선 추가적인 공부가 필요해보인다.

 

--그리고 IE 8은 JQuery가 적용되지 않으니 자바스크립트를 사용할 때 Vanila JS를 사용해야 하는 것 같다. 현재 나의 코드는 JQuery가 있기 때문에 IE 8에서는 동작하지 않는다.

 

여기 까지 나의 결론

Chrome, firefox, Edge, IE 11, 10은 SockJS를 사용하지 않아도 WebSocket만으로 잘 동작한다. 그러나 IE는 자바스크립트의 화살표 함수를 지원하지 않기 때문에 이를 변경해주어야 하고, IE 8은 JQuery가 적용되지 않기에 바닐라JS를 사용해야 한다. WebSocket을 지원하지 않는 IE 버전의 경우 iframe 기반의 기술을 사용하는데 이때 X-frame-Option이 deny로 되어있으면 SockJS 객체를 어떻게 생성하느냐에 따라 동작의 여부가 갈린다. 그러므로 동일한 도메인에서는 허용할 수 있도록 'SameOrigin'으로 설정해주면 좋을 것 같다.

이에 관한 보안적인 문제는 추후 더 깊이 공부하며 정리하도록 한다.

 

추가로 IE의 하위 버전에서도 서비스 해야한다면 Javascript의 const, let은 인식할 수 없으므로 var를 사용해야한다.

Heartbeats

SockJS 프로토콜은 프록시가 연결이 끊겼다는 결론을 내리는 것을 방지하기 위해 서버가 Heartbeat 메세지를 보내도록 요구한다. Spring SockJS 구성에는 HeartbeatTime 빈도를 사용자 정의하는 데 사용할 수 있는 속성이 있다. 기본값은 해당 연결에 어떤 메세지도 없는 25초를 사용한다. 이 25초는 IETF 권고안을 따른다.

 

만약 STOMP를 이용해 Heartbeat를 주고 받는 경우 SockJS Heartbeat 설정은 비활성화 된다.

 

개발자도구 - 네트워크 - WebSocket - Message 탭을 눌러보면 WebSocket의 메세지가 나오는데 25s마다 h 문자가 하나씩 찍힌다.

 

이상 WebSocket 위에 SockJS를 얹어 WebSocket을 지원하지 않는 IE 8, 9에서의 동작을 확인해보았다. 우여곡절이 많았지만 그만큼 새로운 사실을 많이 알게되었다. 다만 아직 모바일 크롬에서는 확인을 못해보았지만 아마 모바일에서는 동작이 안될 것이라 예상된다. 찾아보니 모바일에 관한 문제는 조금 더 깊이 있는 문제라 추후에 수정을 거듭해보아야겠다.

 

(+추가)

모바일 환경에서 안되었던 이유를 찾아내었다. 바로 localhost에 관한 문제였다. 하긴 모바일 크롬에서 웹소켓이 안된다는 것은 말이 안되는 것이지...

호스트 파일에는 로컬호스트가 127.0.0.1에 매핑되어있다. 이것은 Wi-Fi 연결과 상관없이 Loop back 주소 (자기 로컬에 대해 바인딩된 주소), 내 로컬 IP를 사용할 때 물리 주소(MAC)와 매핑될 수 있는 주소가 IPv4 말고 루프백에도 매핑되어있다. 따라서 이 로컬(디바이스)에서만 사용할 수 있는 것이다. 다른 디바이스에서 localhost에 연결하려고 한다면, 그 디바이스만의 루프백 주소에 연결되므로 디바이스의 물리적 주소를 호출하게 된 것이다.

 

결론 : 자바스크립트에서 SockJS 객체 생성 시 new SockJS(/ws/chat)으로 생성하면 해결

//var sockJs = new SockJS("http://localhost:8080/ws/chat");
//var sockJs = new SockJS("http://192.168.219.102/ws/chat", null, {transports: ["websocket", "xhr-streaming", "xhr-polling"]});
var sockJs = new SockJS("/ws/chat", null, {transports: ["websocket", "xhr-streaming", "xhr-polling"]});

 

이제 WebSocket을 지원하지 않는 브라우저에서 방법을 우회해서 동작하게끔 만들었으나, 여전히 채팅방은 1개 뿐이다.

다음 글에서는 STOMP라는 것과, 그것을 이용하면 어떻게 되는지 등을 공부하며 글을 작성해보고자 한다. 추가적으로 CORS가 굉장히 중요한 것 같아, CORS에 대해서도 깊이있게 알아보아야 할 것 같다.

 

 

[Spring Boot] WebSocket과 채팅 (3) - STOMP

[Spring Boot] WebSocket과 채팅 (2) - SockJS [Spring Boot] WebSocket과 채팅 (1) 일전에 WebSocket(웹소켓)과 SockJS를 사용해 Spring 프레임워크 환경에서 간단한 하나의 채팅방을 구현해본 적이 있다. [Sprin..

dev-gorany.tistory.com

 

 # References 

반응형
Comments