[실시간 채팅 구축 프로젝트(미니프로젝트2)] 2. Private Chat 구현하기(DM)

Shy·2023년 10월 5일
0

NodeJS(Express&Next.js)

목록 보기
38/39

Private Chat 구현하기(DM)

앱 기본 구조 생성

기본 구조 생성

Express App 생성

const express = require('express')

const app = express() // Creates an Express application

app.listen(port, () => {
  console.log(`Server is up on port ${port}!`)
})

정적 파일 제공

const express = require('express')

const app = express() // Create an Express application

const publicDirectoryPath = path.join(__dirname, '../public')

app.use(express.static(publicDirectoryPath))

app.listen(port, () => {
  console.log(`Server is up on port ${port}!`)
})

미들웨어 등록

app.use(express.json());
app.use(express.static(path.join(__dirname, '../public')));

HTML 기본 구조

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Chat App</title>
  <link rel="stylesheet" href="style.css">
  <script src="/socket.io/socket.io.js"></script>
</head>

<body>
  <script src="main.js"></script>
</body>

부트스트랩 5 CDN

부트스트랩 5 CDN

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Chat App</title>
    <link rel="stylesheet" href="style.css">
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-GLhlTQ8iRABdZLl6O3oVMWSktQOp6b7In1Zl3/Jr59b6EGGoI1aFkw7cmDA6j6gD" crossorigin="anonymous">
</head>

전체 HTML 소스 코드

<!-- header -->
<div class="bg-primary p-2 d-flex">
  <h5 class="text-white">채팅방</h5>
</div>

User Login Form

<!-- User Login Form -->
<div class="login-container p-5">
  <form method="post" class="user-login d-flex justify-content-center flex-column">
    <input type="text" class="form-control mb-3" placeholder="유저 이름을 적어주세요." name="username" id="username" required>
    <button type="submit" class="btn btn-primary">입장</button>
  </form>
</div>
<!-- Sidebar -->
<div class="col-4">
  <div class="sidebar border-end">
    <div class="title p-2 bg-success bg-opacity-50">
      나의 이름: <span id="user-title"></span>
    </div>
    <div class="user-title p-2 border-bottom">
      <span id="users-tagline"></span>
    </div>
    <div class="users">
    </div>
  </div>
</div>

Main

<!-- Main -->
<div class="col-8">
  <div class="chat-container">
    <!-- Active user title -->
    <div class="title p-2 bg-success bg-opacity-50">
      상대방 이름: <span id="active-user">&nbsp;</span>
    </div>
    
    <!-- Message area -->
    <div class="messages p-2"></div>

    <!-- Message form -->
    <div class="msg-form d-flex justify-content-center border-top align-items-center p-2 bg-success bg-opacity-50 d-none">
      <form method="post" class="msgForm w-100">
        <div class="d-flex">
          <input type="text" class="form-control" name="message" id="message" placeholder="메시지 보내기..." required>
          <button type="submit" style="min-width: 70px" class="ms-2 btn btn-success">전송</button>
        </div>
      </form>
    </div>
  </div>

Socket io 연동하기

Express App에 Socket IO연동

const express = require('express')
const app = express() // Creates an Express application

const http = require('http')
const { Server } = require("socket.io");
const server = http.createServer(app)
const io = new Server(server);

const publicDirectoryPath = path.join(__dirname, '../public')

app.use(express.static(publicDirectoryPath))

server.listen(port, () => {
  console.log(`Server is up on port ${port}!`)
})

HTTP 모듈 가져오기

const http = require('http');
  • Node.js의 내장 모듈인 http를 http라는 상수에 할당합니다. 이 모듈은 HTTP 서버 및 클라이언트 기능을 제공한다.

Socket.io 라이브러리에서 Server 가져오기

const { Server } = require("socket.io");
  • socket.io 라이브러리에서 Server 클래스를 가져온다. 이 클래스를 사용하여 WebSocket 서버를 생성할 수 있다.

HTTP서버 생성

const server = http.createServer(app);
  • http.createServer() 메서드를 사용하여 HTTP 서버를 생성한다. app은 (이 코드 조각에서는 정의되지 않았지만) 일반적으로 Express와 같은 웹 애플리케이션 프레임워크의 인스턴스를 참조한다.

WebSocket 서버 생성

const io = new Server(server);
  • server는 이전 단계에서 생성한 HTTP 서버의 인스턴스이다. socket.io는 이 HTTP 서버 위에서 WebSocket 서버를 구동하기 위해 이를 사용한다.

    이 코드는 app이라는 Express 애플리케이션을 기반으로 HTTP 및 WebSocket 서버를 동시에 생성한다.

socket 이벤트에 따른 이벤트 리스너 추가해주기

Server

// socket events
let users = [];
io.on('connection', async socket => {
  // get all users
  let userData = {}
  users.push(userData);
  io.emit('users-data', { users });
  
  // get message from client
  socket.on('message-to-server', );
  
  // fetch previous messages
  socket.on('fetch-messages', );
  
  socket.on('disconnect', );
});

이 코드는 Socket.io를 사용하여 실시간 웹 애플리케이션에서 소켓 이벤트를 핸들링하는 부분을 보여준다.

let users = [];
  • users 배열은 연결된 사용자의 데이터를 저장하는 데 사용된다.
