반응형

 

이전 장. [채팅 웹사이트 구현 - 2장] Node.js에서 Socket.io를 이용하여 소켓 서버 만들기

 

 

 

채팅 웹사이트 프론트엔드 구현 들어가기

 

이전 장에서 소켓 서버를 만들어 채팅 웹사이트에서 필요한 소켓 이벤트들을 정의하고 그 기능들을 구현했습니다. 이제 클라이언트 쪽도 개발하여 하나의 완전품으로서 동작할 수 있는 채팅 웹을 만들 것입니다. 프론트엔드는 React기술로 구현을 해볼 것입니다. 먼저 프로젝트 세팅은 간단하게 create-react-app으로 진행합니다.

 

npx create-react-app chat-app

 

 

 

기술 스택 정리

 

Socket.io로 서버를 구현했기 때문에 클라이언트는 socket.io-client 라이브러리를 사용해야합니다.

양방향 통신이기 때문에 클라이언트에서 소켓 이벤트 리스너를 쓰는 방법도 백엔드 개발했을 때와 똑같습니다. 이벤트 리스너를 설치하여 이벤트가 들어오면 콜백 함수를 통해 ping-pong 하는 형태입니다.

 

yarn add socket.io-client

 

지금 만드는 앱의 규모는 작기 때문에 redux같은 상태 관리 라이브러리는 사용하는 것이 오히려 복잡도만 늘릴 뿐 관리에 도움이 되지 않기 때문에 사용하지 않겠습니다. 대신 socket 객체는 context를 사용해서 어떤 컴포넌트에서든 props로 복잡하게 받지 않아도 사용할 수 있도록 할 것입니다.

 

디자인은 bootstrap이 기본적으로 제공하는 디자인을 사용할 것입니다.

 

yarn add bootstrap

 

public/index.html 에서 head 태그 안에 cdn 링크를 추가해줍니다.

 

<link
  href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0/dist/css/bootstrap.min.css"
  rel="stylesheet"
  integrity="sha384-wEmeIV1mKuiNpC+IOBjI7aAzPcEZeedi5yW5f2yOq55WWLwNGmvvx4Um1vskeMj0"
  crossorigin="anonymous"
/>

 

환경변수 관리는 이전 장에서 한 것과 같이 env-cmd 라이브러리를 사용합니다. 

 

yarn add env-cmd

 

* .env.development

REACT_APP_BACK_URL=http://localhost:4000

 

 

 

소켓 객체 생성하기

 

src/service/socket.js에서 소켓 객체를 생성하고 소켓과 context를 export하겠습니다.

 

* src/service/socket.js

import { createContext } from "react";
import socketIo from "socket.io-client";

export const socket = socketIo(String(process.env.REACT_APP_BACK_URL), { withCredentials: true });
export const SocketContext = createContext(socket);

socket.on("connect", () => {
  console.log("socket server connected.");
});

socket.on("disconnect", () => {
  console.log("socket server disconnected.");
});

 

App 컴포넌트에서는 SocketContext를 provider로 묶어줍니다.

또한 App 컴포넌트가 unmount될 때에는 소켓 연결을 끊도록 useEffect return 안에 disconnect를 실행하는 함수를 추가합니다.

 

* App.jsx

import { useEffect } from "react";

import { socket, SocketContext } from "src/service/socket";

function App() {
  useEffect(() => {
    return () => {
      socket.disconnect();
    }
  }, []);
  
  return (
    <SocketContext.Provider value={socket}>
      App
    </SocketContext.Provider>
  );
}

export default App;

 

 

 

 

 

 

이벤트 타입별로 기능 구현하기

 

서버를 개발할 때와 마찬가지로 이벤트 타입별로 기능 구현을 하겠습니다. 서버에서 총 4가지 타입을 정의했었는데 클라이언트에서도 이 타입들을 똑같이 정의합니다.

 

"JOIN_ROOM": 유저가 방에 참가했을 때 발생
"UPDATE_NICKNAME": 유저가 닉네임을 변경했을 때 발생
"SEND_MESSAGE": 유저가 메시지를 전송했을 때 발생
"RECEIVE_MESSAGE": 유저가 메시지를 받을 때 발생

 

