소켓관련문제 - dltmdrbtjd/HANG GitHub Wiki

채팅에서 발생되는 문제들

  1. 한 이벤트가 on 될 때마다 이전에 발생했던 이벤트의 결괏값이 같이 넘어오는 현상
  2. 상태 값의 변화가 빈번하게 일어나 페이지 리렌더링이 너무 자주 수행되는 현상 ​

1. socket에서 이벤트를 on 하는 코드는 한 번만 수행되면 충분하다.

const [chatLog, setChatLog] = React.useState([]);React.useEffect(() => {
  socket.on('updateMessage', (data) => {
    setChatLog([...chatLog, data]);
  });scrollToBottom();
}, [chatLog]);const sendMessage = () => {
  if (message) {
    socket.emit('sendMessage', {
      roomName,
      targetPk: targetUserPk,
      message,
      userPk,
    });setMessage('');
  }
};

​ 위 코드는 sendMessage 이후 chatLog의 상태가 변경될 때마다 updateMessage가 새롭게 on되는 코드입니다. 위 코드를 실행하면 새로운 메시지를 보낼 때마다 채팅방 렌더링 속도가 느려지고 심하면 홈페이지 자체가 먹통이 되는 문제가 생겼습니다. ​

1차 개선

문제 파악

  • chatLog가 변경될 때만 리렌더링 되어야 하는 컴포넌트들이 모든 state가 업데이트 될 때마다 리렌더링 되어서 속도가 느려지는게 아닐까? ​

해결 방안

  • updateMessage가 on 될 때마다 chatLog 배열 자체를 업데이트 하지말고 다른 state를 업데이트 한 후 chatLog배열에 적용시키자
  • SpeechBubble 컴포넌트를 chatLog 배열이 업데이트 될 때만 리렌더링되도록 코드를 개선하자 ​
const ShowChatLog = React.memo<ShowChatLogType>(({ userPk, chatLogs }) => {
  return (
    <>
      {chatLogs.map((chat, idx) => (
        <SpeechBubble
          person={userPk === chat.userPk}
          next={idx < chatLogs.length - 1 ? chat.userPk === chatLogs[idx + 1].userPk : false}
          key={(Date.now() + Math.random() + idx).toString(36)}
        >
          {chat.message}
        </SpeechBubble>
      ))}
    </>
  );
});const [chatLogs, setChatLogs] = React.useState<ChatLogType[]>([]);
const [chatLog, setChatLog] = React.useState<ChatLogType>({
  curTime: 0,
  message: '',
  userPk: 0,
});React.useEffect(() => {
  socket.on('updateMessage', (data) => {
    setChatLog(data);
  });setChatLogs(chatLogs.concat(chatLog));
}, [chatLog]);const sendMessage = () => {
  if (message) {
    socket.emit('sendMessage', {
      roomName,
      targetPk: targetUserPk,
      message,
      userPk,
    });setMessage('');
  }
};

​ 위 코드 적용 결과 채팅 작성 시마다 말풍선이 리렌더링되고 채팅이 버벅이다가 튕겨버리는 문제는 해결되었습니다. ​ 하지만 여전히 updateMessage가 수행되면 이전에 보냈던 메시지도 같이 넘어오는 현상은 남아있었고, 간헐적으로 채팅이 먹통이 되는 문제도 발생했습니다. ​

2차 개선

문제 파악

  • 문제는 socket에 대한 이해 부족으로 이벤트를 on 시키는 로직 자체가 잘못 짜여진 것 이었습니다.
  • socket에서 이벤트를 on 시키면 socket은 일정 시간마다 서버에 동기적으로 요청을 보내고 사용 가능한 정보가 있으면 정보를 받아와서 이벤트에 연결되어 있는 로직을 수행하는 방식으로 동작합니다. 따라서 이벤트를 on 시키는 코드는 최초 한 번만 실행되면 되는 것이고 위처럼 코드를 작성하면 chatLog가 변경될 때마다 새로운 이벤트를 on 시키며 서버에서 사용가능한 정보를 모두 받아오는 것이 문제였습니다. ​

해결 방안

  • updateMessage 이벤트는 무조건 한 번만 on 되도록 수정하자 ​
React.useEffect(() => {
  dispatch(CheckChatAlarm(targetPk));socket.emit('join', {
    joiningUserPk: userPk,
    targetUserPk: targetPk,
    nickname,
  });socket.on('chatLogs', (logs) => {
    const addedChatLog = logs.chatLogs.map((log: string) => JSON.parse(log));chatLogState.setChatLogs(addedChatLog);
  });socket.on('updateMessage', (data) => {
    chatLogState.setChatLogs((chatLog) => chatLog.concat(data));
  });return () => {
    socket.emit('leave', { roomName, userPk });
    socket.off('chatLogs');
    socket.off('updateMessage');
  };
}, []);

  • updateMessage는 채팅방에 들어갈 때 한 번만 on 되도록 수정하였고, 채팅방을 벗어난다면 updateMessage를 off 시키는 방법으로 코드를 개선하였습니다. ​ 적용 결과 채팅이 간헐적으로 먹통이 되는 문제, 채팅을 여러개 입력하면 홈페이지가 버벅이는 문제가 해결되었습니다. ​

