IT Study/Nodejs 채팅서버 튜토리얼

[Nodejs] 채팅구현하기 2 - SocketIO

ComputerScientist 2020. 11. 28. 09:58

 

 

[Nodejs] 채팅구현하기 1 - SocketIO (처음부터 끝까지)

Nodejs + socketio 를 활용하여 채팅을 구현해보자. 필수 설치 목록 nodejs npm 이 곳에서 다운로드하면 nodejs, npm 모두 설치된다. 사용하게 될 스펙 및 중요 모듈 Back-end : nodejs, express, socket.io, Fro..

itseminar.tistory.com

(이 튜토리얼은 시리즈형태이므로 "채팅구현하기 1"을 보고 오셔야 튜토리얼의 원할한 진행이 가능합니다.)

 

튜토리얼 진행 시 사용된 코드들은 튜토리얼 하단부에 모두 전체파일로 제공되어 있습니다.

 

 

 


순서

  1. JOIN이란?
  2. 채팅방 그리기
  3. 채팅방 참여
  4. 채팅방 메세지 전송
  5. 전체 코드

 

 

 

 

 

실제 코딩에 앞서 먼저 이번 튜토리얼에서 사용하게될 socketio api 중 join에 대해 먼저 알아보자.

 

1. JOIN이란?

SocketIO 에서 JOIN에 대한 개념은 사실 여기에 보면 "아주 잘" 나와있다. (Socket.io - Rooms)

하지만 좀 간단하게 설명을 하자면, 혹은, 좀 더 한글로 설명을 하자면,

 

첫째로, JOIN에 대해 알려면 namespace에 대해 알아야한다. (사실상 이 튜토리얼에서는 namespace를 사용하지 않는다) SocketIO 에는 namespace라는 개념이 존재한다.

  Namespace Room
정의 한 Connection에서 각 각의 로직을 처리할 수 있게 해주는 커뮤니케이션 채널 각 각의 namespace에 속해 있는 소켓들의 subset
정리 Room 의 상위 개념 Namespace 의 하위 개념
예시 "채널" "방"
  채널123 개발자 오세요
  채널235 개발자 오세요

간단하게 보고 넘어갈 수 있게 표로 정리해두었다.

 

:: namespace란 일종의 분기점이라고 생각하면 편한데, 한 connection(클라이언트-서버)에서 각 각의 namespace마다 각 각의 로직을 처리할 수 있게 해주는 커뮤니케이션 채널이다. (Socket.io - Namespaces)

 

각 각의 communication channel인 namepsace 에는 임의로 Room 이라는 것을 지정해서 쓸 수 있다. Room이라고 하는 개념은 주로, 소켓들을 room 단위(subset)로 나눠 해당 소켓 그룹에 패킷을 송출할 때 많이 쓰인다. 

 

이 두가지 개념은 채팅방을 활용해서 예를 들 수가 있다.

 

온라인 채팅서비스에, 직장인 "채널"이 있고, 해당 채널엔 각 각의 사용자가 모여있는 ""이 있을 것이다. 구체적으로 예를 들자면,

"채널123"에 "개발자 오세요"라는 방이 있다.

 

이 경우, "채널123"은 Namespace가 되고, "개발자 오세요"가 Room이 되는 것이다.

 

이제 JOIN에 대한 개념은 알았으니, 실제 코드를 작성해보자.

우선 채팅방이 어떻게 구성될지 프론트를 그려보자

 

 

 

 

 

 

 

2. 채팅방 그리기

튜토리얼1에서 만들었던, index.html, app.js 를 수정하여 진행한다.

<!-- index.html -->

<style>
#room-messages { list-style-type: none; margin: 0; padding: 0; }
#room-messages li { padding: 5px 10px; }
#room-messages li:nth-child(odd) { background: #eee; }
</style>


...


<!-- 방선택 -->
<div class="col-lg-8">
	<div class="card">
		<div class="card-header">
			방선택
		</div>
		<div class="card-body">
			<div class="input-group mb-1">
                <select id="select-room" class="form-control">
					<option value="none" selected="selected">방을 선택해주세요</option>
                    <!-- 수동으로 만들어진 채팅 방 -->
					<option value="test-room">테스트 방</option>
				</select>
				<div class="input-group-append">
					<button id="select-room-button" class="btn btn-success" placeholder="message">Select</button>
				</div>
			</div>
			<form action="">
				<div class="input-group mb-1">
					<input type="text" class="form-control" id="room-message" autocomplete="off" />
					<div class="input-group-append">
						<button id="room-msg-send" class="btn btn-primary" placeholder="message">Send</button>
					</div>
				</div>
			</form>
		</div>
		<div class="card-footer">
			<ul id="room-messages"></ul>
		</div>
	</div>
