[코딩온] 프로젝트 회고록 : React & socket.io 실시간 채팅 구현

Yunhye Park·2023년 12월 24일
0
post-thumbnail

프로젝트 소개

✔️ 기간 : 2023. 12. 21 ~ 12. 24 (4일)

✔️ 작업 환경

  • FE
    • react, socket.io-client
  • BE
    • node, express, socket.io

✔️ 폴더 구조

📦back
 ┣ 📜.gitignore
 ┣ 📜index.js
 ┣ 📜package-lock.json
 ┗ 📜package.json
 
 📦front
 ┣ 📂public
 ┃ ┗ 📜index.html
 ┣ 📂src
 ┃ ┣ 📂components
 ┃ ┃ ┣ 📜Chat.js
 ┃ ┃ ┣ 📜Chatting.js
 ┃ ┃ ┣ 📜Header.js
 ┃ ┃ ┗ 📜Notice.js
 ┃ ┣ 📂hooks
 ┃ ┃ ┗ 📜UseToggle.js
 ┃ ┣ 📂styles
 ┃ ┃ ┗ 📜chat.css
 ┃ ┣ 📜App.js
 ┃ ┣ 📜index.css
 ┗ ┗ 📜index.js

개발 포인트

1. 성능 최적화 고민

  • useCallback, useMemo 등으로 효율화할 수 있는 상황인지 고려

2. 편리한 UI, UX에 대한 고찰

  • Enter 입력 시 버튼 동작
  • 메시지 전송 시간 보여주기
  • 채팅 참여자 목록을 모달창으로
    • 본인은 따로 표기
    • 개인 DM <-> 전체 메시지 전환 가능
  • 개인 DM과 전체 메시지를 구분하여 스타일링
  • 메시지 창에 여러줄 입력 시 자동으로 길이 조절

3. 예외처리

  • 닉네임 중복 입력 시 안내 문구
  • 입력값 없이 메시지 전송 불가

시연 영상 및 화면 구조/기능

초기 화면

  • 닉네임 중복 방지

  • 엔터키 입력 시 버튼 동작

채팅창 화면

  • 채팅 보낸 시간

  • 개인 DM

  • 메시지 길이에 따라 입력창 길이 자동 조절

  • 상대 닉네임만 보여주기

  • 말풍선 디자인

  • 메시지창 빈값일 땐 버튼 색 어둡게 + disabled

트러블 슈팅

채팅 시간 누적

  • 상황 : 각 메세지마다 채팅 시간이 하나씩 나오지 않고, 메시지마다 채팅 시간이 누적

  • 원인 : map을 돌리고 있는 컴포넌트의 하위에 다시 map을 돌렸기 때문
    💡 이때 근본적인 원인이 있었다. ChatTime이라는 컴포넌트를 새로 만들어서 시간만 담아줬는데, 메시지 정보를 담고있던 state에 timestamp를 추가해주는 게 훨씬 간결하다.

  • 해결 :

  // sendMsg : 메시지 전송
  const sendMsg = () => {
    if (msgInput.trim() !== '') {
      textareaRef.current.style.height = 'auto';
      const timestamp = getMsgTime();
      socket.emit('sendMsg', {
        userId: userId,
        msg: msgInput,
        dm: dmTo,
        timestamp: timestamp,
      });
    }
  };

  // msg time 전달하기
  const getMsgTime = () => {
    const currentTime = new Date();
    const hours = currentTime.getHours();
    const minutes = currentTime.getMinutes();

    const msgTime = `${hours < 10 ? '0' : ''}${hours}:${
      minutes < 10 ? '0' : ''
    }${minutes}`;

    return msgTime;
  };

시간을 구하는 함수를 만들어서 메시지를 전송할 때마다 실행해 주었다. 메시지 전송할 때에만 getMsgTime을 실행하도록 useMemo를 사용하려 했으나, 적절한 state가 없어서 생략했다.

최초 메시지 type 구분의 어려움

  • 상황 : 유저가 최초 접속하면 userId에 res.userId가 담기는 로직인데 초기값인 null이 콘솔에 찍힘

  • 원인 : 처리 순서 문제

  • 해결 : userId를 먼저 보내고, 이후 채팅방에 입장 알리도록 변경

