socket.io로 실시간 채팅 구현 2탄

요들레이후·2023년 6월 29일
2

프로젝트

목록 보기
5/5
post-thumbnail

socket통신을 구현하며 경험했던 이벤트 기획, 구현과정 중 경험했던 이슈와 해결방안에 대해 서술하고 결과 코드를 작성한 글입니다.

당시 얼레벌레 기획하고 작성을 하게 되었는데, 틀린 부분에 대한 조언과 관심 대🌟환🌟영 입니다. 🙌🏻

1. 이벤트 기획

1-1. 서비스 기획

  • 서비스 전제
    • Admin계정은 모든 유저와의 1:1 채팅 리스트를 볼 수 있으며, 접근이 가능하다.
    • User계정은 오직 Admin계정과의 1:1 채팅리스트만 볼 수 있으며 접근이 가능하다.
    • User계정이 Admin계정에게 메세지를 먼저 보내야만 방이 생성되어 Admin계정의 채팅리스트에 추가된다.

우리가 기획한 채팅 서비스는 유저와 관리자의 1:n채팅이었다. 위 내용과 같이 유저가 첫 메세지를 보낼 때 방이 생성되는 걸로 기획을 잡고 갔다.


  • 클라이언트 측 보낼 데이터 분기

    • 로그인한 유저가 관리자일 때 자신의 email과 룸 리스트 정보(해당 채팅 룸의 대화 상대의 email)을 알 수 있다.
    • 로그인한 유저가 일반 유저일 때 자신의 email만 알 수 있다. 관리자 email은 특정 email을 default로 정해놓았다.

  • 소켓 연결 flow
    소켓 연결지점을 어디로 잡아야할까 고민을 하다가 service flow를 고려해보았을 때 다음과 같은 답이 나오게 되었다.
    • 로그인한 유저가 관리자일 때
      • 채팅 플로팅 버튼 클릭 > 채팅리스트 모달창 뜸 > 채팅리스트 선택시 해당 id로 find를 통해 채딩 모달창이 뜸(여기서부터 socket연결 시작) > 채팅 주고 받음
    • 로그인한 유저가 일반 유저일 때
      • 채팅 플로팅 버튼 클릭 > 채팅 모달창이 뜸(여기서부터 socket연결 시작) > 채팅 주고 받음

즉 채팅 모달창이 떴을 때만 socket연결을 하면 되었기에 굳이 전역으로 관리하지 않았다.

1-2. 이벤트 선정

  • 채팅룸 입장 : ('enterChatRoom', userEmail)

    • 현재 로그인한 유저의 이메일을 통해 해당 채팅룸으로 들어가는 이벤트이다.
    • 채팅룸으로 들어가면 해당 채팅룸에 있는 모든 메세지 list를 요청한다.
  • 채팅 리스트 반환 : ('AllMessages', allMessages)

    • enterChatRoom에서 받은 유저 이메일을 통해 해당 채팅 방에 채팅메세지 리스트를 조회한다.
    • 채팅 메세지 list가 있으면 list 반환, 없으면 빈 배열 반환한다.
  • 채팅방 생성 : ('createChatRoom', userEmail)

    • AllMessages를 통해 받은 채팅 리스트가 빈배열이면 채팅룸이 존재하지 않다는 것이므로 현재 유저가 보내는 메세지가 채팅방의 첫번째 메세지라는 것을 알 수 있다.
    • 현재 로그인한 유저 이메일로 채팅방 테이블에 데이터를 저장하고 roomId 반환한다. roomId는 새롭게 삽입된 데이터의 result 객체의 insertId 의 값으로 가져왔다.
  • 클라이언트측 채팅 전송 : ('message', memberEmail, senderEmail, message)

    • AllMessages에서 받은 채팅 리스트가 빈배열이 아닐 때 해당 이벤트로 메세지를 보낸다.
    • 현재 로그인한 유저 email과 해당 룸 유저 email, 메세지 인풋값을 보낸다.
    • 관리자일 때 : (관리자 이메일, 해당 룸 유저 이메일)
    • 일반 유저일 때 : (로그인한 유저 이메일, 로그인한 유저 이메일)
  • 서버측 가장 최신 채팅 반환 : ('latestMessage', latestMessage)

    • 클라이언트측에서 메세지를 받고 db에 저장하고 다시 해당 메세지를 클라이언트로 보내준다.
  • 클라이언트측 접속 조회 요청 전송 : (’isOnlineStatus’, member_email, admin_email)

    • 현재 채팅룸 안에 두 사람이 온라인인지 확인 요청하는 이벤트이다. 현재 방 안에 있는 일반 유저의 이메일, 관리자 이메일을 보낸다.
    • 우리는 로그인 기점을 온라인이라고 지정했고, 로그인/로그아웃 시 http통신을 통해 접속테이블에 접근하였다.
  • 서버측 접속 조회 결과 전송 : ('onlineStatus', connectionData)

    • 백엔드에서 접속 테이블에 있는 유저의 email list에서 두 이메일이 존재하는 지 판단한다.
    • 존재하는 이메일을 배열로 반환한다.
      • 일반 유저, 관리자 두 명이 둘 다 로그인이면 [일반 유저, 관리자] 로 반환된다.
      • 관리자 한 명만 로그인이면 [관리자]로 반환된다.