io.on('connection', async socket => {
  • io.on('connection', ...)은 새로운 사용자가 서버에 연결될 때마다 실행되는 이벤트 리스너이다. 콜백 함수의 socket 파라미터는 연결된 개별 사용자를 나타낸다.
let userData = {}
users.push(userData);
  • userData 객체를 초기화하고, users 배열에 추가한다. 현재는 빈 객체를 추가하지만, 일반적으로는 사용자의 정보(예: 이름, ID 등)를 저장한다.
io.emit('users-data', { users });
  • 현재 연결된 모든 사용자에게 users-data 이벤트를 전송하고 users 배열을 함께 보낸다. 이렇게 하면 모든 클라이언트가 현재 연결된 사용자 목록을 받게 된다.
socket.on('message-to-server', );
  • 사용자가 서버에 메시지를 보내면 message-to-server 이벤트가 발생한다. 현재 콜백 함수가 정의되지 않았으므로 이 이벤트를 처리하는 로직을 추가해야 한다.
socket.on('fetch-messages', );

fetch-messages 이벤트는 사용자가 이전 메시지를 가져오려 할 때 발생한다. 이 역시 콜백 함수가 정의되지 않았으므로 이 이벤트를 처리하는 로직을 추가해야 한다.

socket.on('disconnect', );
  • 사용자가 서버와의 연결을 종료하면 disconnect 이벤트가 발생한다. 예를 들어, 사용자가 페이지를 닫거나 네트워크 연결이 끊어지면 이 이벤트가 발생할 수 있다. 이 이벤트를 핸들링하여 연결이 끊긴 사용자의 데이터를 처리하거나 다른 사용자에게 알릴 수 있다.

Client

const socket = io('http://localhost:4000/', {
  autoConnect: false
});

socket.onAny((event, ...args) => {
  console.log(event, args);
});

해당 코드는 Socket.io 클라이언트 라이브러리를 사용하여 특정 서버와 웹소켓 연결을 설정하는 것이다.

Socket.io클라이언트 초기화

const socket = io('http://localhost:4000/', {
  autoConnect: false
});
  • io('http://localhost:4000/'): 여기서는 Socket.io 서버가 localhost의 4000 포트에서 실행되고 있음을 나타낸다. 클라이언트는 이 서버로 연결을 시도한다.
  • autoConnect: false: 기본적으로 Socket.io 클라이언트는 초기화될 때 자동으로 서버에 연결을 시도한다. 하지만 autoConnect 옵션을 false로 설정하면 자동 연결을 중지하고 개발자가 명시적으로 socket.connect() 메서드를 호출할 때만 연결을 시작한다.

모든 이벤트 수신 리스너 설정

socket.onAny((event, ...args) => {
  console.log(event, args);
});
  • socket.onAny(): Socket.io 클라이언트는 서버에서 발생하는 모든 이벤트를 감지하고 처리할 수 있다. onAny 메서드는 서버에서 보내는 모든 종류의 이벤트를 감지하기 위한 리스너를 설정하는데 사용된다.
  • (event, ...args) => { ... }: 이 콜백 함수는 서버에서 이벤트가 발생할 때마다 호출된다. event는 이벤트의 이름을 나타내고, ...args는 해당 이벤트와 함께 전송되는 추가 인자들을 나타낸다.
  • console.log(event, args): 서버에서 수신된 이벤트의 이름과 인자들을 콘솔에 출력한다.

Client 전역 변수 생성

// 전역 변수들
const chatBody = document.querySelector('.chat-body');
const userTitle = document.querySelector('#user-title');
const loginContainer = document.querySelector('.login-container');
const userTable = document.querySelector('.users');
const userTagline = document.querySelector('#users-tagline');
const title = document.querySelector('#active-user');
const messages = document.querySelector('.messages');
const msgDiv = document.querySelector('.msg-form');

몽고 DB 연동하기

몽고DB에 연결하기 위해서는 클러스터의 아이디와 비밀번호가 필요하다!

MongoDB 연결

mongoose.set('strictQuery', false);
mongoose.connect(MONGO_URI) // 여기에 URI입력
  .then(() => console.log('디비 연결 성공!'))
  .catch(err => console.log(err))

모델 생성

const { default: mongoose } = require("mongoose");

const messageSchema = mongoose.Schema({
    userToken: {
        type: String,
        required: true,
    },
    messages: [
        {
            from: {
                type: String,
                required: true
            },
            message: {
                type: String,
                required: true
            },
            time: {
                type: String,
                required: true
            }
        }
    ]
})

const messageModel = mongoose.model("Message", messageSchema);
module.exports = messageModel;

이 코드는 두 개의 문자열인 sender와 receiver를 가져와서 배열에 넣고, 이 배열의 항목들을 알파벳순으로 정렬한 다음에, _를 사용하여 두 문자열을 연결한다.

  1. [sender, receiver]:
    • sender와 receiver 두 문자열을 배열로 만듭니다.
  2. .sort()
    • 배열의 sort() 메서드를 호출하여 배열의 항목들을 알파벳 순서로 정렬한다. 문자열의 비교 시, ASCII 문자 순서를 기준으로 한다.
  3. .join("_")
    • join() 메서드는 배열의 모든 항목들을 하나의 문자열로 연결한다. 이 경우, _ 문자를 구분자로 사용하여 두 문자열을 연결한다.

예시

  • sender = "Alice" 및 receiver = "Bob"일 경우, 결과 key는 "Alice_Bob"가 된다.
  • sender = "Bob" 및 receiver = "Alice"일 경우, 결과 key는 또한 "Alice_Bob"가 된다. (정렬 때문에 순서가 바뀌었습니다.)

이런 방식은 sender와 receiver의 순서와 관계없이 일관된 키를 생성하게 해준다.
누가 receiver가 되고 sender가 되던, 둘을 합쳐서 똑같은 토큰을 만들어서, 해당 토큰을 사용하여 DB에서 메시지를 가져온다.

(userToken에 해당된다.)

유저 세션 생성하기

유저 세션 데이터 생성

main.js

// login form handler
const loginForm = document.querySelector('.user-login');
loginForm.addEventListener('submit', (e) => {
    e.preventDefault();
    const username = document.getElementById('username');
    createSession(username.value.toLowerCase());
    username.value = '';
})

const createSession = async (username) => {
    const options = {
        method: 'Post',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ username })
    }
    await fetch('/session', options)
        .then(res => res.json())
        .then(data => {

            socketConnect(data.username, data.userID);

            // localStorage에 세션을 Set
            localStorage.setItem('session-username', data.username);
            localStorage.setItem('session-userID', data.userID);

            loginContainer.classList.add('d-none');
            chatBody.classList.remove('d-none');
            userTitle.innerHTML = data.username;
        })
        .catch(err => console.error(err));
}