</div>


...

 

튜토리얼 1번에 비어있던 방선택 부분을 위와 같이 채워서 채팅방을 그려보자.

여기서 알아야할 부분은,

 

  • #select-room 이라는 select DOM 을 활용하여 사용자가 방을 선택하게 만들어두었다.
  • #test-room 이라는 방을 html DOM 으로 미리 만들어 두었고,
  • option value 로 방을 만들 수 있다.
  • 메시지 창으로는 #room-message 로 채팅 유저의 텍스트를 받으며,
  • #room-msg-send 버튼으로 메시지를 보낸다.
  • 소켓을 통해서 받은 데이터(메세지)들은 #room_messages 에 prepend 된다.

그럼 이제 html파일을 참고하여 채팅방 참여를 구현해보자.

 

 

 

 

 

 

채팅방 참여를 구현하기 전, 우선 흐름을 알아보자.


꼭 이해하고 넘어가자 !

채팅방 참여

  1. 클라이언트에서 req_join_room프로토콜로 서버에 message를 보낸다.
  2. 서버에서 req_join_room프로토콜 request를 확인한다.
  3. 서버에서 해당 socket을 roomName방에 join 시킨다.
  4. 서버에서 해당 roomName방에 참여하고 있는 참여자(socket)에게 noti_join_room을 방출(emit)한다.
  5. 클라이언트에서 noti_join_room프로토콜을 확인한다.
  6. 클라이언트에서 다른 사용자가 join했음을 알린다.

채팅방 메세지 전송

  1. 클라이언트에서 req_room_message프로토콜로 서버에 message를 보낸다.
  2. 서버에서 req_room_message프로토콜 request를 확인한다.
  3. 서버에서 해당 socket이 속해있는 방에 noti_room_message프로토콜을 방출(emit)한다.
  4. 클라이언트에서 noti_room_message프로토콜을 확인한다.
  5. 클라이언트에서 noti_room_message 프로토콜 데이터를 활용하여 client UI에 메세지를 그린다.

 

 

 

 

 

 

3. 채팅방 참여

<!-- index.html -->

<script>
$('#select-room-button').click(() => {
	let roomName = $('#select-room').val();
	if(roomName === "none") 
		return alert("방을 선택해주세요.");
	socket.emit('req_join_room', roomName);
});
</script>

#select-room-button이 클릭 되었을 때, #select-room으로 방의 이름을 정한 뒤, 

서버에 req_join_room 프로토콜 패킷을 roomName과 함께 방출(emit)한다. 

 

 

 

 

브라우저에서 req_join_room 패킷을 그럼 서버에선 어떻게 처리할까?

// app.js

let rooms = [];

// 방참여 요청
socket.on('req_join_room', async (msg) => {
	let roomName = 'Room_' + msg.roomName;
	if(!rooms.includes(roomName)) {
		rooms.push(roomName);
	}else{        
		// does nothing
	}
	socket.join(roomName);
	io.to(roomName).emit('noti_join_room', "방에 입장하였습니다.");
});

let rooms 는 생성되는 모든 방을 담아두기 위해 만들어놓은 배열이고, 새로운 방이 생길 때 마다, rooms에 추가한다.

그냥 방을 추가해도 되지만, 방이름을 좀 더 명확하게 하기위해 앞에 Room_ 이라는 프리픽스를 붙여 방을 생성한다.

방이름을 설정하고 난 뒤, 

 

 

여기서 join 이라는 개념을 사용하게 되는데, 해당 사용자를 roomName으로 방 참여(join)을 시킨다.

(이전 튜토리얼에서 설명했던 것 처럼 socket은 요청한 사용자를 나타낸다.)

socket.join(roomName);

 

 

 

소켓을 방에 join 시켜 놓은 뒤, noti_join_room 프로토콜을 방 참여자에게 방출한다.

io.to(roomName).emit('noti_join_room', "방에 입장하였습니다.");

