본문 바로가기

Utils

WebSocket 구현하기(feat. Spring Framework) | JAVA

WebSocket 구현하기(feat.Spring Framework) ❘ JAVA

WebSocket 구현하기 feat. Spring Framework

실시간 수신 알림, 방문자 수 등 적은 데이터를 업데이트하는데 화면 전체를 새로고침하는 것은 비효율적입니다.

채팅 등 기존 데이터에 추가되는 형식의 서비스는 더더욱 그렇겠죠.

 

WebSocket을 사용하여 실시간으로 갱신된 데이터를 조회하는 로직을 구현해봅시다.


프로젝트 환경

Framework Language Build Tool
Spring Framework 5.1.8.RELEASE JAVA Maven

 

사전 지식

WebSocket 웹소켓

단일 TCP 연결로 동시에 양방향 소통을 지원하는 통신 프로토콜입니다.

HTTP와는 다르지만, HTTP가 사용하는 443, 80 포트에서 동작하도록 설계되어 HTTP와 호환됩니다.

WebSocket handshake는 HTTP header의 Upgrade를 사용하여 HTTP에서 WebSocket으로 변경합니다.

SockJs vs. with StompJs

SockJs

메시지 포맷을 직접 설계합니다.

완전 자유성이 있으므로 커스터마이징이 편리합니다.

SockJs + StompJs ✅

STOMP 메시지 포맷을 규칙화합니다.(connect, subscribe, send, disconnect)

표준 pub/sub 메시징이므로 Spring과 잘 맞습니다.

구독 패턴으로 라우팅을 자동화하여 클라이언트 유지보수성이 높아집니다.

 

💡SockJs + StompJs 구조로 구현합니다.

구현 - 서버

Dependency 추가

pom.xml

  • spring-websocket
  • spring-messaging
<!-- websocket -->
<!-- https://mvnrepository.com/artifact/org.springframework/spring-websocket -->
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-websocket</artifactId>
    <version>5.1.8.RELEASE</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.springframework/spring-messaging -->
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-messaging</artifactId>
    <version>5.1.8.RELEASE</version>
</dependency>

 

Servlet Mapping

web.xml

async-supported

<servlet> 항목에 속성을 허용합니다.

<servlet>
	<servlet-name>AppServlet</servlet-name>
	// ...
	<async-supported>true</async-supported>
</servlet>

모든 <filter> 항목에 속성을 허용합니다.

<filter>
	<filter-name>FilterName</filter-name>
	// ...
	<async-supported>true</async-supported>
</filter>

 

url mapping 설정