io.on('connection', (socket) => {
  socket.on('entry', (res) => {
    if (Object.values(userIdArr).includes(res.userId)) {
      socket.emit('error', { msg: '중복된 닉네임입니다.' });
    } else {
      socket.emit('entrySuccess', { userId: res.userId });
      io.emit('notice', { msg: `${res.userId}님이 입장했습니다.` });
      userIdArr[socket.id] = res.userId;
      updateUserList();
    }
  });

textarea 관련 문제

최초 메시지 전송 후 자동 개행

  • 원인 : setMsgInput('')의 실행 시점

  • 해결 : 메시지를 서버로 보낼 때가 아닌, 서버에서 받은 정보로 chatList를 만드는 시점에 setMsgInput('') 적용

  // chat : 새로운 채팅 내용
  const addChatList = useCallback(
    (res) => {
      console.log('userid', userId);
      const type = res.userId === userId || !userId ? 'my' : 'other';

      const newChatList = [
        ...chatList,
        {
          type: type,
          content: res.msg,
          userId: type === 'my' ? '' : res.userId,
          timestamp: res.timestamp,
          dm: res.dm,
        },
      ];
      textareaRef.current.style.height = 'auto';
      setMsgInput('');
      setChatList(newChatList);
    },
    [chatList]
  );

메시지 전송 후 height이 2줄로 커짐

  • 상황 : 최초 메시지 전송 후 height이 41px로 고정.resizeHeight(자동 높이 조절)의 문제 같은데 정확한 원인은 모르겠다.

  • 임시 해결 :

    • 메시지 전송(sendMsg)과 addChatList할 때 기본 길이로 조절하는 코드 추가 textareaRef.current.style.height = 'auto';
  const textareaRef = useRef();

  const resizeHeight = () => {
    textareaRef.current.style.height = 'auto';
    textareaRef.current.style.height
      = `${textareaRef.current.scrollHeight}px`;
  };

  <textarea
    ref={textareaRef}
    className="input-msg input-basic"
    value={msgInput}
    placeholder="메시지를 입력하세요."
    onChange={(e) => {
      setMsgInput(e.target.value);
      resizeHeight();
    }}
    rows={1}
    ...
  />

🐞 강제로 height을 줄이기 때문에 메시지 전송 후 height이 움찔하는 버그 존재.

메시지 전송 후 메시지 창이 비어있지 않음

  • 상황 : 화면 상에서는 공백이 없으나, 완전한 빈값으로 인식되지도 않음. 앞선 setMsgInput('')이 완전한 빈값으로 동작하지 않는다고 추정.
  • 해결 : trim 메서드 사용
  // sendMsg : 메시지 전송
  const sendMsg = () => {
    if (msgInput.trim() !== '') {
      ...
      
// Enter 누르면 button onClick과 동일
  const handleMsgEnter = (e) => {
    if (isComposing) return;
    else {
      if (e.key === 'Enter') {
        if (msgInput.trim() !== '')
          ...

🐞 handleMsgEnter도 결국 sendMsg 실행하므로, if (msgInput.trim() !== '')를 적지 않아도 공백 문제는 사라진다. 하지만 이 조건을 추가했을 때 height가 원래 크기로 줄어드는 반응이 더욱 빠르다.

배운 것

JSX에서 배열은 나열된다

닉네임 목록 나열처럼 단순 텍스트 나열일 땐 빈 배열을 선언해 원하는 요소를 push해주고, 그 배열을 return. 그리고 JSX문에서 그냥 {변수}를 기입하는 식으로 처리할 수 있다.

  const userListOptions = useMemo(() => {
    const options = [];
    for (const key in userList) {
      options.push(
        <li className="dm-name" key={key} 
            onClick={() => sendDmTo(key)}>
          {userList[key] === userId ?
         `${userList[key]} (나)` : userList[key]}
        </li>
      );
    }

    return options;
  }, [userList, userId]);

...

  <ul className="input__select-dm">
    <span>채팅 참여자</span>
      <li onClick={() => setDmTo('all')}>전체</li>
      {userListOptions}
  </ul>

한글 입력 시 keyDown 이벤트 중복

  • 원인 : IME composition

    IME composition?
    영어 외 다른 언어 사용 시 다양한 브러우저에서 해당 언어들을 지원하기 위한 OS 차원의 언어 변환 과정. Web API에서 event target 중 isComposing이라는 프러퍼티를 제공하여, event 발생 여부를 불리언 값으로 처리한다.
    isComposing이 true일 경우 아직 변환 과정에 있으므로 이벤트를 처리하지 않고, false일 경우만 해당 이벤트를 걸어주면 된다.

ex.

const handleEvent = (e) => {
  if (e.isComposing) return;
  else {
      // 원하는 이벤트 동작을 여기에 입력
  }
}

하지만 리액트는

키보드 이벤트에 isComposing이라는 프로퍼티가 없다. 대신 리액트에서 제공하는 컴포지션 이벤트가 별도로 존재한다 : onCompositionEnd, onCompositionStart.

그래서 composing state 변수를 하나 선언해서

  const [isComposing, setIsComposing] = useState(false);

composing 시작 시점에 true를 반환하고, 끝난 시점에 false를 반환하는 함수를 각 프로퍼티에 설정해준다.

ex.

<textarea
  ref={textareaRef}
  className="input-msg input-basic"
  value={msgInput.replace('\n', '')}
  placeholder="메시지를 입력하세요."
  onCompositionStart={() => setIsComposing(true)}
  onCompositionEnd={() => setIsComposing(false)}
  onKeyDown={handleMsgEnter}
  onChange={(e) => {
    setMsgInput(e.target.value);
    resizeHeight();
  }}
  rows={1}
/>

이 블로그에서 해당 내용을 배웠다.

io.to('소켓 아이디')

뒤늦게 DM 기능에 문제가 생겼음을 발견했다. 여러 기능들이 생겼던지라 어디서부터 문제가 생긴 건지 디버깅 과정이 꽤 험난했다. 달라진 점은 select - option 대신 ul - li를 썼다는 건데..

  • 상황 : 개인 DM을 보낸 사람에겐 DM 메시지가 보이고, 받는 사람에겐 보이지 않는다.

서버 측 코드를 확인했으나, 여전히 잘 구현되었다. 전적으로 클라이언트 측의 문제였다.

  • 원인 :socket.id가 아닌 userId를 io.to에 보냄

  • 해결 :
    userList의 key가 socket id라서 해당 값을 넘겨준다.

  const sendDmTo = useCallback(
    (selectedUserId) => {
      setDmTo(selectedUserId);
    },
    [dmTo]
  );

  const userListOptions = useMemo(() => {
    const options = [];
    for (const key in userList) {
      options.push(
        <li className="dm-name" key={key}
    	onClick={() => sendDmTo(key)}>
          {userList[key] === userId ? 
    	  `${userList[key]} (나)` : userList[key]}
        </li>
      );
    }

    return options;
  }, [userList, userId]);

소감

  • 이것저것 일 벌이며 기능 추가하다가 갑자기 한참 전에 해둔 기능에 문제가 생길 때 가장 괴롭다.. 허무해서. 하지만 침착하게 하다보면 결국 찾게 된다.. 코딩 하면서 제일 크게 느끼는 건데, 필요한 건 시간뿐이다.

  • 프로젝트가 하나 둘씩 늘면서 약간 자괴감이 들었다. 어느 하나 제대로 끝마친 게 없는 것 같아서. 하면 할수록 미완성이 늘어난다면 아예 안 하는 게 낫겠다는 생각이 순간 들었다.

    • 완벽하지 못한 나를 마주하기란 쉽지 않다. 그러나 세상 어디에도 완벽함은 없고, 반드시 완벽을 추구할 필요도 없다. 그저 어제보다 더 나은 나를 만들자.
  • 여러 줄의 input이 필요하면 textarea를 써야 한다!

  • 리액트 hook을 다양하게(useCallback, useMemo, useRef, custom hook) 사용할 수 있어서 좋았던 것 같다.

profile
일단 해보는 편

0개의 댓글