리액트

[리액트] 실시간 채팅 구현하기 (socket.io, Nodejs) - 3

MC류짱 2023. 1. 27. 22:23

https://ryuwc.tistory.com/234

 

[리액트] 실시간 채팅 구현하기 (socket.io, Nodejs) - 2

1탄에 이어 글을 작성한다. Client 먼저, 아래의 라이브러리들을 설치해준다. npm i query-string npm i socket.io-client 클라이언트 에서 socket 설치 npm i react-emoji 이모지를 사용하기위해 설치 npm i react-scroll-t

ryuwc.tistory.com

2편에 이어서 작성하겠다.

 

이번 편에서는 만들어논 컴포넌트들을 어떻게 작성해야 하는지 중점으로 두겠다.

 

Server

users.js파일을 생성한 후 아래와 같이 작성한다.

const users = [];

// This is the function that will be called when a user joins a room
const addUser = ({id, name, room}) => {
  // Clean the data
  name = name.trim().toLowerCase();
  room = room.trim().toLowerCase();

  // Check for existing user
  const existingUser = users.find((user) => user.room === room && user.name === name);

  // Validate name and room
  if (!name || !room) return {error: '이름과 방이 필요해요.'};

  // Validate username
  if (existingUser) {
    return {error: '이미 존재하는 이름입니다.'};
  }

  // Store user
  const user = {id, name, room};
  users.push(user);

  return {user};
}

// This is the function that will be called when a user leaves a room
const removeUser = (id) => {
  const index = users.findIndex((user) => user.id === id);

  if (index !== -1) {
    return users.splice(index, 1)[0];
  }
}

// This is the function that will be called when a user sends a message
const getUser = (id) => users.find((user) => user.id === id);

// This is the function that will be called when a user sends a message
const getUsersInRoom = (room) => users.filter((user) => user.room === room);

module.exports = {addUser, removeUser, getUser, getUsersInRoom};

 

index.js도 아래와 같이 수정한다.

const express = require('express')
const socketio = require('socket.io')
const http = require('http')

const cors = require('cors')
const router = require('./router')
const { addUser, removeUser, getUser, getUsersInRoom } = require('./users')

const PORT = process.env.PORT || 5000

const app = express();
const server = http.createServer(app)
const io = socketio(server)
app.use(cors())
app.use(router)
io.on('connection', (socket) => {
  console.log('새로운 유저가 접속했습니다.')
  socket.on('join', ({name, room}, callback) => {
    const { error, user } = addUser({ id: socket.id, name, room })
    if (error) callback({error : '에러가 발생했습니다.'})

    socket.emit('message', {
      user: 'admin',
      text: `${user.name}, ${user.room}에 오신 것을 환영합니다.`,
    })
    // socket.broadcast.to(user.room).emit('message', {
    //   user: 'admin',
    //   text: `${user.name}님이 가입하셨습니다.`,
    // })
    io.to(user.room).emit('roomData', {
      room: user.room,
      users: getUsersInRoom(user.room),
    })
    socket.join(user.room)
    callback()
  })
  socket.on('sendMessage', (message, callback) => {
    const user = getUser(socket.id)
    // console.log(user)
    // console.log(typeof message, message)
    io.to(user.room).emit('message', {
      user: user.name,
      text: message,
    })
    callback()
  })
  socket.on('disconnect', () => {
    const user = removeUser(socket.id)
    if (user) {
      io.to(user.room).emit('message', {
        user: 'admin',
        text: `${user.name}님이 퇴장하셨습니다.`,
      })
      io.to(user.room).emit('roomData', {
        room: user.room,
        users: getUsersInRoom(user.room),
      })
    }
    console.log('유저가 나갔습니다.')
  })
})

server.listen(PORT,()=>console.log(`서버가 ${PORT} 에서 시작되었어요`))

 

Client

다시 Chat.js부터 작성하겠다.

 

Chat.js

import React, { useState, useEffect } from 'react'
import queryString from 'query-string'
import io from 'socket.io-client'

import InfoBar from "../components/InfoBar/InfoBar";
import Input from "../components/Input/Input";
import Messages from "../components/Messages/Messages";

import './Chat.css'
import TextContainer from "../components/TextContainer/TextContainer";

const ENDPOINT = 'http://localhost:5000'

let socket

const Chat = ({ location }) => {
  const [name, setName] = useState('')
  const [room, setRoom] = useState('')
  const [users, setUsers] = useState('')
  const [message, setMessage] = useState('')
  const [messages, setMessages] = useState([])


  useEffect(() => {
    // 여기선 name과 room을 url에서 가져온다.
    // 이유는 setRoom과 setName이 적용되기 전에 socket.emit('join')이 실행되기 때문이다.
    // url에서 가져오는 방법이 아닌 다른 방법으로 name과 room을 가져오려면
    // 미리 정해진 방법으로 name과 room을 가져오는 것이 아닌
    // socket.emit('join')이 실행되기 전에 setRoom과 setName이 실행되도록 해야 한다.
    const { name, room } = queryString.parse(window.location.search)

    console.log(name, room)

    socket = io(ENDPOINT)

    setRoom(room)
    setName(name)

    socket.emit('join', { name, room }, (error) => {
      if (error) {
        alert(error)
      }
    })
  }, [ENDPOINT, window.location.search])

  useEffect(() => {
    socket.on('message', (message) => {
      setMessages((messages) => [...messages, message])
    })

    socket.on('roomData', ({ users }) => {
      setUsers(users)
    })
  }, [])

  const sendMessage = (event) => {
    event.preventDefault()

    if (message) {
      // console.log(message)
      socket.emit('sendMessage', message, () => setMessage(''))
    }
  }

  return (
    <div className='outerContainer'>
      <div className='container'>
        <InfoBar room={room} />
        <Messages messages={messages} name={name} />
        <Input message={message} setMessage={setMessage} sendMessage={sendMessage} />
      </div>
      <TextContainer users={users} />
    </div>
  )
}

