Spring

[Spring Boot] 웹소켓 연결 구현, 특정 사용자에게만 메세지 보내기, 서버로부터의 메세지 실시간 수신&처리

KJE1218 2023. 6. 13. 17:51
개발환경: Spring Boot 5.1.7.RELEASE 

 


1. 들어가기 전에...

보통 웹소캣 관련 개발 하면 실시간 채팅방 관련 기능을 떠올리기 쉽다. 내가 한 것은 이것과 아예 관련이 없지는 않은데(구현 방법을 참고함), 목적은 다르다. 클라이언트간의 대화에 중점을 둔 게 아니라 여러 클라이언트가 실시간으로 서버의 요청을 수신하고 그에 따른 처리도 하는것이다. 

 

구현 목표는 다음과 같다.

1. 서버는 특정한 요청을 알맞은 대상(클라이언트)에게 전송한다.

3. 클라이언트는 수신받고 그에 따른 처리를 한다.

 

즉, 실시간으로 서버로부터 특정 요청을 수신받고 이에 따른 처리를 수행해야하는 화면에 가장 적합하다.

 

2. SockJs란?

websocket과 비슷한 기능을 제공하는 브라우저 javascript라이브러리다.
브라우저와 서버 사이에서 짧은 지연시간, 그리고 크로스 브라우징을 지원하는API이기 때문에 사용한다.
크롬, 사파리, 파이어폭스, 그리고 websocket 프로토콜을 지원하지 않는 최신 브라우저에서도 해당 라이브러리의 API가 잘 작동되도록 지원하는 라이브러리이다.

 

3. STOMP란?

STOMP는 Simple Text Oriented Messaging Protocol의 약자이다. WebSocket 프로토콜은 Text 또는 Binary 두 가지 유형의 메시지 타입은 정의하지만 메시지의 내용에 대해서는 정의하지 않는다. 즉, WebSocket만 사용해서 구현하게 되면 해당 메시지가 어떤 요청인지, 어떤 포맷으로 오는지, 메시지 통신 과정을 어떻게 처리해야 하는지 정해져 있지 않아 일일이 구현해야 한다. 따라서 STOMP라는 프로토콜을 서브 프로토콜로 사용한다. STOMP는 클라이언트와 서버가 서로 통신하는 데 있어 메시지의 형식, 유형, 내용 등을 정의해주는 프로토콜이라고 할 수 있다. STOMP를 사용하게 되면 단순한 Binary, Text가 아닌 규격을 갖춘 메시지를 보낼 수 있다. 스프링은 spring-websocket 모듈을 통해서 STOMP를 제공하고 있다.

STOMP의 형식은 HTTP와 닮았다.

COMMAND
header1:value1
header2:value2

클라이언트는 메시지를 전송하기 위해 COMMAND로 SEND 또는 SUBSCRIBE 명령을 사용하며, header와 value로 메시지의 수신 대상과 메시지에 대한 정보를 설명할 수 있다. 기존의 WebSocket만으로는 표현할 수 없는 형식이다. 이를 통해 STOMP 프로토콜은 Publisher(송신자)-Subscriber(수신자)를 지정하고, 메시지 브로커를 통해 특정 사용자에게만 메시지를 전송하는 기능 등을 가능하게 한다.

 

4. 웹소켓 연결 구현

1. build.gradle에 아래 구문을 추가한다.

    compile("org.webjars:sockjs-client:1.0.2")
    compile("org.webjars:stomp-websocket:2.3.3")

만약 내부 jar파일로 사용하고 싶다면 아래 jar파일들을 다운받아서 포함시키면 된다.

sockjs-client-1.0.2.jar
0.11MB
stomp-websocket-2.3.3.jar
0.01MB

 

2. WebSocketConfig.java 작성

package com.rrsi.r1337.config;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration;
import org.springframework.web.socket.handler.WebSocketHandlerDecorator;
import org.springframework.web.socket.handler.WebSocketHandlerDecoratorFactory;

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
	
	private static final Logger logger = LoggerFactory.getLogger(WebSocketConfig.class);
	
	@Autowired
	private WebSocketHandler callDetlWebSocketHandler;

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/agent").setAllowedOrigins("*").withSockJS();
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.setApplicationDestinationPrefixes("/app");
        registry.enableSimpleBroker("/topic");
    }
    
    @Override
    public void configureWebSocketTransport(WebSocketTransportRegistration registration) {
        registration.addDecoratorFactory((WebSocketHandlerDecoratorFactory) (webSocketHandler) -> {
            return new WebSocketHandlerDecorator(webSocketHandler) {
                @Override
                public void afterConnectionEstablished(WebSocketSession session) throws Exception {
                    callDetlWebSocketHandler.afterConnectionEstablished(session);
                    super.afterConnectionEstablished(session);
                }

                @Override
                public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) throws Exception {
                    callDetlWebSocketHandler.afterConnectionClosed(session, closeStatus);
                    super.afterConnectionClosed(session, closeStatus);
                }
            };
        });
    }
    
}

 

registerStompEndpoints()

