브라우저와 서버가 양방향 통신을 가능하게 하는 TCP기반으로 한 웹 프로토콜.
stateless한 http프로토콜의 한계로 인해서 개발된 기술이며 지속적인 데이터교환을 가능하게 한다.
요청헤더예시
GET /chat HTTP/1.1
Host: localhost:8080
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
Origin: http://localhost:9000
응답헤더 예시
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
Sec-WebSocket-Protocol: chat
HTTP/1.1 101 Switching Protocols
101은 HTTP에서 WS로 프로토콜 전환이 승인 되었다는 응답코드이다.
Sec-WebSocket-Accept
요청 헤더의 Sec-WebSocket-Key에 유니크 아이디를 더해서 SHA-1로 해싱한 후 base64로 인코딩한 결과이다.
웹 소켓 연결이 개시되었음을 알린다.
이런 방식으로 3단계의 handshake로 연결을 완료한다
연결이 완료된 후에는 tcp/ip 소켓을 기반으로 지속적인 연결을 한다.
양쪽 모두가 메시지를 보낼 수 있고, 상호적으로 메시지를 주고받을 수 있다.
메시지는 텍스트 또는 이진 데이터의 형태로 전송될 수 있으며, 데이터의 크기에 제한이 없다.
헤더는 http와 비교해 간단하여 데이터교환트래픽이 http보다 적다.
웹소켓은 메시지를 빠르게 전송하기 위해 최소한의 프레임으로 나누어 전송한다.
handshake방식은 동일하며, 4단계를 거치며 연결을 해제한다.
위와 같이 양방향 통신을 요구하는 어플리케이션에 적합하다.
Ajax, polling등 브라우저에서 상호작용을 위한 기법들이 개발되었으나, 결국 http 요청을 해야한다는 본질은 동일하였고,
양방향 통신을 통해 더 적은 트래픽으로 지속적인 완전한 양방향 통신을 사용할 수 있게 되었다.
지속적인 연결은 브라우저가 많아질수록 서버에 부하가 많이 발생한다.
해서 소켓서버를 따로 운용하는 경우가 많은데, 이 경우 본래 서버와
소켓 서버간에 보안문제도 해결해야 한다.
비정상적인 연결해제에 대한 방안을 강구해야하고, 에러메시지가 구체적이지 않아서 디버깅이 어렵다.
종합해보면 프로그램 구현에 보다 높은 복잡성을 요구한다.
프로젝트에는 Socket.io를 활용했다.
socket.io는 websocket을 기반으로한 node.js 라이브러리이다.
websocket보다 많은 기능과 거의 모든 웹 브라우저, 모바일 기기와 연결을 가능하게 해준다.
1-1 패키지 설치
$ npm i --save @nestjs/websockets @nestjs/platform-socket.io
$ npm i --save-dev @types/socket.io
1-2 의존성 주입
// event.gateway.ts
@WebSocketGateway(port, { namespace: 'chat', cors: '*' })
export class EventGateway
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
...
}
// app.module.ts
@Module({
imports: [
ConfigModule.forRoot({
...
EventModule,
ChatModule,
],
...
@WebSocketGateway 가 포함된 eventgateway파일을 app.module.ts에
의존성 주입을 해줬다.
이런 식으로 구성하게 되면 서버가 켜질 시 소켓서버도 같이 켜지게 되고,
서버와 소켓서버가 다르기 때문에 cors옵션을 전체로 설정해주었다.
// event.gateway.ts
@WebSocketGateway(8080, { namespace: 'chat', cors: '*' })
export class EventGateway
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
{
constructor(
private jwtService: JwtService,
private configService: ConfigService,
private userService: UserService,
) {}
private logger: Logger = new Logger('EventsGateway');
@WebSocketServer() server: Server;
// 현재 접속해 있는 클라이언트들을 서버메모리에 저장
connectedClients: {
[socketId: string]: { userId: number; userName: string };
} = {};
// 메시지 처리
@SubscribeMessage('message')
handleMesssage(
client: Socket,
data: { message: string; room: number },
): void {
// socketio에서 제공하는 room은 문자열만 가능하다.
const room = String(data.room);
if (!client.rooms.has(room)) {
return;
}
this.server.to(room).emit('message', {
sender: this.connectedClients[client.id],
message: data.message,
});
}
@SubscribeMessage('join')
handleJoin(client: Socket, room: number): void {
// 이미 접속한 방인지 확인
const roomToString = String(room);
if (client.rooms.has(roomToString)) {
return;
}
client.join(roomToString);
this.server.to(roomToString).emit('userJoined', {
uesrName: this.connectedClients[client.id].userName,
room: roomToString,
});
this.logger.log(`JOIN to ${roomToString}`);
}
@SubscribeMessage('exit')
handleExit(client: Socket, room: string): void {
// 방에 접속되어 있지 않은 경우는 무시
if (!client.rooms.has(room)) {
return;
}
client.leave(room);
this.server.to(room).emit('userLeft', {
userName: this.connectedClients[client.id].userName,
room,
});
}
afterInit(server: Server) {
this.logger.log('웹소켓 서버 초기화 ✅');
}
async handleConnection(client: Socket, ...args: any[]) {
// 토큰으로 유저조회
const sub = await this.validateJwt(client);
const user = await this.userService.getUserById(sub);
// 서버메모리에 유저정보를 저장
this.connectedClients[client.id] = {
userId: user.userId,
userName: user.name,
};
// 소켓서버에 현재 유저들을 전송
this.server.emit('getUserList', { userList: this.connectedClients });
this.logger.log(`Client Connected : ${user.name}`);
}
handleDisconnect(client: Socket) {
this.logger.log(
`Client Disconnected : ${this.connectedClients[client.id].userName}`,
);
// room에 전달
client.rooms.forEach((room) => {
this.server.to(room).emit('userLeft', {
userName: this.connectedClients[client.id].userName,
room,
});
client.leave(room);
});
// 해당 유저 삭제 후 유저정보 전송
delete this.connectedClients[client.id];
this.server.emit('getUserList', { userList: this.connectedClients });
}
private async validateJwt(socket: Socket) {
try {
const token = socket.handshake.query?.token as string;
const payload = await this.jwtService.verifyAsync(token, {
secret: this.configService.get<string>('JWT_SECRET_KEY'),
});
return payload.sub;
} catch (error) {
throw new BusinessException('token', error, error, HttpStatus.FORBIDDEN);
}
}
}
사용자 인증을 하는 도중에 애를 먹었는데
알아본 정보로는
socket은 http요청과 다르기 때문에 request에 접근할 수 없다.
즉 헤더와 쿠키에 접근할 수 없고, query나 handshake로 토큰을 가져와
인증을 해주었다.