const socketConnect = async (username, userID) => {
    socket.auth = { username, userID };

    await socket.connect();
}

socket.on('users-data', ({ users }) => {
    // 자신은 제거하기  
    const index = users.findIndex(user => user.userID === socket.id);
    if (index > -1) {
        users.splice(index, 1);
    }

    // user table list 생성하기
    userTable.innerHTML = '';
    let ul = `<table class="table table-hover">`;
    for (const user of users) {
        ul += `<tr class="socket-users"token interpolation">${user.username}', '${user.userID}')"><td>${user.username}<span class="text-danger ps-1 d-none" id="${user.userID}">!</span></td></tr>`
    }
    ul += `</table>`;
    if (users.length > 0) {
        userTable.innerHTML = ul;
        userTagline.innerHTML = '접속 중인 유저';
        userTagline.classList.remove('text-danger');
        userTagline.classList.add('text-success');
    } else {
        userTagline.innerHTML = '접속 중인 유저 없음';
        userTagline.classList.remove('text-success');
        userTagline.classList.add('text-danger');
    }
})
코드 분석

로그인 폼 핸들러 설정

const loginForm = document.querySelector('.user-login');
loginForm.addEventListener('submit', (e) => {
    e.preventDefault();
    const username = document.getElementById('username');
    createSession(username.value.toLowerCase());
    username.value = '';
})
  • 웹 페이지에서 .user-login 클래스를 가진 요소 (로그인 폼)을 찾아 이벤트 리스너를 설정한다.
  • 폼 제출 시, 기본 제출 동작을 막고(e.preventDefault()), 사용자 이름을 가져와 createSession 함수를 호출한다.
  • 마지막으로 입력된 사용자 이름을 비운다.

세션 생성 함수

const createSession = async (username) => {
    // ... (중략)
}
  • createSession 함수는 서버에 POST 요청을 보내어 세션을 생성하려고 시도한다.
  • 요청이 성공하면, 반환된 데이터를 기반으로 소켓을 연결하고, 로컬 스토리지에 사용자 정보를 저장한다.
  • 또한 로그인 컨테이너를 숨기고 채팅 본문을 표시하며, 유저 이름을 화면에 표시한다.

소켓 연결 함수

const socketConnect = async (username, userID) => {
    socket.auth = { username, userID };
    await socket.connect();
}
  • socketConnect 함수는 사용자 이름과 사용자 ID를 기반으로 소켓을 연결한다.

소켓 리스너 설정

socket.on('users-data', ({ users }) => {
    // ... (중략)
})
  • users-data 이벤트가 발생할 때 실행된다. 주로 다른 클라이언트에서 보내진 사용자 데이터를 처리한다.
  • 현재 사용자(소켓 연결자)를 사용자 목록에서 제거한다.
  • 사용자 테이블 목록을 생성하고, 화면에 접속 중인 사용자 목록을 업데이트한다.

이 코드는 웹 채팅 어플리케이션의 일부다. 사용자는 로그인 폼을 통해 이름을 입력하면, 서버에 세션을 생성하고, 해당 세션을 기반으로 소켓 연결을 수립한다. 다른 사용자들이 접속하거나 퇴장할 때, 접속 중인 사용자 목록이 실시간으로 업데이트된다.

index.js

io.use((socket, next) => {
    const username = socket.handshake.auth.username;
    const userID = socket.handshake.auth.userID;
    if (!username) {
        return next(new Error('Invalid username'));
    }

    socket.username = username;
    socket.id = userID;

    next();
})

let users = [];
io.on('connection', async socket => {

    let userData = {
        username: socket.username,
        userID: socket.id
    };
    users.push(userData);
    io.emit('users-data', { users })
});
코드 분석

Middleware설정

io.use((socket, next) => {
    // ... (중략)
});
  • 여기서 io.use()는 Socket.io 서버의 미들웨어를 정의한다. 모든 소켓 연결 시도는 이 미들웨어를 통과한다.
  • 소켓의 handshake 객체에서 username과 userID를 추출한다.
  • 만약 username이 없으면, "Invalid username"이라는 오류와 함께 연결을 거부한다.
  • 그렇지 않다면, socket 객체에 username과 userID 프로퍼티를 할당한다.
  • 마지막으로 next()를 호출하여 연결 과정을 계속 진행한다.

사용자 연결 핸들링

let users = [];
io.on('connection', async socket => {
    // ... (중략)
});
  • 사용자가 소켓 서버에 연결할 때마다 이 핸들러가 호출된다.
  • socket.username과 socket.id를 이용하여 사용자 데이터 객체(userData)를 생성한다.
  • 이 사용자 데이터를 전역 users 배열에 추가한다.
  • 모든 연결된 클라이언트들에게 현재 사용자 목록을 전송한다. 이 때 'users-data' 이벤트를 사용하여 데이터를 보낸다.

전반적으로, 이 코드는 사용자가 소켓 서버에 연결할 때 사용자 이름과 ID를 기반으로 연결을 핸들링하는 방법을 보여준다. 연결된 사용자 목록은 실시간으로 업데이트되며, 모든 연결된 클라이언트에게 이 정보가 전송된다.

페이지 리프레시 할 때 자동 로그인

const sessUsername = localStorage.getItem('session-username');
const sessUserID = localStorage.getItem('session-userID');

if (sessUsername && sessUserID) {
    socketConnect(sessUsername, sessUserID);

    loginContainer.classList.add('d-none');
    chatBody.classList.remove('d-none');
    userTitle.innerHTML = sessUsername;
}

