web socket 활용한 라이브 채팅 기능 구현, 서버 로깅 개선

박재하·2023년 11월 24일
0

목표

  • 웹 소켓을 활용한 라이브 채팅 기능 추가
    • FE: 라이브 채팅 모달, 토글버튼 구현 및 토글버튼 이벤트 처리
    • 웹소켓 서버 구현
    • 웹소켓 클라이언트 구현 및 채팅 전송 처리
    • 테스트
  • 서버 로깅 개선
    • 로깅 메시지 개선
    • 성공/에러 메시지 로깅

리뷰 요청

OAuth2.0 이용한 GitHub 로그인과 JWT 인증 구현은 완료했고,
오늘은 웹 소켓을 이용한 라이브 채팅 기능과 서버 로깅 개선을 진행했습니다.

오늘자 코드에 대한 리뷰 요청과 함께 질문 두 가지 정도가 있습니다.

1. JWT 관련

JWT 인증을 AccessToken, RefreshToken을 이용하는 방식으로 구현해봤는데, 현재 둘 다 쿠키에 들어갑니다.
그런데 이러면 결국 모든 통신에서 두 토큰이 함께 서버로 전달되어 "AccessToken만 따로 탈취되어도 안전하다"는 말이
큰 의미가 없어 보입니다.

원래 Cookie 저장 방식으로 JWT 인증을 구현하면 refresh 요청 시 까지 RefreshToken은 별도의 저장공간에 따로 보관하나요?
보관한다면 어디에 보관하는지, 아니면 둘 다 쿠키에 보관해도 만료기간이 다른 두 개의 토큰을 생성하는 게 의미가 있는건지 궁금합니다.

2. 테스트 코드 작성 관련

TS로의 전환 및 리뷰 피드백 반영 등에 앞서 최종 프로젝트의 기능상 변경이 없음을 보장하기 위해 테스트 코드를 작성하려 합니다.

그런데 사실 여태 작성을 하지 않고 있다보니 어떤 기준으로 얼마나 상세한 테스트 코드를 작성할지 조금 막막합니다.

보통 현업에서는 TDD처럼 테스트 코드를 먼저 작성하고 개발하나요? 그리고 테스트 케이스를 작성하는 상세한 정도의 기준이 있나요?
(예를 들면 모든 조건문에 대한 분기가 다 나와야 한다던지) 있다면 그런 기준과 작성 방식, 사용하는 라이브러리 등이 궁금합니다!

고민과 해결 과정

웹 소켓을 활용한 라이브 채팅 기능 추가

FE: 라이브 채팅 모달, 토글버튼 구현 및 토글버튼 이벤트 처리

웹 소켓 기능 구현을 위해 대표적인 컨텐츠인 라이브 채팅을 구현해보고자 한다.

실질적인 기능 구현이 가능하도록 HTML/CSS를 다음 조건에 맞게 구성해줬다.

  • 라이브 채팅 토글 버튼을 클릭하면 채팅 모달이 사라지거나 보임.
    • 토클로 모달이 보일 때마다 새로 웹 소켓을 연결하고, 사라질 때 연결 해제하는 것으로 추후 구현하자.
  • 채팅 모달은 사용자 채팅 내역(스크롤 가능)과, 입력/전송 폼이 주어짐.
    • 가능하면 이후 엔터 입력 시 채팅 전송이 가능하도록 해보자.
스크린샷 2023-10-24 오후 2 27 02

왼쪽 아래 고정 위치(position: fixed)에 토글버튼을 만들어줬다. 웹소켓 로고 활용

스크린샷 2023-10-24 오후 2 26 12

클릭하면 모달이 잘 나타남. 다음 단계로!

웹소켓 서버 구현

GPT를 참고하여 웹 소켓 서버를 만들어주자.

${닉네임}: ${채팅메시지}형태로 메시지를 받으면 그대로 모든 클라이언트에게 echo해주는 간단한 형태면 충분하다.

