ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Spring WebSocket 활용
    프로젝트/WebSocket 2023. 6. 12. 00:01
    728x90

    개요

    - WebSocket connect, disconnect시 클라이언트에게 알리기

    - 서버에서 클라이언트에 disconnect 요청 보내기

    - 개발테스트를 위한 http로 메시지 발송

     

    개발자도구와 SocketJS

    http://host:port/myApp/myEndpoint/{server-id}/{session-id}/{transport}

     

    소켓을 연결하면 다음과 같은 url로 연동됩니다.
    • server-id는 클러스터 환경에서 요청을 라우팅 하기 위해 사용
    • session_id는 SockJS 세션에 속하는 HTTP 요청을 연관시킴
    • transport는 전송 타입을 가리킴 (websocket, xhr-streaming, xhr-polling)
     
    예를들어 제가한 튜토리얼의 개발자도구 -> Network -> ws로 이동하여 살펴보면 위와 동일한 형식임을 알 수 있습니다.

    여기서의 sessionId는 client마다 고유하게 가지며 추후 아래에서 SessionConnect, Disconnect 등이 발생하였을 때 서버에서 동일하게 사용됩니다.

     

     

    Websocket connect, disconnet시 클라이언트에게 알리기

    사용자가 socket에 접근하였을 때 뭔가 연결이 잘 되었다, 끊어졌다를 알리고 싶었습니다.

    어떻게 해야 할까 고민하던 중 WebSocket 자체에서 Event를 제공한다는 사실을 알게 되었습니다.

    https://docs.spring.io/spring-framework/reference/web/websocket/stomp/application-context-events.html

     

    Events :: Spring Framework

    BrokerAvailabilityEvent: Indicates when the broker becomes available or unavailable. While the “simple” broker becomes available immediately on startup and remains so while the application is running, the STOMP “broker relay” can lose its connectio

    docs.spring.io

     

    그중 다음과 같은 이벤트를 활용하고자 했습니다.

    • SessionConnectedEvent
    • SessionSubscribeEvent
    • SessionDisconnectEvent

     

    하지만 Connect 되었을 때는 Subscribe 되지 않았기 때문에 Client에게 메시지를 보낼 수 없습니다.

    또한 Disconnect가 수신되었을 때는 클라이언트에서 이미 끊어졌기 때문에 Client는 메시지를 받을 수 없습니다.

    @Component
    class SessionEventListener(
      private val messagingTemplate: SimpMessagingTemplate
    ) {
    
      @EventListener
      fun subscribeSessionConnect(event: SessionConnectedEvent) {
        val sessionId = event.message.headers["simpSessionId"] as String
        val sendMessage = "subscribeSessionConnect Event Success"
        val payload = Greeting(
          content = sendMessage
        )
        println("----------------------------------Connect Event------------------------")
        println(sessionId)
        messagingTemplate.convertAndSendToUser(sessionId, "/user/queue/greetings", payload)
        println("----------------------------------Connect Event------------------------")
      }
    
      @EventListener
      fun subscribeSessionConnect(event: SessionSubscribeEvent) {
        val sessionId = event.message.headers["simpSessionId"] as String
        val sendMessage = "subscribeSessionConnect Event Success"
        val payload = Greeting(
          content = sendMessage
        )
        println("----------------------------------Subscribe Event------------------------")
        println(event)
        println(sessionId)
        //메세지를 보낼 수 있다
        messagingTemplate.convertAndSendToUser(sessionId, "/topic/greetings", payload)
        messagingTemplate.convertAndSend("/topic/greetings", payload)
        println("----------------------------------Subscribe Event------------------------")
      }
    
      @EventListener
      fun subscribeSessionDisconnect(event: SessionDisconnectEvent) {
        val sessionId = event.message.headers["simpSessionId"] as String
        val sendMessage = "subscribeSessionConnect Event Success"
        val payload = Greeting(
          content = sendMessage
        )
        println("----------------------------------DisConnect Event------------------------")
        println(sessionId)
        messagingTemplate.convertAndSend("/user/queue/greetings", payload)
        println("----------------------------------DisConnect Event------------------------")
    
      }
    }

     

    그러면 근본적으로 connect, disconnect시 클라이언트에게 알려야 할까?

    개인적으로 조사해 보았을 때 connect시, disconnect시에 클라이언트에게 알리는 것을 찾기 힘들었습니다.

    또한 앞서 위에서 처럼 클라이언트에게 알리기 위해서는 subscribe도 선행적으로 필요했습니다.

     

    다시 생각해하여 connect, disconnect시에 클라이언트에게 알려주어야 할까?라는 생각을 하게 되었습니다.

    websocket은 전화와 같은 전이중통신이기 때문에 정상적인 경우라면 connect, disconnect를 감지할 수 있습니다.

     

    그러면 비정상케이스를 생각해 보겠습니다.

    1. 서버에게 connect가 도달하지 않음 -> 일시적인 네트워크 에러라면 클라이언트가 다시 요청하면 해결된다.

    2. 클라이언트에서 disconnect 요청을 보내지 않음 -> 서버에서 일정기간이 지나면 끊어버리자

    3. 서버에서 disconnect 요청을 보내지 않고 끊어버림 -> 클라이언트에서 서버 상태를 체크하기 위해 헬스 체크를 보내자, 혹은 재연결하면 된다.

     

    서버에서 클라이언트에 disconnect 요청 보내기

    예를 들어 5분이 지나면 서버에서 클라이언트에게 disconnect 요청을 보내버리고 싶습니다.

     

    유휴 상태라는 개념이 존재합니다.

    유휴 상태란 특정 시간 동안 메시지를 주고받지 않으면 연결을 끊어버립니다.

     

    이는 API Gateway에서도 설정할 수 있으며, 애플리케이션에서도 설정할 수 있다고 합니다.

     

    간편한 설정으로 될까?

    server:
      servlet:
        session:
          timeout: 10

    기본값은 30분입니다.

    하지만 해당 설정은 http에서만 적용되고 websocket에는 적용되지 않습니다.

    실제로 클라이언트와 연결 테스트 시 10초가 지난 뒤에도 계속 h(health check)를 client에서 수신하며 연결이 끊어지지 않습니다.

     

    다음으로는 ServletServerContainerFactoryBean에서 설정

    @Configuration
    @ComponentScan(basePackageClasses = [WebSocketConfig::class])
    @EnableWebSocketMessageBroker
    class WebSocketConfig : WebSocketMessageBrokerConfigurer {
    
      override fun registerStompEndpoints(registry: StompEndpointRegistry) {
        registry.addEndpoint("/qr-websocket").setAllowedOriginPatterns("*").withSockJS()
      }
    
      override fun configureMessageBroker(config: MessageBrokerRegistry) {
        config.setApplicationDestinationPrefixes("/app")
        config.enableSimpleBroker("/topic")
      }
    
      @Bean
      fun createWebSocketContainer(): ServletServerContainerFactoryBean {
        val container = ServletServerContainerFactoryBean()
        container.setMaxSessionIdleTimeout(30 * 1_000) //in milliseconds
        return container
      }
    }

    10초 동안 클라이언트에서 응답이 없으면 끊어지게 됩니다.

    소켓이 끊어졌으며, send는 더 이상 동작하지 않습니다.

     

    하지만 위의 설정처럼 30초로 바꾸어보니 25초 간격으로 헬스체크가 되기 때문에 더 이상 끊어지지 않습니다.

    이를 위해서 헬스체크를 포기해야 하는 건 좀 아닌 것 같습니다.

    다른 방법을 찾아보았습니다.

     

    서버 로직을 통해 disconnect 시키기

    @Component
    class WebSocketSessionManager {
      private val sessionMap: MutableMap<String, WebSocketSession> = ConcurrentHashMap()
    
      fun registerSession(session: WebSocketSession) {
        sessionMap[session.id] = session
      }
    
      fun removeSession(sessionId: String) {
        sessionMap.remove(sessionId)
      }
    }

    ConcurrentHashMap을 통해 session을 관리합니다.

     

    configureWebSocketTransport의 WebSocketHandlerDecorator 클래스를 활용해 afterConnectionEstablished를 활용합니다.

    @Configuration
    @EnableWebSocketMessageBroker
    class WebSocketConfig(
      private val webSocketSessionManager: WebSocketSessionManager,
    ) : WebSocketMessageBrokerConfigurer {
    
      private val TIMEOUT_MINUTES: Long = 1 // Set your desired timeout period
    
    
      override fun registerStompEndpoints(registry: StompEndpointRegistry) {
        registry.addEndpoint("/qr-websocket").setAllowedOriginPatterns("*").withSockJS()
      }
    
      override fun configureMessageBroker(config: MessageBrokerRegistry) {
        config.setApplicationDestinationPrefixes("/app")
        config.enableSimpleBroker("/topic")
      }
    
      override fun configureWebSocketTransport(registration: WebSocketTransportRegistration) {
        registration.addDecoratorFactory { handler ->
          object : WebSocketHandlerDecorator(handler) {
            override fun afterConnectionEstablished(session: WebSocketSession) {
              println("client socket connection, start session id : ${session}")
              webSocketSessionManager.registerSession(session)
              println(session.id)
              // Schedule a task to disconnect the session after the timeout period
              val executorService = Executors.newSingleThreadScheduledExecutor()
              executorService.schedule({
                if (session.isOpen) {
                  session.close(CloseStatus.NORMAL.withReason("Forcefully closed by the server"))
                  webSocketSessionManager.removeSession(session.id)
                }
              }, TIMEOUT_MINUTES, TimeUnit.MINUTES)
    
              super.afterConnectionEstablished(session)
            }
          }
        }
        super.configureWebSocketTransport(registration)
      }
    }

    webSocketSeesionManager에 session을 등록시키고, 지정된 시간 예(1분)로 스케줄을 발행합니다.

    이후 해당 시점에 세션이 열려있다면 close가 일어나게 되고 session을 제거합니다.

     

    개발테스트를 위한 http endpoint 호출로 메시지 발송시키기

    @Controller
    class GreetingController(
      private val template: SimpMessagingTemplate
    ) {
    
      @MessageMapping("/hello")
      @SendTo("/topic/greetings")
      fun greeting(message: HelloMessage): Greeting {
        //service 호출
        sleep(1000)
        return Greeting("Hello, ${HtmlUtils.htmlEscape(message.name)} !")
      }
    
      @PostMapping("/post-event")
      @ResponseBody
      fun loginForTest() {
        val sendMessage = "testPayload"
        val payload = Greeting(
          content = sendMessage
        )
        template.convertAndSend("/topic/greetings", payload)
      }
    }

     

    아래의 요청과 같이 /post-event가 들어오면 /topic/greetings를 구독하는 사람들에게 메시지를 보냅니다.

    ### 구독하는 사람들에게 이벤트 보내보기 테스트
    POST {{qr-api}}:9000/post-event

     

     

     

    참고자료

    https://docs.spring.io/spring-framework/reference/web/websocket.html

    https://docs.spring.io/spring-framework/docs/4.3.x/spring-framework-reference/html/websocket.html

    https://stackoverflow.com/questions/28552033/disconnect-client-session-from-spring-websocket-stomp-server

     

    '프로젝트 > WebSocket' 카테고리의 다른 글

    RSocket이란?  (0) 2023.07.20
    WebSocket Scale Out - 이론편  (0) 2023.06.24
    TCP Socket vs WebSocket  (0) 2023.06.23
    Spring Websocket 이론과 간단한 구현  (1) 2023.06.11
    QR코드 인증방식 원리  (0) 2023.06.02

    댓글

Designed by Tistory.