WebSocket 게이트웨이에서도 HTTP 컨트롤러의 에러 처리와 동일한 방식으로 예외를 던지면 클라이언트는 아무런 응답도 받지 못한다.
NestJS의 Pipe와 Filter를 사용해, WebSocket에서도 HTTP처럼 명확하게 에러를 처리해보자.
HTTP 컨트롤러 (@Controller):
서비스 로직에서 throw new BadRequestException('에러');를 던지면, NestJS가 알아서 {"statusCode": 400, "message": "에러"}와 같은 JSON 응답을 클라이언트에게 보내준다.
WebSocket 게이트웨이 (@WebSocketGateway):
똑같이 BadRequestException을 던져도, NestJS는 이를 JSON 응답으로 변환해주지 않음.
WebSocket에서도 예외가 발생하면, 요청을 보낸 클라이언트에게 'error' 이벤트를 통해 실패 사유를 전달해줘야한다.
간단하게 실시간 채팅을 예시로 들어 진행해보자.
게이트웨이:
ChatGateway
이벤트:message(클라이언트가 메시지를 보냄)
데이터 (DTO):CreateMessageDto(보낸 사람username과content가 필요)
ValidationPipe를 사용하려면 class-validator 라이브러리를 설치해야한다.
pnpm add class-validator class-transformer
# 또는 npm i / yarn add
클라이언트가 빈 메시지나 비정상적인 데이터를 보내는 것을 막기 위해 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을 던지게 된다.
ValidationPipe가 예외를 던져도, 클라이언트는 여전히 이 사실을 알 수 없다. 이 예외를 가로채서 클라이언트에게 emit 해줄 번역기가 필요하다.
역할: 게이트웨이에서 발생하는 모든 종류의 예외(ValidationPipe 에러, 서비스 로직 에러, 런타임 버그 등)를 catch한다.
동작 흐름
catch) -> 정상적인 return (ack 응답) 흐름은 즉시 중단됨client)와 예외 정보(exception)를 가져옴"content should not be empty")를 추출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 { ... }
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 };
}
}