[React 강의] Slack 클론 코딩[실시간 채팅 with React] 섹션5-1

강경서·2025년 9월 21일
post-thumbnail

📞섹션 5. 실시간 채팅 + 각종 프론트 기술 배우기

Socket.IO

npm install socket.io-client
const socket = io.connect(backUrl); // 서버(backUrl)에 소켓 연결을 시도합니다.
socket.emit('hello', 'world'); // 'world' 데이터를 'hello' 이벤트로 서버에 보냅니다.
socket.on('message', (data) => { // 서버에서 오는 "message" 이벤트를 수신합니다.
  console.log(data)
});
socket.disconnect() // 서버와의 연결을 끊습니다.

Socket.IO 는 WebSocket 기반의 라이브러리로 실시간 양방향 통신을 구현할 수 있습니다.


useSocket

import { useCallback } from 'react';
import { io, Socket } from 'socket.io-client';

const backUrl = process.env.NODE_ENV === 'production' ? 'https://sleact.nodebird.com' : 'http://localhost:3095';
const sockets: { [key: string]: Socket } = {};

const useSocket = (workspace: string): [Socket | undefined, () => void] => {
  const disconnect = useCallback(() => {
    if (workspace && sockets[workspace]) {
      console.log('소켓 연결 끊음');
      sockets[workspace].disconnect();
      delete sockets[workspace];
    }
  }, [workspace]);

  if (!workspace) {
    return [undefined, disconnect];
  }

  if (!sockets[workspace]) {
    sockets[workspace] = io(`${backUrl}/ws-${workspace}`, {
      transports: ['websocket'], // 구형 브라우저를 위한 http 통신을 보내지 않고 즉시 ws 통신을 시도합니다.
    }); // Namespace별로 한 번만 Socket을 생성하여 중복 연결을 방지합니다.
    console.info('create socket', workspace, sockets[workspace]);
    sockets[workspace].on('connect_error', (err) => {
      console.error(err);
      console.log(`connect_error due to ${err.message}`);
    });
  }

  return [sockets[workspace], disconnect];
};

export default useSocket;

React 의 컴포넌트내에서 Socket 연결 시, 컴포넌트 리렌더링마다 새로운 Socket 연결이 생겼다가 끊기는 불필요한 재연결, 이벤트 중복 등록 등의 성능 문제 발생할 수 있습니다. 위의 코드를 통해 sockets 객체를 통해 Socket 들을 관리하고 이미 존재하는 Namespace 의 중복 생성을 방지하여 위의 문제를 해결합니다.


socket.io 이벤트 연결하기

// front/layouts/Workspace

const [socket, disconnect] = useSocket(workspace);

useEffect(() => {
  if (userData && channelData && socket) {
    socket.emit('login', { id: userData.id, channels: channelData.map((channel) => channel.id) });
  }
}, [userData, channelData, socket]);

useEffect(() => {
  return () => {
    disconnect();
  };
}, [workspace, disconnect]);

// front/components/DMList

useEffect(() => {
    socket?.on('onLineList', (data: number[]) => {
      setOnlineList(data);
    });

    return () => {
      socket?.off('onLineList');
    };
  }, [socket]);

위의 코드는 유저가 로그인 후 특정 워크스페이스에 접속 시 서버에게 알리고 서버는 해당 워크스페이스의 로그인 되어있는 유저들을 알려주는 코드입니다.

먼저 워크스페이스 로딩 완료되었을 때 emit 을 통해 login 이벤트로 필요한 데이터를 서버에 전달하면, 서버는 on 을 통해onLineList 이벤트로 현재 접속중인 유저들의 데이터를 전달해줍니다.

불필요한 리소스 사용 방지를 위해 Socket 은 disconnect 를 해주어야 하고, 이벤트 중복 실행 방지를 막기 위해 Socket 의 이벤트도 언제나 잘 꺼주어야 합니다.


커스텀 스크롤바와 dayjs

react-custom-scrollbars

npm install react-custom-scrollbars

react-custom-scrollbars 는 React 에서 스크롤바를 자유롭게 커스터마이징할 수 있게 해주는 라이브러리입니다.

import Scrollbars from 'react-custom-scrollbars';

const ChatList = ({ chatData }: Props) => {
  const scrollbarRef = useRef(null);
  const onScroll = useCallback(() => {}, []);

  return (
    <ChatZone>
      <Scrollbars autoHide ref={scrollbarRef} onScrollFrame={onScroll}>
        {chatData?.map((chat) => (
          <Chat data={chat} key={chat.id} />
        ))}
      </Scrollbars>
    </ChatZone>
  );
};

export default ChatList;

autoHide 옵션을 사용하여 스크롤바를 숨길 수 있습니다.
autoHideTimeout 옵션을 사용하여 스크롤바가 사라지기까지의 시간(ms)을 설정할 수 있습니다.
autoHideDuration 옵션을 사용하여 사라질 때 애니메이션 시간(ms)을 설정할 수 있습니다.
onScrollFrame 옵션은 스크롤 상태를 0~1 범위의 비율로 제공하여 그에 따른 조작을 할 수있고,
onScroll 원본 DOM 이벤트와 거의 동일하여 스크롤 컨테이너 DOM 요소를 관리할 수 있습니다.