2. 프론트 이슈

다음은 리액트 훅과 소켓 통신을 적절하게 사용하지 못해 클라이언트측에서 발생한 버그들이다!

2-1. 메세지가 중복되어 렌더링 되는 이슈

채팅창에 메세지를 입력하고 전송하면 화면에 똑같은 메세지가 2개, 4개, 8개... 2의 제곱으로 늘어나며 출력되는 현상이 발생했다.

이럴 때는 백엔드 코드의 문제인 지 내 코드의 문제인지 알아보기 위해 postman으로 테스트 하면서 진행했다. postman으로 테스트했을 때는 메세지 반환은 정상적으로 1개씩 반환되고 있었고, 이는 프론트 코드의 문제임을 확실히 알 수 있었다.

구글링을 통해 살펴보니 나와 같은 문제를 겪고 있던 사람들 몇몇을 확인할 수 있었다.

스택오버플로우 같은 이슈 관련 질문
socket.io 깃허브 discussion
같은 오류를 겪으신 블로그

위의 링크들을 보면, 결국 메세지가 중복 렌더링 된다는 것은 이벤트가 중복으로 읽히고 있다는 것이다.

가장 최신의 메세지를 받게 되는 이벤트가 어디서 작성이 되고 있는지가 관건이었고, 나와 같은 경우는 다음과 같이 작성하여 해결하였다.

/*...*/
function ChatModal() {
  /*...*/
    useEffect(() => {
    onMessage();  // 가장 최신 메세지를 받는 이벤트 함수를 useEffect내에 작성한다.
    getOnline();
    return () => {
      socket.off('message'); 
      socket.off('latestMessage'); // 가장 최신 메세지를 받는 이벤트를 한 번 실행했으니 리스너를 제거해준다.
      socket.off('onlineStatus');
    };
  }, [addChat, setOnlineEmailList]);
  // addChat은 채팅 하나를 서버로부터 받으면 리덕스의 리듀서에 해당 채팅을 기존 채팅 리스트에 추가해주는 액션 함수이다. 
  // 채팅을 하나 주고 받을 때 실행되는 함수들을 의존성 배열에 걸어줘서 채팅을 딱 한 번만 주고 받게 실행하게끔 코드를 작성했다. 
  
  /*...*/
  
  /* 메세지를 보내는 함수 */
  function handleSend(value: string) {
    if (value.trim().length === 0) {
      return;
    }
    sendMessage(value);
    sendOnline();
  }
  
  /*...*/
  
    /* 메세지를 보내는 이벤트 함수 */
    function sendMessage(message: string) {
    if (userEmail === adminEmail) {
      socket.emit('message', chatRoomDetail.email, adminEmail, message);
    } else {
      socket.emit('message', userEmail, userEmail, message);
    }
  }

  /* 가장 최신 채팅을 받는 이벤트 함수 */
  function onMessage() {
    socket.on('latestMessage', (data: IChatMessage[]) => {
      console.log('latestMessage: ', data);
      const newChatMessage = {
        sender_email: data[0].sender_email,
        name: data[0].name,
        generation: data[0].generation,
        message: data[0].message,
        sentAt: data[0].sentAt,
      };
      dispatch(addChat({ chatMessage: newChatMessage }));
    });
  }
  /*...*/
}

