최근 소켓을 사용하는 프로젝트의 Socket.io 라이브러리 버전을 v2에서 v4로 마이그레이션 하게되었습니다.
그 과정에서 알게되었던 주요 요소들에 대해 정리해보려고 합니다.
https://socket.io/docs/v2/client-api/#io
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>
<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 export
로 io
객체를 보내주고 있었는데 v4
에서부터는 named export
방식으로 변경 되었습니다.
하지만 실제로 코드를 v4에서 나온 방식처럼 바꾸지 않고 실행을 시켜보았을 때에도 정상적으로 잘 작동하였는데, 그 이유는 다음과 같았습니다.
export {
Manager,
ManagerOptions,
Socket,
SocketOptions,
lookup as io,
lookup as connect,
lookup as default,
};
socket.io-client 오픈 소스 코드를 살펴보면 93번째 라인에서 여러 객체들을 export
해주고 있습니다.
이때, default
로 export
해주는 객체와 io
로 export
해주는 객체가 lookup
이라는 객체로 같았기 때문에 굳이 코드를 v4
에 맞게 변경해주지 않아도 정상적으로 잘 작동하는 것이었습니다.
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 ~~~~~~
따라서 apply
나 call
와 같은 this
객체를 받는 함수를 호출하는 경우 namespace 객체를 넣어주어야 합니다.
io.of('/').emit.apply(<namespace object>, arguments);
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
);
}
위 코드와 같이 Namespace
의 Emit
함수는 BroadcastOperator
라는 객체의 emit
함수를 호출하는 형태로 구현이 되어있습니다.
기존 Namespace
에서 room
에 대한 정보를 관리하는 형태에서 BroadcastOperator
에서 room
데이터를 관리하는 형태로 바뀌게 되었고, 이에 따라 기존의 emit
함수의 로직은 BroadcastOperator
의 emit
함수로 옮겨가게 되었습니다.
// 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;
}
즉, v4
의 namespace
에는 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 에서 왜 저렇게 동작하는지 원인을 알아보도록 하겠습니다.
// 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 배열
에 name
을 push
하고 자기자신(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;
};
또한 v2
의 emit
함수를 살펴보면 함수가 모두 실행 된 이후 마지막에 rooms
와 flags
배열을 비워주고 있습니다.
이 부분때문에 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');
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
마이그레이션을 고려하는 다른 분들께 조금이나마 도움이 되었으면 좋겠습니다.