저는 웹소켓 요청 관련 도메인을 /ws/*로 통일하였습니다.

<servlet-mapping>
	<servlet-name>AppServlet</servlet-name>
	<url-pattern>/ws/*</url-pattern>
</servlet-mapping>

 

WebSocket Config

JavaConfig 방식으로 정의합니다.

각 도메인은 추후 상수 처리하여 실수를 줄일 수 있습니다.

JavaConfig로 정의하면 context.xml에 따로 추가하지 않아도 되요!
@Configuration
@EnableWebSocketMessageBroker
public class WebsocketConfig implements WebSocketMessageBrokerConfigurer {
	// Interceptor 객체
	@Bean
	public WebsocketInterceptor websocketInterceptor() {
		return new WebsocketInterceptor();
	}
	
	@Override
	public void registerStompEndpoints(StompEndpointRegistry registry) {
		registry.addEndpoint("/dashboard", "/alarm", ...)  // 클라이언트에서 연결할 websocket 타겟 도메인
			.setAllowedOrigins("*")
			.addInterceptors(websocketInterceptor())    // Websocket용 Interceptor 등록
			.withSockJS();
	}
	
	@Override
	public void configureMessageBroker(MessageBrokerRegistry registry) {
	  // 클라이언트 송신 prefix
		registry.setApplicationDestinationPrefixes("/app");
		// 클라이언트 구독채널 prefix
		registry.enableSimpleBroker("/topic");
	}

}

 

WebSocket Interceptor 정의

websocket 최초 연결시 동작하는 interceptor를 정의합니다.

이전에 구현한 Web Interceptor는 사용할 수 없습니다!
public class WebsocketInterceptor implements HandshakeInterceptor {
	private final Logger logger = LoggerFactory.getLogger(this.getClass());
	
	// 연결전 동작, 연결 허용 여부 체크, HTTP 객체 접근, 인증, 권한체크, CORS 등
	// afterHandshake()에서는 HttpRequest 접근 불가하므로 before에서 처리
	@Override
	public boolean beforeHandshake(ServerHttpRequest req, ServerHttpResponse res, WebSocketHandler wsHandler, Map<String, Object> attrs) {
		if (req instanceof ServletServerHttpRequest) {
			HttpServletRequest servletRequest = ((ServletServerHttpRequest) req).getServletRequest();
			
			// 세션에 특정 정보(유저정보) 저장
			User userInfo = getUserInfo(servletRequest);	// service 처리
			attrs.put("userInfo", userInfo);	// ## KEY
			
			// debugging
			logger.info("========== WebsocketInterceptor info :: {}", userInfo.getUserName());
		}
		
		return true;
	}
	
	// 연결후 동작, 사용자별 데이터 조회/연결 성공 알림/초기 데이터 셋팅 등
	@Override
	public void afterHandshake(ServerHttpRequest req, ServerHttpResponse res, WebSocketHandler wsHandler, Exception ex) {
	}
}

 

Controller

클라이언트의 요청에 대한 응답을 처리합니다.

Web Controller와 마찬가지로 필요한 Service 객체를 Autowired하여 사용할 수 있습니다.

  • @MessageMapping - 클라이언트 전송 도메인, 필수, 1개 1도메인
  • @SendTo - 클라이언트가 구독하는 도메인
    WebSocket Config에서 설정한 구독채널 도메인 prefix를 포함하여 매핑합니다.
@Controller
public class WebsocketController {
	private final Logger logger = LoggerFactory.getLogger(this.getClass());

	@MessagingMapping("/alarm")
	@SendTo("/topic/alarm")
	public int getAlarmCnt(Message<?> msg) {
		// TODO - service 동작
		return 3;
	}
}

 

beforeHandshake시 Interceptor에서 저장한 데이터 꺼내오기

private User getUserInfo(Message<?> msg) {
	StompHeaderAccessor accessor = StompHeaderAccessor.wrap(msg);
	User userInfo = (User) accessor.getSessionAttributes().get("userInfo");	// ## 동일한 KEY
	return userInfo;
}

 

구현 - 클라이언트

라이브러리 추가

<script src="/js/sockjs"></script>
<script src="/js/stompjs"></script>

 

WebSocket 연결

객체 생성

const sock = new SockJS("/ws/dashboard"); // Websocket Endpoint
const stompObj = Stomp.over(sock);

cf. 디버깅로그 커스텀

stompObj.debug = (log) => {
	// customizing
	console.debug(log);
};

연결

stompObj.connect({}, function(frame) {
	// 채널 구독 - controller의 @SendTo
	stompObj.subscribe("/topic/alarm", function(msg) {
		const result = JSON.parse(msg.body);
		// TODO - 화면 업데이트
	});
	
	// TODO - 다른 채널 구독 가능
});

서버로 데이터 전송

// controller의 @MessageMapping
stompObj.send("/app/alarm", {}, JSON.stringify({userNo: 1, type: "SELECT",})); // json 데이터는 stringify로 전송

추가사항

Sitemesh

템플릿 엔진으로 sitemesh를 사용하는 경우, decorator 설정에서 websocket 요청 도메인을 제외 처리합니다.

<decorators>
	<excludes>
		// ...
		<pattern>/ws/*</pattern>
	</excludes>
	// ...
</decorators>

 

Nginx

로컬에서 정상 테스트 후 nginx로 구성된 서버에 배포시 connection fail이라면?

nginx 도메인 설정에서 upgrade 헤더를 추가해준다.

location /ws {
	// ...
	proxy_set_header Upgrade $http_upgrade;
	proxy_set_header Connection "upgrade;
	// ...
}

 

728x90