- HandShake와 통신을 담당할 EndPoint를 지정한다.
- setAllowedOrigins("*")를 통해 모든 출처(Origin)에서의 접속을 허용하고 있다.
- withSockJS()는 WebSocket을 지원하지 않는 브라우저와의 호환성을 위해 SockJS를 사용하도록 설정한다.

>> 특정 출처만 허용하고 싶다면 아래와 같이 허용하는 출처들을 적어주면 된다.

registry.addEndpoint("/agent").setAllowedOrigins("http://example.com", "http://192.168.0.100:8080").withSockJS();

 

configureMessageBroker()
- 이 메서드는 메시지 브로커를 구성한다.
- 메시지 브로커는 서버가 클라이언트에게 메시지를 라우팅하는 데 사용되며, 클라이언트 간의 메시지 교환을 중개한다.
setApplicationDestinationPrefixes("/app")는 클라이언트에서 수신하는 메시지의 URL prefix를 정의한다. 클라이언트가 메시지를 전송할 때는 /app 접두사가 필요하다.
- enableSimpleBroker("/topic")은 클라이언트에게 메시지를 보내는 데 사용되는 대상 주제(prefix)를 설정한다. 여기서는 /topic을 설정하여, 클라이언트가 /topic으로 시작하는 대상 주제를 구독하면 서버로부터 해당 주제의 메시지를 받을 수 있다.

 

configureWebSocketTransport()
- 이 메서드는 WebSocket 전송을 구성하는 데 사용된다. WebSocketHandlerDecorator를 사용하여 WebSocket 세션의 연결 설정 및 해제 시점에서 추가 동작을 수행하도록 한다.
- afterConnectionEstablished()은 WebSocket 연결이 성립된 후 호출되며, afterConnectionClosed()는 WebSocket 연결이 닫힌 후 호출된다.

- 이 코드에서는 callDetlWebSocketHandler라는 WebSocketHandler의 afterConnectionEstablished() 및 afterConnectionClosed() 메서드를 호출한 후에 기존의 동작을 수행하도록 설정하고 있다.

 

3. WebSocketHandler.java 작성

import java.security.Principal;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;

@Component
public class WebSocketHandler extends TextWebSocketHandler {
	
	private static final Logger logger = LoggerFactory.getLogger(WebSocketHandler.class);

	@Autowired
	private CallDetlChangeListener callDetlChangeListener;
	
	@Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
		Principal principal = session.getPrincipal();
	    String email = principal.getName();
	    
	    session.getAttributes().put("email", email);
    }
	
	@Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
		String email = (String) session.getAttributes().get("email");
    }
	
}

 

afterConnectionEstablished()
WebSocket 연결이 성립된 후 호출되는 메서드이다.
WebSocketSession 객체를 통해 클라이언트와의 상호작용을 수행한다. 여기서는 세션에서 Principal 객체를 가져와서 연결된 사용자의 이메일을 추출하고, 이메일 정보를 세션의 속성으로 저장한다. 이메일은 후속 이벤트 처리에 사용될 수 있다.

afterConnectionClosed()

WebSocket 연결이 닫힌 후 호출되는 메서드입니다. 연결이 닫힌 세션에서 이메일 정보를 추출한다.

 

연결/종료 시점에 특정 이벤트를 구현하지는 않았다. 나중에 필요할 지도 몰라서 구조 자체는 구현해 놨다.

 

5. 특정 사용자에게만 메세지 보내기
import java.util.Map;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.messaging.simp.user.SimpSession;
import org.springframework.messaging.simp.user.SimpUser;
import org.springframework.messaging.simp.user.SimpUserRegistry;
import org.springframework.stereotype.Service;

import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.databind.ObjectMapper;

@Service
public class AgentConnect {
	
	private static final Logger logger = LoggerFactory.getLogger(AgentConnect.class);
	
	@Autowired
    private SimpMessagingTemplate messagingTemplate;
    
    @Autowired
    private SimpUserRegistry userRegistry;
	
	public void send(Map<String, Object> result) {
		
		try {
			
        	String targetId = result.get("targetId").toString();
        	
    		logger.info("=========================================================================");
    		logger.info("targetId : " + targetId);
    		logger.info(result.toString());
    		logger.info("=========================================================================");
    		
    		// value가 null이더라도 누락되지않고 포함되어서 리턴되도록함
    		ObjectMapper mapper = new ObjectMapper();
    		mapper.setSerializationInclusion(Include.ALWAYS);
        	
            // 특정 클라이언트에게 메시지를 보내기
            for (SimpUser user : userRegistry.getUsers()) {
                for (SimpSession userSession : user.getSessions()) {
                    String userId = user.getName();
                    
                    if (userId.equals(targetId)) {
                        messagingTemplate.convertAndSendToUser(userId, "/topic/messages", mapper.writeValueAsString(result));
                        
                        break;
                    }
                }
            }
			
		} catch (Exception e) {
			e.printStackTrace();
		}
		
	}
	
	
}

컨트롤러나 다른 파일에서 쉽게 호출해 사용할 목적으로 하나의 서비스 클래스로 분리했다. 

