Socket.io를 이용한 실시간 채팅구현 - 코드편

이수빈·2023년 3월 6일
2
post-thumbnail
  • 이론편에 이어서 Socket.io를 이용해 실시간 채팅을 구현했던 코드를 정리하려고 한다.

Socket의 연결

클라이언트 Socket의 컨텍스트화

  • socket 연결을 여러 컴포넌트에서 사용하고, 하나의 소켓만 연결해야 하기 때문에 따로 context를 만들어 전역상태로 관리하였다.

  • Context를 사용하기 위해서는 context를 만든후, 사용하고자 하는 App의 하위 컴포넌트들을 Context.Provider로 감싸줘야 한다.

  • 여기서 defaultValue는 Provider가 존재하지 않는 경우에만 defaultValue가 적용된다.

// React 공식문서

const MyContext = React.createContext(defaultValue);
<MyContext.Provider value={/* 어떤 값 */}>
// SocketContext.tsx
import React from 'react';
import socketio, { Socket } from 'socket.io-client';
import { DefaultEventsMap } from 'socket.io/dist/typed-events';

const ENDPOINT = 'http://localhost:3100';

export const socket = socketio(ENDPOINT); 
//서버 socket과 연결 

type socketType = Socket<DefaultEventsMap, DefaultEventsMap>;

export const SocketContext = React.createContext<socketType>(socket); 
// socket Context 생성


//App.tsx

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Provider store={store}>
        <MuiThemeProvider theme={muitheme}>
          <ThemeProvider theme={theme}>
            <SocketContext.Provider value={socket}>
              <Router />
              <ChatApp />
              <GlobalStyle />
              <GlobalFont />
            </SocketContext.Provider>
          </ThemeProvider>
        </MuiThemeProvider>
      </Provider>
      <ReactQueryDevtools initialIsOpen={false} position="bottom-right" />
    </QueryClientProvider>
  );
}

export default App;

서버 Socket 연결

  • CORS(Cross-Origin Resource Sharing) 같은경우, 연결시 두번째 인자값에 cors라는 객체에서 설정 가능하다.

  • 프로젝트에서 서버와 클라이언트의 포트가 달랐기 때문에 따로 CORS 설정을 해주었다.

  • socket 이벤트인 chatEvent와 sliderEvent를 각각 모듈화했다.

// index.js
const server = http.createServer(app); 
// http 서버 생성

const io = SocketIO(server, socketSetting);
// 서버와 소켓 연결 

const chatEvents = require("./src/socket/chatEvents");
const sliderEvents = require("./src/socket/sliderEvents");

const onConnection = (socket) => {
  socket["nickname"] = "Anon";
  logger.info("소켓서버와 연결되었습니다.");
  logger.info("Sid", io.sockets.adapter.sids);
  chatEvents(io, socket);
  sliderEvents(io, socket);
};

io.on("connection", onConnection);


// 	socketUtil.js
const socketSetting = {
  cors: {
    origin: 'http://localhost:3000',
    methods: ["GET", "POST"],
    allowedHeaders: ["my-custom-header"],
    credentials: true,
  },
}; // local 개발용 설정

채팅방에 입장하는 과정

클라이언트 로직

  • ChatList 컴포넌트에서는 Query에 있는 내가 찜한 파티목록을 가져온다.

  • 데이터를 받아오고, 로그인이 된 상태라면, nickname 이벤트를 방출해 socket의 nickname 프로퍼티를 userName으로 설정한다.

  • Sid 자체는 소켓마다 고유하기 때문에 닉네임이 중복되더라도 괜찮다.(Sid가 다름, 즉 애초에 다른 소켓임)

  • 이 목록들 중에서 모집인원과 좋아요 숫자와 일치한다면 모집완료가 된 파티이므로

  • 목록을 클릭하면, socket에게 enterRoom 이벤트를 방출한다. 파라미터는 3개를 전달하는데 roomname과, partyId, 클라이언트에서 콜백함수로 실행할 moveRoom 이다.

//ChatList.tsx
const ChatList = ({ moveRoom }: ChatListProps) => {
  const socket = useContext(SocketContext);
  const { data: user, isSuccess: isUserSuccess } = useUser();
  const isLogin = useSelector<RootState>((state) => state.loginReducer.isLogin);
  const { data: myPartyList, isSuccess: isPartiesSuccess } = useMyParties();
  const [completedParty, setCompletedParty] = useState<Party[]>([]);

  const handleMove = (e: React.MouseEvent<HTMLDivElement>) => {
    const { roomname, partyid } = e.currentTarget.dataset;
    socket.emit('enterRoom', roomname, partyid, moveRoom);
  };

  useEffect(() => {
    if (isUserSuccess && isPartiesSuccess) {
      socket.emit('nickname', user.name);

      setCompletedParty(myPartyList.filter((party) => party.likedNum === party.partyLimit));
    }

    // 실제 room이 만들어진걸 확인함.
  }, [user]);

  return (
    <>
      <Title>Chat Lists</Title>
      <S.ChatContainer>
        {!isLogin ? (
          <S.CursorDiv>"로그인을 해주세요"</S.CursorDiv>
        ) : completedParty.length === 0 ? (
          <S.CursorDiv>생성된 채팅방이 없습니다.</S.CursorDiv>
        ) : (
          completedParty.map((party) => (
            <>
              <S.CursorDiv
                onClick={handleMove}
                key={party.partyId}
                data-roomname={party.name}
                data-partyid={party.partyId}>
                {party.name}
              </S.CursorDiv>
            </>
          ))
        )}
      </S.ChatContainer>
    </>
  );
};

