웹소켓 실시간 채팅 구현

손병진·2020년 11월 12일
16

지난 2차 프로젝트에서 제대로 작동하지 않은 실시간 채팅 기능을 이번에 다시 구현해보았다.
추가적으로 유저별 닉네임 및 말풍선 색깔을 부여했으며 입장, 퇴장할때 메시지가 나오도록 했다. 그리고 완성된 코드를 AWS 서버에 배포하는 작업까지 완료했다.

결과물

  • 처음에 닉네임을 입력하고 '확인'을 클릭하면 유저의 닉네임이 설정된다
  • 그리고 채팅창에 '닉네임님이 입장하셨습니다' 라는 문구가 업로드 된다
  • 채팅을 치면 유저별 닉네임, 말풍선 색깔(자동 랜덤 부여), 내용이 업로드된다
  • 유저가 브라우저를 닫고 나갔을 때는 해당유저의 '닉네임님이 퇴장하셨습니다' 문구가 업로드된다

코드 및 로직 설명

서버

  • 소켓의 주요 원리는 프론트엔드 혹은 백엔드에서 emit 으로 데이터를 보내면 on 으로 받는 것이다. 어느 한 쪽의 역할이 정해져있지 않고 양쪽에서 주고 받는 역할을 모두 수행한다.
const http = require('http');
const express = require('express');
const cors = require('cors'); // cors error 해결하기위한 설정
const app = express();
app.use(cors());

const server = http.createServer(app); // 서버 생성
const socketio = require('socket.io'); // socket 라이브러리 입력
const io = socketio.listen(server); // socket과 서버 연결
const port = 8000; // 포트 번호 설정(추후에 AWS 설정과 일치시켜야함)

server.listen(port, () => {
  console.log(`Listening on port ${port}`);
}); // 서버 생성시 콘솔에 출력되는 문구

app.get('/', function (req, res, next) {
  res.json('hi'); // 서버 ip 접속시 나오는 반응(볼일 없었음..)
});


/* 
본격적인 소켓 로직
아래 보면 기본 연결을 제외하고 설정해놓은 연결이 총 6개가 있다
- newUser / enter : 접속했을 때(닉네임을 입력하고 확인을 클릭했을 때)
- message / upload : 채팅 업로드(채팅을 입력하고 엔터 혹은 확인을 눌렀을 때)
- leaveUser / out : 브라우저를 닫았을때(채팅방을 나갔을 때)
*/
io.on('connection', (socket) => { // 기본 연결

  socket.on('newUser', (data) => { // on 데이터를 받을때
    io.emit('enter', data); // emit 데이터를 보낼때

    /* 주의! 여기서 emit 은 socket과 연결된 내부 함수의 메서드가 아닌
    처음에 서버와 연결된 소켓 변수(여기서는 io)에서 해주어야한다*/
  });

  socket.on('message', (data) => {
    console.log('client가 보낸 데이터: ', data);
    io.emit('upload', data);
  });

  socket.on('leaveUser', (nick) => {
    io.emit('out', nick);
  });
});

레이아웃

프론트엔드 코드에서는 모두 설명하는 것보다 굉장히 고민되었던 부분을 소개하고자 한다

소켓 연결은 어디서 선언해야 할까?

  • 채팅방을 구현하고 채팅 내용을 갱신할 때 일어난 문제가 생겼는데, 처음에는 서버로 요청이 순조롭게 가다가 어느 시간이 지나면 요청이 가지 않는 에러(Bad Request)가 발생했다. 그러다가 다시 새로고침하면 요청이 가는 현상이었다.

  • 이 때 문제는 소켓 연결을 선언하는 위치 였다.

import React from 'react';
import socketio from 'socket.io-client';
import chattingApi from '../../Config';

/* 소켓 연결은 컴포넌트와 동등한 위치에서 선언되어야 한다.
왜냐하면 지속적으로 연결이 유지되어야 하기 때문이다*/
const socket = socketio.connect(`${chattingApi}`);

const Main = () => {

  /* 처음에는 여기 상태값, 함수와 동등한 위치에서 선언되었고,
  그렇기 때문에 컴포넌트의 렌더링이 끝나고 난 뒤에 연결이 끊겼던 것이다*/
  
  return (
    <>
      <Chatting socket={socket} />
      <Modal socket={socket} />
    </>
  );
};

export default Main;

채팅 내용이 갱신될 때마다 어떻게 하나씩만 추가되도록 보여줄 수 있을까?

  • 그리고 채팅이 갱신될 때에 생겼던 문제점이 내용이 하나씩 추가되지 않고, 갱신될 때마다 내용 전체가 다시 처음부터 렌더링되는 현상이 보여졌다.

  • 이는 상태관리 에 대한 문제였다.

import React, { useState, useEffect } from 'react';
import styled from 'styled-components';

