nest의 @WebSocketGateway에서 예외 throw 시 클라이언트 콜백으로 응답 전달하기

eora21·2024년 11월 10일
0

본 글은 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();

이러한 코드가 작성되어 있습니다.
NestFactoryStaticcreate 코드를 살펴보도록 하겠습니다(편의를 위해 오버라이딩 정의 코드는 제거하고 본문만 담았습니다).

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를 통해 데코레이터를 해석하게 됩니다.
SocketModuleconnectGatewayToServer를 살펴보겠습니다.

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에서의 메타데이터 사용처도 알아보기 위해 WebSocketsControllerconnectGatewayToServer를 살펴보겠습니다.

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.metadataExplorerGatewayMetadataExplorer 타입입니다.

private readonly metadataExplorer = new GatewayMetadataExplorer(
  new MetadataScanner(),
);

GatewayMetadataExplorerexplore도 살펴봅시다.

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;
    }

    ...
  });

이처럼 socketemit을 이용하는 경우 세번째 인자로 콜백을 지정할 수 있습니다.
해당 콜백을 통해 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번째 인덱스에서 이를 발견할 수 있습니다.

@UseFilters를 통해 콜백으로 예외 정보 전송

WsProxyhandleError 메서드도 살펴봅시다.

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 };
}

발생하는 예외를 확인 후 클라이언트 콜백으로 전달한 것을 확인할 수 있습니다.

위 예시는 매우 간단한 형태이므로, 여러분의 코드에 맞게 변형하시면 되겠습니다.

profile
나누며 타오르는 프로그래머, 타프입니다.

0개의 댓글

관련 채용 정보