Spring Boot

Spring WebSocket

수수한개발자 2022. 9. 9.
728x90

클라이언트가 서버와 통신하는 방법

일반적인 클라이언트 - 서버 HTTP 통신은 클라이언트에서 서버로 Request 후 서버에서 클라이언트로 Response 해주고 연결을 끊어 버리는 식으로 통신을 합니다.

 

그렇다면 만약에 실시간 검색어 같은 기능은 어떻게 구현을 해야 될까요?

실시간 검색어는 주기적으로 업데이트가 되어야 합니다. 웹 소캣을 몰랐을 때는 폴링을 사용하여서 했을 거라 생각했습니다.

폴링

폴링이란 위키백과에서는

폴링(polling)이란 하나의 장치(또는 프로그램)가 충돌 회피 또는 동기화 처리 등을 목적으로 다른 장치(또는 프로그램)의 상태를 주기적으로 검사하여 일정한 조건을 만족할 때 송수신 등의 자료처리를 하는 방식을 말한다. 이 방식은 버스, 멀티포인트 형태와 같이 여러 개의 장치가 동일 회선을 사용하는 상황에서 주로 사용된다. 서버의 제어 장치(또는 프로그램)는 순차적으로 각 단말 장치(또는 프로그램)에 회선을 사용하기 원하는지를 물어본다.

쉽게 말하면 클라이언트에서 서버에 Ajax 통신을 주기적으로 요청한 후, 응답받은 데이터를 화면에 렌더링 하는 방식으로 개발했을 거라고 생각했습니다. 즉 30초 또는 1분에 한 번씩 클라이언트에서 서버에 실시간 검색어 최신 데이터를 요청하며, 최신 데이터를 가져와서 화면에 업데이트해주는 방식입니다.

 

하지만, 주기적으로 호출하는 위와 같은 폴링 방식보다는 서버에서 클라이언트로 변경이 필요한 데이터를 전송해주면 더 효율적일 것입니다.

SSE

이때 생각할 수 있는 방법이 SSE (Server-Sent-Events)입니다. SSE역시 HTTP 프로토콜에 의해서 동작합니다.

방식은 다음과 같습니다.

발그림...죄송합니다.

클라이언트와 서버가 연결된뒤 서버에서 클라이언트로 단방향으로 제공하는 데이터입니다.

실시간 검색이라던지 주식정보, 날씨 정보 등은 양방향이 필요 없기 때문에 서버에서 업데이트가 필요한 시점에 클라이언트에 단방향으로 데이터를 전송해주면 됩니다.

 

반면에, 웹사이트에 채팅기능을 만든다고 하였을 때를 가정해봅니다. 채팅은, 상대방의 메시지를 실시간으로 전달받아야 하며, 내가 작성한 메시지를 상대방에게 실시간으로 보내야 합니다. 즉 채팅 기능은 모든 사용자가 실시간으로 양방향 통신을 해야합니다. 이 경우 폴링이나 SSE 등의 기술이 적합하지 않습니다. 

이때 생각해볼수 있는 기술이 Websocket입니다. 

Websocket

위에서 1,2,3,4 순서로 작동하게 됩니다.

 client1이 안녕을 보내면 client2 가 client1이 보낸 안녕을 받고 client2가 안녕~하고 보내면 client1이 안녕~을 받습니다. 소켓 통신은 한번 연결을 하면 연결이 유지되어 별다른 설정 없이 정보를 주고받을 수 있습니다.

websocket 동작 원리 

Postman에서 websocket을 테스트할수있는 기능을 제공하고 있습니다.

먼저 클라이언트는 서버에 HTTP 프로토콜로 핸드 셰이크 요청을 합니다. 

웹소켓은 HTTP 포트 80, HTTPS 443 위에서 동작되도록 설계가 되어 있습니다. 별도의 포트가 필요 없으며 호환을 위해서 핸드 셰이크는 HTTP upgrade 헤더를 사용하여 HTTP 프로토콜에서 웹소켓 프로토콜로 변경합니다.

 

그러면 이제 websocket에 대해서 간단히 알아봤으니 스프링에서 제공하는 Spring WebSocket STOMP를 알아보고 구현해보도록 하겠습니다.

 

Spring WebSocket STOMP

STOMP는 Simple Text Oriented Messaging Protocol의 약자로, 메시징 전송을 효율적으로 하기 위한 프로토콜입니다. pub/sub 기반으로 동작하며, 메시지를 송신, 수신에 대한 처리가 명확하게 정의할 수 있습니다. 또한 WebSocketHandler를 직접 구현할 필요 없이, @MessagingMeapping 같은 어노테이션을 사용해서, 메시지 발생 시 엔드포인트를 별도로 분리해서 관리할 수 있습니다.

 

