Socket.io V2에서 V4로 마이그레이션 하기

Chani·2024년 4월 13일
0
post-thumbnail

최근 소켓을 사용하는 프로젝트의 Socket.io 라이브러리 버전을 v2에서 v4로 마이그레이션 하게되었습니다.
그 과정에서 알게되었던 주요 요소들에 대해 정리해보려고 합니다.

socket.io-client로부터 io import 하기

https://socket.io/docs/v2/client-api/#io

기존 (v2)

const io = require('socket.io-client');
// or with import syntax
import io from 'socket.io-client';
<script src="/socket.io/socket.io.js"></script>
<script>
  const socket = io('http://localhost');
</script>

변경 이후 (v4)

<script src="/socket.io/socket.io.js"></script>
<script>
  const socket = io();
</script>
<script type="module">
  import { io } from "https://cdn.socket.io/4.7.5/socket.io.esm.min.js";

  const socket = io();
</script>
// ES modules
import { io } from "socket.io-client";

// CommonJS
const { io } = require("socket.io-client");

변경 사항

기존에는 default exportio 객체를 보내주고 있었는데 v4 에서부터는 named export 방식으로 변경 되었습니다.

하지만 실제로 코드를 v4에서 나온 방식처럼 바꾸지 않고 실행을 시켜보았을 때에도 정상적으로 잘 작동하였는데, 그 이유는 다음과 같았습니다.

export {
  Manager,
  ManagerOptions,
  Socket,
  SocketOptions,
  lookup as io,
  lookup as connect,
  lookup as default,
};

socket.io-client 오픈 소스 코드를 살펴보면 93번째 라인에서 여러 객체들을 export 해주고 있습니다.

이때, defaultexport 해주는 객체와 ioexport 해주는 객체가 lookup 이라는 객체로 같았기 때문에 굳이 코드를 v4에 맞게 변경해주지 않아도 정상적으로 잘 작동하는 것이었습니다.

Emit Function

v2

v2에서 이벤트를 발생시키는 Emit 함수는 Namespace 클래스 내부에서 관리되는 함수였고, 이때 Emit 함수에서는 Namespace 클래스 내부의 rooms 라는 데이터를 사용하고 있었습니다.