JOIN_ROOM은 채팅 웹에 처음 들어왔을 때 발생시킬 것입니다. 지금 만들 웹은 원페이지이고, 로그인 기능도 따로 없습니다. 따라서 App컴포넌트가 마운트될 때 JOIN_ROOM을 emit해주려고 합니다. 그런데 이 event를 발생시킬 때 서버에 유저 닉네임을 같이 전송해줄 것이므로, useEffect는 dependency로 nickname이라는 상태 값을 가지게 될 것입니다. 그리고 이 nickname을 App컴포넌트에서 state로 관리할 것입니다.

 

* App.jsx

import { useState, useEffect } from "react";

import { socket, SocketContext, SOCKET_EVENT } from "src/service/socket";

function App() {
  const [nickname, setNickname] = useState("김첨지");

  useEffect(() => {
    return () => { // App 컴포넌트 unmount시 실행
      socket.disconnect();
    }
  }, []);
  
  useEffect(() => {
    socket.emit(SOCKET_EVENT.JOIN_ROOM, { nickname }); // JOIN_ROOM event type과 nickname data를 서버에 전송한다.
  }, [nickname]);
  
  return (
    <SocketContext.Provider value={socket}>
      App
    </SocketContext.Provider>
  );
}

export default App;

 

event type들은 src/service/socket.js에 정의해서 관리할 것입니다. 이전 장 리팩터링에서 했던 것과 같은 방법입니다.

 

* src/service/socket.js

// ...

export const SOCKET_EVENT = {
  JOIN_ROOM: "JOIN_ROOM",
  UPDATE_NICKNAME: "UPDATE_NICKNAME",
  SEND_MESSAGE: "SEND_MESSAGE",
  RECEIVE_MESSAGE: "RECEIVE_MESSAGE",
};

// ...

 

다음으로는 닉네임을 변경할 때 발생하는 UPDATE_NICKNAME 이벤트를 구현할 것입니다. 지금 구현한 코드에서는 nickname이 변경될 때마다 useEffect에서 JOIN_ROOM 이벤트를 emit해주고 있습니다. 이것을 처음에만 JOIN_ROOM이벤트를 emit하고 다음 nickname변경부터는 UPDATE_NICKNAME 이벤트를 emit하도록 하려고 합니다.

 

UPDATE_NICKNAME 이벤트는 변경된 닉네임 값과 이전 닉네임 값을 함께 전송할 것입니다. 그래서 이전 닉네임 값을 가지는 prevNickname이라는 변수를 App 컴포넌트 내에서 선언하겠습니다. 이 값의 변경은 App 컴포넌트 리렌더링을 발생시키지 않기를 원하기 때문에 state가 아닌 ref로 관리할 것입니다.

 

* App.jsx

import { useState, useRef, useEffect } from "react";

// ...

function App() {
  const prevNickname = useRef(null); // prevNickname 변경은 컴포넌트를 리렌더링 하지않습니다.
  const [nickname, setNickname] = useState("김첨지");

  // ...
  
  useEffect(() => {
    if (prevNickname.current) {
      socket.emit(SOCKET_EVENT.UPDATE_NICKNAME, { // 서버에는 이전 닉네임과 바뀐 닉네임을 전송해줍니다.
        prevNickname: prevNickname.current,
        nickname,
      });
    } else {
      socket.emit(SOCKET_EVENT.JOIN_ROOM, { nickname });
    }
  }, [nickname]);
  
  return (
    <SocketContext.Provider value={socket}>
      App
    </SocketContext.Provider>
  );
}

export default App;

 

이벤트 전송 코드는 작성했는데 닉네임을 변경할 수 있는 UI가 없습니다. 이제 src/components/NicknameForm.jsx 파일을 만듭니다. 이 컴포넌트는 닉네임을 입력할 수 있는 input과 전송할 수 있는 button을 가져야 합니다.

 

* src/components/NicknameForm.jsx

import { useState, useCallback } from "react";