여기서 나는 소켓 관련 함수들을 화살표 함수를 사용하지 않고, function을 사용하였는데, 이는 useEffect의 의존성 배열로 함수를 넣어주기 위해 작성했었다.

이런식으로 의존성 배열을 적절히 넣어 이벤트를 딱 한 번만 실행되게 하는 것이 관건이었다.

2-2. input 렌더링 제어 이슈

소켓 연결을 분명히 채팅 모달창이 열릴 때 딱 한 번만 연결되게 코드를 짰는데, 연결이 계속해서 무한으로 연결이 되는 현상이 발생했다.

서버쪽 콘솔에도, 클라이언트쪽 콘솔에도 connect가 계속해서 찍히고 있었는데, 잘 살펴보니 채팅창 인풋에 한 글자씩 입력할 때마다 connect가 찍히고 있었다.

채팅 인풋은 채팅 모달창의 자식 컴포넌트였는데, 채팅 모달창 컴포넌트에 소켓 연결 시도하는 로직과 input의 state값을 관리하는 로직이 있다.

그리고 자식 컴포넌트인 채팅 인풋 컴포넌트로부터 input의 값과 set함수를 props로 받아와 onChange 이벤트로 값을 업데이트하는 방식으로 구성되고 있었다.

  • chatInput.tsx : 자식 컴포넌트
import { useMemo } from 'react';
import styles from './chatInput.module.scss';
import darkStyles from './chatInputDark.module.scss';
import { ReactComponent as Send } from 'assets/Send.svg';
import { useSelector } from 'react-redux';
import { RootState } from 'store/configureStore';

import { IChatInput } from 'types/chat';

function ChatInput({
  inputValue,
  handleInputChange,
  handleClick,
  handleEnter,
}: IChatInput) {
  const isDarkMode = useSelector(
    (state: RootState) => state.checkMode.isDarkMode,
  );

  const selectedStyles = useMemo(() => {
    return isDarkMode ? darkStyles : styles;
  }, [isDarkMode]);

  return (
    <div className={selectedStyles.chatInputContainer}>
      <textarea
        className={selectedStyles.chatInput}
        value={inputValue}
        maxLength={500}
        onChange={handleInputChange}
        onKeyPress={handleEnter}
      />
      <button className={selectedStyles.sendButton} onClick={handleClick}>
        <div className={selectedStyles.sendIconWrapper}>
          <Send />
        </div>
      </button>
    </div>
  );
}

export default ChatInput;
  • chatModal.tsx : 부모 컴포넌트
/*...*/
function ChatModal() {
  const [inputValue, setInputValue] = useState<string>('');
  
  /*...*/
  function handleInputChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
    setInputValue(e.target.value);
  }

  function handleSend() {
    if (inputValue.trim().length === 0) {
      return;
    }
    sendMessage(inputValue);
    sendOnline();
    setInputValue('');
  }

  function handleEnter(e: React.KeyboardEvent<HTMLTextAreaElement>) {
    if (e.key === 'Enter') {
      e.preventDefault();
      handleSend();
    }
  }

  /*...*/
  
  return(
    /*...*/
    <ChatInput
      inputValue={inputValue}
      handleInputChange={handleInputChange}
      handleClick={handleSend}
      handleEnter={handleEnter}
    />
    /*...*/
  );
}