PUB/SUB(발행/구독)에 대한 이해

발행/구독의 이해를 돕기 위해 그림 하나를 가져왔습니다.

출처 https://gobae.tistory.com/122

발행자 Publisher가 메시지의 타깃을 TopicA, TopicB로 설정해서 메시지를 보내면 서버에서는 발행자의 메시지를 확인후 TopicA,B 채널을 구독하는 모든 사용자( Subscriber1,2,3)에게 메세지를 보내게 됩니다.

구독 url이 다른 사용자는 메세지를 받지 못하게 됩니다. 위와 같은 그림에서 TopicA로 보낸 메시지는 Subscriber3은 받지 못하게 됩니다.

 

그리고 구독과 발행 역할을 동시에 수행할 수 있습니다.

쉽게 설명하면 채팅방 A가 있을 때 사용자 1, 사용자 2가 채팅방 A에 입장하게 됩니다.

그리면 현재 사용자 1과 사용자 2는 채팅방 A라는 채널을 구독 중인 것입니다.

그리고 사용자 1이  채팅방A에서 채팅을 보내게 되면 사용자1이 발행자가 되면서 메시지를 발행하게 됩니다. 이때 발행된 메시지에는 채팅방 A의 위치도 함께 보내게 됩니다. 그래서 그 발행된 메시지는 채팅방 A로 보내지게 되면서 채팅방 A를 구독하고 있는 사용자 1과 사용자 2가 받게 되는 것입니다. 

 

 

Spring WebSocket STOMP 구현

현재 제가 구현하려는 것은 모임이 있고 각각의 모임마다 채팅방이 하나씩 있는 것입니다.

그래서  각각의 모임 id를 가져와 발행과 구독을 하게 구현하였습니다.

build.gradle

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

기본적으로 web과 타임리프 등 다른 dependency가 있다고 가정하고 글을 쓰겠습니다.

 

ChatDto

@Getter
@AllArgsConstructor
@NoArgsConstructor
public class ChatDto {

    private MessageType type;
    private String content;
    private String sender;
    private Long groupId;
}

 

MessageType

public enum MessageType {
    CHAT,
    JOIN,
    LEAVE
}

 

STOMP를 사용하기 위해서는 아래와 같이 @EnableWebSocketMessageBroker 어노테이션을 선언해주어야 합니다.

 