function NicknameForm({ handleSubmitNickname }) {
  const [nickname, setNickname] = useState("");

  const handleChangeNickname = useCallback(event => {
    setNickname(event.target.value);
  }, []);

  const handleSubmit = useCallback(() => {
    handleSubmitNickname(nickname);
    setNickname("");
  }, [handleSubmitNickname, nickname]);

  return (
    <form className="d-flex">
      <div className="card d-flex flex-row align-items-center">
        <label htmlFor="user-name-input" style={{ width: 60 }}>
          닉네임
        </label>
        <input
          type="text"
          className="form-control w300"
          id="user-name-input"
          maxLength={12}
          value={nickname}
          onChange={handleChangeNickname}
        />
        <button
          type="button"
          className="btn btn-primary send-btn"
          value="확인"
          onClick={handleSubmit}
        />
      </div>
    </form>
  );
}

export default NicknameForm;

 

bootstrap을 사용해서 className으로 디자인을 해주고 있습니다. 추가적인 CSS 코드가 만들어서 화면에서 디자인 적용은 아직 제대로 안된 상태일 것입니다. 추가 CSS 코드는 기능 구현이 끝낸 후 소개하겠습니다.

input에서 값을 타이핑할 때마다 NicknameForm 컴포넌트가 그 값을 state로 관리를 해주다가 submit을 할 때에 그 값을 App 컴포넌트에 넘겨주어, App 컴포넌트에서 선언된 nickname state값을 변경해줄 것입니다. 따라서 handleSubmitNickname은 App에서 선언합니다.

 

* App.jsx

import { useState, useRef, useCallback, useEffect } from "react";

import NicknameForm from "src/components/NicknameForm";

// ...

function App() {
  // ...
  
  const handleSubmitNickname = useCallback(newNickname => {
      prevNickname.current = nickname;
      setNickname(newNickname);
    },
    [nickname]
  );
  
  return (
    <SocketContext.Provider value={socket}>
      <div className="d-flex flex-column justify-content-center align-items-center vh-100">
        <NicknameForm handleSubmitNickname={handleSubmitNickname} />
      </div>
    </SocketContext.Provider>
  );
}

export default App;

 

지금까지 구현한 내용을 테스트해보겠습니다. 백엔드 서버와 프론트엔드 서버를 둘다 실행시킵니다.

(터미널을 열고 Node.js 소켓 서버 프로젝트 폴더 경로에서 yarn start 입력, 또 다른 터미널을 열고 프론트엔드 프로젝트 폴더 경로에서 yarn start 입력, 하여 두 서버를 실행합니다.)

그리고 브라우저를 열고 localhost:3000 주소에 접속하여 클라이언트에 접속합니다.

인풋에 닉네임을 입력하고 버튼을 누르면 소켓 서버를 실행 중인 터미널에서 로그가 출력될 것입니다.

 

로그의 세부적인 텍스트는 지금까지 따라온 코드랑 스크린샷 사이에 다소 차이가 있을 수 있습니다만.. 어쨌든 터미널에 JOIN_ROOM, UPDATE_NICKNAME 순서로 이벤트 로그가 출력되었으면 정상적으로 작동한 것입니다.

 

테스트 결과 소켓 서버 로그 예시

 

이제 채팅메시지 리스트를 구현할 것입니다. 이 리스트는 RECEIVE_MESSAGE 이벤트와 함께 받은 데이터를 저장하며 화면 내 채팅창에 보여주는 기능을 할 것입니다. src/components/chatRoom.jsx를 만듭니다.

 

* src/components/chatRoom.jsx

import { useState, useCallback, useEffect, useContext, useRef } from "react";

import { SocketContext, SOCKET_EVENT } from "src/service/socket";