export default ChatList;

서버로직

  • enterRoom 이벤트를 받은 서버소켓은 ${roomName}/${partyId} 값을 roomKey로 설정해 Room을 생성한다.

  • roomKey에 partyId 값을 넣은 이유는, roomName으로만 key를 설정하면 중복되는 채팅방이 발생 할 수 있기 때문이다.

  • 브로드캐스팅 과정에서 동일한 식당 이름의 다른 채팅방에도 메세지가 전달되는것을 막고자 했다.

< 채팅방을 생성하는 로직에서 발생했던 문제들 >

  • 채팅방을 생성하는 로직은 모집파티 인원이 찼을때 바로 서버소켓에서 생성하는게 아니라 클라이언트에서 채팅방 목록을 클릭했을때 채팅방을 생성해 입장하도록 설계하였다.

  • 서버소켓의 adpater라는 객체는 sids라는 소켓의 id들의 Map객체와 rooms라는 전체 활성화 된 Room 목록을 보유하고 있다.

  • 여기서 연결된 소켓 자체도 Private Room을 갖는다. 즉, Rooms에는 기본적으로 소켓 하나가 담긴 Private Room이 존재한다.

  • 여기서 Public Room을 구하려면, 전체 Room 목록에서 Private Room 목록 즉 Sid값을 제외시키면 된다.

  • 이렇게 서버소켓에서 Public Room의 목록을 구한다음 채팅방이 생성되었을 때 Room에 속한 Sid정보를 따로 기록하고, 채팅 컴포넌트가 마운트된다면 Sid값을 기반으로 Sid가 속한 방만 Socket을 통해 전달하려고 했었다.

  • 이런 방식에서는 이미 채팅방이 생성된 상태이므로, Room에 어떤 Sid가 들어있는지 알고있어야 Sid가 속한 채팅방 목록만 전달 할 수 있다.

  • 먼저 adpater에 또 다른 map객체를 만들어 Room에 속한 Sid를 채팅방이 생성될 때 따로 기록했지만, io.sockets.adapter라는 객체가 socket 마다 다른 값을 가지고 있어서 실패했다.

  • 또한 DB를 사용해서 기록을 한다고 하면 너무 비효율적이였다. 채팅방에 아무도 없다면 DB에 있는 데이터도 지워져야 하는데, 이를 구현하는것도 쉽지 않았고 빠르게 변하는 데이터이기 때문에 동일한 방이 생성되면 데이터 동기화 문제가 발생할 수도 있다.

  • 그래서 어떻게 해결을 할까 고민을 하던 중, 발상을 바꿔서 채팅방을 모집인원이 차면 미리 생성하는게 아니라, 채팅방에 들어갈때 생성을 하는 방식으로 바꾸니 모든 문제가 해결되었다.

  console.log("Sid", io.sockets.adapter.sids);
  console.log("Rooms", io.sockets.adapter.rooms);

  • 이후 클라이언트에서 받은 callback Fn인 moveRoom을 실행한다. moveRoom은 클라이언트에서 채팅방으로 이동하는 함수이다.

  • Room에 입장하면 roomKey에 해당되는 Room에 있는 모든 소켓들에게 messageInfo라는 객체를 방출한다.

  • messageInfo는 3가지 프로퍼티를 갖는데, 이건 밑에서 설명하겠다.

// chatEvents.js

  const setNickName = (nickname) => {
    if (!nickname) return;
    socket["nickname"] = nickname;
  };


 const enterRoom = (roomName, partyId, moveRoom) => {
    const welcome = `${socket.nickname}님이 방에 입장하셨습니다.`;
    const roomkey = `${roomName}/${partyId}`
    socket.join(roomkey);
    moveRoom(roomkey);
    const messageInfo = { userId: 0, userName: "", message: welcome };
    socket.to(roomkey).emit("getMessage", messageInfo);
  };


  socket.on("enterRoom", enterRoom);
  socket.on("nickname", setNickName);

메세지를 보내고 받는 과정

