지난 포스트들에서는 controller, providers, module 등 NestJS에서 기초적인 개념들을 배웠다면 파헤치기 5편부터는 좀 더 심화되고, 옵셔널하게 사용할 수 있는 개념들을 다루고자 한다.
이번 5편에서는 실시간 서버 api 호출 관련하여 현업에서 많이 사용되고 있는 websocket에 관해 다뤄볼 예정이다.
전통적으로 서버와 클라이언트 송수신은 HTTP를 통해서 이루어졌다.
이는 철저히 request/response로 문자메시지 방식처럼 일방적으로 왔다갔다 했으며 서버에서 먼저 request를 보내지 않았다.
90년대까지는 http 요청만으로 웹서비스를 만들었는데 점점 인터넷이 발전하고 다양한 웹기술의 등장, 발전과 더불어 실시간 데이터 처리, 클라이언트-서버 양방향 통신법에 대한 논의가 시작되었다.
웹브라우저는 http 프로토콜로 request/response 가 이루어지는 데 TCP/IP socket처럼 connection이 유지되어 실시간으로 통신을 할 수 없다는 특징이 있다. 그래서 등장한 것이 TCP 기반 양방향 송수신의 ws(websocket) 프로토콜이다. OSI layers에서는 HTTP와 같은 7계층 위치하며, 현재 인터넷 환경(HTML5)에서 많이 사용되고 HTTP와 포트 공유가 가능하며 성능이 매우 좋다는 이점을 갖는다.
이를 바탕으로
1. 최초 접속에서만 http 프로토콜 위에서 handshaking 하기 때문에 http header를 사용함
2. 웹소켓을 위한 별도의 포트는 없으며, 기존 포트(http-80, https-443)을 사용
3. 프레임으로 구성된 메시지라는 논리적 단위로 송수신
4. 메시지에 포함될 수 있는 교환 가능한 메시지는 텍스트와 바이너리 뿐
이렇게 웹소켓의 특징을 정리해볼 수 있다. 그러나 웹소켓도 마냥 완벽한 것은 아니고, 한계가 있다.
앞서 언급했듯 HTML5에 최적화 된 프로토콜이기 때문에 HTML5 이전의 기술로 구현된 서비스에서는 동작하지 않게 된다.
이를 보완하기 위해 nodeJS의 경우 socket.io나 sockJS, 스프링의 경우 STOMP라는 기술을 추가적으로 사용하며, 이를 이용해 해당 브라우저에 맞게 동작할 수 있게 설정할 수 있다.
NestJS에서는 websocket과 socket.io 모두 지원하고 있으며, socket.io에 대해서 좀 더 자세히 살펴보자.
socket.io는 node.js 기반으로 실시간 이벤트 서버를 개발할 수 있는 오픈소스 라이브러리이다. 멀티 디바이스(web, android, ios, windows)를 지원하며, ws를 지원하지 않는 브라우저도 직관적으로 지원한다.
websocket server는 client와 서버 간에 http protocol로 커넥션을 초기에 맺고 ws-websocket protocol로 upgrade한 후 서로에게 heartbeat를 주기적으로 발생시켜 커넥션이 유지되고 있는지 체크하며 네트워크를 유지하는 방식이다.
Namespace, room, and event
socket io에서 트래픽을 격리하여 구분할 때 사용되는 단위이다. event는 명칭 그대로 송/수신하는 이벤트의 이름을 말한다.
트래픽격리 구분없이 이벤트를 송/수신하면 이벤트 리스너를 등록하여 이벤트를 처리하는 코드가 존재하지 않더라도 접속한 모든 client에 전송 및 수신을 하게 된다.
과도하게 많거나 설계가 중구난방이면 불필요한 트래픽이 발생하게 되고 서버 자체의 성능도 저하되기 때문에 적절한 설계로 구분할 필요가 있다.
특징은 다음과 같다.
Public & Private & Broadcasting
socket io에서 이벤트를 송수신하는 방식을 말한다.
Cluster
nodejs는 기본적으로 싱글 프로세스로 동작하며 서버 CPU core 수만큼 process 생성하여 multi process로 구동하기 위해서는 cluster를 이용하여 process를 생성해야함
Master & worker
nodejs cluster를 이용해서 process를 생성하면 실제 일을 수행하는 process를 worker라고 하며 worker들을 제어하는 역할을 하는 process를 master라고 부른다.
NestJS는 두 기능 다 지원한다고 하였는데, 각 모듈은 서로 미세한 차이가 있다. 그 차이는 다음과 같다.
1. socket.io는 추가적인 설치를 해야 함
2. ws는 string 형으로 데이터를 전송함
3. ws에는 room이 없다.
4. 자신을 제외한 사용자에게 데이터를 보내려면
5. socket.io는 연결이 끊어져도 주기적으로 연결을 시도함
6. socketIO에서는 to, of 같은 특정 클라이언트를 지정하는 기능도 제공함
웹소켓과 socketIO에 대한 기본적인 개념과 특징은 알았으니 직접 실습하면서 이해해보자. 간단한 채팅앱을 만들어보겠다.
~$ nest new ws-chat-sample
nestJS 애플리케이션을 하나 만들고, 이 애플리케이션 내에 아래 라이브러리들을 설치하여 준다.
~$ yarn add @nestjs/websockets @nestjs/platform-socket.io
~$ yarn add -D @types/socket.io
우리가 만들 app tree는 다음과 같다.
socket-client
|-chat-socket.js # client 단에서 웹소켓 연결에 필요한 js
|-index.html # 화면에서 서버와 chat할 간단한 화면
src
|- main.ts # Nest 앱의 실행파일
|- app.module.ts # 실행파일에서 등록, 사용하는 root 모듈파일
|- app.controller.ts # Nest 앱의 root 기본 컨트롤러
|- chat.gateway.ts # Nest 앱의 root 기본 비즈니스 서비스
먼저 chat.gateway.ts 파일을 생성하고 다음과 같이 class를 구성한다.
// chat.gateway.ts
import {
MessageBody,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Server } from 'socket.io';
@WebSocketGateway() // 안에 port와 namespace를 속성으로 넣어줄 수 있다.
export class ChatGateway {
@WebSocketServer()
server: Server;
@SubscribeMessage('message')
// handleMessage(client, data): void {} // client 직접적으로 사용하고 싶거나 decorator 사용 안 원하면 이렇게도 가능
handleMessage(@MessageBody() message: string): void {
this.server.emit('message', message);
}
}
위 코드는 ChatGateway
라는 웹소켓 게이트웨이 클래스에서 웹소켓 서버 인스턴스인 server를 public 속성값으로 갖고,
'message'라는 이벤트로 들어오는 string의 메시지를 치리하는 handleMessage라는 메서드를 통해 클라이언트에서 들어오는 data를 emit 할 것을 주문하는 코드이다.
이를 통해 클라이언트의 데이터는 서버로 들어와서 얘를 클라이언트로 다시 뿌려주는 역할을 할 수 있게 된다. 이렇게 구현한 gateway를 모듈 프로바이더로 등록하자.
// app.module.ts
import { AppController } from './app.controller';
import { Module } from '@nestjs/common';
import { ChatGateway } from './chat.gateway';
@Module({
controllers: [AppController],
providers: [ChatGateway],
})
export class AppModule {}
그 다음, 통신할 클라이언트를 간단하게 만들어주기 위해 socket-client
라는 폴더를 만들고 여기에 index.html
을 다음과 같이 만들어준다.
<!DOCTYPE html>
<html class="no-js" lang="">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<div>
<ul id="messages"></ul>
</div>
<div>
<input id="message" type="text" />
<button onclick="handleSubmitNewMessage()">Submit</button>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.3.0/socket.io.js"></script>
<script src="./chat-socket.js"></script>
</body>
</html>
이 index 파일이 불러오는 자바스크립트 파일인 chat-socket.js
는 아래와 같다.
const socket = io('http://localhost:3000', { transports: ['websocket'] }); // 서버주소가 http 프로토콜임을 유의
const message = document.getElementById('message');
const messages = document.getElementById('messages');
const handleSubmitNewMessage = () => {
socket.emit('message', { data: message.value }); // 클라이언트에서 서버로 이벤트를 발생시킨다
};
// socket.on은 클라이언트단에서 발생한 이벤트를 선택적으로 캐치하여 이벤트 핸들러를 등록함
socket.on('message', ({ data }) => {
handleNewMessage(data);
});
handleNewMessage = (message) => {
messages.appendChild(buildNewMessage(message));
};
const buildNewMessage = (message) => {
const li = document.createElement('li');
li.appendChild(document.createTextNode(message));
return li;
};
클라이언트에서 socket io의 io 객체를 사용하여 backend 주소를 첫번째 인자로, 두번째 인자로 option을 넘겨주고 있다. 이제 백엔드를 실행하고 index.html을 두개 열어보면 다음과 같이 동작함을 확인할 수 있다.
간단하게만 socket.io를 다루어보았는데 이 외에 of, to 등을 사용할 일이 있을 것이다. 추후 실습에서는 이를 다뤄보도록 하겠다.
app.module.ts에서 따로 gateway를 호출 안해도 되는건가요? 루트로 들어가면 어차피 실행이 되니까?
아니면 app.controller.ts에서 호출해야되나요? 그리고 또 port설정해서 서버에서 소켓을 여는게 있던데 main.ts에서 적혀져있는 3000번 포트랑 동일하게 해야하나요?