function ChatRoom({ nickname }) {
  const [messages, setMessages] = useState([]);
  const chatWindow = useRef(null);
  const socket = useContext(SocketContext);

  // 새 메시지를 받으면 스크롤을 이동하는 함수
  const moveScrollToReceiveMessage = useCallback(() => { 
    if (chatWindow.current) {
      chatWindow.current.scrollTo({
        top: chatWindow.current.scrollHeight,
        behavior: "smooth",
      });
    }
  }, []);

  // RECEIVE_MESSAGE 이벤트 콜백: messages state에 데이터를 추가합니다.
  const handleReceiveMessage = useCallback(pongData => {
      const newMessage = makeMessage(pongData); // makeMessage는 아직 구현하지 않은 함수.
      setMessages(messages => [...messages, newMessage]);
      moveScrollToReceiveMessage();
    },
    [moveScrollToReceiveMessage]
  );

  useEffect(() => {
    socket.on(SOCKET_EVENT.RECEIVE_MESSAGE, handleReceiveMessage); // 이벤트 리스너 설치

    return () => {
      socket.off(SOCKET_EVENT.RECEIVE_MESSAGE, handleReceiveMessage); // 이벤트 리스너 해제
    };
  }, [socket, handleReceiveMessage]);

  return (
    <div
      className="d-flex flex-column"
      style={{ width: 1000 }}
    >
      <div className="text-box">
        <span>{nickname}</span> 님 환영합니다!
      </div>
      <div
        className="chat-window card"
        ref={chatWindow}
      >
        {messages.map((message, index) => { 
          const { nickname, content, time } = message;
          // messages 배열을 map함수로 돌려 각 원소마다 item을 렌더링 해줍니다.
          return (
            <div key={index} className="d-flex flex-row">
              {nickname && <div className="message-nickname">{nickname}: </div>}
              <div>{content}</div>
              <div className="time">{time}</div>
            </div>
          );
        })}
      </div>
    </div>
  );
}

export default ChatRoom;

 

이 ChatRoom 컴포넌트는 받은 메시지들을 렌더링하는 컴포넌트입니다. 받은 메시지는 배열 자료구조로 state로 선언되어 있으며, 각 메시지들은 nickname, content, time 프로퍼티를 가지고 있어서 이 값들을 화면에 보여주고 있습니다.

 

메시지를 받는 이벤트는 RECEIVE_MESSAGE로 정의했었는데 이 이벤트를 useEffect() 안에서 컴포넌트가 마운트될 때 설치하고 언마운트될 때 해제하고 있습니다.

 

또한 채팅 메시지 목록 창 엘리먼트를 useRef를 이용해 참조하여, 새 메시지를 받을 때마다 스크롤을 이동하는 moveScrollToReceiveMessage 함수를 정의했습니다.

 

socket 객체는 props을 통해 받지 않아도 미리 만들어놨던 context로 사용할 수 있으므로 useContext를 통해 사용하고 있습니다.

 

handleReceiveMessage 안에는 아직 구현하지 않은 makeMessage라는 함수가 있습니다. 이것은 서버가 던져준 데이터를 가지고 화면에 보여줄 텍스트를 가공해서 반환해주는 역할을 할 것입니다.

 

서버가 미리 텍스트를 만들어서 보내주는 것이 아니라 클라이언트가 raw data를 화면에 출력할 텍스트로 가공하는 역할을 부담하는 이유는 뷰단에서만 사용될 데이터의 단순 연산에 대한 부담을 서버 컴퓨터가 갖지 않도록 하기 위해서입니다.

 

makeMessage는 src/service/socket.js에서 선언하겠습니다.

 

* src/service/socket.js

// ...

export const makeMessage = pongData => {
  const { prevNickname, nickname, content, type, time } = pongData;

  let nicknameLabel;
  let contentLabel = "";

  switch (type) {
    case SOCKET_EVENT.JOIN_ROOM: {
      contentLabel = `${nickname} has joined the room.`;
      break;
    }
    case SOCKET_EVENT.UPDATE_NICKNAME: {
      contentLabel = `User's name has been changed.\n ${prevNickname} => ${nickname}.`;
      break;
    }
    case SOCKET_EVENT.SEND_MESSAGE: {
      contentLabel = String(content);
      nicknameLabel = nickname;
      break;
    }
    default:
  }

  return {
    nickname: nicknameLabel,
    content: contentLabel,
    time: dayjs(time).format("HH:mm"),
  };
};

// ...

 

 

