본 글은 nest의 @WebSocketGateway`를 사용하며 '예외가 발생했을 때 클라이언트의 콜백으로 상태를 전달할 수는 없을까?'의 고민이 담겨 있습니다.
데코레이터의 역할과 실제 사용처를 먼저 찾아보고, 예외 발생 시 어디에서 어떻게 처리하면 좋을 지의 순서로 서술되었습니다.
@WebSocketGateway
데코레이터의 역할은?nest에서 간단히 소켓 통신을 가능하게 해 주는 데코레이터입니다.
데코레이터의 역할 자체는 '메타데이터 등록'입니다. 하지만 진정한 가치는 nest가 실행될 때 발생합니다. nest는 등록된 메타데이터를 통해 해당 객체를 어떻게 관리할 지 결정합니다.
아래는 클래스 데코레이터인 @WebSocketGateway
입니다.
import { GATEWAY_METADATA, GATEWAY_OPTIONS, PORT_METADATA } from '../constants';
import { GatewayMetadata } from '../interfaces';
/**
* Decorator that marks a class as a Nest gateway that enables real-time, bidirectional
* and event-based communication between the browser and the server.
*
* @publicApi
*/
export function WebSocketGateway(port?: number): ClassDecorator;
export function WebSocketGateway<
T extends Record<string, any> = GatewayMetadata,
>(options?: T): ClassDecorator;
export function WebSocketGateway<
T extends Record<string, any> = GatewayMetadata,
>(port?: number, options?: T): ClassDecorator;
export function WebSocketGateway<
T extends Record<string, any> = GatewayMetadata,
>(portOrOptions?: number | T, options?: T): ClassDecorator {
const isPortInt = Number.isInteger(portOrOptions as number);
// eslint-disable-next-line prefer-const
let [port, opt] = isPortInt ? [portOrOptions, options] : [0, portOrOptions];
opt = opt || ({} as T);
return (target: object) => {
Reflect.defineMetadata(GATEWAY_METADATA, true, target);
Reflect.defineMetadata(PORT_METADATA, port, target);
Reflect.defineMetadata(GATEWAY_OPTIONS, opt, target);
};
}
이벤트를 전달받는 @SubscribeMessage
도 같이 살펴보겠습니다.
import { MESSAGE_MAPPING_METADATA, MESSAGE_METADATA } from '../constants';
/**
* Subscribes to messages that fulfils chosen pattern.
*
* @publicApi
*/
export const SubscribeMessage = <T = string>(message: T): MethodDecorator => {
return (
target: object,
key: string | symbol,
descriptor: PropertyDescriptor,
) => {
Reflect.defineMetadata(MESSAGE_MAPPING_METADATA, true, descriptor.value);
Reflect.defineMetadata(MESSAGE_METADATA, message, descriptor.value);
return descriptor;
};
};
둘 다 공통적으로 메타데이터를 정의한다는 것을 확인할 수 있습니다.
const app = await NestFactory.create(AppModule);
보통은 위와 같이 nest application을 생성합니다.
packages/core/nest-factory.ts
의 맨 마지막 코드를 살펴보면
export const NestFactory = new NestFactoryStatic();
이러한 코드가 작성되어 있습니다.
NestFactoryStatic
의 create
코드를 살펴보도록 하겠습니다(편의를 위해 오버라이딩 정의 코드는 제거하고 본문만 담았습니다).
public async create<T extends INestApplication = INestApplication>(
moduleCls: any,
serverOrOptions?: AbstractHttpAdapter | NestApplicationOptions,
options?: NestApplicationOptions,
): Promise<T> {
const [httpServer, appOptions] = this.isHttpServer(serverOrOptions)
? [serverOrOptions, options]
: [this.createHttpAdapter(), serverOrOptions];
const applicationConfig = new ApplicationConfig();
const container = new NestContainer(applicationConfig);
const graphInspector = this.createGraphInspector(appOptions, container);
this.setAbortOnError(serverOrOptions, options);
this.registerLoggerConfiguration(appOptions);
await this.initialize(
moduleCls,
container,
graphInspector,
applicationConfig,
appOptions,
httpServer,
);
const instance = new NestApplication(
container,
httpServer,
applicationConfig,
graphInspector,
appOptions,
);
const target = this.createNestInstance(instance);
return this.createAdapterProxy<T>(target, httpServer);
}
해당 과정의 initialize
도 살펴보도록 하겠습니다.
private async initialize(
module: any,
container: NestContainer,
graphInspector: GraphInspector,
config = new ApplicationConfig(),
options: NestApplicationContextOptions = {},
httpServer: HttpServer = null,
) {
UuidFactory.mode = options.snapshot
? UuidFactoryMode.Deterministic
: UuidFactoryMode.Random;
const injector = new Injector({ preview: options.preview });
const instanceLoader = new InstanceLoader(
container,
injector,
graphInspector,
);
const metadataScanner = new MetadataScanner();
const dependenciesScanner = new DependenciesScanner(
container,
metadataScanner,
graphInspector,
config,
);
container.setHttpAdapter(httpServer);
const teardown = this.abortOnError === false ? rethrow : undefined;
await httpServer?.init();
try {
this.logger.log(MESSAGES.APPLICATION_START);
await ExceptionsZone.asyncRun(
async () => {
await dependenciesScanner.scan(module);
await instanceLoader.createInstancesOfDependencies();
dependenciesScanner.applyApplicationProviders();
},
teardown,
this.autoFlushLogs,
);
} catch (e) {
this.handleInitializationError(e);
}
}
여기서 주목해야 할 부분은 await ExceptionsZone.asyncRun
입니다.
dependenciesScanner.scan(module)
: 주어진 module
내의 모든 클래스와 프로바이더를 스캔합니다. 이 과정에서 데코레이터 메타데이터를 읽고 @WebSocketGateway
나 다른 데코레이터가 지정된 클래스들을 식별합니다.instanceLoader.createInstancesOfDependencies()
: 스캔된 클래스들을 기반으로 의존성을 주입하고 인스턴스를 생성합니다. 즉, @WebSocketGateway
가 붙은 클래스의 인스턴스가 이 단계에서 생성됩니다.dependenciesScanner.applyApplicationProviders()
: 모든 프로바이더가 애플리케이션 레벨에서 사용 가능하도록 설정하고 등록하는 작업을 수행합니다.이후 packages/websockets/socket-module.ts
를 통해 데코레이터를 해석하게 됩니다.
SocketModule
의 connectGatewayToServer
를 살펴보겠습니다.
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,
);
}
GATEWAY_METADATA
를 사용하고 있는 것을 알 수 있습니다.
추가적으로 @SubscribeMessage
에서의 메타데이터 사용처도 알아보기 위해 WebSocketsController
의 connectGatewayToServer
를 살펴보겠습니다.
public connectGatewayToServer(
instance: NestGateway,
metatype: Type<unknown> | Function,
moduleKey: string,
instanceWrapperId: string,
) {
const options = Reflect.getMetadata(GATEWAY_OPTIONS, metatype) || {};
const port = Reflect.getMetadata(PORT_METADATA, metatype) || 0;
if (!Number.isInteger(port)) {
throw new InvalidSocketPortException(port, metatype);
}
this.subscribeToServerEvents(
instance,
options,
port,
moduleKey,
instanceWrapperId,
);
}
option과 port 부분에서 메타데이터를 활용하는 것을 확인할 수 있습니다.
하지만 직접적인 메타데이터 획득은 subscribeToServerEvents
에서 일어납니다.
public subscribeToServerEvents<T extends GatewayMetadata>(
instance: NestGateway,
options: T,
port: number,
moduleKey: string,
instanceWrapperId: string,
) {
const nativeMessageHandlers = this.metadataExplorer.explore(instance); // 여기
const messageHandlers = nativeMessageHandlers.map(
({ callback, message, methodName }) => ({
message,
methodName,
callback: this.contextCreator.create(
instance,
callback,
moduleKey,
methodName,
),
}),
);
this.inspectEntrypointDefinitions(
instance,
port,
messageHandlers,
instanceWrapperId,
);
if (this.appOptions.preview) {
return;
}
const observableServer = this.socketServerProvider.scanForSocketServer<T>(
options,
port,
);
this.assignServerToProperties(instance, observableServer.server);
this.subscribeEvents(instance, messageHandlers, observableServer);
}
this.metadataExplorer
는 GatewayMetadataExplorer
타입입니다.
private readonly metadataExplorer = new GatewayMetadataExplorer(
new MetadataScanner(),
);
GatewayMetadataExplorer
의 explore
도 살펴봅시다.
public explore(instance: NestGateway): MessageMappingProperties[] {
const instancePrototype = Object.getPrototypeOf(instance);
return this.metadataScanner
.getAllMethodNames(instancePrototype)
.map(method => this.exploreMethodMetadata(instancePrototype, method))
.filter(metadata => metadata);
}
exploreMethodMetadata
도 살펴봅시다.
public exploreMethodMetadata(
instancePrototype: object,
methodName: string,
): MessageMappingProperties {
const callback = instancePrototype[methodName];
const isMessageMapping = Reflect.getMetadata(
MESSAGE_MAPPING_METADATA,
callback,
);
if (isUndefined(isMessageMapping)) {
return null;
}
const message = Reflect.getMetadata(MESSAGE_METADATA, callback);
return {
callback,
message,
methodName,
};
}
해당 부분에서 우리가 원하던 메타데이터를 사용하는 것을 알 수 있습니다.
이로서 데코레이터를 통한 메타데이터 등록과 실제 사용처를 확인했지만, 아직 확인해야 할 부분이 많습니다.
웹소켓 통신은 기존의 api와는 다릅니다. 일반적인 HTTP처럼 '요청 -> 응답'이 아닌 경우가 굉장히 많습니다.
이처럼 여러 갈래가 있을 수 있지만, 우리는 '클라이언트 → 서버 → 클라이언트(콜백)' 부분에 집중해봅시다.
아래는 클라이언트 코드입니다.
socket.emit('join', { roomId: uuid }, (response) => {
if (response.status === 'error') {
alert(response.message);
return;
}
...
});
이처럼 socket
의 emit
을 이용하는 경우 세번째 인자로 콜백을 지정할 수 있습니다.
해당 콜백을 통해 nest의 @SubscribeMessage
를 지정한 메서드에서 return 값을 받아올 수 있습니다.
@SubscribeMessage('join')
join(@ConnectedSocket() client: Socket, @MessageBody() { roomId }: RoomsEnterRequestDto): RoomsEnterResponseDto {
...
return { status: 'ok', body: roomsJoinDto };
}
하지만 WsException
을 throw하는 경우 아무런 응답도 돌려주지 않습니다.
이를 위해서는 클라이언트가 지정한 콜백이 어디로 전달되는지 확인해봐야 합니다.
WsContextCreator
의 일부를 살펴보겠습니다.
return this.wsProxy.create(
async (...args: unknown[]) => {
args.push(targetPattern);
const initialArgs = this.contextUtils.createNullArray(argsLength);
fnCanActivate && (await fnCanActivate(args));
return this.interceptorsConsumer.intercept(
interceptors,
args,
instance,
callback,
handler(initialArgs, args),
contextType,
);
},
exceptionHandler,
targetPattern,
);
WsProxy
를 살펴보겠습니다.
public create(
targetCallback: (...args: unknown[]) => Promise<any>,
exceptionsHandler: WsExceptionsHandler,
targetPattern?: string,
): (...args: unknown[]) => Promise<any> {
return async (...args: unknown[]) => {
args = [...args, targetPattern ?? 'unknown'];
try {
const result = await targetCallback(...args);
return !isObservable(result)
? result
: result.pipe(
catchError(error => {
this.handleError(exceptionsHandler, args, error);
return EMPTY;
}),
);
} catch (error) {
this.handleError(exceptionsHandler, args, error);
}
};
}
클라이언트에서 콜백을 작성했을 경우 args
의 2번째 인덱스에서 이를 발견할 수 있습니다.
WsProxy
의 handleError
메서드도 살펴봅시다.
handleError<T>(
exceptionsHandler: WsExceptionsHandler,
args: unknown[],
error: T,
) {
const host = new ExecutionContextHost(args);
host.setType('ws');
exceptionsHandler.handle(error, host);
}
클라이언트 콜백이 들어있는 args
를 통해 host
를 만들고, WsExceptionsHandler
에게 처리하도록 만드는 것을 확인할 수 있습니다.
WsExceptionsHandler
도 살펴봅시다.
private filters: ExceptionFilterMetadata[] = [];
public handle(exception: Error | WsException | any, host: ArgumentsHost) {
const client = host.switchToWs().getClient();
if (this.invokeCustomFilters(exception, host) || !client.emit) {
return;
}
super.catch(exception, host);
}
public setCustomFilters(filters: ExceptionFilterMetadata[]) {
if (!Array.isArray(filters)) {
throw new InvalidExceptionFilterException();
}
this.filters = filters;
}
public invokeCustomFilters<T = any>(
exception: T,
args: ArgumentsHost,
): boolean {
if (isEmpty(this.filters)) return false;
const filter = selectExceptionFilterMetadata(this.filters, exception);
filter && filter.func(exception, args);
return !!filter;
}
커스텀 필터를 사용한다면 예외 발생 시 콜백으로 원하는 응답을 전달할 수 있을 것 같군요.
아주 간단하게 만들어 보도록 합시다.
const CALLBACK_INDEX = 2;
@Catch(WsException)
export class WsExceptionFilter extends BaseWsExceptionFilter {
catch(exception: WsException, host: ArgumentsHost) {
const client = host.switchToWs().getClient();
const callback = host.getArgByIndex(CALLBACK_INDEX);
const response = { status: 'error', message: exception.message };
if (typeof callback === 'function') {
callback(response);
} else {
client.emit('error', response);
}
}
}
그 후 기존 코드에 해당 필터를 적용하겠습니다.
@UseFilters(WsExceptionFilter)
@SubscribeMessage('join')
join(@ConnectedSocket() client: Socket, @MessageBody() { roomId }: RoomsEnterRequestDto): RoomsEnterResponseDto {
if (!this.roomsService.isExistRoom(roomId)) {
throw new WsException('존재하지 않는 방입니다.');
}
...
return { status: 'ok', body: roomsJoinDto };
}
발생하는 예외를 확인 후 클라이언트 콜백으로 전달한 것을 확인할 수 있습니다.
위 예시는 매우 간단한 형태이므로, 여러분의 코드에 맞게 변형하시면 되겠습니다.