매개변수로 Map<String, Object> result 를 전달해 사용한다.

해당 map에는 targetId(보낼 사용자 정보/필수)와 전송 데이터를 key/value 형식으로 넣어주면 된다.

호출하면 /topic/messages를 구독하고있는 특정 사용자에게 map을 전송한다.

이때, 화면에서 json으로 수신받고 value가 null인 경우에도 전송이 누락되지 않도록 mapper.setSerializationInclusion(Include.ALWAYS); 를 사용했다.

 

사용 방법

1) AgentConnect.java 의존성 주입

	@Autowired
	private AgentConnect agentConnect;

 

2) 클래스 내부에서 아래와 같이 호출

예시)

    	Map<String, Object> result = new HashedMap<String, Object>();
    		
    		result.put("targetId", email);
    		result.put("type", "usersStatusUpdate");
    		result.put("data", resultList);
    		
    		agentConnect.send(result);

 

6. 서버로부터 메세지 실시간 수신&처리

이제 남은건 클라이언트측에서 /topic/message를 구도하게 하는것과 메세지를 받는걸 구현하는 것이다.

우선 아래 라이브러리를 다운받아 script태그로 불러온다.

sockjs.min.js
0.05MB
stomp.min.js
0.01MB

<script type="text/javascript" src="/resource/js/sockjs.min.js"></script>
<script type="text/javascript" src="/resource/js/stomp.min.js"></script>

 

websocket연결 구현

$(document).ready(function() {
	
	var socket = new SockJS('/agent');
	stompClient = Stomp.over(socket);

	stompClient.connect({}, function(frame) {
	    var topic = "/user/topic/messages"; // 특정 사용자 메세지 전송/받기 해야할때 사용
	    //var topic = "/topic/messages"; // 전체 수신되는 메세지를 받을때 사용
		
	    stompClient.subscribe(topic, function(message) {
	    	
	    	var data = JSON.parse(message.body);
	    	
	    	switch(data.type) {
	    		case "callDetl":
	    			callDetlProgress(data);
	    	    	break;
	    	    	
	    	  	case "usersStatusUpdate":
	    	  		usersStatusUpdate(data.data);
	    	    	break;
	    	    	
	    	  	case "CallStart":
	    	  		callStart(data);
		    		break;
		    		
	    	  	case "CallEnd":
	    	  		callEnd(data);
		      		break;
		      		
	    	  	case "statisticsUpdate":
	    	  		statisticsUpdate(data);
		    		break;
	    	}
	    	
	    });
	    
	});
    
});

registerStompEndpoints 에서 설정한 endpoint인 /agent 로 SockJS를 생성한다.

그리고 /user/topic/message로 연결을 시도한다. 

설정했던 prefix 앞에 /user를 붙여야 특정 클라이언트로만 메세지 전송이 가능하다.

 

stompClient = Stomp.over(socket);

생성된 SockJS 객체를 사용하여 STOMP 프로토콜을 사용하는 StompClient 객체를 생성한다. StompClient는 STOMP 메시지를 보내고 받기 위한 기능을 제공한다.

stompClient.connect({}, function(frame) { ... });

 StompClient 객체를 사용하여 서버와의 연결을 수립한다. {}는 헤더(header)에 대한 옵션을 전달하는 객체이다. frame은 연결이 성공적으로 수립된 경우에 실행되는 콜백 함수이다.

stompClient.subscribe(topic, function(message) { ... });

서버에서 클라이언트로 메시지를 전송할 때, 클라이언트는 해당 주제(topic)를 구독하고 있어야 메시지를 수신할 수 있습니다. topic은 구독할 주제를 나타내는 문자열입니다. 위의 코드에서는 /user/topic/messages를 구독하고 있습니다. function(message)은 메시지를 수신한 경우 실행되는 콜백 함수입니다.

var data = JSON.parse(message.body);

수신한 메시지의 본문(body)을 JSON 형식으로 파싱하여 data 변수에 저장합니다. 메시지는 일반적으로 JSON 형식으로 전송되기 때문에 이를 객체로 변환하여 사용합니다.


switch(data.type) { ... }

data 객체의 type 속성 값에 따라 다양한 동작을 수행합니다. 예를 들어, callDetl인 경우 callDetlProgress(data) 함수를 호출하고, usersStatusUpdate인 경우 usersStatusUpdate(data.data) 함수를 호출합니다. 각각의 경우에 따라 적절한 동작을 수행할 수 있도록 처리합니다.

구현 완료후 해당 화면 진입시 콘솔에 찍히는 코드

 

위 콘솔에서 찍히는 user-name:agent01@test.com이 사용자를 식별할 일종의 id다. 5번의 targetId에 user-name을 넣어줘야 특정 사용자에게만 메세지 전송하는게 가능해진다.

 

본문처럼 구현한 웹소켓 소스를 활용하는건 아래와 같다.

1. agentConnect.send(result); 로 특정 클라이언트에게 메세지 전송

2. 해당 클라이언트에서 메세지 수신 후 switch문 활용해서 알맞은 처리를 수행