이제 App.jsx에서 이 ChatRoom 컴포넌트를 렌더링 해줍니다.

 

* App.jsx

// ...

import NicknameForm from "src/components/NicknameForm";
import ChatRoom from "src/components/ChatRoom";

// ...

function App() {
  // ...
  
  return (
    <SocketContext.Provider value={socket}>
      <div className="d-flex flex-column justify-content-center align-items-center vh-100">
        <NicknameForm handleSubmitNickname={handleSubmitNickname} />
        <ChatRoom nickname={nickname} />
      </div>
    </SocketContext.Provider>
  );
}

export default App;

 

기능이 잘 작동하는지 확인하기 위해 localhost:3000에 접속한 후 닉네임 변경을 해봅니다. 메시지가 화면에 있는 채팅창에 출력되면 제대로 기능하는 것입니다.

 

이제 마지막으로 남은 이벤트를 구현해봅시다. 유저가 메시지를 전송할 때 발생하는 SEND_MESSAGE 이벤트입니다.

src/components/MessageForm.jsx를 만듭니다.

 

* src/components/MessageForm.jsx

import { useState, useCallback, useContext } from "react";

import { SocketContext, SOCKET_EVENT } from "src/service/socket";

function MessageForm({ nickname }) {
  const [typingMessage, setTypingMessage] = useState("");
  const socket = useContext(SocketContext);

  // textarea에서 텍스트를 입력하면 typingMessage state를 변경합니다.
  const handleChangeTypingMessage = useCallback(event => {
    setTypingMessage(event.target.value);
  }, []);

 // 버튼을 누르면 실행합니다.
  const handleSendMesssage = useCallback(() => {
    // 공백을 trim()으로 제거합니다.
    const noContent = typingMessage.trim() === "";

    // 아무 메시지도 없으면 아무 일도 발생하지 않습니다.
    if (noContent) {
      return;
    }

    // 메시지가 있으면 nickname과 message를 SEND_MESSAGE 이벤트 타입과 함께 소켓 서버로 전송합니다.
    socket.emit(SOCKET_EVENT.SEND_MESSAGE, {
      nickname,
      content: typingMessage,
    });
    // state값은 공백으로 변경해줍니다.
    setTypingMessage("");
  }, [socket, nickname, typingMessage]);

  return (
    <form className="card">
      <div className="d-flex align-items-center">
        <textarea
          className="form-control"
          maxLength={400}
          autoFocus
          value={typingMessage}
          onChange={handleChangeTypingMessage}
        />
        <button
          type="button"
          className="btn btn-primary send-btn"
          onClick={handleSendMesssage}
        >
          전송
        </button>
      </div>
    </form>
  );
}

export default MessageForm;

 

이 컴포넌트는 메시지 입력창에 입력하고 있는 텍스트를 state로 관리합니다. 그리고 전송 버튼을 누르면 handleSendMessage함수가 실행되어 SEND_MESSAGE 이벤트를 nickname과 입력한 텍스트 데이터와 함께 소켓 서버로 emit합니다. ChatRoom에서 이 컴포넌트를 import 해줍니다.

 

// ...

import MessageForm from "src/components/MessageForm";

function ChatRoom({ nickname }) {
  // ...

  return (
    <div
      className="d-flex flex-column"
      style={{ width: 1000 }}
    >
      <div className="text-box">
        <span>{nickname}</span> 님 환영합니다!
      </div>
      <div
        className="chat-window card"
        ref={chatWindow}
      >
        {messages.map((message, index) => { 
          const { nickname, content, time } = message;
          return (
            <div key={index} className="d-flex flex-row">
              {nickname && <div className="message-nickname">{nickname}: </div>}
              <div>{content}</div>
              <div className="time">{time}</div>
            </div>
          );
        })}
      </div>
      <MessageForm nickname={nickname} />
    </div>
  );
}

export default ChatRoom;

 

 

 

CSS 덧붙이기 & 완성

 

계속 화면을 보면서 따라오고 계셨다면 퍼블리싱이 뭔가 이상하다고 느끼셨을 것 같습니다. 사실 여태까지 미리 정의해놓은 CSS 코드들을 className 안에 사용하고 있었는데요. 작성된 CSS 코드를 이제 공개합니다. index.css에 다음 코드를 추가해주시면 됩니다.

 