여기서 noti_join_room 이라는 이름의 프로토콜을 사용했다. 이 프로토콜을 사용한 이유는,

보통 request-response 의 관계는 request를 한 client 에게 response라는 답을 주는 형태이다. 하지만 request를 한 client 외에 다른 client에게 "알려야"하기때문에, noti_ 라는 프로토콜을 이름으로 지정하였다.

그리고 또 여기서 중요하게 봐야할 부분은,

해당 방에만 송출할 때는 io.to(roomName).emit(...)을 활용한다.

 

 

 

 

그럼 noti_join_room을 client에서는 어떻게 처리할까?

 

noti_ (room emit)라 다를 거라고 처리하는 방법도 다를 거라고 생각할 수 있지만, 일반 소켓 처리하는 방법과 동일하다.

<!-- index.html -->

<script>
socket.on('noti_join_room', (res) => {
	$('#room-messages').prepend($('<li>').text(res));
});
</script>

 

 

#room-messages에 서버에서 보낸 "방에 입장하였습니다." 가 표시되게 된다.

 

 

 

 

 

 

 

4. 채팅방 메세지 전송

사용자가 채팅방에 입장 후, 방 참여자들에게 메시지를 전송해보자.

<!-- index.html -->

<script>
$('#room-msg-send').click(() => {
	socket.emit('req_room_message', $('#room-message').val());
	$('#room-message').val('');
	return false;
});
</script>

jQuery를 사용하여, #room-msg-send 버튼이 클릭되었을 때, socket에 방출 명령어를 입력한다.

보낼 프로토콜은 req_room_message이며, #room-message input 의 value 값을 data 로 보낸다. 

 

 

 

 

그렇다면 서버에서는 어떻게 방참여자들에게 메시지를 보낼까?

// app.js

// 채팅방에 채팅 요청
socket.on('req_room_message', async(msg) => {
	let userCurrentRoom = getUserCurrentRoom(socket);
	io.to(userCurrentRoom).emit('noti_room_message', msg);
});

...

// 소켓이 현재 연결된 방의 이름 
function getUserCurrentRoom(socket){
    let currentRoom = '';
    let socketRooms = Object.keys(socket.rooms);

    for( let i = 0 ; i < socketRooms.length; i ++ ){
        if(socketRooms[i].indexOf('Room_') !== -1){
            currentRoom = socketRooms[i];
            break;
        } 
    }
    return currentRoom;
}

서버에서는 해당 소켓에 참여중인 rooms 중 방 생성 시 prefix로 설정했던 Room_을 포함한 방에, 전달받은 메세지를 전달한다.

(메세지를 전달할 프로토콜은 noti_room_message이다.)

let userCurrentRoom = getUserCurrentRoom(socket);
io.to(userCurrentRoom).emit('noti_room_message', msg);

위에 설명에 나와있던 것과 동일하게 , 해당방에 참여하고 있는 사람에게 메세지를 전달할 때는,

io.to(userCurrentRoom).emit('noti_room_message', msg);

 

io.to(room).emit(msg) 를 사용하여 발송한다.

 

 

 

해당 메세지를 전달받은 클라이언트는 

<!-- index.html -->
<script>
socket.on('noti_room_message', (res) => {
	$('#room-messages').prepend($('<li>').text(res));
});
</script>

#room_messages 에 전달받은 메세지를 아래 그림과 같이 prepend한다

 

튜토리얼2 완료

 

 

지금까지 socketio 기본중의 기본을 활용한 채팅서버를 구현해보았다. 사실상 이 이외에도 구현방법이 여럿 있는데, 기본에 충실하기 위해 간단하게 제작했다.

 

 

 

 

 

 

 

다음 튜토리얼3에서는 

단순히 메세지 발송, 방참여 뿐만 아니라,

  • 닉네임 기능 추가
  • 텍스트에 발신자명 추가
  • 지금까지의 코드를 리팩토링하여 메인터넌스(유지보수)가 용이하도록 코드를 수정

해보도록 하자.

 

 

 

 

 

 

 

5. 전체 코드