클라이언트 로직

  • 컴포넌트가 마운트될때 localStorage에 roomKey에 해당하는 log가 있다면 현재 메세지를 뿌려주고, enterRoom으로 방에 입장했다는 메세지를 사용자에게 알려준다.

  • 채팅방이 만남을 위해 1회성인 목적이 강하고 채팅 어플리케이션이 아니기때문에, 채팅로그는 로컬스토리지를 사용하여 저장하였다.

  • getMessage 이벤트 핸들러를 등록한다. 이것은 로그를 저장하고 현재 메세지 state를 업데이트 하는 역할을 한다.

  • message 컴포넌트가 변하면, 스크롤이 제일 하단으로 내려가도록 구현하였다.

  • sendMessage 이벤트 핸들러를 통해 소켓에게 메세지를 보내고, Room에 연결된 다른 클라이언트 소켓들에게 메세지를 전달한다.

//ChatRoom.tsx

 const [messages, setMessage] = useState<MessageInfo[]>([]);
  const [content, setContent] = useState<string>('');
  const socket = useContext(SocketContext);
  const scrollRef = useRef<HTMLDivElement>(null);
  const { data: user, isSuccess } = useUser();
  const [roomName, partyId] = roomKey.split('/');

  const addMessage = (messageInfo: MessageInfo) => {
    setLog(roomKey, messageInfo);
    setMessage((current) => [...current, messageInfo]);
  };

  const enterRoom = () => {
    const message = `방에 입장하셨습니다.`;
    const messageInfo = { userId: 0, userName: '', message };
    setMessage((current) => [...current, messageInfo]);
  };

  const sendMessage = (e: sendMessageType) => {
    e.preventDefault();
    if (content === '') {
      alert('메세지를 입력해주세요');
      return;
    }
    const messageInfo = { userId: user!.userId, userName: user!.name, message: content };
    socket.emit('sendMessage', messageInfo, roomKey, addMessage);
    setContent('');
  };

  useEffect(() => {
    const log = localStorage.getItem(roomKey);

    if (log) {
      const logArr = JSON.parse(log);
      setMessage(logArr);
    }

    enterRoom();

    socket.on('getMessage', (messageInfo) => {
      addMessage(messageInfo);
    });
  }, []);

  useEffect(() => {
    scrollRef.current!.scrollTop = scrollRef.current!.scrollHeight;
  }, [messages]);

서버로직

  • message, roomKey, addMessage 3개의 파라미터를 받는다.

  • roomKey에 해당하는 room에 있는 클라이언트 소켓들에게 메세지를 전달하고, 메세지를 보낸 클라이언트 소켓의 addMessage callback Fn을 실행한다.

//chatEvents.js

  const sendMessage = (messageInfo, roomKey, addMessage) => {
    socket.to(roomKey).emit("getMessage", messageInfo);
    addMessage(messageInfo);
  };

  socket.on("sendMessage", sendMessage);

메세지를 뿌리는 과정

클라이언트 로직

  • 메세지는 내가 보낸메세지와 다른 사람한테 받은 메세지를 구분했다.

  • messageInfo는 userId, userName, message 프로퍼티를 가진다.

  • 여기서 로그인정보와 받은 메세지 Info의 userId 값을 비교해 내가 보낸 메세지와 남이보낸메세지를 구분한다.

  • 로그인 userId와 messageInfo의 userId 값이 같다면 내가 보낸 메세지이므로 왼쪽에, 다르다면 남이 보낸 메세지이므로 오른쪽에 뿌려준다.

// ChatMessage.tsx

import { RootState } from '../../../store/store';
import { MessageInfo } from '../chatAppApi';
import * as S from '../styles/chatMessageStyle';
import useUser from './../../../queries/useUserQuery';

interface ChatMessageProps {
  messageInfo: MessageInfo;
}

const ChatMessage = ({ messageInfo: { userId, userName, message } }: ChatMessageProps) => {
  const { data: user, isSuccess } = useUser();

  return (
    <>
      {isSuccess &&
        (userId !== user.userId ? (
          <S.OtherMessage>
            <S.TextWrapper>
              {userId !== 0 && userId !== user.userId && (
                <span className="labelName">{userName}</span>
              )}
              {<S.TextBox>{message}</S.TextBox>}
            </S.TextWrapper>
          </S.OtherMessage>
        ) : (
          <S.MyMessage>
            <S.TextWrapper>
              {userId !== 0 && userId !== user.userId && (
                <span className="labelName">{userName}</span>
              )}
              {<S.TextBox>{message}</S.TextBox>}
            </S.TextWrapper>
          </S.MyMessage>
        ))}
    </>
  );
};

export default ChatMessage;

특이점

  • Socket에 이벤트 핸들러를 등록하는 과정은 useEffect를 통해 관리해야 했다.

  • deps를 넣어주지 않으면 이벤트핸들러가 여러번 등록되는 문제가 발생했기 때문이다.

profile
응애 나 애기 개발자

0개의 댓글