* index.css

#root {
  min-height: 100vh;
  background-color: #f8f9fa;
}

button.with-icon {
  display: flex;
}

.w300 {
  width: 300px;
}

.message-nickname {
  font-weight: bold;
  margin-right: 4px;
}

.send-btn {
  height: 38px;
  margin-left: 6px;
}

.card {
  padding: 14px;
}

.text-box {
  padding: 6px;
}

.time {
  margin-left: 4px;
  color: #aaa;
  font-size: 13px;
  line-height: 2;
}

.chat-window {
  height: 400px;
  overflow-y: auto;
}

 

 

완성...!

 

 

완성된 채팅 웹 사용하는 모습

 

 

 

 

 

 

마무리 - 프론트엔드 아쉬운 부분들

 

지금까지 Node.js로 소켓 서버를 만들고 React.js로 프론트엔드 개발을 하여 Socket.io로 실시간 양방향 통신이 가능한 채팅 웹을 만들었습니다. 프론트엔드도 마찬가지로 남아있는 아쉬운 부분들을 정리하고 마무리하려고 합니다. 이번에는 리팩터링과 UX 개선을 간단하게 얘기해보려고 합니다.

 

 

 

1. 리팩터링 - 컴포넌트 구조 개선

 

지금 저희의 React 프로젝트 컴포넌트 구조는 다음과 같습니다.

 

App ㅡ Nicknameform
    ㄴ ChatRoom ㅡ MessageForm

 

이대로도 큰 문제는 없습니다만 하나하나 뜯어보면 더 작은 단위로 분리를 하면 훨씬 가독성이 높은 컴포넌트가 될 여지가 있는 부분들이 있습니다.

 

우선 ChatRoom 컴포넌트의 jsx 부분을 보겠습니다.

 

* ChatRoom

function ChatRoom({ nickname }) {
  // ...
  
  return (
    <div
      className="d-flex flex-column"
      style={{ width: 1000 }}
    >
      <div className="text-box">
        <span>{nickname}</span> 님 환영합니다!
      </div>
      <div
        className="chat-window card"
        ref={chatWindow}
      >
        {messages.map((message, index) => { 
          const { nickname, content, time } = message;
          return (
            <div key={index} className="d-flex flex-row">
              {nickname && <div className="message-nickname">{nickname}: </div>}
              <div>{content}</div>
              <div className="time">{time}</div>
            </div>
          );
        })}
      </div>
      <MessageForm nickname={nickname} />
    </div>
  );
}

 

messages 배열을 map을 이용해 각 원소를 렌더링하는 부분입니다. 지금도 문제는 없지만 map함수 내의 return 안에 속하는 jsx 부분은 message item이라는 하나의 독립적 의미를 가지기 때문에 MessageItem이라는 이름의 컴포넌트로 만들어서 관리를 해주면 가독성이 훨씬 좋아집니다. 마찬가지로 이 MessageItem들을 그리는 map 함수를 포함하는 div를 MessageList라는 이름의 컴포넌트로 만들면 ChatRoom 안에 MessageList가 있고 해당 MessageList는 MessageItem을 매핑하고 있다는 가독성을 가지게 됩니다.

 

* ChatRoom 컴포넌트 jsx 개선

function ChatRoom({ nickname }) {
  // ...
  
  return (
    <div
      className="d-flex flex-column"
      style={{ width: 1000 }}
    >
      <div className="text-box">
        <span>{nickname}</span> 님 환영합니다!
      </div>
      <MessageList />
      <MessageForm nickname={nickname} />
    </div>
  );
}

 