2. 컴포넌트를 리렌더링에 관여하는 state 단위로 구분하자

chat_room

import React from 'react';
// redux
import { useDispatch, useSelector } from 'react-redux';
import { DeleteChatRoom, ChatAlarmCheck, getUnchecked } from 'src/redux/modules/ChatModule/chat';
// socket
import socket from 'src/util/socket';
// apis
import apis from 'src/shared/api';
// moment
import moment from 'moment';
// history
import { history, useTypedSelector } from '../../../redux/configureStore';
// user info
import { delUserInfo, getUserInfo } from '../../../shared/userInfo';
// elements
import { Grid, Text, Input, Button, Container } from '../../../elements';
// components
import SpeechBubble from './SpeechBubble';
import RoomHeader from './RoomHeader';
import Modal from '../../../components/Modal';
// style
import { WarningText, ChatInputAreaSize } from './style';
import { setMediaLimitBoxSize } from '../../../styles/Media';const ChatRoom = () => {
  const targetUserInfo = getUserInfo('targetUserInfo');
  const targetUserPk = targetUserInfo.targetPk;const dispatch = useDispatch();
  const alarmCount = useTypedSelector((state) => state.chat.alarmCount);
  const unchecked: number = useSelector(getUnchecked(targetUserPk));const { userPk, nickname } = getUserInfo('userInfo');const [chatLog, setChatLog] = React.useState([]);
  const [message, setMessage] = React.useState('');
  const [open, setOpen] = React.useState(false);const messageRef = React.useRef(null);const roomName =
    (userPk < targetUserPk && `${userPk}:${targetUserPk}`) || `${targetUserPk}:${userPk}`;const QuitRoom = () => {
    socket.emit('ByeBye', { roomName, userPk });
    dispatch(DeleteChatRoom(targetUserPk));history.replace('/chat');
  };const BlockUser = () => {
    apis
      .AddBlockList({ targetPk: targetUserPk })
      .then(() => QuitRoom())
      .catch((err) => console.log(err));
  };const scrollToBottom = () => {
    if (messageRef.current) {
      messageRef.current.scrollIntoView({ block: 'end' });
    }
  };React.useEffect(() => {
    if (alarmCount > 0) dispatch(ChatAlarmCheck(alarmCount - unchecked));socket.emit('join', { joiningUserPk: userPk, targetUserPk, nickname });socket.on('chatLogs', (logs) => {
      const addedChatLog = logs.chatLogs.map((log) => JSON.parse(log));setChatLog(addedChatLog);
    });return () => {
      socket.emit('leave', { roomName, userPk });
      delUserInfo('targetUserInfo');
    };
  }, []);React.useEffect(() => {
    socket.on('updateMessage', (data) => {
      setChatLog([...chatLog, data]);
    });scrollToBottom();
  }, [chatLog]);const sendMessage = () => {
    if (message) {
      socket.emit('sendMessage', {
        roomName,
        targetPk: targetUserPk,
        message,
        userPk,
      });setMessage('');
    }
  };const DATEFORMAT = 'YYYY년 M월 D일';
  const weekdays = ['일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일'];
  const date = chatLog[0] ? moment(chatLog[0].curTime) : moment();return (
    <div ref={messageRef}>
      <Container>
        <Grid margin="0 0 95px">
          <RoomHeader methods={[QuitRoom, () => setOpen(true)]} targetUserInfo={targetUserInfo} /><Text
            fs="xs"
            wb="keep-all"
            color="darkG"
            padding="10px 12px"
            ls="-0.5px"
            addstyle={WarningText}
          >
            매너있는 채팅 부탁드립니다.
            <br />
            약속을 일방적으로 파기하거나 지키지 않을 경우 제재 대상이   있습니다.
          </Text><Text fs="xs" textAlign="center" margin="0 0 20px">
            {`${date.format(DATEFORMAT)} ${weekdays[date.days()]}`}
          </Text>{chatLog.map((chat, idx) => (
            <SpeechBubble
              person={userPk === chat.userPk}
              next={idx < chatLog.length - 1 ? chat.userPk === chatLog[idx + 1].userPk : false}
              key={(Date.now() + Math.random() + idx).toString(36)}
            >
              {chat.message}
            </SpeechBubble>
          ))}<Grid
            position="fixed"
            bottom="110px"
            left="50%"
            translate="-50%, 0"
            width="90%"
            radius="12px"
            bgColor="white"
            border="1px solid #E7E7E7"
            isFlex
            hoz="space-between"
            ver="center"
            addstyle={setMediaLimitBoxSize('768px')}
          >
            <Input
              placeholder="채팅 내용 입력"
              border="none"
              value={message}
              addstyle={ChatInputAreaSize}
              _onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMessage(e.target.value)}
              _onKeyPress={(e: React.KeyboardEvent<HTMLInputElement>) =>
                e.key === 'Enter' ? sendMessage() : null
              }
            /><Button padding="6px 15px" margin="0 9px 0 0" _onClick={() => sendMessage()}>
              전송
            </Button>
          </Grid>
        </Grid><Modal
          open={open}
          close={() => setOpen(false)}
          mainText="차단하기"
          subText2={`${targetUserInfo.nickname} 님을 정말 차단시겠습니까?`}
          agreeText="확인"
          agree={BlockUser}
        />
      </Container>
    </div>
  );
};export default React.memo(ChatRoom);