WebSocketConfig

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.setApplicationDestinationPrefixes("/pub");
        registry.enableSimpleBroker("/topic");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws").withSockJS();
    }
}
  • setApplicationDestinationPrefixes : client에서 SEND 요청을 처리한다.
    • 쉽게 말하면 발행 역할을 정의하는 곳이다.
    • 그래서 결국에 구독자들은 /pub/** 경로 밑으로 구독하게 된다.
  • enableSimpleBroker : 해당 경로로 SimpleBroker를 등록한다. SimpleBroker는 해당하는 경로를 SUBSCRIBE하는 client에게 메시지를 전달하는 간단한 작업을 수행한다. => 쉽게 말하면 구독자들이 내용을 받는 곳.
  • enableStompBrokerRelay : SimpleBroker의 기능과 외부 message broker(RabbitMQ, ActiveMQ 등)에 메시지를 전달하는 기능을 가지고 있다. => 여기서는 사용하지 않았다.

여기서 withSockJS는 현재는 없어졌지만 IE가 WebSocket을 지원하지 않는데 SockJs를 사용하여 WebSocket을 지원하지않는 브라우저를 지원할 수 있습니다.

저는 클라이언트 사용자는 구독 경로를 "/topic/모임 아이디"의 형태로 구독하도록 구현하였습니다.

메시지를 발송할 때는 "/pub/chat_sendMessage/모임아이디" 로 메세지를 보내며, 메시지에 모임 아이디를 포함시켜야 합니다. 위의 코드만 작성하면 스프링 프레임워크는 자동으로 STOMP 통신이 가능한 웹소켓 서버를 실행시켜줍니다.

 

ChatController

@Controller
public class ChatController {

    @MessageMapping("/chat_sendMessage/{groupId}")
    @SendTo("/topic/{groupId}")
    public ChatDto sendMessage(ChatDto chatMessage) {
        return chatMessage;
    }

    @MessageMapping("/chat_addUser/{groupId}")
    @SendTo("/topic/{groupId}")
    public ChatDto addUser(ChatDto chatMessage, SimpMessageHeaderAccessor headerAccessor){
        headerAccessor.getSessionAttributes().put("username", chatMessage.getSender());
        headerAccessor.getSessionAttributes().put("groupId", chatMessage.getGroupId());
        return chatMessage;
    }
}

제가 구현한 방식은 

위에서 작성한 WebSocketConfig에서

"/pub"로 시작하는 대상이 있는 클라이언트에서 보낸 모든 메시지는 @MessageMapping 어노테이션이 달린 

메서드로 라우팅 됩니다.

예를 들어

"/pub/chat_sendMessage"인

"/pub/chat_addUser"인

sendMessage는 메서드명 그대로 메시지를 보냈을때 호출되는 메소드이며 메세지를 보내는 용도로 사용됩니다.

addUser는 채팅방에 유저가 입장하였을 때 "~~ 님이 입장하였습니다"라는 메시지를 띄어주기 위하여 만들었습니다.

 

여기서 @SendTo 어노테이션은 1:n으로 메세지를 뿌릴 때 사용하는 구조이며 보통 /topic으로 시작합니다.

이 어노테이션을 사용하게 되면 "/topic/{groupId}"의 경로로 메시지를 보내게(발행하게) 됩니다.

여기서 @SendTo 어노테이션을 사용하지 않을 경우 밑의 WebSocketEventListner에서 사용하는 SimpMessageSendingOperaitions를 사용하셔도 됩니다.

 

event listner를 이용하여 소켓 연결(socket connect) 그리고 소켓 연결 끊기(disconnect이벤트를 수신하여

사용자가 채팅방을 참여(JOIN)하거나 떠날때(LEAVE) 이벤트를 logging 하거나 broadcast 할 수 있습니다.

event listner에서 소켓 연결을 끊었을 때 이벤트를 수신하게 되면 몇 번 채팅방에서 누가 나갔는지를 확인하기 위해 

headerAccessor의 username과 groupId를 넣어줍니다.

 

WebSocketEventListner

@Component
@RequiredArgsConstructor
@Slf4j
public class WebSocketEventListener {

    private final SimpMessageSendingOperations messagingTemplate;

    @EventListener
    public void handleWebSocketConnectListener(SessionConnectedEvent event) {
        log.info("Received a new web socket connection");
    }

    @EventListener
    public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
        StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
        String username = (String) headerAccessor.getSessionAttributes().get("username");
        Long groupId = (Long) headerAccessor.getSessionAttributes().get("groupId");
        
        if(username != null) {
            log.info("User Disconnected : " + username);
            ChatDto chatMessage = new ChatDto();
            chatMessage.setType(MessageType.LEAVE);
            chatMessage.setSender(username);
            messagingTemplate.convertAndSend("/topic/" + groupId, chatMessage);
        }
    }
}

 

@Component - 어노테이션은 자바 클래스를 스프링 빈이라고 표시하는 역할을 합니다. 이 어노테이션을 사용함으로써 스프링의 component-scanning 기술이 이 클래스를 애플리케이션 콘텍스트에 빈으로 등록하게 됩니다.

 

@EventListener - Spring 4.2부터는 이벤트 리스너가 ApplicationListener 인터페이스를 구현하는 Bean 일 필요가 없어졌습니다. @EventListener 주석을 통해 관리되는 Bean의 모든 public 메서드에 등록할 수 있습니다.

해당 어노테이션은 Bean으로 등록된 Class의 메서드에서 사용할 수 있습니다.

해당 어노테이션이 적용되어 있는 메서드의 인수로 현재 SessionConnectedEvent SessionDisconnectEvent

있습니다. 해당 클래스들의 상속관계를 거슬로 올라가다 보면 ApplicationEvent를 상속받는 것을 알 수 있습니다.(Spring 4.2부터는ApplicationEvent를 상속받지 않는 POJO클래스로도 이벤트로 사용 가능하다고 합니다.)

https://www.baeldung.com/spring-events

 

이미 ChatController의 사용자 참여 이벤트를  broadcast 하였기 때문에

첫 번째 메서드인 handleWebSocketConnectListener()에서 사용하는 SessionConnected 이벤트 에서는

별다른 동작 없이 log처리를 하였습니다.

 

두 번째 메서드인 SessionDisconnect 이벤트에서는 웹 소켓 세션에서 사용자 이름을 추출하고 연결된 모든 클라이언트에게 사용자 퇴장 이벤트를 broadcast하는 코드를 작성했습니다.

여기서 SimpMessageSendingOperaitions는 특정 Broker로 메시지를 전달하는 기능을 한다.

SimpleMessagingTemplate은 SimpMessageSendingOperaitions을 상속받아 만든 구현체인데 @EnableWebSocketMessageBroker를 통해서 등록되는 bean이다. 둘 중 아무거나 사용해도 구현하는 데에는 문제가 없다.

 

부족한 부분 지적해주시면 감사하겠습니다. 

즐거운 연휴 되세요. 감사합니다.

 

참고: https://spring.io/guides/gs/messaging-stomp-websocket/

728x90

댓글