export default Chat

 

InfoBar에 사용하기 위해 아래의 파일을 적절한 경로에 넣어준다.

이미지.zip
0.00MB

 

InfoBar.js

import React from 'react';

import onlineIcon from '../../icons/onlineIcon.png';
import closeIcon from '../../icons/closeIcon.png';

import './InfoBar.css';

function InfoBar() {

  return (
    <div className='infoBar'>
      <div className='leftInnerContainer'>
        <img className='onlineIcon' src={onlineIcon} alt='online icon' />
        <h3>room</h3>
      </div>
      <div className='rightInnerContainer'>
        <a href='/'>
          <img src={closeIcon} alt='close icon' />
        </a>
      </div>
    </div>
  );
}

export default InfoBar;

 

Input.js

import React from 'react';

import './Input.css';

const Input = ({ setMessage, sendMessage, message }) => (
  <form className="form">
    <input
      className="input"
      type="text"
      placeholder="전송하려는 메시지를 입력하세요."
      value={message}
      onChange={({ target: { value } }) => setMessage(value)}
      onKeyPress={event => event.key === 'Enter' ? sendMessage(event) : null}
    />
    <button className="sendButton" onClick={e => sendMessage(e)}>전송</button>
  </form>
)

export default Input;

 

Message.js

import React from 'react';

import './Message.css';

import ReactEmoji from 'react-emoji';

function Message({ message: { user, text }, name }) {

  let isSentByCurrentUser = false;

  const trimmedName = name.trim().toLowerCase();

  if(user === trimmedName) {
    isSentByCurrentUser = true;
  }

  return isSentByCurrentUser ? (
    <div className='messageContainer justifyEnd'>
      <p className='sentText pr-10'>{trimmedName}</p>
      <div className='messageBox backgroundBlue'>
        <p className='messageText colorWhite'>{ReactEmoji.emojify(text)}</p>
      </div>
    </div>
  ) : (
    <div className='messageContainer justifyStart'>
      <div className='messageBox backgroundLight'>
        <p className='messageText colorDark'>{ReactEmoji.emojify(text)}</p>
      </div>
      <p className='sentText pl-10 '>{user}</p>
    </div>
  )
}

export default Message;

 

Messages.js

import React, {useEffect} from 'react';

import BasicScrollToBottom from "react-scroll-to-bottom";
import Message from "./Message/Message";

import './Messages.css';

function Messages({ messages, name }) {
  useEffect(() => {
    console.log(messages);
  }, [messages]);

  return (
    <BasicScrollToBottom className="messages">
      {messages.map((message, i) => {
        return <div key={i}><Message message={message} name={name} /></div>
      })}
    </BasicScrollToBottom>
  );
}

export default Messages;

 

TextContainer.js

import React from 'react';

import onlineIcon from '../../icons/onlineIcon.png';

import './TextContainer.css';

function TextContainer({ users }) {
  return (
    <div className='textContainer'>
      <div>
        <h1>
          실시간 채팅 프로그램{' '}
          <span role='img' aria-label='emoji'>
          💬
        </span>
        </h1>
        <h2>
          Created with React, Express, Node and Socket.IO{' '}
          <span role='img' aria-label='emoji'>
          ❤️
        </span>
        </h2>
        <h2>
          Try it out right now!{' '}
          <span role='img' aria-label='emoji'>
          ⬅️
        </span>
        </h2>
      </div>
      {users ? (
        <div>
          <h1>현재 채팅중인 사람들 : </h1>
          <div className='activeContainer'>
            <h2>
              {users.map(({ name }) => (
                <div key={name} className='activeItem'>
                  {name}
                  <img alt='Online Icon' src={onlineIcon} />
                </div>
              ))}
            </h2>
          </div>
        </div>
      ) : null}
    </div>
  );
}

export default TextContainer;

 

코드는 모두 작성되었고, client에서는 npm start, server에서는 npm run start를 실행시키면

 

동일한 룸을 입력하면 채팅이 연결된다.

 

코드 설명

코드 설명은 아직 제대로 socket을 이해하진 못해서 정확히 작성하긴 어렵다.

나 또한 코드를 복붙하지 않고 직접 쳐보며 client의 컴포넌트 코드들, server의 코드들을 뜯어보며 대충 이해했다.

그래서 급한 상황이 아니라면 직접 코드를 쳐보며 만져보며 조금이라도 작동 방식을 이해하길 추천한다.

 

나는 socket의 작동방식을 채팅이 text가 아닌 html태그로 만들 필요가 있어서 이것 저것 열심히 만져보며 작동 방식을 조금은 이해했다.

client의 거의 모든 컴포넌트에서 server로 넘겨주는 과정을 건드려봤고, 채팅을 텍스트(문자열)이 아닌 html 태그로 만들기 위해 시간을 많이 썻다.

현재 해결법은 대충 알았지만, 이 포스트에서는 그 과정은 작성하지 않겠다.

 

결론은, 급하게 써야되면 코드를 복붙해서 사용하고 아니라면 코드를 직접 쳐보고 만져보고 socket의 작동 방식을 구글링 하여 알아보며 사용하는 것을 추천한다.

 

코드의 자세한 설명들이나 작동 방식, 문자열 채팅이 아닌 html태그로 전달하는 과정등은 시간이 남거나 원하는 사람이 있으면 작성하겠다.