ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Spring] 채팅 구현 중 웹소켓 중복 구독, 연결 끊김 문제 해결 (2)
    Code 2024. 11. 3. 18:23

    ↓ 여기서 이어집니다

     

    Spring, Web Socket, STOMP, MongoDB 환경에서 채팅 구현

    웹소켓 관련 의존성 추가(build.gradle) implementation 'org.webjars:webjars-locator-core:0.59' implementation 'org.webjars:sockjs-client:1.5.1' implementation 'org.webjars:stomp-websocket:2.3.4' Websocket + STOMP config 구성@Configuration@Enable

    henhen.tistory.com

     

    문제

    frontend

    • 채팅 서비스 구현 중 웹소켓을 중복 구독해서 메세지가 이중으로 표시되는 문제
    • 페이지 새로고침이나 컴포넌트 이동 시 연결이 끊기는 문제
      1. 구독 확인 후 unsubscribe하고 다시 연결
      2. context api
      3. 부모 컴포넌트에 연결 후 하위 컴포넌트로 전달

    => 웹소켓을 이용하는 기능이 채팅 뿐이라 방법 3으로 선택

    backend

    • 일정 짧은 시간(1분 ~ 2분) 사이에 웹소켓 동작이 없을 시 연결이 끊기는 문제

    => stomp 기본 설정으로 heart-beat 설정이 되어 있었지만 프론트엔드에서 연결이 풀리는 문제를 해결하고자 ping-pong 로직을 따로 생성했다.

     

    원인

     

    Chrome 88부터 체인으로 연결된 JS 타이머의 과도한 제한  |  Blog  |  Chrome for Developers

    강도 조절은 페이지가 5분 이상 숨겨졌거나, 페이지가 30초 이상 무음 상태이고, WebRTC가 사용되지 않고, 타이머 체인이 5개 이상인 경우에 적용됩니다.

    developer.chrome.com

     

     

    브라우저 최소화 상태에서 웹소켓 끊김 현상

    업무 중 겪었던 문제와 해결방법 기록. 현상 회사에서 진행 중인 프로젝트에서 mqtt.js를 사용하는데, 브라우저를 최소화한 상태로 일정 시간이 지나면 웹소켓 연결이 끊기는 현상이 발견됐다.

    imsangin.tistory.com

     

    (원인 파악에 있어서 참고한 링크들)

        stomp heart-beat 설정처럼 일정 간격에 따라 메세지를 전달하고, 마지막으로 전달한 ping의 여부에 따라 연결 상태를 파악하는 방법을 사용했다. heart-beat의 경우 해당 브라우저에서 이탈하지 않으면 로그아웃 상태가 되더라도 지속적으로 ping을 보내기 때문에 이것을 내 입맛대로 잡아내기가 어려웠다.

     

    구현

    1. WebSocketController 생성

    @Slf4j
    @Controller
    @RequiredArgsConstructor
    public class WebSocketController {
    
        private final WebSocketService webSocketService;
    
        @MessageMapping("/ping")
        @SendTo("/sub/pong")
        public String handlePing(String message) {
            return "pong";
        }
    
        @GetMapping(value = "/last/connect")
        public ResponseEntity<WebSocketResponseDto> findLastWebsocket(@RequestParam("memberId") Long memberId) {
            WebSocketResponseDto webSocketResponseDto = webSocketService.findLastWebsocket(memberId);
            return ResponseEntity.ok(webSocketResponseDto);
        }
    }
    • ping-pong 로직 컨트롤러 생성
      • 단순히 핑 하면 퐁 하고 돌아오는 컨트롤러다.
      • 프론트에서 wss로 연결해주고 stomp 연결해준 뒤에 첫번째 메세지로 바로 보내줄 수 있도록 설정할 것이다.
    • 마지막으로 웹소켓과 연결한 시간을 반환하는 컨트롤러 생성
      • 꼭 필요한 컨트롤러는 아니지만 접속을 종료한 사이 참여 중인 채팅방에 메세지가 존재하는지 확인하고, 안읽은 채팅 알림 표시를 위해 마지막으로 웹소켓과 연결한 시간(종료 기준)을 저장했다.
      • 자세한건 서비스에서 ..

    2. WebSocketService 생성

    @Service
    @Transactional
    @RequiredArgsConstructor
    @Slf4j
    public class WebSocketService extends TextWebSocketHandler {
    
        private final WebSocketRepository webSocketRepository;
        private final Map<String, Timer> sessionTimers = new ConcurrentHashMap<>();
    
        public WebSocketResponseDto findLastWebsocket(Long memberId) {
    
            List<WebSocket> webSocketList = webSocketRepository.findByMemberIdOrderByLastConnectDesc(memberId);
            if (webSocketList.isEmpty()) {
                throw new CustomException(CONNECTION_NOT_FOUND);
    //            saveWebSocketConnection(memberId, null);
            }
            WebSocket mostRecentWebSocket = webSocketList.get(0);
    
            WebSocketResponseDto ResponseDto = new WebSocketResponseDto(mostRecentWebSocket);
            return ResponseDto;
        }
    
        // WebSocket 연결 시 MongoDB에 저장
        public void saveWebSocketConnection(Long memberId, String sessionId) {
            webSocketRepository.save(WebSocket.createLastConnect(memberId, sessionId, LocalDateTime.now().toString()));
            startPingTimeoutTimer(sessionId);
        }

    (레알 서비스단의 로직)

    • 마지막 웹소켓 연결시간을 찾는 서비스 -> 레포지토리에서 기본으로 제공하는 함수로 정렬한 후 가장 첫 시간을 가져왔다.
    • 처음 웹소켓 연결 정보를 저장하는 서비스(현재 접속중인 멤버의 id와 세션 id, 현재 타임스탬프를 저장)
      • 아래 타이머에서 연결 종료 시 레포지토리에 연결 종료 시간을 저장해서 해당 멤버의 연결 시간을 알 수 있다.
      • 해당 정보는 조작 없이 단순히 읽기만 할 내용이라 RDBMS에 저장하기보다는 NOSQL에 저장하고 일정 기간마다 날려주는 게 좋을 것 같다고 판단, 채팅 기록이 저장되는 mongoDB에 저장했다.
        private void startPingTimeoutTimer(String sessionId) {
            Timer timer = new Timer();
            timer.schedule(new TimerTask() {
                @Override
                public void run() {
                    handlePingTimeout(sessionId);  // 타임아웃 발생 시 처리
                }
            }, 60000);  // 60초 후 타임아웃
            sessionTimers.put(sessionId, timer);
        }
    
        private void resetPingTimeoutTimer(String sessionId) {
            cancelPingTimeoutTimer(sessionId);
            startPingTimeoutTimer(sessionId);
        }
    
        private void cancelPingTimeoutTimer(String sessionId) {
            Timer timer = sessionTimers.remove(sessionId);
            if (timer != null) {
                timer.cancel();
            }
        }
        
        // ping 발생 시 타이머 리셋
        public void updateLastConnectTime(String sessionId) {
            List<WebSocket> webSocket = webSocketRepository.findBySessionIdOrderByLastConnectDesc(sessionId);
    
            if (webSocket != null) {
                resetPingTimeoutTimer(sessionId);
            }
        }
    • 차례로 ping의 타이머를 조작하는 로직들이다.
      • start: 타이머를 60초로 설정하고 초과할 시 타임아웃 서비스를 실행한다.
      • reset: 타이머를 초기화하고 다시 타이머를 시작한다.
      • cancel: 타이머 초기화 서비스
      • update: ping이 발생할 경우 타이머 reset 처리
        // 웹소켓 연결 종료 시 호출
        public void handleWebSocketDisconnection(String sessionId) {
            List<WebSocket> webSocket = webSocketRepository.findBySessionIdOrderByLastConnectDesc(sessionId);
    
            if (webSocket != null) {
                webSocket.get(0).updateLastConnect(LocalDateTime.now().toString());
                webSocketRepository.save(webSocket.get(0));
                cancelPingTimeoutTimer(sessionId);
            }
        }
    
        // 타임아웃 발생 시 WebSocket 연결 종료 처리
        public void handlePingTimeout(String sessionId) {
            List<WebSocket> webSocket = webSocketRepository.findBySessionIdOrderByLastConnectDesc(sessionId);
    
            if (webSocket != null) {
                webSocket.get(0).updateLastConnect(LocalDateTime.now().toString());  // 마지막 연결 시간 기록
                webSocketRepository.save(webSocket.get(0));
            }
            handleWebSocketDisconnection(sessionId);
        }
    • 웹소켓이 정상적으로 종료되거나(DISCONNECT), Timeout이 발생하는 경우 동일한 세션 id를 가져와 마지막 연결 시간을 업데이트 및 저장하고, 타이머를 종료한다.
    • 지금 보니까 두 서비스가 똑같은 내용이라 분리할 필요가 없는 것 같다..^^ timeout 시 이중으로 저장하겠다..

    3. StompHandler 수정

            if (StompCommand.CONNECT.equals(stompHeaderAccessor.getCommand())) {
    			// 인증 관련 로직들...
                webSocketService.saveWebSocketConnection(memberId, sessionId);
    
            }
    
            else if (StompCommand.DISCONNECT.equals(stompHeaderAccessor.getCommand())) {
                String sessionId = stompHeaderAccessor.getSessionId();
                webSocketService.handleWebSocketDisconnection(sessionId);
            }
    
            else if (StompCommand.SEND.equals(stompHeaderAccessor.getCommand())) {
                if ("/pub/ping".equals(stompHeaderAccessor.getDestination())) {
                    String sessionId = stompHeaderAccessor.getSessionId();
                    webSocketService.updateLastConnectTime(sessionId);
                }
            }
            return message;
        }
    • StompCommand == CONNECT인 경우: 최초 연결시간을 저장한다(save)
    • StompCommand == DISCONNECT인 경우: 정상 종료로 판단, disconnection 서비스를 실행한다.
    • StompCommand에서 ping메세지를 SEND한 경우: 정상 유지로 판단, 타이머를 update한다.
    • 타이머 분기를 거치고 message를 리턴한다.

    4. 프론트에서 웹소켓 연결 중앙 처리

    const connectWebSocket = (chatRooms) => {
        if (!stompClient.value || !stompClient.value.connected) {
            const socket = new SockJS('/wss');
            stompClient.value = Stomp.over(socket);
            stompClient.value.debug = function (string) {
                if (!(string.includes('ping')) && !(string.includes('pong'))) {
                console.log(string);
                }
            };
        }
    
            stompClient.value.connect({ Authorization: `${accessToken}` }, () => {
                subscribeChatRoom(chatRooms);
                setInterval(() => {
                    lastPingTime.value = new Date();
                    stompClient.value.send("/pub/ping", {}, JSON.stringify({ message: 'ping' }));
    
                    clearTimeout(timeoutHandle);
                    timeoutHandle = setTimeout(() => {
                        handlePingTimeout();
                    }, 60000);
                }, 20000);  
                
                stompClient.value.subscribe('/sub/pong', (message) => {
                    console.log("Pong 받음:", message.body);
                    clearTimeout(timeoutHandle);
                });
            }, (error) => {
                console.error('채팅방을 연결하는 데 실패했습니다.', error);
            });
        };
    • 프론트에서 웹소켓 연결과 동시에 ping pong 서비스를 끌어와 ping 메세지를 전송하고 pong 채널을 구독한다.
    • stomp 메세지가 좀 콘솔로 찍히면 내용이 많기도 하고.. 1분마다 찍히면 너무 메세지 고봉밥이라서 'ping'이나 'pong'이 들어가는 메세지는 콘솔에서 무시하기로 했다.
    const findLastWebSocketApi = async (memberId) => {
        try {
            const response = await axios.get(`/chat/last/connect?memberId=${memberId}`);
            lastPingTime.value = response.data.lastConnect;
            console.log("lastPingTime: ", lastPingTime.value);
    
            findLastMessageApi();
        } catch (err) {
            console.error("웹소켓 저장 시간을 가져오는데 실패했습니다.", err);
        }
    }
    
    const findLastMessageApi = async () => {
        try {
            const response = await axios.get("/chat/last/message");
            lastMessageTime = response.data;
            console.log("lastMessageTime: ", lastMessageTime);
    
            filterNewMessages(lastMessageTime);
        } catch (err) {
            console.error("마지막 메세지 저장 시간을 가져오는데 실패했습니다.", err);
        }
    }
    • 안읽은 알림 처리를 위한 마지막 웹소켓 저장 시간과 구독한 채팅방에 대한 마지막 메세지 저장 시간을 찾는다.
    • 원래였으면 구독한 채팅방들마다 메세지를 300개정도 읽고, 마지막 웹소켓 저장 시간을 기준으로 이분탐색해서 메세지 개수를 카운트하는 방법(멋져보였다!)을 사용하려고 했는데, 시간적인 문제로 마지막 한개의 메세지만 읽고 마지막 웹소켓 저장 시간 이후면 알림 뱃지를, 그 이전이면 표시하지 않았다.
    const props = defineProps({
        chat: {
            type: Object,
            required: true,
        },
        showChatRoom: {
            type: Boolean,
            required: true
        },
        messages: Array,
        stompClient: Object
    });
    
    const sendMessage = async () => {
        if (!newMessage.value || newMessage.value.trim() === "") {
            return;
        }
    
        const message = {
            memberId: loggedInMemberId.value,
            nickname: loggedInMemberName.value,
            message: newMessage.value,
        };
    
        props.stompClient.send(`/pub/chat/${props.chat.chatRoomId}`, {}, JSON.stringify(message));
        scrollToBottom();
        newMessage.value = '';
    };
    • 부모 컴포넌트에서는 웹소켓과 채팅방 구독 관련한 모든 로직을 처리하고, 하위 컴포넌트인 채팅방에서는 stompClient에 대한 정보를 props로 전달받았다.
    • 여기서는 메세지 전송만 처리했다.

    결과

    프론트 많이 깎았다..!!

     

        위 과정들을 통해서 웹소켓이 연결 도중 끊기거나 중복 구독 문제가 발생하지 않도록 해결할 수 있었다. 끊기는 것도 따로 어느 정도 시간 기준인지 모르겠어서 내가 잘못 코드를 짠건지 웹소켓이 이상한 건지 판단이 어려웠는데 이런 문제가 있었다,..

    자꾸 연결이 내 맘같지 않다 보니 처음에는 채팅방에 접근할 때마다 웹소켓을 새로 연결하거나, 채팅 목록을 열 때마다 연결하는 방식을 사용했는데, 그렇게 하다 보니 또 1분 내로 동작했던 소켓의 경우 중복 구독이 되면서 토스트 메세지 알림이 이중 삼중으로 오는 문제가 연달아서 생기고 했는데 그래도 소켓 연결 때문에 신경 쓰이는 문제는 많이 잡은 것 같다.

    'Code' 카테고리의 다른 글

    [Spring] Spring Security JWT 구현 (1)  (0) 2024.12.07
    [Spring] Kafka 채팅 파티션 분산 (3)  (3) 2024.11.11
    Kafka 메모  (0) 2024.10.02
    [Spring] Web Socket, STOMP, MongoDB 환경에서 채팅 구현 (1)  (2) 2024.09.26
    Kafka, Redis  (0) 2024.09.17