문제는 자식 컴포넌트인 채팅 인풋에서 값을 업데이트 할 때마다, 글자를 한 글자씩 칠 때마다 부모 컴포넌트가 리렌더링이 일어나면서 socket연결을 계속 시도하게 되었다.

부모 컴포넌트의 리렌더링을 해결하면 되는 문제라고 생각되어 useRef로 input값을 업데이트 하는 방법을 생각했다.

useRef

Javascript 를 사용할때, 특정 DOM 을 선택하여 정보를 얻거나 임의로 조작해야 할때, getElementById 혹은 querySelector 과 같은 DOM Selector 함수를 사용하여 DOM 을 선택하였다. 하지만, React 는 이 기능을 대체할 수 있는 useRef 훅을 제공한다.

useState는 state가 변경되면 내부의 모든 변수들이 초기화 되는 반면에, useRef 컴포넌트가 리렌더링 되지않는다. 값을 입력 할때, 값을 저장만 할뿐 컴포넌트를 리렌더링 시키지않는다.

즉, 현재는 props로 받은 state값이 업데이트가 되어서 부모 컴포넌트까지 리렌더링이 되고 있으니 ref를 사용하면서 state 관리를 채팅 인풋 컴포넌트에서 제어해서 자식 컴포넌트만 리렌더링이 가능하게 코드를 수정하면 해결이 되는 것이다!

다음은 해결한 부분의 수정된 부분을 나타내는 코드이다.

  • chatInput.tsx

  • chatModal.tsx

3. 회고 및 결과 코드

백엔드 담당하신 분이랑 협업을 진행하며 이벤트 기획부터 구현까지 그 사이에 정말 많은 버그들이 발생했었다. 안타깝게도 백엔드 분의 당시 포스트맨이 먹통이 되는 바람에 프론트 코드를 동시에 작성해서 백엔드분께서 테스트를 할 수 있게끔 계속 보내줬었어야 했다. 동시에 코드를 작성하고 각자의 코드를 넘겨받고 프론트와 백엔드를 경계짓지 않고 버그를 잡는데에 온 집중을 했었던 것 같다. 약간 온라인으로 하는 페어프로그래밍 느낌도 나면서 쏠쏠한 재미가 있었다.

어느정도의 백엔드 지식을 확실히 갖춰야하구나라고 느꼈던 협업이었다. 소켓 통신 구현을 시작하기 전날에 유투브에서 밤을 새워 인도형님 영상을 보며 클론 코딩을 했던 것이 서버와 클라이언트 사이 소켓 통신의 전반적인 동작을 알게 되는 데에 큰 도움이 되었다. 다만 그 영상에 너무 포커싱하여 우리의 개발환경과 서비스 로직을 고려하지 않고 영상의 이벤트 기획과 흐름, API 따라하게 된 것은 큰 오산이었다. 차후 스택오버플로우와 공식문서 등을 살펴보며 socket.emit을 io.to.emit으로 고치는 등 적절한 emit API를 사용하고, 이벤트 명을 받는 이벤트명과 주는 이벤트명을 따로 두게 됨으로써 버그를 고칠 수 있었던 것 같다. 정말 고생 많으셨습니다 백엔드 최고🥹

버그를 마주하고 해결하는 과정을 다시 회고해보니 블로그와 유투브보다 공식문서가 제일 정확하고 답을 알려주고 있구나를 깨닫게 되었다. 소켓 공식문서에 진짜 친절하게 설명이 되어있었더라.. 하지만 영어 너무 어려운걸...

마지막으로 결과 코드가 있는 레포지토리 주소를 남기며 글을 마무리 하겠다.

백엔드 레포지토리
프론트 레포지토리

profile
성공 = 무한도전 + 무한실패

1개의 댓글

comment-user-thumbnail
2024년 4월 18일

잘봤습니다 소중한 경험 공유 감사합니다!

답글 달기