React, socket.io로 만들어보는 실시간 기능 - 2🍊 (입장퇴장, 채팅 기능)

수정·2023년 7월 15일
1

React

목록 보기
2/14
post-thumbnail

지난번에는 socket 연결 설정하는 법에 대해 글을 작성했다면,
이번에는 연결한 socket 값을 가지고 서버와 통신하는 코드에 대해 정리해보려고 한다.
거의 클라이언트측에서 on과 emit을 어떻게 적용시키는지 이야기하는 글이 될 것 같다😉

✔️ 코드 위주로 정리를 할 예정이라 조금 길어질 수 있을 것 같다ㅎㅎ


🍊 방 입장/퇴장 기능 (on 응답 받아오기)

우선 방 입장/퇴장과 관련된 서버측 소켓 이벤트는 다음과 같다.

입장

서버측에서 정상적으로 namespace 연결이 되면 자동으로 alert 이벤트명으로 클라이언트에 응답을 보내주도록 코드를 작성해줬다.
그래서 입장의 경우 아래와 같이 코드를 작성하여 입장알림을 받아올 수 있도록 했다.

import { socket } from '../../utils/socket';

import type { Socket } from 'socket.io-client';

const socketInstances: Record<string, Socket> = {};

const RoomPage = () => {
  const [chat, setChat] = useState<ChatMsg[]>([]); // 입장/퇴장 알림 텍스트를 담을 곳
  
  useEffect(() => {
    // 이때 id는 각 경매방이 가진 고유 Id
    // socket 연결
    if (!socketInstances[id]) {
      socketInstances[id] = socket(`${id}`)
      
      // 서버에서 넘어오는 alert 응답 받아오기
      socketInstances[id].on('alert', (message: string) => {
        const welcomeChat: ChatMsg = {
          username: '알림',
          message: message,
        };
        
        setChat((prevChat) => [...prevChat, welcomeChat]);
     }
  }, [])
}
    
-----
type ChatMsg[]의 경우, src/types/index.d.ts 파일에 아래와 같이 선언해두었다.
  
interface ChatMsg {
  username: string;
  message: string;
  admin?: boolean;
}

입장/퇴장알림 모두 채팅을 입력하는 창에 함께 보일 수 있도록 채팅기능과 동일한 useState값을 이용한다.

useEffect를 통해 첫 입장 시,
1. socket 인스턴스 값이 없는 경우 연결한다.
2. namespace 연결이 잘 된 경우, on 이벤트를 통해 서버에서 보내주는 이벤트의 응답을 받아온다.

응답 값이 잘 들어간 chat을 가지고 html 부분에 잘 보이도록 넣어주면 된다!

퇴장

채팅도 입장과 동일한 소켓 이벤트를 사용한다.
서버측에서는 socket이 종료되는 순간 퇴장에 대한 알림을 보내주도록 코드를 작성해줬다 ▶️ (socket.disconnect 될 때)

그런데 퇴장 기능을 넣는데 약간의 문제가 생겨, 고민하는 데 시간을 좀 쓴 것 같다.

퇴장 문제점 : 네비게이션 바 레이아웃 때문에..🤔

경매방에 입장했다가 나가고 싶을 때, 네비게이션 바에 있는 로고를 클릭하거나 브라우저 뒤로 가기를 누르면 소켓 연결이 끊기며 경매방에 아직 머무는 유저들에게 어떤 유저가 퇴장했는지 알림을 보내줘야 했다.

그러나 네비게이션 바 로고를 클릭해도 socket.disconnect가 말을 듣질 않았다.

원인을 파악하다보니 생각난건 우리 프로젝트는 모든 페이지에 네비게이션바가 있었기 때문에 처음 UI를 만들때 네비게이션 바를 Layout 파일에 포함시켜 모든 페이지에서 보일 수 있도록 만들었다.

그래서 네비게이션 바는 레이아웃으로 설정되어 경매방 컴포넌트보다 상위에 존재했다.
소켓 연결을 끊기 위해선 같은 소켓 인스턴스 값을 공유하여 연결을 끊는 이벤트를 작성해야 하는데 같은 값을 공유할 수가 없어서 퇴장이 되질 않던 것이었다.

처음엔 recoil로 변수를 공유해보도록 코드를 작성했는데 잘못된 방법이었던 건지, 원래 불가능한 로직인건지 값이 잘 넘어가질 않았다.

그래서 Layout 파일과 App.tsx을 조금 바꾸는 걸로 결정했다.
🔥 경매방 컴포넌트에만 예외적으로 네비게이션 바를 직접 불러오는 것으로 결정 🔥

[Layout.tsx]

import { Outlet } from 'react-router-dom';
import styled from 'styled-components';

import NavigationBar from '../common/NavigationBar';

interface layoutProps {
  children?: React.ReactNode;
}

const Layout = ({ children }: layoutProps) => (
  <LayoutContainer>
    <NavigationBar />
    <LayoutContents>{children || <Outlet />}</LayoutContents>
  </LayoutContainer>
);

[App.tsx]

function App() {
  return (
    <div className="App">
      <ThemeProvider theme={Theme}>
        <GlobalStyle />
        <Routes>
          // 경매방 컴포넌트 RoomPage를 제외한 모든 페이지는 Layout으로 감싸주기
          <Route element={<Layout />}>
            <Route path="/" element={<MainPage />} />
            <Route path="/sign-in" element={<SignIn />} />
            <Route path="/sign-up" element={<SignUp />} />
            <Route
              path="/Registration"
              element={
                <AccessRightRoute
                  token={token.userToken}
                  component={<Registration />}
                />
              }
            />
          </Route>
		  // 경매방 컴포넌트 RoomPage만 Layout에서 제외
          <Route
            path="/room"
            element={
              <AccessRightRoute
                token={token.userToken}
                component={<RoomPage />}
              />
            }
          />
        </Routes>
        <Toast />
      </ThemeProvider>
    </div>
  );
}

export default App;

이제 경매방 페이지로 돌아와서

  • 네비게이션 로고 클릭 시 퇴장
  • 웹 브라우저 자체 뒤로가기를 클릭 시 퇴장

이 2가지를 구현해주면 된다.

우선 둘 다 퇴장하는 건 똑같기 때문에 이벤트 하나만 만들어서 함께 사용하면 된다.
문제가 되던 부분은 로고 클릭 부분이었기 때문에 우선 웹 브라우저 자체 뒤로가기에 대한 퇴장부터 구현했다.

웹 브라우저 자체 뒤로가기 퇴장

[경매방 RoomPage.tsx]

import { socket } from '../../utils/socket';

import type { Socket } from 'socket.io-client';

const socketInstances: Record<string, Socket> = {};

const RoomPage = () => {
  const [chat, setChat] = useState<ChatMsg[]>([]); // 받아올 채팅
  
  useEffect(() => {
    // 이때 id는 각 경매방이 가진 고유 Id
    // socket 연결
    if (!socketInstances[id]) {
      socketInstances[id] = socket(`${id}`)
      
      socketInstances[id].on('alert', (message: string) => {
        const welcomeChat: ChatMsg = {
          username: '알림',
          message: message,
        };
        
        setChat((prevChat) => [...prevChat, welcomeChat]);
     }
     
     // popstate JS이벤트를 사용해서 웹 브라우저 뒤로가기 클릭 시 handlePopstate 이벤트 실행
     window.addEventListener('popstate', handlePopstate);
  }, [])
      
  // socket 인스턴스 값 연결 해제 이벤트
  const handlePopstate = () => {
    if (socketInstances[id]) {
      socketInstances[id].disconnect();
      delete socketInstances[id];
    }
  };
}

네비게이션 로고 클릭 시 퇴장

이제 문제되던 네비게이션 로고 클릭 시 퇴장 부분에 대해 이야기하려고 한다.

경매방 페이지만 예외적으로 네비게이션 바를 불러오도록 코드를 바꾼 것은 위에서 언급했기 때문에 알 것이라고 생각한다.

나는 네비게이션 바를 RoomPage에 불러와 handlePopstate 함수를 네비게이션의 property 값으로 전달했다.

[경매방 RoomPage.tsx]

import { socket } from '../../utils/socket';

import type { Socket } from 'socket.io-client';

const socketInstances: Record<string, Socket> = {};

const RoomPage = () => {
  const [chat, setChat] = useState<ChatMsg[]>([]); // 받아올 채팅
  
  useEffect(() => {
    // 이때 id는 각 경매방이 가진 고유 Id
    // socket 연결
    if (!socketInstances[id]) {
      socketInstances[id] = socket(`${id}`)
      
      socketInstances[id].on('alert', (message: string) => {
        const welcomeChat: ChatMsg = {
          username: '알림',
          message: message,
        };
        
        setChat((prevChat) => [...prevChat, welcomeChat]);
     }
     
     // popstate JS이벤트를 사용해서 웹 브라우저 뒤로가기 클릭 시 handlePopstate 이벤트 실행
     window.addEventListener('popstate', handlePopstate);
  }, [])
      
  // socket 인스턴스 값 연결 해제 이벤트
  const handlePopstate = () => {
    if (socketInstances[id]) {
      socketInstances[id].disconnect();
      delete socketInstances[id];
    }
  };
    
  return (
  	<Container>
      // 네비게이션 컴포넌트에 handlePopstate 이벤트 전달하기
      <NavigationBar socketDisconnect={handlePopstate} />
   	  ... 이외 html 내용 ...
    </Container>
  )
}

[Navigation.tsx]

interface roomSocketProps {
  socketDisconnect?: () => void;
}

const NavigationBar = ({ socketDisconnect = () => {} }: roomSocketProps) => {    
  return (
  	<NaviBarWrap>
      <Logo onClick={socketDisconnect}>
    	... 내용 ...
      </Logo>
	  ... 내용 ...
    </NaviBarWrap>
  )
}

이렇게 같은 소켓 인스턴스 값을 가지고 만든 이벤트를 바로 넘겨 공유할 수 있도록 코드를 바꿨더니,
로고 클릭 시 이루어져야 하는 퇴장 기능도 잘 구현된 것을 볼 수 있었다.

🍊 채팅 기능 (emit으로 요청 보내기, on 응답 받아오기)

채팅과 관련된 서버측 소켓 이벤트는 다음과 같다.

채팅 보내기

채팅을 보낼 때 필요한 것들에는

  • 어떤 채팅들이 오고가고 했는지 내용을 담아놓을 곳 1️⃣
  • 입력한 채팅 내용을 담아놓을 곳 (사용자가 입력) 2️⃣

우선 이렇게 2가지가 필요하다.

import { socket } from '../../utils/socket';

import type { Socket } from 'socket.io-client';

const socketInstances: Record<string, Socket> = {};

const RoomPage = () => {
  const [chat, setChat] = useState<ChatMsg[]>([]); // 받아올 채팅 1️⃣
  const [sendMsg, setSendMsg] = useState(''); // 입력한 채팅 2️⃣
  
  useEffect(() => {
    // 이때 id는 각 경매방이 가진 고유 Id
    // socket 연결
    if (!socketInstances[id]) {
      socketInstances[id] = socket(`${id}`)
      
      ... 내용 ...
  }, [])
      
  // onChange 이벤트에 사용할 함수
  const handleChangeMsg = (message: string) => {
    setSendMsg(message);
  };

  // 입력한 채팅을 서버 측으로 보내는 소켓 이벤트 함수
  const handleSendMsg = () => {
    socketInstances[id].emit('chat', {
      message: sendMsg,
    });
    setSendMsg('');
  };
      
  return (
  	<Container>
      <NavigationBar socketDisconnect={handlePopstate} />
   	  ... 이외 html 내용 ...
      
      <ChatContainer
    	chat={chat}
    	sendMsg={sendMsg}
    	onChange={(e) => handleChangeMsg(e.target.value)}
        onClick={handleSendMsg}
      />
          
      ... 이외 html 내용 ...
    </Container>
  )
}

채팅 받아오기

채팅으로 보내고 싶은 값을 emit 소켓 이벤트로 서버에 전달하고 나서 돌아오는 응답을 on으로 다시 받아와야 한다.

import { socket } from '../../utils/socket';

import type { Socket } from 'socket.io-client';

const socketInstances: Record<string, Socket> = {};

const RoomPage = () => {
  const [chat, setChat] = useState<ChatMsg[]>([]); // 받아올 채팅 1️⃣
  const [sendMsg, setSendMsg] = useState(''); // 입력한 채팅 2️⃣
  
  useEffect(() => {
    // 이때 id는 각 경매방이 가진 고유 Id
    // socket 연결
    if (!socketInstances[id]) {
      socketInstances[id] = socket(`${id}`)
      
      // 서버에서 넘어오는 chat 응답 받아오기
      socketInstances[id].on('chat', (data: socketChatMsg) => {
        const newChat: ChatMsg = {
          username: data.userInfo.userId,
          message: data.message.message,
          admin: data.userInfo.isAdmin,
        };
        setChat((prevChat) => [...prevChat, newChat]);
      });
  }, [])
}

-----
type ChatMsg[]의 경우, src/types/index.d.ts 파일에 아래와 같이 선언해두었다.
  
interface ChatMsg {
  username: string;
  message: string;
  admin?: boolean;
}

채팅 또한 응답 값이 잘 들어간 chat을 가지고 html 부분에 잘 보이도록 넣어주면 된다!


🍊 마무리

소켓 부분은 말로 설명하면 오히려 이해하기가 힘들어질 것 같다고 생각했다.

그래서 그런지 이번 게시글을 코드가 위주인 게시글이 된 것 같다.

오히려 가독성이 떨어질까봐 조금 걱정은 되지만,, 이 코드를 참고하여 소켓을 사용하는 사람들에게 도움이 되었으면 한다.
나 또한 다시 복습을 할 수 있었고, 다음에 소켓을 또 적용하게 될 땐 좀 더 익숙하게 사용할 수 있지 않을까 싶다.

profile
💛

0개의 댓글