코드 분석

로컬 스토리지에서 세션 데이터 가져오기

const sessUsername = localStorage.getItem('session-username');
const sessUserID = localStorage.getItem('session-userID');
  • localStorage는 웹 브라우저에서 키-값 쌍으로 데이터를 저장하는 데 사용되는 Web Storage API의 일부이다. 이 스토리지는 영구적이며 웹 페이지 세션이 종료된 후에도 데이터가 유지된다.
  • 위 코드는 로컬 스토리지에서 session-username과 session-userID라는 키로 저장된 값을 가져와 sessUsername와 sessUserID 변수에 할당한다.

세션 데이터 확인 및 동작 실행

if (sessUsername && sessUserID) {
    socketConnect(sessUsername, sessUserID);
    // ... (중략)
}
  • if 조건문을 사용하여 sessUsername과 sessUserID가 모두 존재하는지 확인한다.
  • 만약 두 값 모두 존재한다면, 아래의 동작들이 실행된다.
    • 소켓 연결: socketConnect 함수를 호출하여 사용자 이름과 사용자 ID를 기반으로 소켓 연결을 시도한다.
    • UI 업데이트: 로그인 컨테이너(loginContainer)는 숨겨지고 (d-none 클래스 추가), 채팅 본문(chatBody)은 보이게 된다 (d-none 클래스 제거).
    • 사용자 제목 업데이트: userTitle 엘리먼트의 내용을 현재 세션의 사용자 이름(sessUsername)으로 설정한다.

요약하면, 이 코드는 웹 페이지가 로드될 때 로컬 스토리지를 확인하여 이전에 저장된 사용자 세션 데이터(사용자 이름 및 사용자 ID)가 있는지 확인한다. 만약 세션 데이터가 있다면, 해당 사용자로 자동 로그인하고 UI를 업데이트한다.

메시지 보낼 상대 선택하기