<!-- index.html -->

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Socket Tester</title>

    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">

    <style>
    #messages { list-style-type: none; margin: 0; padding: 0; }
    #messages li { padding: 5px 10px; }
    #messages li:nth-child(odd) { background: #eee; }

    </style>
  </head>
  <body>
    <div class="row">

      <!-- 대기실 -->
      <div class="col-lg-4">
        <div class="card">
          <div class="card-header">
            대기실
          </div>
          <div class="card-body">
            <form action="">
              <div class="input-group mb-3">
                <input type="text" class="form-control" id="m" autocomplete="off" />
                <div class="input-group-append">
                  <button id="msg-send" class="btn btn-primary" placeholder="message">Send</button>
                </div>
              </div>
            </form>
          </div>
          <div class="card-footer">
            <ul id="messages"></ul>
          </div>
        </div>
      </div>


      <!-- 방선택 -->
      <div class="col-lg-8">
        <div class="card">
          <div class="card-header">
            방선택
          </div>
          <div class="card-body">
            <div class="input-group mb-1">
              <select id="select-room" class="form-control">
                <option value="none" selected="selected">방을 선택해주세요</option>
                <!-- 수동으로 만들어진 채팅 방 -->
                <option value="test-room">테스트 방</option>
              </select>
              <div class="input-group-append">
                <button id="select-room-button" class="btn btn-success" placeholder="message">Select</button>
              </div>
            </div>
            <form action="">
              <div class="input-group mb-1">
                <input type="text" class="form-control" id="room-message" autocomplete="off" />
                <div class="input-group-append">
                  <button id="room-msg-send" class="btn btn-primary" placeholder="message">Send</button>
                </div>
              </div>
            </form>
          </div>
          <div class="card-footer">
            <ul id="room-messages"></ul>
          </div>
        </div>
      </div>
    </div>
    
    <script src="/socket.io/socket.io.js"></script>
    <script src="https://code.jquery.com/jquery-1.11.1.js"></script>
    <script>
      $(() => {
        /** Socket Starts **/
        const socket = io();
        
        // 클라이언트에서 reuqest_message 프로토콜로 id='m' 의 input 값을 보낸다.
        $('#msg-send').click(() => {
          socket.emit('request_message', $('#m').val());
          $('#m').val('');
          return false; 
        });

        $('#select-room-button').click(() => {
          let roomName = $('#select-room').val();
          if(roomName === "none") 
            return alert("방을 선택해주세요.");
          socket.emit('req_join_room', roomName)
        });

        $('#room-msg-send').click(() => {
          socket.emit('req_room_message', $('#room-message').val());
          $('#room-message').val('');
          return false;
        });

        socket.on('response_message', (res) => {
          $('#messages').prepend($('<li>').text(res));
        });

        socket.on('noti_join_room', (res) => {
          $('#room-messages').prepend($('<li>').text(res));
        });

        socket.on('noti_room_message', (res) => {
          $('#room-messages').prepend($('<li>').text(res));
        });

      });
    </script>
  </body>
</html>

 

// app.js

const app = require('express')();
const http = require('http').createServer(app);
const io = require('socket.io')(http);

let rooms = [];

app.get('/', (req, res) => {
    res.sendFile(__dirname + '/index.html');
});

io.on('connection', (socket)=>{
    socket.on('request_message', (msg) => {
        // response_message로 접속중인 모든 사용자에게 msg 를 담은 정보를 방출한다.
        io.emit('response_message', msg);
    });

    // 방참여 요청
    socket.on('req_join_room', async (msg) => {
        let roomName = 'Room_' + msg;
        if(!rooms.includes(roomName)) {
            rooms.push(roomName);
        }else{
            
        }
        socket.join(roomName);
        io.to(roomName).emit('noti_join_room', "방에 입장하였습니다.");
    });

    // 채팅방에 채팅 요청
    socket.on('req_room_message', async(msg) => {
        let userCurrentRoom = getUserCurrentRoom(socket);
        io.to(userCurrentRoom).emit('noti_room_message', msg);
    });

    socket.on('disconnect', async () => {
        console.log('user disconnected');
    });
});


// TEST CODE GOES HERE
(async function(){
})();

function getUserCurrentRoom(socket){
    let currentRoom = '';
    let socketRooms = Object.keys(socket.rooms);

    for( let i = 0 ; i < socketRooms.length; i ++ ){
        if(socketRooms[i].indexOf('Room_') !== -1){
            currentRoom = socketRooms[i];
            break;
        } 
    }
    return currentRoom;
}



http.listen(3000, () => {
    console.log('Connected at 3000');
});