Spring + WebSocket

Yeseong31·2023년 8월 25일
0

Spring-WebSocket

목록 보기
2/5
  • WebSocket을 지원하지 않는 환경에서는 SockJS, socket.io 라이브러리를 사용한다.
    • WebSocket을 지원하는 브라우저는 WebSocket 기술을 활용한다.
    • 그렇지 않다면 HTTP의 Streaming 또는 Polling 방식을 이용한다.

스프링은 SockJS를 제공한다.




프로젝트 진행


build.gradle 의존성 추가

implementation 'org.springframework.boot:spring-boot-starter-websocket'

TextWebSocketHandler를 상속받은 WebSocketHandler 추가

@Slf4j
@Component
@RequiredArgsConstructor
public class WebSocketChatHandler extends TextWebSocketHandler {
...

WebSocketConfigurer 인터페이스를 구현한 WebSocketConfig 추가

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

	private final WebSocketChatHandler webSocketChatHandler;

	@Override
	public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
		registry
				.addHandler(webSocketChatHandler, "/ws/chat")
				.setAllowedOrigins("*")
				.withSockJS();	
	}
}
  • @EnableWebSocket은 WebSocket 서버를 사용하도록 정의하는 데 사용한다.
  • WebSocket 서버의 엔드포인트는 /ws/chat으로 설정한다.
  • setAllowedArigins("*")를 통해 클라이언트에서 WebSocket 서버에 요청을 보낸다면, 모든 요청을 수용한다. (CORS)
    • 스프링에서 WebSocket을 사용할 때, same-origin만 허용하는 것이 기본 정책이다.
  • WebSocketChatHandler 클래스를 WebSocket 핸들러로 지정한다.
  • withSockJS()SockJS 라이브러리를 사용하도록 설정할 수 있다.



WebSocketChatHandler 적용


서버-클라이언트 소켓 통신에서 사용하는 메시지 스펙 MessageDto 정의

@Getter
@Setter
@NoArgsConstructor(access = PROTECTED)
public class MessageDto {
    
    private MessageType type;    // 메시지 타입
    private String sender;       // 보내는 사람
    private String receiver;     // 받는 사람
    private String message;      // 메시지
    private LocalDateTime time;  // 채팅 발송 시간
    
    @Builder
    public MessageDto(
            MessageType type, String sender, String receiver, String message, LocalDateTime time) {
        
        this.type = type;
        this.sender = sender;
        this.receiver = receiver;
        this.message = message;
        this.time = time;
    }
}

메시지 전달 JSON 형식

{
	"type": "TALK",
	"receiver": [UUID],
	"sender": [UUID],
	"message": "hello"
}

WebSocket 핸들러 클래스 WebSocketChatHandler

@Slf4j
@Component
@RequiredArgsConstructor
public class WebSocketChatHandler extends TextWebSocketHandler {
   
    // 양방향 데이터 통신
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { ... }
   
    // 웹 소켓 연결
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception { ... }
   
    // 소켓 연결 종료
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { ... }
   
    // 소켓 통신 에러
    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { ... }
}



WebSocketChatHandler 구현

WebSocket 최초 연결 시

  • WebSocket에 처음 연결되었을 때 WebSocket 서버에 연결된 다른 사용자에게 접속 여부를 알리는 로직을 작성한다.
  • 해당 로직을 구현하기 위해서는 기존 접속 사용자의 WebSocket 세션을 모두 관리하고 있어야 한다.

WebSocketHandler에 세션 정보를 담을 sessions 추가

private final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>();
  • MariaDB 등의 데이터베이스를 연결하지 않은 상태이므로 메모리 상에서 저장소 구현

WebSocketHandler의 afterConnectionEstablished() 메서드 구현

@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
    
    // 세션에 사용자 저장
    String sessionId = session.getId();
    sessions.put(sessionId, session);
    log.info("[afterConnectionEstablished] ID={} 접속", sessionId);
    
    // 입장 메시지 구성
    MessageDto chatMessage = MessageDto.builder()
            .type(ENTER)
            .message(sessionId + "님이 입장했습니다")
            .sender(sessionId)
            .time(LocalDateTime.now())
            .build();
    
    // 본인을 제외한 나머지 세션에 입장 이벤트 전송
    sessions.values().forEach(s -> {
        if (!s.getId().equals(sessionId)) {
            try {
                s.sendMessage(new TextMessage(objectMapper.writeValueAsString(chatMessage)));
            } catch (IOException e) {
                log.info("[afterConnectionEstablished] errors={}", e.getMessage());
            }
        }
    });
}

WebSocket 양방향 데이터 통신

@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
    
    // 웹 소켓 클라이언트로부터 채팅 메시지(JSON)를 전달받음
    String payload = message.getPayload();
    log.info("[handleTextMessage] payload={}", payload);
    
    // 전달받은 채팅 메시지를 채팅 메시지 객체로 변환 (JSON -> ChatDto)
    MessageDto chatMessage = objectMapper.readValue(payload, MessageDto.class);
    chatMessage.setType(TALK);
    chatMessage.setTime(LocalDateTime.now());
    
    // 메시지를 받는 대상
    WebSocketSession receiver = sessions.get(chatMessage.getReceiver());
    
    // 메시지를 받아야 하는 타겟 상대방 조회 후 메시지 전송
    if (receiver != null && receiver.isOpen()) {
        receiver.sendMessage(new TextMessage(objectMapper.writeValueAsString(chatMessage)));
    }
}
  • handleTextMessage()는 첫 번째 사용자가 두 번째 사용자에게 메시지를 전송할 때 거치게 되는 메서드이다.
  • 이때 반드시 메시지를 받을 타겟 사용자의 세션 아이디(UUID)를 지정해야 한다.

WebSocket 연결 종료 시

@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
    
    // 세션 저장소에서 연결이 끊긴 사용자 삭제
    String sessionId = session.getId();
    sessions.remove(sessionId);
    log.info("[afterConnectionClosed] ID={} 접속 해제", sessionId);
    
    // 퇴장 메시지 구성
    MessageDto chatMessage = MessageDto.builder()
            .type(LEAVE)
            .message(sessionId + "님이 나갔습니다")
            .sender(sessionId)
            .time(LocalDateTime.now())
            .build();
    
    // 본인을 제외한 나머지 세션에 퇴장 이벤트 전송
    sessions.values().forEach(s -> {
        try {
            s.sendMessage(new TextMessage(objectMapper.writeValueAsString(chatMessage)));
        } catch (IOException e) {
            log.info("[afterConnectionEstablished] errors={}", e.getMessage());
        }
    });
}

WebSocket 통신 에러 시

@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
    
    log.info("[handleTransportError] errors={}", exception.getMessage());
}



WebSocketHandler의 한계점

  • 현재의 WebSocketHandlerWebSocket이 1대인 경우에만 동작한다.
  • WebSocket 서버가 2대 이상인 경우에는 메모리 기반으로 관리하는 세션 정보를 서로 알아야 메시지 송수신이 가능하다.

해결 방법

  • STOMP 사용
  • Redis Pub/Sub 사용
  • 세션이 어떤 서버에 저장하는지 접속 정보를 별도의 저장소에 저장
profile
역시 개발자는 알아야 할 게 많다.

0개의 댓글