// socket.io v2 Namespace emit 함수
Namespace.prototype.emit = function(ev){
  if (~exports.events.indexOf(ev)) {
    emit.apply(this, arguments);
  } else {
    // set up packet object
    var args = Array.prototype.slice.call(arguments);
    var packet = { type: parser.EVENT, data: args };

    if ('function' == typeof args[args.length - 1]) {
      throw new Error('Callbacks are not supported when broadcasting');
    }

    this.adapter.broadcast(packet, {
      rooms: this.rooms,
      flags: this.flags
    });
    
 ~~~~~~ more code ~~~~~~

따라서 applycall 와 같은 this 객체를 받는 함수를 호출하는 경우 namespace 객체를 넣어주어야 합니다.

io.of('/').emit.apply(<namespace object>, arguments);

v4

v4 부터는 emit 함수를 Namespace 에서 관리하지 않습니다.
(namespace 에서 emit 함수를 사용은 가능합니다.)

// https://github.com/socketio/socket.io/blob/14d4997dbc976be5df9492465061f0c47119600d/lib/namespace.ts#L444

  public emit<Ev extends EventNamesWithoutAck<EmitEvents>>(
    ev: Ev,
    ...args: EventParams<EmitEvents, Ev>
  ): boolean {
    return new BroadcastOperator<EmitEvents, SocketData>(this.adapter).emit(
      ev,
      ...args
    );
  }

위 코드와 같이 NamespaceEmit 함수는 BroadcastOperator 라는 객체의 emit 함수를 호출하는 형태로 구현이 되어있습니다.

기존 Namespace에서 room 에 대한 정보를 관리하는 형태에서 BroadcastOperator 에서 room 데이터를 관리하는 형태로 바뀌게 되었고, 이에 따라 기존의 emit 함수의 로직은 BroadcastOperatoremit 함수로 옮겨가게 되었습니다.

// https://github.com/socketio/socket.io/blob/14d4997dbc976be5df9492465061f0c47119600d/lib/broadcast-operator.ts#L206
  public emit<Ev extends EventNames<EmitEvents>>(
    ev: Ev,
    ...args: EventParams<EmitEvents, Ev>
  ): boolean {
    if (RESERVED_EVENTS.has(ev)) {
      throw new Error(`"${String(ev)}" is a reserved event name`);
    }
    // set up packet object
    const data = [ev, ...args];
    const packet = {
      type: PacketType.EVENT,
      data: data,
    };

    const withAck = typeof data[data.length - 1] === "function";

    if (!withAck) {
      this.adapter.broadcast(packet, {
        rooms: this.rooms,
        except: this.exceptRooms,
        flags: this.flags,
      });

      return true;
    }

즉, v4namespace에는 emit 함수만 존재하고 rooms 데이터가 없기 때문에 apply, call 과 같은 함수에서 v2처럼 this 객체에 동일하게 namespace 객체를 넣어주면 오류가 발생하게 됩니다.
rooms 데이터를 가지고 있는 broadcast operator를 넣어주어야 오류가 발생하지 않습니다.

broadcast operator 객체를 얻기 위해 저같은 경우는 in 함수 (to 함수도 동일한 기능 수행)를 사용하였습니다.

const broadcastOperator = io.in(room);
broadcastOperator.emit.apply(broadcastOperator, arguments);

io.to() is now immutable

위에서 소개한 emit 함수의 변경의 중요 포인트 중 하나는 broadcast operator 입니다.

v4 부터 broadcast operator 라는 새로운 객체가 생겼고, namespace 에서 관리하던 emit 함수broadcast operator 에서 관리하도록 바꾸게 되었는데, 왜 이렇게 많은 부분을 고치게 된 것일까요?

https://github.com/socketio/socket.io/issues/3444

위의 이슈에서 그 해답을 찾을 수 있었습니다.

먼저 emit 함수는 rooms에 데이터가 있다면 해당 rooms에 있는 소켓에게만 이벤트를 발행하지만, rooms 데이터가 없다면 연결되어있는 모든 socket 에 대해 이벤트를 발행합니다.

// socket.io-adapter broadcast function

 if (rooms.length) {
 ~~~~~~~~~~~ other code ~~~~~~~
    } else {
      for (var id in self.sids) {
        if (self.sids.hasOwnProperty(id)) {
          if (~except.indexOf(id)) continue;
          socket = self.nsp.connected[id];
          if (socket) socket.packet(encodedPackets, packetOpts);
        }
      }

그렇다면 다음 코드는 v2에서 어떻게 작동할까요?

1. const nameSpace1 =  io.to(roomName1);
2. const nameSpace2 = io.to(roomName2);
3. 
4. nameSpace1.emit('event1');
5. nameSpace2.emit('event2');

정답은
"4번 라인에서는 roomName1, roomName2에 이벤트를 발행하고, 5번 라인에서는 모든 socket 에 대해 이벤트를 발행한다"
입니다.

너무나도 상식을 벗어난 코드 동작 때문에 위와 같은 이슈가 제기 되었고, v4 에서 개선되었습니다.

v2

먼저 v2 에서 왜 저렇게 동작하는지 원인을 알아보도록 하겠습니다.

// socket.io v2 to(in) function

Namespace.prototype.to =
Namespace.prototype.in = function(name){
  if (!~this.rooms.indexOf(name)) this.rooms.push(name);
  return this;
};

v2의 to(in) 함수는 해당 함수를 호출하면 namespace 안에 있는 rooms 배열namepush 하고 자기자신(namespace)를 반환해주는 방식으로 구현되어있었습니다.

따라서, 위 예시코드에서 io.to 를 두번 호출하면서 각자의 객체를 따로 저장해두었더라도, 두 객체는 같은 하나의 객체를 바라보고 있기 때문에 원하는대로 코드가 작동하지 않았던 것입니다.

Namespace.prototype.emit = function(ev){
  if (~exports.events.indexOf(ev)) {
    emit.apply(this, arguments);
  } else {
    // set up packet object
    var args = Array.prototype.slice.call(arguments);
    var packet = { type: parser.EVENT, data: args };

    if ('function' == typeof args[args.length - 1]) {
      throw new Error('Callbacks are not supported when broadcasting');
    }

    this.adapter.broadcast(packet, {
      rooms: this.rooms,
      flags: this.flags
    });

    this.rooms = [];
    this.flags = {};
  }
  return this;
};

또한 v2emit 함수를 살펴보면 함수가 모두 실행 된 이후 마지막에 roomsflags 배열을 비워주고 있습니다.

이 부분때문에 nameSpace1.emit 에서는 두개의 roomName1, roomName2 에 대해 이벤트가 발생하고, nameSpace2.emit 에서는 rooms 배열이 비어있기 때문에 모든 소켓에 대해 이벤트가 발생하게 된 것입니다.

제 생각이지만 v2 에서는 emit 함수를 사용할 때 nameSpace 를 저장하여 사용하는 것을 고려하지 않고, 다음과 같이 이어서 사용하는 경우만 고려한 것 같습니다.

/// roomName 을 포함한 nameSpace를 재사용하기
const nameSpace = io.to(roomName);
nameSpace.emit(’some event’);
// emit 할 때마다 어떤 roomName 에 emit 할건지 명시하기
io.to(roomName).to(roomName).emit('some event');

v4

io.to() is now immutable

socket.io Migrating from 3.x to 4.0 문서에 breaking point로 가장 먼저 적혀있는 문구 입니다.

to 로 리턴해주는 객체를 불변 객체로 업데이트를 하였고, 그것을 위해 BroadcastOperator 라는 새로운 객체를 만들었습니다.

// namespace 의 to 함수
 public to(room: Room | Room[]) {
    return new BroadcastOperator<
      DecorateAcknowledgementsWithMultipleResponses<EmitEvents>,
      SocketData
    >(this.adapter).to(room);
  }

NameSpace 를 새로 만들어서 리턴해주는 것은 개념적으로 조금 맞지 않는 느낌이라서 NameSpace 에서 사용하는 데이터, 함수들을 BroadcastOperator 로 위임하고, BroadcastOperator 객체를 새롭게 만들어 리턴해주는 방식을 사용하여 구현한 것 같습니다.

// broadcast operator 의 to 함수
public to(room: Room | Room[]) {
    const rooms = new Set(this.rooms);
    if (Array.isArray(room)) {
      room.forEach((r) => rooms.add(r));
    } else {
      rooms.add(room);
    }
    return new BroadcastOperator<EmitEvents, SocketData>(
      this.adapter,
      rooms,
      this.exceptRooms,
      this.flags
    );
  }

to 함수parameter 에 배열이 들어올 수 있게도 개선되었고, 새로운 객체를 리턴해주도록 개선되었습니다.

또한 emit 함수에서 더이상 rooms 데이터를 초기화 시켜주지 않게 되었습니다.

[참고] broadcast operator emit function

1. const nameSpace1 =  io.to(roomName1);
2. const nameSpace2 = io.to(roomName2);
3. 
4. nameSpace1.emit('event1');
5. nameSpace2.emit('event2');

따라서 현재는 nameSpace1.emit('event1'); 에서는 roomName1 에 대해서만 이벤트를 발행하고, nameSpace2.emit('event2'); 에서는 roomName2 에 대해서만 이벤트를 발행 하도록 되었습니다.

마치며

Socket.io 를 업그레이드 하면서 실제 코드 수정을 한 것보다, 코드 수정이 왜 필요하고, 어떻게 수정해야하는지 파악하는데 더 많은 시간을 투자한 것 같습니다.
이 글이 socket.io 마이그레이션을 고려하는 다른 분들께 조금이나마 도움이 되었으면 좋겠습니다.

참고 문서

profile
프론트엔드에 스며드는 중 🌊

0개의 댓글