ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • WebSocket 채팅 클라이언트 구현기
    Web/자바스크립트 2021. 8. 20. 18:19
    반응형

    개인적으로 이번 프로젝트를 시작하기 전에 걱정이 이만저만이 아니었다.

    웹소켓을 처음 다뤄보기도 했고, stomp.js에 관한 레퍼런스가 생각보다 너무 없었기 때문.

    게다가 리액트까지 더해지니 어떤 식으로 구현을 해야할지 기준이랄 게 딱히 없어서 좀 당황스러웠다.

    어떻게 짜야 효율적인 코드인지, 이게 성능적으로는 괜찮은 코드인지 뭐 그런.

     

    그래서 그냥 일단은 돌아가게만 구현을 해야겠다 싶었지.

    모 개발자분께서도, 웹소켓은 구현하기 나름이라고 하셔서 그냥 그런가보다 했다.

    그래도 우리 팀의 백엔드 크루 '포츈'이 채팅 관련 레퍼런스 코드를 찾아줘서

    그걸 기반으로 최대한 리액트에 맞게 이리 고치고 저리 고쳐보려고 노력해봤다.

    사실 아직까지도 이게 맞는건가 싶긴 하지만, 뭐 어때.

    일단은 잘 돌아간다.


    💬 채팅 구현 내용

    일단 stomp의 기본 메소드 몇 가지만 알아보도록 하자.

    참고로 stomp.js의 최신 버전은 version 5인데, 이번에 우리 팀이 사용한 버전은 version 4.

     

    - client or over: 해당하는 URL에 존재하는 STOMP 서버에 연결된 WebSocket 클라이언트를 생성한다.

    SockJS같은 별도의 솔루션을 이용하고자 하면 over 메소드를, 그렇지 않다면 client 메소드를 사용해주면 되는 듯.

    - connect: STOMP 브로커와의 연결을 열어줄 때 사용하는 메소드이다.

    웹소켓이 연결되었을 때 처리해주고자 하는 동작들을 콜백 함수로 넣어줄 수 있다.

    - subscribe: 특정 STOMP 브로커의 location을 구독한다.

    구독한 location에서 메시지를 수신했을 때 처리해주고자 하는 동작들을 콜백함수로 넣어줄 수 있다.

    - send: 특정 STOMP 브로커를 향해 메시지를 전송한다. 메시지 body는 반드시 문자열 데이터여야 한다.

    - unsubscribe: 구독을 취소할 때(끊어줄 때) 사용하는 메소드.

    - disconnect: 연결을 끊어주고자 할 때 사용하는 메소드.

     

    우선, 우리 팀은 채팅창 컴포넌트의 생애 주기(Lifecycle)와 웹소켓의 생애 주기를 함께 가져가기로 했다.

    그래서 채팅창을 오픈하는 순간 웹소켓이 연결되고, 채팅창을 종료하면 웹소켓 연결이 함께 종료된다.

    이게 나중에 어떤 식으로 바뀔 진 모르겠지만 (웹 페이지에 접속하는 순간 웹소켓이 연결되는 서비스들이 많더라)

    일단은 그렇게 구현을 해놓았고, 성능적으로도 크게 문제는 없어 보인다. (물론 사람이 없어서)

    useEffect(() => {
        const socket = new SockJS('STOMP 서버가 구현되어 있는 URL');
        stompClient.current = Stomp.over(socket);
        
        ...
        
        return () => {
            if (isConnected.current) {
                stompClient.current.disconnect(() => {
                    if (user_subscription.current) {
                        user_subscription.current.unsubscribe();
                    }
                    if (chat_subscription.current) {
                        chat_subscription.current.unsubscribe();
                    }
                    isConnected.current = false;
                });
            }
        };
    }, []);

     

    말 그대로 처음에 컴포넌트가 mount될 때 Stomp.over 메소드로 WebSocket 클라이언트를 생성해주고

    컴포넌트가 unmount될 때 disconnect, unsubscribe로 후처리를 해주면서 소켓 연결을 끊어주고 있다.

     

    참고로 stompClient를 ref로 관리하고 있는 이유는 리렌더링이 되더라도 계속해서 값을 유지하되,

    (생성한 client로 연결이 이루어지므로 연결을 유지하기 위해선 client가 유지되어야 한다고 생각했다)

    stompClient에 값이 부여되더라도 리렌더링이 일어나게 하고 싶지 않았기 때문.

     

    state로 관리하고 있는 내용은 크게 두 가지, 채팅 내역참가자 목록이다.

    채팅 내역의 변화에 따라 즉각적으로 채팅 말풍선이 올라와야 하고,

    참가자에 변화가 생길 때마다 참가자 목록이 즉각적으로 바뀌어야 하므로 해당 데이터들을 state로 관리해서

    데이터에 변화가 생기면 바로바로 리렌더링이 될 수 있게 해줬다.

    const [chattings, setChattings] = useState([]);
    const [participants, setParticipants] = useState({});

     

    채팅 내역과 참가자 목록은 별도의 브로커로 관리를 해주고 있다.

    즉, subscribe를 채팅 내역 따로, 참가자 목록 따로 총 두 번 해주고 있다는 뜻.

    참가자 목록이 갱신되는 타이밍이랑 채팅 내역이 갱신되는 타이밍이 다르기 때문에 

    일단은 subscribe를 각각 따로 해주고 있는데, 개인적인 생각으로는 subscribe를 한 번만 해줘도 동작할 것 같다.

    subscribe 브로커는 하나만 두고, send할 때 채팅과 참가자를 type 같은 인자로 구별해주면 되지 않을까.

     

    추가로, 보통 subscribe 코드는 connect의 콜백 함수로 넣어서,

    연결되는 순간에 subscribe까지 자동으로 할 수 있게 구현하는 경우가 많아서 그냥 동일하게 구현해줬다.

    stompClient.current.connect(
        {},
        () => {
            isConnected.current = true;
            const socketURL = socket._transport.url;
            const sessionId = socketURL.substring(
                socketURL.lastIndexOf(SOCKET_URL_DIVIDER) - SESSION_ID_LENGTH,
                socketURL.lastIndexOf(SOCKET_URL_DIVIDER)
            );
    
            user_subscription.current = stompClient.current.subscribe(
                'subscribe할 브로커의 location(참가자 목록)',
                (message) => {
                    const users = JSON.parse(message.body);
                    setParticipants(users);
                }
            );
    
            chat_subscription.current = stompClient.current.subscribe(
                'subscribe할 브로커의 location(채팅)',
                (message) => {
                    const receivedTime = new Date().toLocaleTimeString([], {
                        timeStyle: 'short',
                    });
                    const receivedChatting = JSON.parse(message.body);
    
                    setChattings((prevChattings) => [
                        ...prevChattings,
                        {
                            ...receivedChatting, 
                            receivedTime,
                        }
                        ,
                    ]);
                }
            );
    
            stompClient.current.send(
                'subscribe한 브로커의 location(참가자 목록)',
                {},
                JSON.stringify({ userId, sessionId })
            );
            
            stompClient.current.send(
                'subscribe한 브로커의 location(채팅)',
                {},
                JSON.stringify({ 
                    userId, 
                    content: `${nickname}님이 입장하셨습니다.`, 
                    type: 'notice' 
                })
            );
        },
        (error) => {
            const errorStrings = error.headers.message.split(':');
            const errorMessage = errorStrings[errorStrings.length - 1].trim();
    
            // ...(후속 에러 조치)
            closeChatting();
        }
    );

    처음에 웹소켓이 연결되면 SockJS 소켓 객체의 _transport.url에 sessionId가 포함되어 날라온다.

    sessionId는 서버와 클라이언트가 웹소켓으로 연결될 때, 클라이언트에게 발급되는 식별자이다.

    뒤쪽에 코드를 보면 userId와 함께 sessionId를 브로커에게 send하고 있는 모습을 볼 수 있다.

    userId는 이름만 봐도 알 수 있듯이, 채팅 참가자(유저) 각각을 구별하는 id이겠거니.

    그러면 sessionId는 뭘까? 왜 userId와 sessionId를 함께 사용하고 있는 것일까?

     

    userId만 가지고 각각의 참가자를 구별해 줄 경우, 특정 참가자가 예기치 못한 원인으로

    급작스레 소켓 연결이 종료되었을 때 필요한 후속 조치를 제대로 해줄 수가 없다. userId가 사라져버렸기 때문.

    그래서 우리는 userId에 sessionId를 매핑시켰고, 유저가 채팅에서 강제 종료된 이후에 userId가 사라져도

    해당 userId에 매핑된 sessionId로 유저를 식별하여 퇴장 후속조치를 해줄 수 있게 되었다.

     

    채팅과 참가자 목록 브로커에 각각 subscribe를 해준 후, 참가자 목록 브로커에게

    "내가 접속했어!" 라는 의미로 userId와 sessionId가 담긴 메시지를 send 하고 있다.

     

    그러면 서버측에서는 해당 메시지를 받아, 기존의 참가자 목록에 새로 참가한 유저를 더한 새로운 참가자 목록을 다시 메시지로 보내준다.

    그러면 해당 브로커를 subscribe 하고 있는 모든 클라이언트들에게 그 메시지가 전송되고,

    그렇게 모든 클라이언트들이 새로운 참가자 목록 데이터를 받아서 뷰를 갱신하는 방식이다.

    이를 두고 'broadcasting'이라는 용어를 사용하드라. (모든 클라이언트들에게 외친다는 의미인듯)

     

    그 다음에는 채팅 브로커에게도 '유저가 입장했습니다' 와 같은 입장 메시지를 broadcast 해주는 모습을 볼 수 있다.

    마찬가지로 채팅 브로커를 구독하는 모든 클라이언트들에게 해당 메시지가 전달되어 뷰가 갱신된다.

    (채팅이 올라온다)

     

    connect 메소드의 마지막 인자로 들어가는 콜백 함수는 연결에 실패했을 때 처리해 줄 내용에 관한 콜백 함수.

    연결이 실패했을 때 서버에서 보내주는 에러 메시지를 뽑아서 유저에게 보여주고 있고, 그 밖에는 열려 있는 채팅창 컴포넌트를 닫거나 하는 후속 조치를 취해주면 된다.

     


    ⛔ 연결 종료 이슈

    ■ 웹소켓을 이용해 메시지를 주고 받을 때는 예기치 못한 연결 종료에 대비해야하는 경우가 많은 것 같다.

    아무래도 한 번 연결해놓은 걸로 계속 메시지를 주고받다보니 연결이 비정상적으로 종료되었을 때에 대한 대비책이 잘 마련되어 있어야 하는 것이 아닌가 싶다.

    아직 우리가 짠 프론트엔드 코드는 그런 부분에 관해서는 살짝 아쉬운 면이 없진 않지만, 조금씩 개선하는 중.

     

    이런 상황에서 채팅 전송의 안정성을 높이기 위해, 채팅을 보내기 전에 현재 웹소켓이 OPEN 상태일 때만 채팅을 보내도록 하는 메소드(waitForConnectionReady)를 레퍼런스를 참고해 사용하고 있다.

     

    웹소켓의 연결 상태는 readonly 프로퍼티인 'readyState'로 알 수 있는데, readyState는 총 4가지 값을 가질 수 있다.

    - 0 또는 CONNECTING: 소켓이 생성되었지만 아직 연결이 되지 않은 상태.

    - 1 또는 OPEN: 연결된 상태.

    - 2 또는 CLOSING: 연결이 닫히는 중

    - 3 또는 CLOSED: 연결이 닫힌 상태.

    const waitForConnectionReady = (callback) => {
        setTimeout(() => {
            if (stompClient.current.ws.readyState === WebSocket.OPEN) {
                callback();
            } else {
                waitForConnectionReady(callback);
            }
        }, 1);
    };
    
    const sendMessage = (content, type) => {
        waitForConnectionReady(() => {
            stompClient.current.send(
                '브로커 location URL(채팅)',
                {},
                JSON.stringify({ userId, content, type })
            );
        });
    };

    waitForConnectionReady 함수는 만약 웹소켓의 상태가 OPEN 상태이면 콜백함수를 실행하고,

    그렇지 않으면 다시 이 함수를 실행하여 연결상태가 될 때까지 반복하는 방식으로 구현이 되어있다.

    그래서 웹소켓이 열려있지 않은 상태라면 웹소켓이 다시 열릴 때까지 계속해서 반복 실행하면서 기다리는 것.

    (아래의 sendMessage 메소드는 그냥 웹소켓 send 메소드를 waitForConnectionReady로 감싼 함수)

     

    추가적으로 아직 코드에 포함되어있지는 않지만, 한 가지 더 고려해야 할 사항이 있다.

    위에서 언급한 대로, 오랫동안 다른 탭을 사용하거나 하는 경우에는 웹소켓 연결이 끊어져버린다는 것.

    그런데 이런 식으로 시간이 지남으로써 연결이 끊기는 경우에는 연결이 끊겼는데도 여전히 참가자 목록에 내가 남아있고, 채팅도 그대로 남아있다. 아직 원인을 정확히 알 순 없지만, 대략 두 가지 정도의 해결책을 생각하고 있다.

     

    첫 번째는 'heartbeat'를 이용해서 주기적으로 연결을 확인해주는 방법이다.

    heartbeat는 서버와 클라이언트가 서로 연결이 잘 되어 있는지를 체크하기 위해 주기적으로 주고 받는 메시지이다.

    이를 이용하면, 비정상적으로 연결이 종료되더라도 서버가 heartbeat를 보냈을 때 응답이 돌아오지 않게 되기 때문에 

    서버 입장에서는 "어? 연결이 안되어있네?" 라고 알아채고 후속 조치가 가능하다.

    (해당 유저가 제외된 참가자 목록을 새로 보내준다던지)

     

    두 번째 방법은 setInterval을 이용해서 주기적으로 readyState를 이용하던 해서 연결 상태를 확인하고, 클라이언트 단에서 직접 재연결을 해주는 방식이다. 

    혹은 stomp5를 사용하면 activate 메소드(stomp4의 connect와 유사한 역할을 하는 메소드)를 이용해서

    연결이 끊겼을 때 자동으로 reconnect를 시도하게끔 만들어줄 수 있는 것 같다.

    그래서 기회가 되면 최대한 stomp5로의 마이그레이션을 노리고 있는 상태.

    사실 stomp5로 마이그레이션을 한 번 시도했는데, sessionId 관련한 이슈로 인해 다시 되돌렸던 건 비밀.

     


    💦 트러블 슈팅

    누가 볼까봐 부끄러운 트러블 슈팅이지만, 호오옥시나 비슷한 문제들로 고통받는 분이 계실까봐.

    1. WebSocket Connection Error

    // 에러 로그
    websocket.js:72 WebSocket connection to 
    'ws://localhost:3000/connection/478/ll5sruwj/websocket' failed: 
    WebSocket is closed before the connection is established.

    SockJS로 웹소켓 클라이언트를 생성할 때 삽입해 준 URL에 관한 문제였다.

    처음에 SockJS를 이용해 웹소켓 클라이언트를 생성하려면 백엔드와 합의된 URL을 삽입해줘야 하는데,

    이 때 들어가는 URL은 'https://iborymagic.tistory.com/socket' 처럼 전체 도메인과 path까지 완전히 다 적어야 한다.

    나는 처음엔 그냥 '/socket' 같은 형태로만 적어줬었고, 그래서 에러가 났던 것.

    조금만 생각해보면 당연한건데 말이야.

     

    예를 들면 아래와 같은 식.

    // X
    const socket = new SockJS('/socket');
    
    // O
    const socket = new SockJS('https://iborymagic.tistory.com/socket');

     

    2. Subscribe 해놓고 Send를 했는데 아무런 반응이 없는 문제

    분명히 connect 콜백 함수 내에서 subscribe를 해놓고 메시지를 send 했는데,

    정상적으로 send를 했다고 로그가 떴는데도 불구하고 아무런 반응이 없었다.

    subscribe의 콜백 함수로 메시지를 수신했을 때 어떻게 반응할지를 전부 구현해 놨던 상황.

     

    1번과 반대되는 문제였다. 

    1번의 문제를 겪고 나서 모든 URL들을 전부 'https://iborymagic.tistory.com/subscribe' 형태로 바꿔놨는데,

    알고 보니 send나 subscribe에 사용되는 URL은 그냥 path만 적어줘야 했던 것.

    아래와 같은 방식이다.

    // X
    const subscription = stompClient.current.subscribe(
        'https://iborymagic.tistory.com/topic/chattings',
            (message) => {
                // ...
            }
        );
        
    // O
    const subscription = stompClient.current.subscribe(
        '/topic/chattings',
            (message) => {
                // ...
            }
        );

     

    3. 채팅 textarea에 내용을 입력하고 엔터키를 눌렀을 때 줄바꿈이 되는 문제

    채팅 내용을 입력하고 엔터키를 누르면 당연히 submit이 되는 것이 인지상정.

    하지만 textarea에 엔터키를 눌렀을 때 기본 동작은 줄바꿈이었다.

    (엔터키로 submit 하는 함수를 form에 onKeyDown 이벤트로 달아놨는데,

    엔터키를 누르면 keyDown 이벤트가 form 내부에 존재하는 textarea에서 발생하고 있다)

     

    해결책은 다음과 같다.

    const onEnterSubmit = (e) => {
        if (e.isComposing || e.keyCode === 229) return;
        if (e.key === 'Enter' && !e.shiftKey) {
            e.preventDefault();
    
            const sendButton = e.target.closest('.chatting-form').send;
            sendButton.click();
            return false;
        }
    };

    우선, 엔터키를 누르면 submit 이벤트를 발생시키는 것이 아니라

    채팅 전송하기 버튼을 클릭하는 이벤트를 발생시키도록 했다. 그렇게 했더니 채팅은 전송이 되더라.

    근데, 문제는 줄바꿈 현상과 채팅 전송하기가 동시에 발생한다는 것.

    채팅 전송과 줄바꿈이 같이 일어나서, 한 줄이 띄워져서 전송되기도 하고 전송된 후에 줄바꿈이 되기도 했다.

     

    이를 해결하기 위해 엔터 키를 눌렀을 때 e.preventDefault를 통해 줄바꿈 동작을 막아놓기로 했다.

    이벤트 발생 순서를 확신할 수 없어서 e.preventDefault와 return false를 모두 적어주긴 했는데,

    두 코드를 적어준 의도는 동일하다. 엔터 키를 눌렀을 때 발생하는 기본 동작을 막아주기 위해서.

    근데 실험해보니 return false는 없어도 잘 동작했다. 일단은 넣어놨지만 추후에 뺄 가능성이 높다.

     

    줄바꿈을 방지해놓고 보니, 줄바꿈을 아예 할 수 없으면 채팅의 자유도가 너무 낮아진다는 생각이 들었다.

    그래서 shift 키 + 엔터키를 눌렀을 때는 줄바꿈이 될 수 있게끔 조건을 하나 더 걸어주는 것으로 합의를 봤다.

     

    참고로, 맨 위의 'if (e.isComposing || e.keyCode === 229) return;' 코드는

    keyDown 이벤트의 브라우저 호환성 문제때문에 한글을 입력했을 때

    같은 이벤트가 중복되어 발생하는 경우가 있다고 해서 붙여준 코드이다.

    (일부 브라우저에서만 사용되는 이벤트가 두 개 있어서 둘을 겹쳐서 발생시키다보니,

    두 이벤트를 모두 사용하는 특정 브라우저에서는 이벤트가 두 번 발생한다는 듯)

    두 이벤트 중 하나만 발생할 수 있게끔 도와주는 조건문.

     

    JavaScript Events Handlers — Keyboard and Load Events

    In JavaScript, events are actions that happen in an app. They’re triggered by various things like inputs being entered, forms being…

    levelup.gitconnected.com

     

    4. 채팅창에 말풍선이 위에서부터 렌더링되는 문제

    유저들은 보통 말풍선이 아래에서부터 생기는 것에 익숙해져 있기 때문에, 우리도 동일하게 구현하고 싶었다.

    근데 계속 말풍선이 위에서부터 생성되어, 어떻게 고칠까 찾아보니 CSS 문제였다.

    .chatting-contents {
        display: flex;
        flex-direction: column;
        justify-content: flex-start;
        height: 100%;
        width: 100%;
    
        & > :first-child {
            margin-top: auto;
        }
    }

    채팅창 영역 내부에 속해있는 말풍선 중 맨 위의 녀석에게 margin-topauto로 줘서

    말풍선이 속한 영역(채팅창 영역)에서 자기 자신을 제외한 나머지 영역을 전부 margin-top으로 채웠다.

    이렇게 하면 margin-top을 준 만큼 말풍선이 아래로 내려오기 때문에

    말풍선이 아래에서부터 생기는 것처럼 구현할 수 있다.

     


    아직 할 일들이 산더미처럼 쌓여있다.

    당장 채팅 관련해서만 해도 stomp5로 마이그레이션을 생각하고 있고, 여러 가지 버그를 고쳐야 한다.

    반응형으로도 구현을 해놨는데, 모바일에서 어플리케이션을 구동하는 건 또 다른 차원의 문제였다.

    개발자 도구만 이용해서 반응형을 구현했는데, 실제로 모바일에서 구동을 해보니

    정말 생각지도 못한 문제들이 발견되곤 했다.

     

    코드 리팩토링도 너무나 필요한 상황이다.

    웹소켓과 관련된 로직들은 별도의 hook으로 분리하고 싶고, HTTP 요청도 따로 분리를 하고 싶다.

    채팅을 최대한 안정적으로 즐길 수 있도록 보강을 해야하고, stomp5를 이용해 이미지 채팅도 구현하고 싶다.

    그 외에 추가하고 싶은 기능들도 너무 많다.

    최적화도 해야 하고.

     

    앞으로 상황이 어떻게 흘러갈지는 아무도 모르겠지만,

    적어도 우테코가 끝나는 시점에는 누군가 흥미를 느끼고 써볼만한 어플리케이션이 완성되어있었으면 하는 바램.

    반응형

    댓글

Designed by Tistory.