제목: "[Spring Boot] WebSocket과 채팅 (2) - SockJS"
작성자: tistory(조용한고라니)
작성자 수정일: 2021년 4월 4일
링크: https://dev-gorany.tistory.com/224
작성일: 2022년 2월 22일
이처럼 고려해야할 상황이 많다. 그럼 어떻게 해야 하나?
바로 WebSocket Emulation을 이용하는 것이다.
이것은 우선 WebSocket을 시도 하고,실패할 경우 HTTP Streaming,Long-polling 같은 HTTP 기반의 다른 기술로 전환해 다시 연결을 시도하는 것을 말한다.
스프링 프레임워크는 서블릿 스택 위에서 서버/클라이언트 용도의 SockJS 프로토콜을 모두 지원한다.
Web on Servlet Stack: https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#websocket-fallback
sockjs-client: https://github.com/sockjs/sockjs-client
SockJS는 어플리케이션이 WebSocket API를 사용하도록 허용하지만, 브라우저에서 WebSocket을 지원하지 않는 경우에 대안으로 어플리케이션의 코드를 변경할 필요 없이 런타임에 필요할 때 대체를 하는 것이다.
SockJS는 다양한 기술을 이용해 웹소켓을 지원하지 않는 브라우저에서 정상적으로 동작하도록 해준다. 전송 타입은 크게 다음의 3가지로 분류된다
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 요청을 필요로 한다.
HTTP 전송은 보다 더 많은 요청을 필요로 한다.
Ajax/XHR Streaming
Long Polling
SockJS는 메세지 Frame의 크기를 최소화하기 위해 노력한다.
예를 들어, 서버는
"o" (open frame)을 초기에 전송하고, 메세지는 ["msg1","msg2"]와 같은 JSON-Encoded 배열로써 전달되며,
"h" (hearbeat frame): 기본적으로 25초간 메세지 흐름이 없는 경우에 전송하고
"c" (close frame): 해당 세션을 종료한다.
Java Configuration을 통해 SockJS를 가능하게 할 수 있다.
@EnableWebSocket
@RequiredArgsConstructor
@Configuration
public class WebSocketConfig implements WebSocketConfigurer {
private final ChatHandler chatHandler;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(chatHandler, "ws/chat")
.setAllowedOriginPatterns("http://localhost:8080")
.withSockJS();
}
/**
* .withSockJS() 추가
* setAllowedOrigins("*")에서 *라는 와일드 카드를 사용하면
* 보안상의 문제로 전체를 허용하는 것보다 직접 하나씩 지정해주어야 한다고 한다.
* /
}
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)
X-Frame-Options
가 SameOrigin
으로 설정되며, JSONP 전송은 오리진 확인이 불가능하므로 비활성화된다. 따라서 이 모드가 활성화된 경우 IE 6, 7은 지원되지 않는다.지정된 Origin목록 허용
http://
or https://
로 시작해야한다. 이 모드에서 SockJS가 활성화되면 iframe 전송이 비활성화되므로 IE 6 ~ 9까지는 지원되지 않는다.모든 Origin 허용
참고
Spring WebSocket이 Spring MVC 외에도 사용할 수 있도록 제공되듯이, SockJS도 그러하다. 이는 SockJSHttpRequestHandler 를 통하여 제공된다. 클라이언트는 sockjs-client를 사용할 수 있다.
10버전 부터는 XMLHttpRequest(xhr)
사용을 권장하여 XDomainRequest
를 제거했다. XDR
, XHR
모두 CORS
를 지원하기 위한 도구이다.
따라서 서버 측 Cookies 필요 여부에 따라 HTTP Streaming, HTTP Long Polling에서 사용하는 기술이 달라진다.
위에서 "/info"를 GET 요청한다고 했는데, 거기서 'cookie_needed'라는 속성을 볼 수 있다.
- 의미는 Cookies 정보가 필요한지인데, WebSocketConfig
에서 변경할 수 있으며 메소드는 setSessionCookieNeeded
이고 값은 true/false이다.
- 자바 어플리케이션에서는 'JSESSIONID' 쿠키를 많이 사용하기 때문에 기본값은 'true'이다.
IE 10이상부터는 xdr을 제거했다고 했으니, Cookies 사용을 허가하고 iframe 기반의 기술을 사용하도록 해보자.
그전에 [X-Frame-Options] 응답 헤더를 알아야 한다.
<frame>
또는 <iframe>
, <object>
에서 렌더링할 수 있는지 여부를 나타내는데 사용되고, 사이트 내 컨텐츠들이 다른 사이트에 포함되지 않도록해 clickjacking
공격을 막아내기 위해 사용된다.X-Frame-Options: deny
X-Frame-Options: sameorigin
X-Frame-Options: allow-from https://example.com/
frame
상에서 보여질 수 없다.frame
에서만 보여진다.frame
에서만 보여진다.이를 바꾸기 위해 Spring Security 설정 파일인 SecurityConfig
에서 변경을 주었다.
스프링 부트의 설정파일인 properties에서도 변경이 가능하다.
application.properties
security.headers.frame=false
or
SecurityConfig
@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가 클라이언트의 위치를 알고있어야 한다는 것이다.
@EnableWebSocket
@RequiredArgsConstructor
@Configuration
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을 지원하는 걸로 알고 있는데 동작하지 않는 것이다.
Arrow Function
)를 지원하지 않는다는 것. 그리고 위에서 X-Frame-Option을 'sameorigin'으로 변경했었는데, 이에 대해서 두 가지 방법으로 시도해보았다. 이는 IE 8, 9버전에 영향을 미친다. (나머지 크롬, 엣지, 파이어폭스 등은 이와 무관)
1) X-Frame-Option을 'sameOrigin'으로 설정
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를 사용해야한다.
SockJS 프로토콜은 프록시가 연결이 끊겼다는 결론을 내리는 것을 방지하기 위해 서버가 Heartbeat 메세지를 보내도록 요구한다.
Spring SockJS 구성에는 HeartbeatTime 빈도를 사용자 정의하는 데 사용할 수 있는 속성이 있다.
기본값은 해당 연결에 어떤 메세지도 없는 25초를 사용한다.
만약 STOMP를 이용해 Heartbeat를 주고 받는 경우 SockJS Heartbeat 설정은 비활성화 된다.
개발자도구 - 네트워크 - WebSocket - Message 탭을 눌러보면 WebSocket의 메세지가 나오는데 25s마다 h 문자가 하나씩 찍힌다.
자바스크립트에서 SockJS 객체 생성 시 new SockJS(/ws/chat)으로 생성하면 모바일 크롬에서도 접속할 수 있다.
호스트 파일에는 로컬호스트가 127.0.0.1에 매핑되어있다. 이것은 Wi-Fi 연결과 상관없이 Loop back 주소 (자기 로컬에 대해 바인딩된 주소), 내 로컬 IP를 사용할 때 물리 주소(MAC)와 매핑될 수 있는 주소가 IPv4 말고 루프백에도 매핑되어있다. 따라서 이 로컬(디바이스)에서만 사용할 수 있는 것이다. 다른 디바이스에서 localhost에 연결하려고 한다면, 그 디바이스만의 루프백 주소에 연결되므로 디바이스의 물리적 주소를 호출하게 된 것이다.
var sockJs = new SockJS("/ws/chat", null, {transports: ["websocket", "xhr-streaming", "xhr-polling"]});
추가적으로 CORS가 굉장히 중요한 것 같아, CORS에 대해서도 깊이있게 알아보아야 할 것 같다.