웹소켓 - 실시간 채팅(1) (Socket.io, React, NestJS)
iskkiri • 2022년 10월 02일
WebSocket(ws)은 http 프로토콜과 같이 프로토콜 중 하나입니다. ws을 이용하면 브라우저와 서버 간에 연결을 유지한 채로 양방향 통신을 구현할 수 있습니다.
이번 포스팅에서는 ws 프로토콜을 기반으로 구축된 socket.io 라이브러리를 이용하여 간단한 실시간 채팅 예제를 만들어 보겠습니다.
프론트는 react를 이용하고, 백엔드는 nest를 이용하겠습니다.
Frontend (React)
먼저 react 부터 살펴보겠습니다. 이번 예제에 필요한 라이브러리를 설치해줍니다.
참고로 emotion은 css를 위한 라이브러리이며, classNames는 html class를 간편하게 설정할 수 있게 도와주는 라이브러리입니다.
npm i socket.io-client @emotion/styled classNames
코드를 나눠서 살펴보겠습니다.
import { io } from 'socket.io-client';
// 웹소켓 연결 및 소켓 인스턴스 생성, chat은 namespace입니다.
const socket = io('http://localhost:4000/chat');
const App = () => {
.
.
.
};
io의 첫 번째 인수로 연결할 서버의 주소를 적습니다. 두 번째 인수에는 쿠키를 보낼 때 설정해야하는 credentials와 같은 옵션들을 설정할 수 있습니다.
예제에서는 DB 연결이나, 세션 설정과 같은 것들은 모두 배제할 것이기 때문에 옵션을 설정하지 않았습니다.
주소에서 '/chat'으로 적은 부분은 namespace를 의미합니다. namespace란 일종의 통신 채널입니다.
namespace를 설정하지 않으면 default값인 '/' 으로 설정되며, 서로 다른 namespace에 있는 소켓들은 서로 다른 통신 채널에 있게 됩니다.
예를 들어 namespace 'A'와 namespace 'B'가 있다면 A채널에 있는 소켓들은 A채널끼리 통신할 수 있고, B채널에 있는 소켓들은 B채널끼리 통신할 수 있습니다.
room이라는 개념도 있습니다. room은 소켓이 들어가고 나갈 수 있는 일종의 '방' 으로 생각하면 됩니다.
소켓은 여러 개의 room에 들어갈 수도 있고, 어떤 방에도 들어가지 않을 수도 있습니다.
(default로 생성되는 socketId의 이름을 갖는 방은 제외, 아래의 서버 측 코드를 설명할 때 더 설명하겠습니다.)
// message event listener
useEffect(() => {
const messageHandler = (chat: IChat) =>
setChats((prevChats) => [...prevChats, chat]);
socket.on('message', messageHandler);
return () => {
socket.off('message', messageHandler);
};
}, []);
socket.io는 이벤트 기반으로 동작합니다.
현재는 모두 같은 네임스페이스에 있기 때문에 모두가 같은 채팅방에 있다고 보면 됩니다.
채팅방에서 누군가가 메시지를 보내면 서버에서는 받은 메시지를 채팅방으로 뿌려줄 것입니다.
즉, 서버에서 메시지를 받으면 message 이벤트를 발생시켜 받은 메시지를 채팅방 전체로 뿌려주는 것입니다.
// send message
const onSendMessage = useCallback(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!message) return alert('메시지를 입력해 주세요.');
socket.emit('message', message, (chat: IChat) => {
setChats((prevChats) => [...prevChats, chat]);
setMessage('');
});
},
[message]
);
위에서도 말했듯이 ws은 양방향 통신 프로토콜입니다. 따라서 브라우저에서도 서버로 이벤트를 전송할 수도 있습니다. 이 때, socket.emit 메서드를 사용합니다.
첫 번째 인수에는 이벤트 이름을, 두 번째 인수에는 전송할 데이터를, 세 번째 인수는 콜백 함수로 서버 측의 응답이 오면 실행되는 함수입니다.
콜백함수의 파라미터에는 서버에서 보낸 데이터가 들어오게 됩니다.
다음은 전체 코드입니다. jsx 부분과 css 는 중요한 것이 아니므로 넘어가겠습니다.
// src/App.tsx
import classNames from 'classnames';
import {
ChangeEvent,
FormEvent,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { io } from 'socket.io-client';
import {
ChatContainer,
Message,
MessageBox,
MessageForm,
} from './styles/app.styles';
const socket = io('http://localhost:4000/chat');
interface IChat {
username: string;
message: string;
}
const App = () => {
const [chats, setChats] = useState<IChat[]>([]);
const [message, setMessage] = useState<string>('');
const chatContainerEl = useRef<HTMLDivElement>(null);
// 채팅이 길어지면(chats.length) 스크롤이 생성되므로, 스크롤의 위치를 최근 메시지에 위치시키기 위함
useEffect(() => {
if (!chatContainerEl.current) return;
const chatContainer = chatContainerEl.current;
const { scrollHeight, clientHeight } = chatContainer;
if (scrollHeight > clientHeight) {
chatContainer.scrollTop = scrollHeight - clientHeight;
}
}, [chats.length]);
// message event listener
useEffect(() => {
const messageHandler = (chat: IChat) =>
setChats((prevChats) => [...prevChats, chat]);
socket.on('message', messageHandler);
return () => {
socket.off('message', messageHandler);
};
}, []);
const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setMessage(e.target.value);
}, []);
const onSendMessage = useCallback(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!message) return alert('메시지를 입력해 주세요.');
socket.emit('message', message, (chat: IChat) => {
setChats((prevChats) => [...prevChats, chat]);
setMessage('');
});
},
[message]
);
return (
<>
<h1>WebSocket Chat</h1>
<ChatContainer ref={chatContainerEl}>
{chats.map((chat, index) => (
<MessageBox
key={index}
className={classNames({
my_message: socket.id === chat.username,
alarm: !chat.username,
})}
>
<span>
{chat.username
? socket.id === chat.username
? ''
: chat.username
: ''}
</span>
<Message className="message">{chat.message}</Message>
</MessageBox>
))}
</ChatContainer>
<MessageForm onSubmit={onSendMessage}>
<input type="text" onChange={onChange} value={message} />
<button>보내기</button>
</MessageForm>
</>
);
};
export default App;
// src/styles/app.styles.ts
import styled from '@emotion/styled';
const ChatContainer = styled.div`
display: flex;
flex-direction: column;
border: 1px solid #000;
padding: 1rem;
min-height: 360px;
max-height: 600px;
overflow: auto;
background: #b2c7d9;
`;
const MessageBox = styled.div`
display: flex;
flex-direction: column;
&.my_message {
align-self: flex-end;
.message {
background: yellow;
align-self: flex-end;
}
}
&.alarm {
align-self: center;
}
`;
const Message = styled.span`
margin-bottom: 0.5rem;
background: #fff;
width: fit-content;
padding: 12px;
border-radius: 0.5rem;
`;
const MessageForm = styled.form`
display: flex;
margin-top: 24px;
input {
flex-grow: 1;
margin-right: 1rem;
}
`;
export { ChatContainer, MessageBox, Message, MessageForm };
Backend (NestJS)
이번에는 nest를 살펴보겠습니다. 이번 예제에 필요한 라이브러리를 설치해줍니다.
npm i @nestjs/websockets socket.io
Event 모듈을 생성하고, app 모듈에 EventsModule을 임포트합니다.
// src/app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { EventsModule } from './events/events.module';
@Module({
imports: [EventsModule],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
// src/events/events.module.ts
import { Module } from '@nestjs/common';
import { EventsGateway } from './events.gateway';
@Module({
providers: [EventsGateway],
})
export class EventsModule {}
이번에는 EventGateway를 작성합니다. 실제로 소켓의 동작을 담당하는 부분입니다.
@WebSocketGateway({
namespace: 'chat',
cors: {
origin: ['http://localhost:3000'],
},
})
export class EventsGateway
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
.
.
.
}
@WebSocketGateway 데코레이터의 인수로 옵션을 설정할 수 있습니다.
react는 3000번 포트에서 실행 중이고 nest는 4000번 포트에서 실행 중이므로, 포트가 달라서 CORS에러가 발생합니다. 따라서 cors 설정을 해줍니다.
namespace를 'chat' 으로 설정했습니다. 프론트 측에서 http://localhost:4000/chat에서 '/chat'에 해당되는 부분입니다.
@WebSocketGateway({
namespace: 'chat',
cors: {
origin: ['http://localhost:3000'],
},
})
export class EventsGateway
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
@WebSocketServer() nsp: Namespace;
// 초기화 이후에 실행
afterInit() {}
// 소켓이 연결되면 실행
handleConnection(@ConnectedSocket() socket: Socket) {}
// 소켓 연결이 끊기면 실행
handleDisconnect(@ConnectedSocket() socket: Socket) {}
}
OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect 인터페이스를 이용하여 EventsGateway의 구조를 잡아줍니다.
각 함수에 대한 동작은 주석에 적어두었습니다.
@WebSocketServer 데코레이터 부분을 주목해주세요.
현재 네임스페이스를 설정했기 때문에 @WebSocketServer 데코레이터가 반환하는 값은 서버 인스턴스가 아닌 네임스페이스 인스턴스입니다.
만약 네임스페이스를 설정하지 않았다면 @WebSocketServer 데코레이터가 반환하는 값은 서버 인스턴스가 되고, 그 때는 타입을 다음과 같이 서버 타입을 설정해줘야 합니다.
import { Socket } from 'socket.io';
export class EventsGateway
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
@WebSocketServer() server: Socket;
}
네임스페이스를 설정했음에도 @WebSocketServer 데코레이터가 반환하는 값이 서버 인스턴스라고 여기고 타입을 정하지 않으면(any 타입을 설정한다던가) 하위 객체에 접근하는데 문제가 생깁니다. 반드시 유의해주세요.
afterInit() {
this.nsp.adapter.on('create-room', (room) => {
this.logger.log(`"Room:${room}"이 생성되었습니다.`);
});
this.nsp.adapter.on('join-room', (room, id) => {
this.logger.log(`"Socket:${id}"이 "Room:${room}"에 참여하였습니다.`);
});
this.nsp.adapter.on('leave-room', (room, id) => {
this.logger.log(`"Socket:${id}"이 "Room:${room}"에서 나갔습니다.`);
});
this.nsp.adapter.on('delete-room', (roomName) => {
this.logger.log(`"Room:${roomName}"이 삭제되었습니다.`);
});
this.logger.log('웹소켓 서버 초기화 ✅');
}
웹소켓 서버가 초기화됐을 때, 네임스페이스 이벤트 리스너를 등록하였습니다. 'create-room', 'join-room' 등의 이벤트 이름들은 이미 내장된 이벤트 명입니다.
handleConnection(@ConnectedSocket() socket: Socket) {
this.logger.log(`${socket.id} 소켓 연결`);
}
handleDisconnect(@ConnectedSocket() socket: Socket) {
this.logger.log(`${socket.id} 소켓 연결 해제 ❌`);
}
서버 초기화 이후에 연결이 되고, 연결이 끊길 때 콘솔에 로그가 찍히도록 한 이유가 있습니다.
브라우저와 서버 간에 웹소켓 연결이 성공하면 default로 각 소켓은 자신의 아이디를 갖는 room에 들어가게 됩니다.
위 이미지에서 두 번째 로그를 보면 "Socket:소켓아이디"이 "Room:소켓아이디"에 참여하였습니다. 로그가 찍히고 있죠?
마찬가지로 브라우저와 서버 간에 웹소켓 연결이 끊기면 각 소켓은 자신의 아이디를 갖는 room에서 나가지며, room의 크기가 0이 되면 자동으로 room이 삭제됩니다.
즉, 방의 인원이 0명이면 자동으로 방이 사라진다고 생각하면 됩니다.
@SubscribeMessage('message')
handleMessage(
@ConnectedSocket() socket: Socket,
@MessageBody() message: string,
) {
socket.broadcast.emit('message', { username: socket.id, message });
return { username: socket.id, message };
}
@SubscribeMessage 데코레이터는 이벤트 리스너를 설정합니다. 위 코드에서는 message라는 이벤트에 대해서 리스너를 설정합니다.
@ConnectedSocket은 연결된 소켓 인스턴스를 반환하며, @MessageBody는 브라우저 측에서 보낸 데이터를 반환합니다.
broadcast는 데이터를 보낸 socket을 제외하고, 모든 socket들에게 이벤트를 보내는 것입니다.
예를 들어, 현재 세 명의 유저(A, B, C)가 서버와 연결되어 있다고 가정하겠습니다.
A가 message 이벤트를 서버 측으로 보내면, 서버 측에서는 A를 제외한 B와 C에게만 message 를 브라우저 측으로 보내는 것입니다.
굳이 이번 예제에서는 broadcast를 쓰지 않아도 되었는데, 자주 사용하는 것이라서 일부러 사용하였습니다.
전체 코드는 아래와 같습니다.
import { Logger } from '@nestjs/common';
import {
ConnectedSocket,
MessageBody,
OnGatewayConnection,
OnGatewayDisconnect,
OnGatewayInit,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Namespace, Socket } from 'socket.io';
@WebSocketGateway({
namespace: 'chat',
cors: {
origin: ['http://localhost:3000'],
},
})
export class EventsGateway
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
private logger = new Logger('Gateway');
@WebSocketServer() nsp: Namespace;
afterInit() {
this.nsp.adapter.on('create-room', (room) => {
this.logger.log(`"Room:${room}"이 생성되었습니다.`);
});
this.nsp.adapter.on('join-room', (room, id) => {
this.logger.log(`"Socket:${id}"이 "Room:${room}"에 참여하였습니다.`);
});
this.nsp.adapter.on('leave-room', (room, id) => {
this.logger.log(`"Socket:${id}"이 "Room:${room}"에서 나갔습니다.`);
});
this.nsp.adapter.on('delete-room', (roomName) => {
this.logger.log(`"Room:${roomName}"이 삭제되었습니다.`);
});
this.logger.log('웹소켓 서버 초기화 ✅');
}
handleConnection(@ConnectedSocket() socket: Socket) {
this.logger.log(`${socket.id} 소켓 연결`);
socket.broadcast.emit('message', {
message: `${socket.id}가 들어왔습니다.`,
});
}
handleDisconnect(@ConnectedSocket() socket: Socket) {
this.logger.log(`${socket.id} 소켓 연결 해제 ❌`);
}
@SubscribeMessage('message')
handleMessage(
@ConnectedSocket() socket: Socket,
@MessageBody() message: string,
) {
socket.broadcast.emit('message', { username: socket.id, message });
return { username: socket.id, message };
}
}
이번 포스팅에서는 room을 제외하고 예제를 구성하였습니다.
포스팅을 하기 전에 예제를 먼저 만들어보고 어떻게 포스팅을 할지 생각하는데, 만들다보니 내용이 너무 방대해지는 것 같았습니다.
최대한 socket.io에 초점을 맞춰서 설명하기 위해 잡다한 기능은 빼려고 했습니다.
다음 포스팅에서는 room을 추가한 실시간 채팅 예제에 대해서 다루겠습니다.
잘못된 내용이나 궁금한 내용이 있으면 댓글을 남겨주세요.