먼저 ws(websocket) 모듈을 설치해주고

npm i ws
import WebSocket from "ws";

const initWebSocketServer = () => {
  const wss = new WebSocket.Server({ port: 8080 });

  const clients = [];

  wss.on("connection", (ws) => {
    console.log(`[WebSocket] client connected: ${ws._socket.port}`);
    clients.push(ws);

    // client에서 채팅 메시지가 오면 모든 client에게 전달
    ws.on("message", (message) => {
      console.log(`[WebSocket] received(from ${ws._socket.port}): ${message}`);

      clients.forEach((client) => {
        // 모든 client에게 전달
        client.send(message);
      });
    });

    ws.on("close", () => {
      console.log(`[WebSocket] client disconnected: ${ws._socket.port}`);
      clients.splice(clients.indexOf(ws), 1);
    });
  });
};

export default initWebSocketServer;

클라이언트로부터 메시지를 받으면 연결된 모든 클라이언트들에게 같은 메시지를 전달해주며,

함수 형태로 서버를 실행 가능하도록 initWebSocketServer로 감싸준다.

// app.js
...
if (cluster.isPrimary) {
  console.log(`Primary ${process.pid} is running`);

  // web socket server
  initWebSocketServer();

  // Fork workers.
  for (let i = 0; i < 4; i++) {
    cluster.fork();
  }
} else {
  console.log(`Worker ${process.pid}(Process) started`);

  initServer();
}

해당 함수는 멀티 프로세스로 실행되면 clients를 관리하기 힘들기 때문에, 부모 프로세스에서만 실행하도록 app.js에 추가해준다.

웹소켓 클라이언트 구현 및 채팅 전송 처리

우선, 서버에 연결하고 메시지를 수신/송신하는 웹 소켓 클라이언트 객체를 구현하자.

// WebSocketClient.js
export default class WebSocketClient {
  constructor(chatBoard) {
    this.chatBoard = chatBoard;

    this.ws = new WebSocket("ws://localhost:8080");
    this.ws.onopen = (event) => {
      console.log("[Web Socket] 서버와 연결되었습니다.");
      this.chatBoard.innerHTML = "[Web Socket] 서버와 연결되었습니다.";
    };

    this.ws.onmessage = (event) => {
      const message = event.data;
      this.chatBoard.innerHTML += `<p>${message}</p>`;
    };

    this.ws.onclose = (event) => {
      console.log("[Web Socket] 서버와의 연결이 종료되었습니다.");
    };
  }
  send(nickname, message) {
    this.ws.send(`${nickname}: ${message}`);
  }
  disconnect() {
    this.ws.close();
  }
}

서버 연결 메시지, 서버로 부터 온 메시지는 모두 chatBoard 영역에 디스플레이된다.

이제 채팅 모달의 전송 버튼(submitBtn) 클릭 시 메시지를 보내보자.

로그인 전이면 "guest", 로그인 한 경우 nickname을 가져와 ${닉네임}: ${메시지}를 web socket으로 전달한다.

// index.js
const setEvtsOnOverlay = () => {
  // web socket client 객체 관리. 토글 버튼 클릭 시 생성/삭제
  let wsc = null;

  // liveChat 모달 토글 버튼 클릭 이벤트 처리
  const btnLiveChat = document.querySelector(".overlay .liveChatBtn");
  btnLiveChat.addEventListener("click", () => {
    const divLiveChatModal = document.querySelector(".overlay .liveChatModal");
    if (divLiveChatModal.style.display === "none") {
      divLiveChatModal.style.display = "flex";
      // web socket client 객체 생성
      const chatBoard = document.querySelector(
        ".overlay .liveChatModal .chatBoard"
      );
      wsc = new WebSocketClient(chatBoard);
    } else {
      divLiveChatModal.style.display = "none";
      // web socket client 객체 삭제
      wsc.disconnect();
      wsc = null;
    }
  });

  const divLiveChatModal = document.querySelector(".overlay .liveChatModal");
  const btnSubmit = divLiveChatModal.querySelector(".chatSubmit");
  btnSubmit.addEventListener("click", () => {
    const inputChat = document.querySelector(
      ".overlay .liveChatModal .chatInput"
    );
    const message = inputChat.value;
    if (message === "") return; // 공백이면 전송하지 않음
    inputChat.value = "";

    const divNickname = document.querySelector(".nav .loggedIn .nickname");
    const nickname =
      divNickname.innerHTML !== "닉네임" ? divNickname.innerHTML : "guest";

    // web socket client 객체가 있으면 메시지 전송
    if (wsc) wsc.send(nickname, message);
  });
};