// user table list 생성하기
`<tr class="socket-users" onclick="setActiveUser(this, '${user.username}', '${user.userID}')">
  <td>${user.username}<span class="text-danger ps-1 d-none" id="${user.userID}">!</span></td>
</tr>;
  1. <tr> (table row): 테이블의 행을 나타내는 요소이다.
    • class="socket-users": 해당 행에 "socket-users"라는 CSS 클래스가 부여되어 있다. 이를 통해 특정 스타일링 또는 JavaScript에서의 선택이 가능하게 된다.
    • onclick="setActiveUser(this, '${user.username}', '${user.userID}')": 이 tr 요소를 클릭하면 setActiveUser라는 JavaScript 함수가 호출됩니다. 그 때, 이 함수는 3개의 인자를 받는다.
    • this: 현재 클릭된 tr 요소 자체이다.
    • ${user.username}: 사용자의 이름. ${...} 형태는 JavaScript의 템플릿 리터럴에서 변수를 삽입하기 위한 방식이다.
    • ${user.userID}: 사용자의 고유한 ID.
  2. <td> (table data): 테이블의 한 셀을 나타낸다. 여기에는 사용자의 이름과 아이콘이 포함되어 있다.
    • ${user.username}: 사용자의 이름이 표시된다.
    • <span class="text-danger ps-1 d-none" id="${user.userID}">!</span>: 느낌표(!) 아이콘을 나타내는 부분이다.
    • class="text-danger ps-1 d-none": 여러 CSS 클래스가 적용되어 있다.
    • text-danger: 텍스트의 색상을 "위험" 색상(보통 빨간색)으로 표시하기 위한 클래스다.
    • ps-1: 패딩 스타일이나 여백을 조절하는 클래스로 보인다.
    • d-none: 이 클래스가 적용되면 요소는 화면에서 숨겨진다. (일반적으로 display: none; 스타일이 적용됨)
    • id="${user.userID}": 해당 span 요소에 사용자의 고유 ID를 id 속성 값으로 부여한다.

setActiveUser

const setActiveUser = (element, username, userID) => {
  title.innerHTML = username;
  title.setAttribute('userID', userID);
  
  // 사용자 목록 활성 및 비활성 클래스 이벤트 핸들러
  const lists = document.getElementsByClassName('socket-users');
  for (let i = 0; i < lists.length; i++) {
    lists[i].classList.remove('table-active');
  }

  element.classList.add('table-active');

  // 사용자 선택 후 메시지 영역 표시
  msgDiv.classList.remove('d-none');
  messages.classList.remove('d-none');
  messages.innerHTML = '';
  socket.emit('fetch-messages', { receiver: userID });
  const notify = document.getElementById(userID);
  notify.classList.add('d-none');
}

코드 분석

해당 JavaScript 코드는 웹 페이지에서 특정 사용자를 "활성 사용자"로 설정하는 기능을 제공한다.
setActiveUser 함수 정의

const setActiveUser = (element, username, userID) => {
  title.innerHTML = username;
  title.setAttribute('userID', userID);
  • setActiveUser 함수는 3개의 인자를 받는다.
    • element: 현재 클릭된 (또는 선택된) 테이블 행을 나타내는 DOM 요소이다.
    • username: 해당 사용자의 이름이다.
    • userID: 해당 사용자의 고유 ID다.

함수의 시작 부분에서는 title이라는 DOM 요소의 내부 텍스트를 해당 사용자의 이름으로 설정하고, userID 속성에 해당 사용자의 ID를 설정한다.

모든 사용자 목록의 활성화 상태 제거

  // 사용자 목록 활성 및 비활성 클래스 이벤트 핸들러
  const lists = document.getElementsByClassName('socket-users');
  for (let i = 0; i < lists.length; i++) {
    lists[i].classList.remove('table-active');
  }
  • 웹 페이지에서 'socket-users' 클래스를 가진 모든 요소 (여기서는 테이블 행)의 'table-active' 클래스를 제거한다. 이를 통해 이전에 활성화된 사용자의 상태가 비활성화된다.
  • 여러 유저들이 존재할 때, 클릭된 유저만 활성화 상태로 만들기 위한 것이다.

현재 선택된 사용자 활성화

  element.classList.add('table-active');
  • 현재 클릭된 (또는 선택된) 테이블 행(element)에 'table-active' 클래스를 추가한다. 이를 통해 선택된 사용자가 화면에 활성화 상태로 표시된다.

메시지 영역 표시

  // 사용자 선택 후 메시지 영역 표시
  msgDiv.classList.remove('d-none');
  messages.classList.remove('d-none');
  messages.innerHTML = '';
  • 사용자가 선택되면, 메시지 영역(msgDiv와 messages)이 화면에 표시된다. 여기서 'd-none' 클래스는 요소를 화면에서 숨기는 역할을 한다. 따라서 이 클래스를 제거하여 요소를 표시한다. 그리고 messages의 내부 HTML을 비워서 이전 메시지를 삭제한다.

이전 메시지 요청

  socket.emit('fetch-messages', { receiver: userID });
  • 소켓을 통해 서버에 fetch-messages 이벤트를 전송하고, 이 때 선택된 사용자의 ID를 함께 전달한다. 이를 통해 해당 사용자와의 이전 대화 내용을 요청한다.

알림 숨기기

  const notify = document.getElementById(userID);
  notify.classList.add('d-none');
  • 사용자 ID와 동일한 ID 값을 가진 요소(여기서는 알림)를 선택하고, 해당 알림을 화면에서 숨긴다.
  • 다른 유저와 대화를 할 때, 그 다른 유저가 대화창을 안 열고 있을 때, 알림이 뜨게 된다. 클릭을 하면 알림이 사라져야 하므로(카카오톡 처럼) 알림 숨기는 기능을 만드는 것이다.

요약하면, setActiveUser 함수는 웹 페이지에서 특정 사용자를 활성화 상태로 설정하고, 그에 따라 메시지 영역을 표시하며, 이전 메시지를 요청하고, 새 메시지 알림을 숨기는 기능을 수행한다.

메시지 보내기

main.js

const appendMessage = ({ message, time, background, position }) => {
    let div = document.createElement('div');
    div.classList.add('message', 'bg-opacity-25', 'm-2', 'px-2', 'py-1', background, position);
    div.innerHTML = `<span class="msg-text">${message}</span> <span class="msg-time"> ${time}</span>`;
    messages.append(div);
    messages.scrollTo(0, messages.scrollHeight);
}

const msgForm = document.querySelector('.msgForm');
const message = document.getElementById('message');

msgForm.addEventListener('submit', (e) => {
    e.preventDefault();

    const to = title.getAttribute('userID');
    const time = new Date().toLocaleString('en-US', {
        hour: 'numeric',
        minute: 'numeric',
        hour12: true
    })

    // 메시지 payload 만들기
    const payload = {
        from: socket.id,
        to,
        message: message.value,
        time
    }

    socket.emit('message-to-server', payload);

    appendMessage({ ...payload, background: 'bg-success', position: 'right' });

    message.value = '';
    message.focus();
})

socket.on('message-to-client', ({ from, message, time }) => {
    const receiver = title.getAttribute('userID');
    const notify = document.getElementById(from);

    if (receiver === null) {
        notify.classList.remove('d-none');
    } else if (receiver === from) {
        appendMessage({
            message,
            time,
            background: 'bg-secondary',
            position: 'left'
        })
    } else {
        notify.classList.remove('d-none');
    }
})

위 코드는 웹 기반의 채팅 애플리케이션의 일부이며, 사용자가 메시지를 보내고 받는 기능을 처리한다.

  1. appendMessage 함수
    • 이 함수는 채팅 메시지를 화면에 표시하기 위해 사용된다.(자기가 보낸 메시지는 바로 자기 화면에 추가하면 되므로)
    • 메시지의 내용, 시간, 배경색 및 메시지가 왼쪽(받은 메시지) 또는 오른쪽(보낸 메시지)에 표시될지 결정하는 위치 정보를 인자로 받는다.
    • 새로운 div 요소를 생성하고 해당 요소에 적절한 스타일 및 클래스를 적용한 후, 메시지와 시간 정보를 추가한다.
    • 이 div 요소는 메시지 목록에 추가되고, 사용자는 가장 최근의 메시지를 볼 수 있도록 스크롤된다.
  2. msgForm 및 message 변수
    • msgForm 변수는 사용자가 메시지를 입력하고 전송하는 폼을 나타낸다.
    • message 변수는 사용자가 입력한 메시지의 내용을 가져오기 위한 인풋 요소를 참조한다.
  3. msgForm의 이벤트 리스너
    • 폼 제출 이벤트가 발생하면 해당 이벤트 리스너가 호출된다.
    • 기본 폼 제출 동작을 방지하기 위해 e.preventDefault()를 사용한다.
    • 메시지를 수신할 사용자의 ID와 현재 시간을 가져온다.
    • 메시지 내용, 시간, 발신자 및 수신자 정보로 구성된 payload를 생성한다.
    • socket.emit를 사용하여 서버에 메시지 정보(payload)를 전송한다.
    • appendMessage 함수를 호출하여 사용자 인터페이스에 보낸 메시지를 표시한다.
    • 마지막으로 메시지 입력란을 초기화하고 다시 초점을 맞춘다.
  4. message-to-client 이벤트의 소켓 리스너
    • 서버에서 클라이언트로 메시지가 전송될 때마다 이 리스너가 호출된다.
    • 현재 활성화된 채팅 사용자의 ID를 가져온다.
    • 알림을 나타내는 요소(notify)를 참조한다.
    • 현재 활성화된 채팅 사용자가 없거나 활성화된 채팅 사용자가 메시지를 보낸 사용자와 다를 경우 알림을 표시한다.
    • 활성화된 채팅 사용자가 메시지를 보낸 사용자와 동일한 경우 메시지를 화면에 표시한다.

요약하면, 위 코드는 웹 채팅 애플리케이션에서 사용자가 메시지를 보내고 받을 때의 동작을 처리한다. 사용자가 메시지를 보내면 해당 메시지는 서버에 전송되며, 서버로부터 메시지를 받으면 해당 메시지는 사용자 인터페이스에 표시된다.

if(receiver===null)

    if (receiver === null) {
        notify.classList.remove('d-none');
    } else if (receiver === from) {
        appendMessage({
            message,
            time,
            background: 'bg-secondary',
            position: 'left'
        })
    } else {
        notify.classList.remove('d-none');
    }

해당 코드가 어떤 기능을 하는 지 자세히 보자.

변수 확인

if (receiver === null) {
  • receiver는 현재 선택된 대화 상대의 userID를 나타낸다. 이 조건문은 현재 어떠한 사용자도 선택되지 않은 상태(즉, 어떤 사용자와의 채팅창이 활성화되지 않은 상태)인지 확인한다.

새로운 메시지 알림

notify.classList.remove('d-none');
  • 만약 현재 선택된 대화 상대가 없거나 메시지를 보낸 사람(from)이 현재 선택되지 않은 다른 사용자일 경우, 이 줄은 새로운 메시지 알림을 화면에 표시하도록 한다. d-none은 Bootstrap의 클래스 중 하나로, 해당 요소를 화면에서 숨기는 역할을 한다. classList.remove('d-none')을 사용하면 해당 요소가 화면에 표시됩니다.

메시지 표시

} else if (receiver === from) {
    appendMessage({
        message,
        time,
        background: 'bg-secondary',
        position: 'left'
    })
}
  • 만약 메시지를 보낸 사용자(from)가 현재 선택된 대화 상대(receiver)일 경우, appendMessage 함수를 호출하여 채팅창에 메시지를 추가한다. 메시지는 좌측(즉, 상대방이 보낸 메시지)에 표시되며, bg-secondary는 Bootstrap의 배경색 클래스로 회색 배경을 적용한다.

그 외의 경우

} else {
    notify.classList.remove('d-none');
}
  • 만약 위의 두 조건(receiver가 null이거나 receiver가 메시지를 보낸 사람(from)일 경우)이 아닌 경우에는 새로운 메시지 알림을 표시한다.

이 코드는 수신된 메시지를 기반으로 사용자에게 알림을 보여주거나 채팅창에 메시지를 추가하는 로직을 수행한다.

빨간색 느낌표로 notify가 나왔다.

messages.js

const messageModel = require("../models/messages.model");

const getToken = (sender, receiver) => {
    const key = [sender, receiver].sort().join("_");
    return key;
}

const saveMessages = async ({ from, to, message, time }) => {
    const token = getToken(from, to);
    const data = {
        from, message, time
    }
    // 
    messageModel.updateOne({ userToken: token }, {
        $push: { message: data }
    }, (err, res) => {
        if (err) console.error(err);
        console.log('메시지가 업데이트되었습니다.');
    })

}

위 코드는 데이터베이스에 메시지를 저장하기 위한 로직을 포함하고 있다.

const messageModel = require("../models/messages.model");
  • messages.model 모듈을 임포트하고, 이를 messageModel로 참조한다. 이 모델은 메시지 데이터를 데이터베이스에 저장하거나 조회하는데 사용될 것으로 보인다.

getToken함수

const getToken = (sender, receiver) => {
    const key = [sender, receiver].sort().join("_");
    return key;
}
  • 이 함수는 sender와 receiver를 인자로 받아 두 값을 알파벳순으로 정렬한 후 언더스코어(_)로 연결하여 반환한다.
  • 예: getToken("Bob", "Alice")는 "Alice_Bob"를 반환한다.
  • 이 함수의 목적은 두 사용자 간의 고유한 토큰 또는 키를 생성하는 것이다.

saveMessages함수

const saveMessages = async ({ from, to, message, time }) => {
    const token = getToken(from, to); // 함수로 토큰 생성
    const data = {
        from, message, time
    }
    messageModel.updateOne({ userToken: token }, {
        $push: { message: data }
    }, (err, res) => {
        if (err) console.error(err);
        console.log('메시지가 업데이트되었습니다.');
    })
}
  • 이 함수는 메시지 정보(from, to, message, time)를 인자로 받아 데이터베이스에 저장한다.
  • 먼저 getToken 함수를 사용하여 from과 to를 기반으로 한 고유 토큰을 생성한다.
  • 그 다음, 메시지 정보를 data 객체에 저장한다.
  • messageModel.updateOne을 사용하여 해당 토큰의 메시지 목록에 새 메시지를 추가한다. ($push 연산자를 사용)
  • 메시지 저장 후의 콜백에서 에러가 발생하면 에러를 콘솔에 출력하고, 그렇지 않으면 "메시지가 업데이트되었습니다."라는 메시지를 콘솔에 출력한다.

이 코드의 주된 기능은 메시지를 데이터베이스에 저장하는 것이다. 사용자 간의 메시지는 userToken이라는 고유한 키를 기반으로 구성되며, 메시지는 해당 키의 배열에 추가된다.

위 메세지 DB 배열에 메세지를 하나 넣어주는 것이 updateOne의 기능이다.

index.js

    // 클라이언트에서 보내온 메시지  A ==> Server  ===> B
    socket.on('message-to-server', (payload) => {
        io.to(payload.to).emit('message-to-client', payload); // 클라이언트에게 메세지 페이로드를 보냄
        saveMessages(payload); // 메세지 페이로드를 받아 서버에 저장
    })

이벤트 리스너 설정

socket.on('message-to-server', (payload) => {
  • socket.on('message-to-server', ...)는 클라이언트로부터 'message-to-server' 이벤트가 수신되면 해당 콜백 함수를 실행하도록 설정한다.
  • 이 이벤트는 클라이언트 A가 메시지를 보낼 때 서버로 전송되며, payload에는 메시지와 관련된 데이터가 포함된다.

메시지 전달

io.to(payload.to).emit('message-to-client', payload);
  • 이 코드는 메시지를 받는 사용자(B)에게 메시지를 전송한다.
  • io.to(payload.to)는 payload.to에 명시된 socket ID를 가진 클라이언트를 대상으로 한다.
  • .emit('message-to-client', payload)는 해당 클라이언트에게 'message-to-client' 이벤트와 함께 payload 데이터를 전송한다.
  • 결과적으로, 클라이언트 A가 이 메시지를 보내면 서버는 이를 받아 처리하고, 대상 사용자(B)에게 메시지를 전달한다.

메세지 저장

saveMessages(payload);
  • saveMessages 함수는 위에서 설명한 바와 같이 데이터베이스에 저장한다.

채팅방 나가는 로직 구현하기

지금까지는 채팅방을 나가는 로직이 없어, 새로고침을 하면 위처럼 자신이 계속 추가가 되었었다.
이제 채팅방을 나가는 로직을 구현하여, 새로고침을 해도 자신이 계속 추가되지 않도록 해보자.

main.js

socket.on('user-away', userID => {
  const to = title.getAttribute('userID');
  if (to === userID) {
    title.innerHTML = '&nbsp';
    msgDiv.classList.add('d-none');
    messages.classList.add('d-none');
  }
})

코드분석

이 코드는 Socket.io를 사용하여 클라이언트에서 특정 사용자가 채팅에서 떠난 것을 처리하는 로직이다.

이벤트 리스너 설정

socket.on('user-away', userID => {
  • socket.on('user-away', ...)는 서버로부터 'user-away' 이벤트가 수신될 때 실행될 콜백 함수를 설정한다.
  • 이 이벤트는 어떤 사용자가 채팅에서 떠났을 때 발생하며, userID는 떠난 사용자의 ID를 나타낸다.

현재 활성화된 사용자 확인

const to = title.getAttribute('userID');
  • 이 코드는 현재 활성화된 채팅 상대의 사용자 ID를 가져온다. title은 DOM 요소로서, userID라는 속성에 사용자의 ID 정보를 포함하고 있다.

활성 사용자와 떠난 사용자의 ID가 일치하는지 확인

if (to === userID) {
  • 이 조건문은 현재 활성화된 채팅 상대의 ID(to)가 떠난 사용자의 ID(userID)와 일치하는지 확인한다. 일치하면 아래 코드가 실행된다.

UI 업데이트

title.innerHTML = '&nbsp';
msgDiv.classList.add('d-none');
messages.classList.add('d-none');
  • title.innerHTML = '&nbsp';: 현재 활성화된 채팅 상대의 이름/제목을 삭제한다.  는 HTML에서 공백을 의미한다.
  • msgDiv.classList.add('d-none');: 메시지 입력 영역을 숨긴다. 여기서 'd-none'은 일반적으로 Bootstrap과 같은 프레임워크에서 사용되는 클래스로, 해당 요소를 숨기는데 사용된다.
  • messages.classList.add('d-none');: 메시지 표시 영역을 숨긴다.

새로고침을 여러 번 했는데도, 자기 자신의 아이디가 더 추가되지 않는다.

index.js

// 유저가 방에서 나갔을 때
socket.on('disconnect', () => {
  users = users.filter(user => user.userID !== socket.id);
  // 사이드바 리스트에서 없애기
  io.emit('users-data', { users });
  // 대화 중이라면 대화창 없애기
  io.emit('user-away', socket.id);
});

코드분석

이 코드는 Socket.io를 사용하여 클라이언트의 연결이 끊어졌을 때(예: 사용자가 채팅을 종료하거나 인터넷 연결이 끊어진 경우) 수행될 작업들을 정의하고 있다.

이벤트 리스너 설정

socket.on('disconnect', () => {
  • 이 부분에서는 서버에서 'discoonect' 이벤트를 수신하면 실행될 콜백 함수를 설정하고 있다.

연결이 끊어진 사용자 제거

users = users.filter(user => user.userID !== socket.id);
  • users 배열에서 현재 연결이 끊어진 사용자(socket.id를 통해 식별)를 제거한다.

사이드바 사용자 목록 업데이트

io.emit('users-data', { users });
  • 'users-data' 이벤트를 발생시켜 연결된 모든 클라이언트에게 업데이트된 사용자 목록(users)를 전송한다. 이를 통해 사이드바에 표시된 사용자 목록이 업데이트된다.

대화 중인 사용자 알림

io.emit('user-away', socket.id);
  • user-away' 이벤트를 발생시켜 모든 클라이언트에게 특정 사용자(socket.id)가 채팅에서 떠났음을 알린다. 다시 말해, 해당 사용자와 대화 중이던 다른 사용자들은 이 사용자가 채팅에서 떠났다는 것을 알게 된다.

위 코드는 클라이언트의 연결이 끊어졌을 때 사용자 목록을 업데이트하고, 해당 사용자와 대화 중이던 다른 사용자들에게 이 사실을 알려주는 로직을 정의하고 있다.

데이터베이스에서 메시지 가져오기

데이터베이스에서 메시지 보내기

main.js

const setActiveUser = (element, username, userID) => {
    title.innerHTML = username;
    title.setAttribute('userID', userID);

    const lists = document.getElementsByClassName('socket-users');
    for (let i = 0; i < lists.length; i++) {
        lists[i].classList.remove('table-active');
    }

    element.classList.add('table-active');

    // 사용자 선택 후 메시지 영역 표시
    msgDiv.classList.remove('d-none');
    messages.classList.remove('d-none');
    messages.innerHTML = '';
    socket.emit('fetch-messages', { receiver: userID });
    const notify = document.getElementById(userID);
    notify.classList.add('d-none');
}

socket.on('stored-messages', ({ messages }) => {
    if (messages.length > 0) {
        messages.forEach(msg => {
            const payload = {
                message: msg.message,
                time: msg.time
            }
            if (msg.from === socket.id) {
                appendMessage({
                    ...payload,
                    background: 'bg-success',
                    position: 'right'
                })
            } else {
                appendMessage({
                    ...payload,
                    background: 'bg-secondary',
                    position: 'left'
                })
            }
        })
    }
})
  1. setActiveUser 함수
    • 목적: 특정 사용자를 활성 상태로 설정하고 채팅 UI를 갱신하는 함수이다.
    • title.innerHTML = username;:
      • 현재 선택된 사용자의 이름을 상단 제목 영역에 표시한다.
    • title.setAttribute('userID', userID);:
      • 선택된 사용자의 ID 값을 상단 제목 요소의 속성으로 설정한다. 이는 나중에 메시지를 보낼 때 해당 사용자의 ID를 참조하기 위함이다.
    • const lists = ... 반복문:
      • 모든 사용자 목록에서 활성 상태 표시(table-active 클래스)를 제거한다.
    • element.classList.add('table-active');:
      • 선택된 사용자에 대해 활성 상태 표시를 추가한다.
    • msgDiv.classList.remove('d-none'); ...:
      • 메시지 입력 영역과 메시지 출력 영역을 표시하고, 이전 메시지들을 초기화한다.
    • socket.emit('fetch-messages', { receiver: userID });:
      • 서버에게 선택된 사용자와의 과거 메시지를 요청합니다.
    • const notify = ...:
      • 해당 사용자의 알림 표시를 숨깁니다(새로운 메시지 알림 기능에서 사용됨).
  2. socket.on('stored-messages', ...) 이벤트 핸들러:
    • 목적: 서버에서 전달받은 과거 메시지 데이터를 처리하여 UI에 표시하는 이벤트 리스너이다.
    • if (messages.length > 0) ...:
      • 서버로부터 받은 메시지가 있다면, 각 메시지에 대해 다음 처리를 수행한다.
      • 메시지를 전송한 사람이 현재 클라이언트일 경우:
        • 메시지를 오른쪽(position: 'right')에 녹색 배경(background: 'bg-success')으로 표시한다.
      • 그렇지 않은 경우 (즉, 상대방이 보낸 메시지):
        • 메시지를 왼쪽(position: 'left')에 회색 배경(background: 'bg-secondary')으로 표시한다.

messages.js

const fetchMessages = async (io, sender, receiver) => {
    let token = getToken(sender, receiver);
    const foundToken = await messageModel.findOne({ userToken: token });
    if (foundToken) {
        io.to(sender).emit('stored-messages', { message: foundToken.messages })
    } else {
        let data = {
            userToken: token,
            messages: []
        }
        const message = new messageModel(data);
        const savedMessage = await message.save();
        if (savedMessage) {
            console.log('메시지가 생성되었습니다.');
        } else {
            console.log('메시지 생성 중 에러가 발생했습니다.');
        }
    }
}
  1. 함수 선언
    • fetchMessages라는 async 함수를 선언했습니다. 이 함수는 3개의 인자를 받는다: io, sender, receiver.
    • io: Socket.io의 서버 인스턴스이다. 이를 통해 특정 사용자에게 메시지를 보낼 수 있다.
    • sender: 메시지를 요청하는 사용자의 ID 또는 식별자이다.
    • receiver: 대화 내용을 가져올 상대방의 ID 또는 식별자이다.
  2. 토큰 생성
    • getToken(sender, receiver)를 호출하여 두 사용자 간의 고유한 토큰을 생성한다. 이 토큰은 데이터베이스에서 해당 대화 내용을 식별하는 데 사용된다.
  3. 데이터베이스 조회
    • messageModel.findOne({ userToken: token })를 사용하여 데이터베이스에서 해당 토큰에 해당하는 대화 내용을 찾는다.
  4. 메시지 처리
    • foundToken: 해당 토큰에 맞는 대화 내용이 데이터베이스에서 발견되면:
      • io.to(sender).emit('stored-messages', { message: foundToken.messages }): 해당 대화 내용을 요청한 사용자에게 보낸다.
    • foundToken이 null 또는 undefined인 경우 (즉, 데이터베이스에 해당 토큰에 대한 대화 내용이 없는 경우):
      • 새로운 대화 내용 데이터를 초기화하여 데이터베이스에 저장한다. 이 데이터는 빈 메시지 배열과 함께 생성된다.
      • 저장이 성공하면 "메시지가 생성되었습니다."라는 메시지를 콘솔에 출력하고, 실패하면 "메시지 생성 중 에러가 발생했습니다."라는 메시지를 콘솔에 출력한다.

index.js

    // 데이터베이스에서 메시지 가져오기
    socket.on('fetch-messages', ({ receiver }) => {
        fetchMessages(io, socket.id, receiver);
    })

채팅앱 스타일링 해주기

style.css

.message.left {
    margin-right: 20% !important;
    border-top-left-radius: 0 !important;
}
.message.right {
    margin-left: 20% !important;
    border-bottom-right-radius: 0 !important;
}

.msg-time {
    display: block;
    opacity: .7;
    font-size: .7rem;
}

.messages {
    height: calc(80vh + 11px);
    overflow-y: auto;
}

.sidebar {
    height: calc(80vh + 112px);
}

.table tr td {
    cursor: pointer;
}

profile
초보개발자. 백엔드 지망. 2024년 9월 취업 예정

0개의 댓글