​ 위와 같은 구조는 하나의 state가 변경될 때마다 모든 컴포넌트가 리렌더링되는 단점이 있습니다. input 창에서 메시지를 입력할 때마다 채팅방 전체가 리렌더링 되는데 실시간으로 새로운 컴포넌트가 추가되는 특성상 내부 컨텐츠가 많아질 수 밖에 없어 메시지 입력 시마다 모든 컴포넌트가 리렌더링된다면 페이지의 성능이 저하될 수 밖에 없는 구조입니다. ​

해결 방안

  • 컴포넌트 리렌더링에 영향을 미치는 state 별로 나누자
  • 파일이 여러개로 나뉘면 state를 관리하기 어려워지니 Context API를 사용해 state를 관리하자 ​
import React from 'react';
// user info
import { getUserInfo } from 'src/shared/userInfo';
// socket
import { socket } from 'src/util/socket';
// redux
import { useDispatch } from 'react-redux';
import { CheckChatAlarm } from 'src/redux/modules/ChatModule/chat';export const chatStatus = React.createContext(null);export interface ChatLogType {
  curTime: number;
  message: string;
  userPk: number;
}const useProviderChatLogs = () => {
  const { targetPk } = getUserInfo('targetUserInfo');
  const { userPk } = getUserInfo('userInfo');const roomName = (userPk < targetPk && `${userPk}:${targetPk}`) || `${targetPk}:${userPk}`;const [chatLogs, setChatLogs] = React.useState<ChatLogType[]>([]);
  const [inputBoxHeight, setInputBoxHeight] = React.useState<number>(90);const chatLogState = {
    chatLogs,
    setChatLogs,
  };const inputBoxHeightState = React.useMemo(
    () => ({
      inputBoxHeight,
      setInputBoxHeight,
    }),
    [inputBoxHeight]
  );return {
    roomName,
    chatLogState,
    inputBoxHeightState,
  };
};const ChatContext = ({ children }) => {
  const chat = useProviderChatLogs();const { roomName, chatLogState } = chat;const { targetPk } = getUserInfo('targetUserInfo');
  const { userPk, nickname } = getUserInfo('userInfo');const dispatch = useDispatch();React.useEffect(() => {
    dispatch(CheckChatAlarm(targetPk));socket.emit('join', {
      joiningUserPk: userPk,
      targetUserPk: targetPk,
      nickname,
    });socket.on('chatLogs', (logs) => {
      const addedChatLog = logs.chatLogs.map((log: string) => JSON.parse(log));chatLogState.setChatLogs(addedChatLog);
    });socket.on('updateMessage', (data) => {
      chatLogState.setChatLogs((chatLog) => chatLog.concat(data));
    });return () => {
      socket.emit('leave', { roomName, userPk });
      socket.off('chatLogs');
      socket.off('updateMessage');
    };
  }, []);return <chatStatus.Provider value={chat}>{children}</chatStatus.Provider>;
};export default ChatContext;

​ 위처럼 파일별로 공유되어야 하는 state의 경우 Context API에 담아 하위 컴포넌트에서 사용할 수 있도록 변경하였습니다. ​

chat_context

import React from 'react';
// context
import { chatStatus, ChatLogType } from '../ChatContext';
// user info
import { getUserInfo } from '../../../../shared/userInfo';
// elements
import { Text, Grid } from '../../../../elements';
// style
import { SpeechBubbleStyle } from './style';const SpeechBubble = () => {
  const { userPk } = getUserInfo('userInfo');
  const { chatLogState } = React.useContext(chatStatus);return (
    <>
      {chatLogState.chatLogs.map((chat: ChatLogType, idx: number) => {
        const person = userPk === chat.userPk;
        const next =
          idx < chatLogState.chatLogs.length - 1
            ? chat.userPk === chatLogState.chatLogs[idx + 1].userPk
            : false;return (
          <Grid
            isFlex
            hoz={person && 'flex-end'}
            margin={next ? '0 0 8px' : '0 0 16px'}
            key={(Date.now() + Math.random() + idx).toString(36)}
          >
            <Text
              padding="16px"
              color={person ? 'white' : 'black'}
              addstyle={SpeechBubbleStyle(person)}
            >
              {chat.message}
            </Text>
          </Grid>
        );
      })}
    </>
  );
};export default React.memo(SpeechBubble);

​ 위와 같이 모든 컴포넌트를 사용하는 state 별로 나누어 관리하고, 해당 state값이 변경될 때만 리렌더링되도록 코드를 개선했습니다. ​

채팅방_성능개선

적용 결과 메시지 입력 시 input 창만 리렌더링되는 것을 알 수 있습니다.

⚠️ **GitHub.com Fallback** ⚠️