[NestJS] HTTP처럼 WebSocket에서 에러 응답 받기 (Pipe, Filter 활용)

세하·2025년 11월 15일

NestJS

목록 보기
7/8
post-thumbnail

WebSocket 게이트웨이에서도 HTTP 컨트롤러의 에러 처리와 동일한 방식으로 예외를 던지면 클라이언트는 아무런 응답도 받지 못한다.
NestJS의 PipeFilter를 사용해, WebSocket에서도 HTTP처럼 명확하게 에러를 처리해보자.

  • HTTP 컨트롤러 (@Controller):
    서비스 로직에서 throw new BadRequestException('에러');를 던지면, NestJS가 알아서 {"statusCode": 400, "message": "에러"}와 같은 JSON 응답을 클라이언트에게 보내준다.

  • WebSocket 게이트웨이 (@WebSocketGateway):
    똑같이 BadRequestException을 던져도, NestJS는 이를 JSON 응답으로 변환해주지 않음.
    WebSocket에서도 예외가 발생하면, 요청을 보낸 클라이언트에게 'error' 이벤트를 통해 실패 사유를 전달해줘야한다.

간단하게 실시간 채팅을 예시로 들어 진행해보자.

게이트웨이: ChatGateway
이벤트: message (클라이언트가 메시지를 보냄)
데이터 (DTO): CreateMessageDto (보낸 사람 usernamecontent가 필요)

사전 준비 - 필수 패키지 설치

ValidationPipe를 사용하려면 class-validator 라이브러리를 설치해야한다.

pnpm add class-validator class-transformer
# 또는 npm i / yarn add

ValidationPipe (1차 방어: 데이터 유효성 검사)

클라이언트가 빈 메시지나 비정상적인 데이터를 보내는 것을 막기 위해 DTO와 ValidationPipe를 도입하자.

  • DTO (create-message.dto.ts): class-validator로 채팅 메시지의 규칙을 정의한다.

    import { IsString, IsNotEmpty, MaxLength } from 'class-validator';
    
    export class CreateMessageDto {
      @IsString()
      @IsNotEmpty()
      username: string;
    
      @IsString()
      @IsNotEmpty()
      @MaxLength(500) // 500자 제한
      content: string;
    }
  • 게이트웨이 적용 (chat.gateway.ts): @SubscribeMessage 핸들러에 @UsePipes를 적용한다.

    // In ChatGateway.ts
    @UsePipes(new ValidationPipe())
    @SubscribeMessage('message') // 'message' 이벤트
    handleMessage(@MessageBody() data: CreateMessageDto) { // DTO 타입 지정
      // ...
    }

→ 이제 클라이언트가 {"username": "user1", "content": ""} 처럼 빈 내용을 보내면, ValidationPipe자동으로 BadRequestException을 던지게 된다.

WsExceptionFilter (2차 처리: 핵심 예외 처리기)

ValidationPipe가 예외를 던져도, 클라이언트는 여전히 이 사실을 알 수 없다. 이 예외를 가로채서 클라이언트에게 emit 해줄 번역기가 필요하다.

  • 역할: 게이트웨이에서 발생하는 모든 종류의 예외(ValidationPipe 에러, 서비스 로직 에러, 런타임 버그 등)를 catch한다.

  • 동작 흐름

    1. 예외가 발생하면(catch) -> 정상적인 return (ack 응답) 흐름은 즉시 중단됨
    2. 필터가 클라이언트 객체(client)와 예외 정보(exception)를 가져옴
    3. 예외에서 에러 메시지("content should not be empty")를 추출
    4. 클라이언트에게 client.emit('error', ...) 를 호출해, 우리가 정의한 errorResponse 객체를 전송한다.
  • 구현 코드 (common/filters/ws-exception.filter.ts): (이 코드는 재사용 가능)

    import { Catch, ArgumentsHost } from '@nestjs/common';
    import { BaseWsExceptionFilter, WsException } from '@nestjs/websockets';
    
    @Catch() // 모든 예외를 잡음
    export class WsExceptionFilter extends BaseWsExceptionFilter {
      catch(exception: unknown, host: ArgumentsHost) {
        const ctx = host.switchToWs();
        const client = ctx.getClient();
    
        const errorResponse = {
          event: 'error',
          data: {
            timestamp: new Date().toISOString(),
            path: `event: ${ctx.getPattern()}`, // 어떤 이벤트에서 발생했는지
            message: 'Internal server error', // 기본 메시지
          },
        };
    
        if (exception instanceof WsException) {
          errorResponse.data.message = exception.getError() as string;
        } else if (exception instanceof Error) {
          // ValidationPipe의 에러(BadRequestException)가 여기에 해당된다
          const responseMessage = (exception as any).response?.message;
    
          // ValidationPipe 에러가 배열로 올 경우 쉼표로 연결
          if (Array.isArray(responseMessage)) {
            errorResponse.data.message = responseMessage.join(', ');
          } else {
            errorResponse.data.message = responseMessage || exception.message;
          }
        }
    
        client.emit(errorResponse.event, errorResponse.data);
      }
    }
  • 게이트웨이 적용:

    // In ChatGateway.ts
    @UseFilters(new WsExceptionFilter()) // 게이트웨이 클래스에 적용
    @WebSocketGateway({ ... })
    export class ChatGateway { ... }

