Nest.js와 Gateway를 처음 사용해보며 겪은 방황을 정리하였습니다.
해당 글은 Nest.js와 socket.io의 코드를 포함하고 있습니다.
해당 데코레이터가 어떠한 메타데이터를 설정하는지는 이전 글에서 살펴보았습니다. 그러므로 오늘은 @WebSocketGateway
를 사용한 경우 어떻게 웹소켓 연걸 정보를 처리하는지 확인해 보겠습니다.
const app = await NestFactory.create(AppModule);
해당 코드가 동작하면 NestApplication을 생성하게 됩니다.
const { SocketModule } = optionalRequire(
'@nestjs/websockets/socket-module',
() => require('@nestjs/websockets/socket-module'),
);
NestApplication 상단에는 위와 같은 코드가 존재합니다.
export function optionalRequire(packageName: string, loaderFn?: Function) {
try {
return loaderFn ? loaderFn() : require(packageName);
} catch (e) {
return {};
}
}
만약 loaderFn이 존재하면 해당 함수를 동작시키고, 없다면 명시한 패키지를 가져옵니다.
명시한 패키지가 없더라도 빈 객체를 반환하여 문제가 없게 만듭니다. 즉 '있으면 가져오고, 없으면 말고'의 코드가 되겠습니다.
프로젝트에서 '@nestjs/websockets' 종속성을 추가한 경우, 해당 코드를 통해 '@nestjs/websockets/socket-module'의 SocketModule을 불러오게 됩니다.
public async init(): Promise<this> {
if (this.isInitialized) {
return this;
}
this.applyOptions();
await this.httpAdapter?.init();
const useBodyParser =
this.appOptions && this.appOptions.bodyParser !== false;
useBodyParser && this.registerParserMiddleware();
await this.registerModules(); // 여기서 모듈을 불러옵니다.
await this.registerRouter();
await this.callInitHook();
await this.registerRouterHooks();
await this.callBootstrapHook();
this.isInitialized = true;
this.logger.log(MESSAGES.APPLICATION_READY);
return this;
}
init 과정에서 모듈을 불러옵니다.
public async registerModules() {
this.registerWsModule();
...
}
public registerWsModule() {
if (!this.socketModule) {
return;
}
this.socketModule.register(
this.container,
this.config,
this.graphInspector,
this.appOptions,
this.httpServer,
);
}
해당 과정을 통해 SocketModule의 register가 호출되는군요.
SocketModule 코드를 살펴보겠습니다.
public register(
container: NestContainer,
applicationConfig: ApplicationConfig,
graphInspector: GraphInspector,
appOptions: TAppOptions,
httpServer?: THttpServer,
) {
this.applicationConfig = applicationConfig;
this.appOptions = appOptions;
this.httpServer = httpServer;
const contextCreator = this.getContextCreator(container);
const serverProvider = new SocketServerProvider(
this.socketsContainer,
applicationConfig,
);
this.webSocketsController = new WebSocketsController(
serverProvider,
applicationConfig,
contextCreator,
graphInspector,
this.appOptions,
);
const modules = container.getModules();
modules.forEach(({ providers }, moduleName: string) =>
this.connectAllGateways(providers, moduleName),
);
}
public connectAllGateways(
providers: Map<InjectionToken, InstanceWrapper<Injectable>>,
moduleName: string,
) {
iterate(providers.values())
.filter(wrapper => wrapper && !wrapper.isNotMetatype)
.forEach(wrapper => this.connectGatewayToServer(wrapper, moduleName));
}
public connectGatewayToServer(
wrapper: InstanceWrapper<Injectable>,
moduleName: string,
) {
const { instance, metatype } = wrapper;
const metadataKeys = Reflect.getMetadataKeys(metatype);
if (!metadataKeys.includes(GATEWAY_METADATA)) {
return;
}
if (!this.isAdapterInitialized) {
this.initializeAdapter();
}
this.webSocketsController.connectGatewayToServer(
instance as NestGateway,
metatype,
moduleName,
wrapper.id,
);
}
private initializeAdapter() {
const adapter = this.applicationConfig.getIoAdapter();
if (adapter) {
this.isAdapterInitialized = true;
return;
}
const { IoAdapter } = loadAdapter(
'@nestjs/platform-socket.io',
'WebSockets',
() => require('@nestjs/platform-socket.io'),
);
const ioAdapter = new IoAdapter(this.httpServer);
this.applicationConfig.setIoAdapter(ioAdapter);
this.isAdapterInitialized = true;
}
만약 Adapter를 설정했다면 해당 Adapter를 사용하지만, 그렇지 않다면 IoAdapter를 가져옵니다.
public create(
port: number,
options?: ServerOptions & { namespace?: string; server?: any },
): Server {
if (!options) {
return this.createIOServer(port);
}
const { namespace, server, ...opt } = options;
return server && isFunction(server.of)
? server.of(namespace)
: namespace
? this.createIOServer(port, opt).of(namespace)
: this.createIOServer(port, opt);
}
public createIOServer(port: number, options?: any): any {
if (this.httpServer && port === 0) {
return new Server(this.httpServer, options);
}
return new Server(port, options);
}
IoAdapter는 Socket.io의 Server를 반환합니다.
socket.io의 Server는 웹소켓(을 비롯한 몇몇 http 프로토콜) 요청들을 처리하는 역할입니다.
생성자 부분 코드만 살펴보겠습니다. 이 부분만 따져도 굉장히 길거든요..
constructor(
srv: undefined | Partial<ServerOptions> | TServerInstance | number,
opts: Partial<ServerOptions> = {},
) {
super();
if (
"object" === typeof srv &&
srv instanceof Object &&
!(srv as Partial<http.Server>).listen
) {
opts = srv as Partial<ServerOptions>;
srv = undefined;
}
this.path(opts.path || "/socket.io");
this.connectTimeout(opts.connectTimeout || 45000);
this.serveClient(false !== opts.serveClient);
this._parser = opts.parser || parser;
this.encoder = new this._parser.Encoder();
this.opts = opts;
if (opts.connectionStateRecovery) {
opts.connectionStateRecovery = Object.assign(
{
maxDisconnectionDuration: 2 * 60 * 1000,
skipMiddlewares: true,
},
opts.connectionStateRecovery,
);
this.adapter(opts.adapter || SessionAwareAdapter);
} else {
this.adapter(opts.adapter || Adapter);
}
opts.cleanupEmptyChildNamespaces = !!opts.cleanupEmptyChildNamespaces;
this.sockets = this.of("/");
if (srv || typeof srv == "number")
this.attach(srv as TServerInstance | number);
if (this.opts.cors) {
this._corsMiddleware = corsMiddleware(this.opts.cors);
}
}
여기서 중요한 건 (따로 어댑터를 지정하지 않았을 시)opts.connectionStateRecovery
설정에 따라 SessionAwareAdapter
혹은 Adapter
가 설정된다는 점입니다.
SessionAwareAdapter
는 Adapter
를 상속한 객체이며, 커넥션이 재연결 되었을 때 기존 연결값을 회복시켜 주는 어댑터입니다.
기본적인 동작은 같으므로, 이번 글에서는 Adapter
코드를 살펴보도록 하겠습니다.
Adapter
코드도 내용이 많기 때문에, 제가 이야기하고 싶은 부분인 '방'의 개념 위주로 서술하겠습니다.
그 전에 한가지 중요한 사실을 말씀드리자면, Adapter
는 EventEmitter
를 상속하여 재정의한 객체에 불과하다는 사실입니다.
export class Adapter extends EventEmitter {
...
}
export type Room = string;
'방'은 단순 문자열 타입입니다.
public rooms: Map<Room, Set<SocketId>> = new Map();
public sids: Map<SocketId, Set<Room>> = new Map();
단순 인메모리 어댑터로, 방과 소켓id(랜덤으로 부여되는 사용자의 연결 소켓 id)를 짝지어 둔 형태입니다.
public addAll(id: SocketId, rooms: Set<Room>): Promise<void> | void {
if (!this.sids.has(id)) {
this.sids.set(id, new Set());
}
for (const room of rooms) {
this.sids.get(id).add(room);
if (!this.rooms.has(room)) {
this.rooms.set(room, new Set());
this.emit("create-room", room);
}
if (!this.rooms.get(room).has(id)) {
this.rooms.get(room).add(id);
this.emit("join-room", room, id);
}
}
}
public del(id: SocketId, room: Room): Promise<void> | void {
if (this.sids.has(id)) {
this.sids.get(id).delete(room);
}
this._del(room, id);
}
private _del(room: Room, id: SocketId) {
const _room = this.rooms.get(room);
if (_room != null) {
const deleted = _room.delete(id);
if (deleted) {
this.emit("leave-room", room, id);
}
if (_room.size === 0 && this.rooms.delete(room)) {
this.emit("delete-room", room);
}
}
}
여기서의 '방'은 socket.io에서 정의한 개념입니다. 흔히 알려진 pub/sub 패턴을 기반으로 구성되었습니다.
클라이언트가 특정 이벤트명을 구독하는 것을 '방에 들어갔다'라고 표현하며, 이벤트를 구독 중지하는 경우 '방을 퇴장했다'라고 표현합니다.
socket.io를 통해 소켓이 연결된 경우, 클라이언트는 본인의 id와 같은 방을 만들고 들어갑니다(본인의 소켓id 구독).
또한 다른 방에 들어갈 수도 있습니다(특정 이벤트 구독).
우리가 해당 코드를 통해 깨달을 수 있는 것은, 'Gateway를 사용할 경우 socket.io의 Adapter가 사용자들의 방 입장과 퇴장을 내부적으로 기록한다'는 것입니다.
그러므로 굳이 '어떤 방에 어느 사용자들이 들어와 있는지'를 기록할 필요가 없습니다(서버 인스턴스가 여러 대인 경우 RedisAdapter 등을 참고해보세요).
해당 과정은 socket.io가 정의한 '방'과 저희 프로젝트의 '방' 개념을 일치화시키려고 노력하다 깨달은 내용입니다.
여러분은 어떤 의도로 라이브러리를 사용하시나요?
저는 '프로젝트에 필요한 기능이 있지만 내가 직접 작성하기 힘들거나 귀찮은 경우' 도입하곤 합니다.
저보다 똑똑하신 분들이, 많은 에러케이스를 해결하시면서 탄탄하게 다져놓은 작품이니까요.
하지만 위의 과정처럼 'Nest.js의 Gateway와 socket.io의 Adapter'까지 살펴보고 난 이후, 저는 크게 좌절했습니다.
socket.io가 정의하는 '방'과 저희 프로젝트의 '방' 개념이 불일치했기 때문이에요.
저희 프로젝트의 '방'은 다음과 같은 특성을 가졌습니다.
물론 더 많은 정의들이 있지만, 사실 이에 대해서 꼭 인지하실 필요는 없습니다.
어디까지나 'socket.io의 방은 단순 string인데, 프로젝트의 방은 여러 메타데이터들이 존재해야 한다'라고만 이해하셔도 충분합니다.
저는 socket.io가 왜 Room
을 단순 string
type으로 정의한 것인지 도저히 이해할 수 없었습니다. '방' 개념을 프로젝트에도 똑같이 대입하겠다고 하면, 이를 잘 확장할 수 있도록 interface로 정의해주는 게 좋지 않았을까? 싶은 생각이 들었거든요.
즉, 처음 코드를 보고 난 이후 제 머릿속은 'socket.io는 방의 확장성을 고려하지 않을 걸까?'였습니다.
저는 socket.io의 '방'과 프로젝트의 '방' 개념을 일치시키기 위해 여러 방안을 생각했습니다. 그 중 가장 가능성 높았던 세가지 안을 소개드리고, 맞닥뜨린 문제들을 말씀드리도록 하겠습니다.
socket.io의 Adapter를 상속받아 보기로 했습니다.
어차피 Adapter의 모든 메서드는 string 타입의 Room을 기조로 설계되어 있습니다. 따라서 개념 일치화에 무리가 있었습니다.
Room을 저희 프로젝트에서 필요한 오브젝트 형태로 정의하고 이를 사용하는 CustomAdapter를 만들어 보기로 했습니다.
앞서 설명드렸듯 Adapter는 단순 EventEmitter입니다.
또한 javascript는 깐깐한 타입 추론을 거치지 않기 때문에, '고장나지 않을 정도로만' socket.io의 어댑터를 갈아끼우면 가능하지 않을까 싶었습니다.
실제 빌드 환경에서 Adapter와 똑같은 메서드들을 지니게끔 작성해야 했습니다. 즉 저희가 작성한 typescript 파일을 기반으로 생성된 js 파일은 기존의 Adapter.js 구조와 일치해야 했습니다.
이는 CustomAdapter 코드가 변경될 때마다 '기존 구조와 같은가?'를 매번 검사해야 했습니다.
만약 쌩 js 파일로 구성하더라도 결국 기존 Adapter 구조에 묶여 '오히려 좋지 않은 코드를 작성하게 될 것이 뻔하다'는 생각이 들었습니다.
최종 방법으로 기존의 Adapter를 감싼 프록시를 생성하고, 해당 코드 내부에서 우리가 필요한 오브젝트를 정의하여 사용하는 것을 고려하였습니다.
코드의 복잡도가 늘어나는 것과는 별개의 문제가 있었습니다. 바로 '비즈니스 로직을 잘 관리할 수 있을 것인가?'였습니다.
'방'은 비즈니스 로직과 굉장히 밀접합니다. 만약 ProxyAdapter 내에 우리의 '방' 정보를 기입한다면, 이는 필히 비즈니스 로직과 해당 객체가 얽힘을 예상할 수 있었습니다.
만약 비즈니스 로직이 점점 심화된다면 프록시의 역할이 커지고, 언젠가는 너무나도 커진 프록시를 감당할 수 없는 '죽은 코드'가 되지 않을까 걱정되었습니다.
세가지 방법 모두 힘들다는 것을 깨닫게 되었고, 스스로 깊은 좌절감을 느꼈습니다. '대체 socket.io의 의도가 뭘까?'라는 생각이 떠나질 않았습니다.
그러던 중, 번뜩이는 생각이 스쳐지나갔습니다. 바로 '우리 프로젝트의 방을 socket.io가 추상화해서 사용하는 구조라면 어떨까?'라는 생각이었습니다.
프로젝트의 '방' 정의는 굉장히 다양할 것입니다. 저희처럼 페이즈가 있을 수도 있고, 호스트가 있을 수도 있습니다. 여러 명의 호스트가 존재할 수도 있고, 방의 설정에 따라 아이템 사용 가능, 칼만 사용, 5 vs 5, 6 vs 6 모드 등 엄청난 경우의 수가 생길 수 있습니다.
헌데 '방에 누가 참가했는가'같은 참가 정보에서, 위의 내용들을 모두 알 필요가 있을까요?
'어떤 이름의 방에 누구누구가 들어가 있다' 정도면 충분하지 않을까요?
아, 여기서 저는 깨닫고야 말았습니다.
socket.io의 '방' 개념은 어디까지나 '참가 정보'이지, '프로젝트의 방과 100% 일치시키는 건 오히려 문제'라는 것을요.
즉, socket.io의 '방'은 '프로젝트의 방 정보를 추상화'하였기에 다른 정보 하나 없이 '방 이름'만 알면 됩니다! 오히려 혼란을 막기 위해서는 확장이 불가능하도록 만드는 것이 좋을 듯 합니다.
socket.io가 Room을 단순 string type으로 작성한 것도 혹시 이러한 의도가 숨어있던 게 아닐까요?
아무튼, 이러한 생각을 통해 '라이브러리에 우리 코드를 맞추거나 추상화하여 관리'하던 기존과는 다르게 '라이브러리가 우리의 코드를 추상화하여 사용하도록' 생각을 바꾸니 모든 것이 매끄럽게 진행되었습니다.
위와 같은 과정을 겪고 난 후, 저희는 기존 코드를 리팩터링했습니다.
누가 어느 방에 들어왔는지는 socket.io의 Adapter를 통해 관리하도록 했고, 호스트나 페이즈같은 방의 내부 정보들은 프로젝트의 service를 통해 관리할 수 있도록 했습니다.
물론 아직 프로젝트가 미완성이기에, 코드가 변경될 가능성이 높습니다.
하지만 위와 같은 방 연결 정보 관리 -> socket.io, 방 내부 정보 관리 -> 프로젝트
라는 전체적인 틀은 확고할 듯 합니다.
이렇게 제가 겪은 방황을 정리해 보았습니다.
여러분의 프로젝트에 제 글이 조금이나마 도움되셨으면 합니다.