const Chatting = ({ nick, socket }) => {
  const [inputMessage, setInputMessage] = useState('');
  
  // 여기 두가지 상태 값이 있는데 
  // 하나는 기존의 채팅 내용을 담아두고 UI와 직접 연결되는 상태값이다
  const [chatMonitor, setChatMonitor] = useState([]);
  
  // 나머지 하나는 서버에서 받은 갱신된(새로 추가된) 내용을 받는 상태값이다.
  const [recentChat, setRecentChat] = useState('');
  /*처음에는 상태값을 두가지로 두지 않고 서버에서 받은 갱신된 내용 전체를 받고 그대로 UI와 연결시켰다. 하지만 그래서는 로컬의 입장에서 계속 전체 값이 바뀌는 것이기에 내용 전체가 다시 렌더링 되었던 것이다. 그래서 상태값을 두 종류로 나누어 관리해야 했다*/

  // 입력값을 저장하는 상태값
  const handleInput = (e) => {
    setInputMessage(e.target.value);
  };

  // 입력값을 서버로 보내는 함수
  const handleEnter = (e) => {
    if (e.key === 'Enter') {
      socket.emit('message', { inputMessage });
      setInputMessage({ ...inputMessage, content: '' });
    }
  };
  
  // 서버에서 받은 입력값을 로컬 상태값으로 갱신하는 함수(바로 밑의 함수로 연결된다)
  useEffect(() => {
    socket.on('upload', (data) => {
      setRecentChat(data.inputMessage);
    });
  }, []);

  // 서버에서 갱신된 내용(recentChat)을 받았을 때 로컬 채팅창에 추가하는 함수
  useEffect(() => {
    recentChat.length > 0 && setChatMonitor([...chatMonitor, recentChat]);
    setRecentChat('');
    // 채팅값 초기화 : 이렇게 설정하지 않으면 같은 채팅이 반복됐을 때 이 함수가 반응하지 않는다.
  }, [recentChat]);

채팅창 스크롤 위치를 어떻게 하단에 고정할 수 있을까?

  • 스크롤 위치를 하단으로 이동시키는 함수는 쉽게 찾아볼 수 있다. 하지만 문제는 함수가 실행되는 순서 였다. 채팅이 갱신되고 난 뒤에 스크롤이 맨밑으로 가야 하는데, 스크롤 함수가 먼저 실행되고 채팅이 갱신되어 스크롤의 위치가 계속 살짝 위로 올라와 있었다.

  • 이는 비동기 함수 설정 문제였다.

  // 스크롤을 하단으로 이동시키는 함수
  const scrollToBottom = () => {
    document.getElementById('chatMonitor').scrollBy({ top: 100 });
  };

  // 이때 async, await 구문을 활용해서 아래 함수가 채팅방이 갱신되고 나서 실행되도록 설정하는 것이다
  useEffect(async () => {
    (await recentChat.content?.length) > 0 &&
      setChatMonitor([...chatMonitor, recentChat]);
    
    // await 밑에 스크롤 함수가 위치되어야 한다
    scrollToBottom();
    setRecentChat('');
  }, [recentChat]);

유저별 닉네임과 말풍선 색깔은 어떻게 부여할 수 있을까?

  • 내용 뿐만 아니라 닉네임과 색깔을 부여하기 위해서는 이를 어느 위치에서 선언해줄 것인가, 그리고 서버에 보내는 요청의 시점과 내용을 확인해야 한다. 처음에는 닉네임 따로 선언해서 서버에 요청하거나 서버에서 색깔을 부여하는 식의 시행착오를 겪었다.

  • 이후에 서버로의 요청을 최소화하기 위해 채팅을 갱신하는 request 내용을 수정하였다.

import React, { useState, useEffect } from 'react';
import styled from 'styled-components';
import chroma from 'chroma-js';

// 라이브러리를 활용하여 랜덤으로 유저별 색깔을 부여하였다.
const color = chroma.random()._rgb;

// 닉네임은 모달창 컴포넌트를 활용하여 직접 입력한 내용(nick)을 props 값으로 넘겨받았다.
const Chatting = ({ nick, socket }) => {
  
  // 서버로 보낼 상태값을 채팅 내용 뿐만 아니라 설정된 닉네임, 색깔값을 같이 보내는 것이다
  const [inputMessage, setInputMessage] = useState({
    nickName: '',
    color: color,
    content: '',
  });
  
  // 입력 시에는 해당 key의 value값을 변경한다
  const handleInput = (e) => {
    setInputMessage({
      ...inputMessage,
      nickName: nick,
      content: e.target.value,
    });
  };
  
  // 서버로 요청을 보내는 함수
  const handleEnter = (e) => {
    if (e.key === 'Enter') {
      socket.emit('message', { inputMessage });
      setInputMessage({ ...inputMessage, content: '' });
    }
  };  
  
  // 해당 값을 토대로 갱신되는 채팅의 닉네임과 색깔을 넣는 UI를 구현할 수 있다

AWS 배포

  • 배포는 node.js 서버와 프론트엔드 UI 서버를 별개로 하여 EC2 두개를 활용하였다.

node.js 배포

  • 여기서 겪었던 주요 문제는 터미널을 닫았을 때 서버도 같이 종료되는 것이었다.
    이를 해결하기 위해서 pm2 라이브러리를 활용하였다.

npm install pm2 이후에
pm2 start server.js 실행하면

터미널이 꺼진 뒤에도 실행되는 것을 확인할 수 있었다.
참고 블로그

프론트엔드 배포

  • 여기서도 생긴 문제점이 터미널이 꺼지자 실행이 안된다는 점이었다.
    프론트엔드에서도 express 라이브러리를 활용하여 node 명령어로 실행하여
    build 를 이용한 index.html UI 파일을 보여주어야 했다.

기존에 알고있던
node server.js & 활용해서 되지 않았고
sudo nohup node server.js & 명령어를 실행하여

터미널을 닫아도 서버가 유지되게끔 설정할 수 있었다.
참고 블로그

배포와 명령어에 대해서는 추가적인 학습이 필요하기에 간략한 문제 해결법만 명시하였으며 심층적인 이해가 필요하신 분이라면 참고 블로그를 확인해주시면 감사하겠습니다

profile
https://castie.tistory.com

3개의 댓글

comment-user-thumbnail
2020년 11월 18일

크으 멋져요 병진님!!ㅎㅎ 꼭 원하는 회사 합격하시길 바랄게요!! 화이팅!!

1개의 답글
comment-user-thumbnail
2020년 11월 21일

크으 역시 병진님!! 화이팅!! 👏👏

답글 달기