ParseWsJsonPipe (Optional: 호환성 처리)

Postman 같은 테스트 툴이나 특정 클라이언트는 JavaScript 객체가 아닌, JSON 문자열을 보낼 수 있다. 이 문자열은 ValidationPipe에서 username 속성이 없다며 실패하게 된다.
ValidationPipe가 실행되기 전에 이 문자열을 JSON.parse(파싱) 해주는 커스텀 파이프를 만들어 호환성을 높이자.

  • 구현 코드 (common/pipes/parse-ws-json.pipe.ts): (이 코드도 재사용 가능)

    import { PipeTransform, Injectable, BadRequestException, Logger } from '@nestjs/common';
    
    @Injectable()
    export class ParseWsJsonPipe implements PipeTransform {
      private readonly logger = new Logger(ParseWsJsonPipe.name);
    
      transform(value: any) {
        // 1. 이미 객체이거나, 데이터가 없으면 그냥 통과
        if (typeof value !== 'string' || !value) {
          return value;
        }
    
        // 2. 문자열이면 JSON으로 파싱
        try {
          return JSON.parse(value);
        } catch (e) {
          // 3. 유효하지 않은 JSON 형식이면 에러 발생 (WsExceptionFilter가 잡음)
          if (e instanceof Error) {
            this.logger.error('Failed to parse JSON string.', e.stack);
          } else {
            this.logger.error('Failed to parse JSON string.', e);
          }
          throw new BadRequestException('Invalid JSON format received.');
        }
      }
    }
  • 게이트웨이 적용 (❗️순서 중요❗️):

    // In ChatGateway.ts
    // 1. ParseWsJsonPipe가 먼저 문자열을 객체로 변환
    // 2. ValidationPipe가 이 객체를 검증
    @UsePipes(new ParseWsJsonPipe(), new ValidationPipe())
    @SubscribeMessage('message')
    handleMessage(@MessageBody() data: CreateMessageDto) { ... }

클라이언트에서 에러 수신하기

이제 서버는 모든 예외를 가로채서 표준화된 'error' 이벤트로 보내준다. 클라이언트에서는 이 이벤트를 수신하는 리스너만 등록하면 된다.

  • 클라이언트 (socket.io-client) 예시 - React

    // 에러 이벤트 리스너 등록
    socket.on('error', (errorPayload) => {
      // errorPayload에는 WsExceptionFilter가 보낸 { timestamp, path, message } 객체가 담겨온다.
      console.error('서버 에러 발생:', errorPayload.message);
      alert(`[${errorPayload.path}] 오류: ${errorPayload.message}`);
    });
    
    // ...
    
    // 채팅 메시지 전송 (일부러 에러 발생)
    const myMessage = {
      username: "Gildong",
      content: "" // <- 빈 내용 (ValidationPipe에서 걸림)
    };
    
    socket.emit('message', myMessage);

정리

이 3단계(Parse -> Validate -> Filter) 파이프라인을 통해, ChatGateway의 핸들러 메서드는 이제 깔끔하게 비즈니스 로직에만 집중할 수 있게 된다.

Pipe

  • ParseWsJsonPipe: 컨트롤러/게이트웨이에 도착한 데이터를 변형 (문자열 -> JSON 객체)
  • ValidationPipe: 변형된 데이터를 검증 (이 객체가 DTO 규칙에 맞는지)

Filter

  • WsExceptionFilter: 파이프나 서비스 로직 실행 중 던져진(throw) 예외(에러)를 가로채서 처리

이제 데이터 파싱, 유효성 검사, 예외 처리는 모두 재사용 가능한 파이프와 필터에 위임되었다.

// In ChatGateway.ts
@UseFilters(new WsExceptionFilter())
@WebSocketGateway({ ... })
export class ChatGateway {

  constructor(private chatService: ChatService) {}

  @UsePipes(new ParseWsJsonPipe(), new ValidationPipe())
  @SubscribeMessage('message')
  handleMessage(@MessageBody() data: CreateMessageDto) {
    // 이 함수는 data가 항상 유효함을 보장받는다.
    // 여기서 에러가 나거나, 서비스에서 에러가 나도 WsExceptionFilter가 알아서 클라이언트에게 'error' 이벤트를 보냄
    const result = this.chatService.saveMessage(data);
    
    // 성공 시에만 ack 응답(return)이 나간다.
    return { success: true, data: result };
  }
}

0개의 댓글