Web socket protocol

이승준·2024년 3월 11일
0

😁1. 웹소켓이란?

브라우저와 서버가 양방향 통신을 가능하게 하는 TCP기반으로 한 웹 프로토콜.

stateless한 http프로토콜의 한계로 인해서 개발된 기술이며 지속적인 데이터교환을 가능하게 한다.


😁2. 웹소켓의 원리

2-1 HandShake

  • 연결을 위해 일반적인 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
  • GET /chat HTTP/1.1
    웹소켓의 통신 요청에서,
    HTTP 버전은 1.1 이상이어야하고 GET 메서드를 사용해야햔다.
  • Upgrade
    프로토콜을 전환하기 위해 사용하는 헤더.
    웹소켓 요청시에는 반스에 websocket 이라는 값을 가지며,
    이 값이 없거나 다른 값이면 cross-protocol attack 이라고 간주하여 웹 소켓 접속을 중지시킨다.
  • Connection
    현재의 전송이 완료된 후 네트워크 접속을 유지할 것인가에 대한 정보.
    웹 소켓 요청 시에는 반드시 Upgrade 라는 값을 가진다.
    Upgrade 와 마찬가지로 이 값이 없거나 다른 값이면 웹소켓 접속을 중지시킨다.
  • Sec-WebSocket-Key
    유효한 요청인지 확인하기 위해 사용하는 키 값
  • Sec-WebSocket-Protocol
    사용하고자 하는 하나 이상의 웹 소켓 프로토콜 지정.
    필요한 경우에만 사용
  • Sec-WebSocket-Version
    클라이언트가 사용하고자 하는 웹소켓 프로토콜 버전.
    현재 최신 버전 13
  • Origin
    CORS 정책으로 만들어진 헤더.
    Cross-Site Websocket Hijacking과 같은 공격을 피하기 위함.

응답헤더 예시

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로 연결을 완료한다

2-2 연결 설정

연결이 완료된 후에는 tcp/ip 소켓을 기반으로 지속적인 연결을 한다.

2-3 메시지 교환

양쪽 모두가 메시지를 보낼 수 있고, 상호적으로 메시지를 주고받을 수 있다.

메시지는 텍스트 또는 이진 데이터의 형태로 전송될 수 있으며, 데이터의 크기에 제한이 없다.

헤더는 http와 비교해 간단하여 데이터교환트래픽이 http보다 적다.

웹소켓은 메시지를 빠르게 전송하기 위해 최소한의 프레임으로 나누어 전송한다.

2-4 연결해제

handshake방식은 동일하며, 4단계를 거치며 연결을 해제한다.


😁3. Socket을 사용하는 환경

  • 페이스북 같은 SNS 어플리케이션
  • LOL 같은 멀티플레이어 게임들
  • 구글 Doc 같이 여러 명이 동시 접속해서 수정할 수 있는 Tool
  • 클릭 동향 분석 데이터 어플 (특정 시간동안 어느 사이트에 주로 접속했는지 등의 정보를 파악하는 어플)
  • 증권 거래 정보 사이트 및 어플
  • 스포츠 업데이트 정보 사이트 및 어플
  • 화상 채팅 어플
  • 위치 기반 어플
  • 온라인 교육 사이트 및 어플

위와 같이 양방향 통신을 요구하는 어플리케이션에 적합하다.


😁4. 장점과 단점

4-1 장점

Ajax, polling등 브라우저에서 상호작용을 위한 기법들이 개발되었으나, 결국 http 요청을 해야한다는 본질은 동일하였고,
양방향 통신을 통해 더 적은 트래픽으로 지속적인 완전한 양방향 통신을 사용할 수 있게 되었다.

4-2 단점

지속적인 연결은 브라우저가 많아질수록 서버에 부하가 많이 발생한다.
해서 소켓서버를 따로 운용하는 경우가 많은데, 이 경우 본래 서버와
소켓 서버간에 보안문제도 해결해야 한다.

비정상적인 연결해제에 대한 방안을 강구해야하고, 에러메시지가 구체적이지 않아서 디버깅이 어렵다.

종합해보면 프로그램 구현에 보다 높은 복잡성을 요구한다.


😁5. 프로젝트에 적용

프로젝트에는 Socket.io를 활용했다.
socket.io는 websocket을 기반으로한 node.js 라이브러리이다.
websocket보다 많은 기능과 거의 모든 웹 브라우저, 모바일 기기와 연결을 가능하게 해준다.

1. 서버구성

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옵션을 전체로 설정해주었다.

2. socket서버 구성

// 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로 토큰을 가져와
인증을 해주었다.

출처

0개의 댓글