dayjs

npm install dayjs

dayjs는 JavaScript에서 날짜와 시간을 다루기 편하게 만들어주는 라이브러리입니다.
가볍고 불변성을 가지고 있습니다.Moment.js 와 거의 동일한 API 를 가지고 있어 쉽게 전환이 가능합니다.

import dayjs from 'dayjs';

const now = dayjs();              // 현재 시간
const date = dayjs('2025-09-01'); // 특정 날짜

// 포맷팅

dayjs().format('YYYY-MM-DD');   // 2025-09-18
dayjs().format('YYYY/MM/DD HH:mm:ss'); // 2025/09/18 16:30:45

// 날짜 연산

dayjs().add(1, 'day');    // 내일
dayjs().subtract(2, 'month'); // 두 달 전

// 비교

dayjs('2025-09-18').isBefore('2025-10-01'); // true
dayjs('2025-09-18').isAfter('2025-01-01');  // true

// Diff 계산

dayjs('2025-10-01').diff('2025-09-18', 'day'); // 13 (일 단위 차이)

// Locale

import 'dayjs/locale/ko';
dayjs().locale('ko').format('YYYY년 MM월 DD일'); // 2025년 09월 18일

멘션 기능 만들기

react-mentions

npm install react-mentions

react-mentions는 React에서 @멘션, #태그 기능을 쉽게 구현할 수 있게 도와주는 라이브러리입니다.


import { MentionsInput, Mention, SuggestionDataItem } from 'react-mentions';

const ChatBox = () => {

  const renderSuggestion = useCallback(
  (
    suggestion: SuggestionDataItem,
    search: string,
    highlightedDisplay: React.ReactNode,
    index: number,
    focused: boolean,
  ): React.ReactNode => {
    if (!memberData) return;

    return (
      <EachMention focused={focused}>
        <img
          src={gravatar.url(memberData[index].email, { s: '20px', d: 'retro' })}
          alt={memberData[index].nickname}
        />
        <span>{highlightedDisplay}</span>
      </EachMention>
    );
  },
  [memberData],
);
  
  return (
    <MentionsInput forceSuggestionsAboveCursor >
      <Mention
        appendSpaceOnAdd
        trigger="@"
        data={memberData?.map((data) => ({ id: data.id, display: data.nickname })) || []}
        renderSuggestion={renderSuggestion}
      />
    </MentionsInput>
  )  
}

export default ChatBox;

MentionsInput 컴포넌트내 Mention 컴포넌트가 위치하는 구조로 되어있습니다.

forceSuggestionsAboveCursor 를 MentionsInput 에 사용시 멘션이 항상 위로 배치 가능합니다.
allowSuggestionsAboveCursor 를 사용하게 된다면 아래 공간이 부족하면 위에 배치가 가능하도록, 즉 워크스페이스 사람이 적으면 아래로 배치됩니다.

Mention 컴포넌트의 appendSpaceOnAdd는 멘션 선택 후 자동으로 한 칸 띄어쓰기 추가됩니다. trigger="@" 를 통해 @ 입력 시 리스트를 활성화시켜줍니다. renderSuggestion 리스트의 항목을 렌더링할 때 커스텀 UI 적용할 수 있습니다.

renderSuggestion 함수를 보면 suggestion 를 통해서는 데이터를 가져올 수 있고, search 는 현재 입력한 검색어를 알 수 있습니다. highlightedDisplay를 통해 리스트의 항목을 하이라이트 처리가 가능합니다. focused 는 현재 포커스된 항목을 표시합니다.


문자열의 특별한 패턴을 프로그래밍에서 구현할 때 사용


정규표현식으로 문자열 변환하기

regexify-string

npm install regexify-string

regexify-string 은 문자열을 정규식 기준으로 분리하고, React 컴포넌트 등으로 쉽게 매핑할 수 있게 해주는 라이브러리입니다.


const result = useMemo(
  () =>
    regexifyString({
      input: data.content,
      pattern: /@\[(.+?)\]\((\d+?)\)|\n/g,
      decorator(match, index) {
        const arr: string[] | null = match.match(/@\[(.+?)\]\((\d+?)\)/);

        if (arr) {
          return (
            <Link key={match + index} to={`/workspace/${workspace}/dm/${arr[2]}`}>
              @{arr[1]}
            </Link>
          );
        }
        return <br key={index} />;
      },
    }),
  [data.content],
);

📝 후기

Socket.IO는 이전에 사용해본 경험이 있어 이해하기 어렵지 않았습니다. 다만 당시에는 중복된 소켓 생성, 이벤트 중복에 대한 개념이 부족하여 해당 개념을 익히는데 도움이 되었습니다. 또한 정말 다양한 라이브러리가 또 다시 React 생태계의 장점을 잘 말해주고 있다고 느겼습니다. 그리고 정규표현식은 항상 미뤄왔지만 이제는 배워야 할 시간이 온 것 같습니다.


🧾 Reference

profile
기록하고 배우고 시도하고

0개의 댓글