위 코드는 크게 두 개의 클릭 이벤트 등록을 하는데, 요약하면 다음과 같다.

  1. 이미 처리했던 토글 버튼에 모달 appear시 wsc(web socket client) 인스턴스 생성(connect), hidden 시 disconnect하는 로직을 추가했다.
  2. 채팅 모달의 전송 버튼 클릭 시 만들어진 wsc 인스턴스의 send 메소드를 이용해 사용자가 입력한 채팅 메시지, nav에서 긁어온 닉네임을 서버로 전송한다.
const inputChat = divLiveChatModal.querySelector(".chatInput");
inputChat.addEventListener("keyup", (e) => {
  // 엔터 키 입력 시 버튼 클릭 이벤트 발생
  if (e.keyCode === 13) {
    btnSubmit.click();
  }
  e.preventDefault();
});

추가로 Enter키 입력을 처리하기 위해 keyup시 Enter키(e.keyCode === 13)면 전송 버튼에 클릭 이벤트를 발생시키는 코드도 추가했다.

테스트

화면을 두 개 띄워서 실시간 채팅이 잘 수행되는지 보자.

스크린샷 2023-10-24 오후 4 21 36 스크린샷 2023-10-24 오후 4 21 55 스크린샷 2023-10-24 오후 4 22 21

두 클라이언트 간의 실시간 채팅이 잘 수행되며

스크린샷 2023-10-24 오후 4 23 30

로그도 잘 남는다. logger에 전달해야 좋을텐데, 이제 그걸 해보자!

서버 로깅 개선

초반 페어 프로그래밍 당시 설정해둔 winston은 거의 쓰지 않고 어느새부터 console.log로만 로그를 남겼다.

이제 다시 winston 로그를 제대로 남겨보자. 크게 두 파트에 걸쳐 로깅 메시지를 개선해 로그를 남겨줬다.

로깅 메시지 개선

앞선 설명대로 console.log 남기는 부분들은 모두 logger.info 또는 logger.error로 대체해주고,

추가로 Pascal Case로 어떤 영역에서 해당 로그가 발생했는지를 명시해줬다.

// console.log("[Static] file not exists");
logger.error(`[Static] file not exists: ${fullPath}`);

...
// console.log("matched: ", fullPath);
logger.info(`[Static] matched: ${fullPath}`);

위는 StaticRouterApp.js에서의 예시이다.

성공/에러 메시지 로깅

client에 res.error()로 에러를 반환해주는 부분은 모두 서버에서도 로그가 남도록 해줬다.
성공할 경우에도 가능하면 (DB INSERT 등 중요한 서버 변화는) info 로그를 남기자.

const handleUserCreate = async (req, res) => {
  const messageBodyObj = req.messageBodyObj;
  if (messageBodyObj) {
    ...

    await userCreate(userDTO);

    const url = "/";
    res.redirect(url);
    logger.info(`[User] 회원가입 성공 : (${email}, ${nickname})`);
  } else {
    res.error(400, "잘못된 회원가입 폼");
    logger.error("[User] 잘못된 회원가입 폼");
  }
};

위 예제는 회원가인 성공/실패 시 로그를 남기도록 개선한 로직이다.

학습메모

profile
해커 출신 개발자

0개의 댓글