MessageList 컴포넌트로 분리하고 보니 ChatRoom 컴포넌트는 jsx 내에서 MessageList와 MessageForm을 빼면 담당할 로직이 nickname을 렌더링 해주는 것 외에는 아무것도 없습니다. 모든 정의된 로직들이 MessageList가 담당하고 있기 때문입니다. 따라서 ChatRoom에서 return 위에 있는 모든 코드를 MessageList 안으로 옮겨주겠습니다. MessageList 컴포넌트 안에서만 동작하면 되는 코드를 더 상위 스코프인 ChatRoom에서 사용할 필요가 없기 때문입니다. 이로써 ChatRoom은 nickname props를 받아 별다른 로직없이 바로 jsx를 return하는 컴포넌트가 되었습니다.

 

* ChatRoom

function ChatRoom({ nickname }) {
  return (
    <div
      className="d-flex flex-column"
      style={{ width: 1000 }}
    >
      <div className="text-box">
        <span>{nickname}</span> 님 환영합니다!
      </div>
      <MessageList />
      <MessageForm nickname={nickname} />
    </div>
  );
}

 

그리고 MessageList가 로직을 가져와 다음과 같이 됩니다.

 

* MessageList

import { useState, useCallback, useEffect, useContext, useRef } from "react";

import MessageItem from "src/components/chatRoom/MessageItem";
import { SocketContext, SOCKET_EVENT, makeMessage } from "src/service/socket";

function MessageList() {
  const [messages, setMessages] = useState([]);
  const chatWindow = useRef(null);
  const socket = useContext(SocketContext);

  const moveScrollToReceiveMessage = useCallback(() => {
    if (chatWindow.current) {
      chatWindow.current.scrollTo({
        top: chatWindow.current.scrollHeight,
        behavior: "smooth",
      });
    }
  }, []);

  const handleReceiveMessage = useCallback(pongData => {
      const newMessage = makeMessage(pongData);
      setMessages(messages => [...messages, newMessage]);
      moveScrollToReceiveMessage();
    },
    [moveScrollToReceiveMessage]
  );

  useEffect(() => {
    socket.on(SOCKET_EVENT.RECEIVE_MESSAGE, handleReceiveMessage);

    return () => {
      socket.off(SOCKET_EVENT.RECEIVE_MESSAGE, handleReceiveMessage);
    };
  }, [socket, handleReceiveMessage]);

  return (
    <div className="chat-window card" ref={chatWindow}>
      {messages.map((message, index) => {
        return <MessageItem key={index} message={message} />;
      })}
    </div>
  );
}

export default MessageList;

 

* MessageItem

import React from "react";

function MessageItem({ message }) {
  const { nickname, content, time } = message;

  return (
    <div className="d-flex flex-row">
      {nickname && <div className="message-nickname">{nickname}: </div>}
      <div>{content}</div>
      <div className="time">{time}</div>
    </div>
  );
}

export default React.memo(MessageItem);

 

MessageItem은 추가적으로 React.memo()로 감싸주어 새 메시지가 추가되었을 때, 이미 화면에 출력되어있는 MessageItem들의 리렌더링을 막고, 추가된 새 아이템만 리렌더링하도록 최적화시켰습니다.

컴포넌트의 단위를 잘 쪼개면 이렇게 컴포넌트 단위별 최적화 코드 작성도 편리해집니다.

 

 

 

2. UX 개선

 

input이나 textarea가 enter key를 받았을 때 그대로 submit을 해줄 수 있으면 굳이 양손으로 타자를 치다가 오른손을 마우스로 옮겨서 버튼을 클릭하는 불편한 행동을 유저 입장에서 하지 않아도 됩니다.

 

input과 textarea에 enter key를 처리하는 로직을 추가해보려고 합니다 방법은 같습니다.

onKeyPress event를 추가해주면 됩니다.

 

<input
  value={typingMessage}
  onChange={handleChangeTypingMessage}
  onKeyPress={event => {
   if (event.code === "Enter") {
      event.preventDefault();
      handleSendMesssage();
    }
  }}
/>

 

 

 

소스코드

 

작업 결과는 github 레포지토리에서 확인할 수 있습니다.

frontend: https://github.com/cocoder16/toy-web-socket-front

backend: https://github.com/cocoder16/toy-web-socket-back

 

 

 

  • 네이버 블러그 공유하기
  • 네이버 밴드에 공유하기
  • 페이스북 공유하기
  • 카카오스토리 공유하기