-
Notifications
You must be signed in to change notification settings - Fork 2
e2e 테스트 진행
| ⚙️ Web BE |
|---|
![]() |
| 김진 |
| 2025.01.14. 작성 |
해당 일지는 앞선 테스트 수행 과정에서 잘못된 부분이 있음을 발견하고 해당 문제를 해결하고 테스트를 완성하기 위한 일지입니다.
우선, 아래 코드에서도 console.log(e) 를 출력하지 못하는 걸 확인할 수 있었다.
for (const auth of authInfo) {
const invalidClient = io(URL, {
auth: { auth },
});
// WsExceptionFilter에서 사용하는 'error' 이벤트도 캐치하지 못함
invalidClient.on('connect_error', (e) => {
console.log(e);
expect(e.message).toBe('Room ID and Player ID are required');
invalidClient.close();
});
}Gateway 코드는 다음과 같이 작성되어 있어서, WsExceptionFilter에서 분명히 에러를 catch해서 error 또는 connect_error 를 수신받을 수 있어야 한다고 생각했다.
// drawing.gateway.ts
@WebSocketGateway({
cors: '*',
namespace: '/socket.io/drawing',
})
@UseFilters(WsExceptionFilter)
export class DrawingGateway implements OnGatewayConnection {
@WebSocketServer()
server: Server;
constructor(private readonly drawingService: DrawingService) {}
async handleConnection(client: Socket) {
const roomId = client.handshake.auth.roomId;
const playerId = client.handshake.auth.playerId;
if (!roomId || !playerId)
throw new BadRequestException('Room ID and Player ID are required');
// ...
}
}
// ws-exception.filter.ts
@Catch()
export class WsExceptionFilter extends BaseWsExceptionFilter {
catch(exception: any, host: ArgumentsHost) {
const client = host.switchToWs().getClient<Socket>();
if (exception instanceof GameException) {
client.emit('error', {
code: exception.code,
message: exception.message,
});
} else {
client.emit('error', {
code: 'INTERNAL_ERROR',
message: 'Internal server error',
});
}
console.error('WebSocket Error:', exception);
}
}그러나… 실제로는 이렇게 발생한 에러를 catch하지 못하고 종료된다.

이 문제를 해결하기 위해 열심히 찾아보던 중에, 이런 글을 볼 수 있었다
The @UseFilters() decorator is not used directly on WebSocket gateways like it is with controllers. Instead, you need to apply the filter within the @WebSocketGateway() decorator using the exceptionFilters property.
답변을 확인해보면 Gateway에서는 @UseFilters() 데코레이터를 사용하지 않고, @WebSocketGateway() 데코레이터에 exceptionFilters 속성을 이용해야 한다는 내용이었다.
이 답변을 보고 바로 @WebSocketGateway() 데코레이터에 해당 속성이 있는지 확인하러 갔다.
gateway-metadata.interface.d.ts 파일을 확인했는데 exceptionFilters 속성은 없었고.. NestJS Exception Filter에 대한 공식문서를 확인하러 갔다.
Filters#
Web sockets exception filters behave equivalently to HTTP exception filters. The following example uses a manually instantiated method-scoped filter. Just as with HTTP based applications, you can also use gateway-scoped filters (i.e., prefix the gateway class with a
@UseFilters()decorator).
공식 문서에는 gateway class에 접두사로 데코레이터를 사용할 수 있다고 적혀있다. 그럼 우리는 왜 filter가 적용이 되지 않는 것일까..?
우선 별 다른 정보를 얻지 못해, 생성형 AI에게 질문을 해보았고 이런 답변을 받았다.

공식 문서에서 @SubscribeMessage 데코레이터 위에만 @UseFilters 데코레이터를 쓴 이유가 이거 때문인가하는 생각이 들었다.

그래도 공식 문서에서 정확히 “handleConnection 메서드가 @SubscribeMessage 데코레이터와 다르게 동작해서 filter가 동작하지 않는다” 와 같은 말을 전혀 확인할 수가 없어서, 주변에 작성 중이던 이 일지를 공유하며 아시는게 있는지 여쭤보았다.
그 결과, 정동교님께서 정말 믿음이 가는 글을 하나 찾아주셨다!

@SusbscribeMessage 데코레이터가 아닌 handleConnection 메서드에는 필터가 적용되지 않는게 의도된 사항이라는 답변이었다. 적용되지 않는 것이 의도된 사항이면, 저렇게 에러가 반환되며 종료되는 것이 당연한 동작이라는 것이 된다.
이를 통해 기존의 gateway 코드가 잘못되었다는 것을 알게 되었다.
// 기존 코드
async handleConnection(client: Socket) {
const roomId = client.handshake.auth.roomId;
const playerId = client.handshake.auth.playerId;
if (!roomId || !playerId) throw new BadRequestException('Room ID and Player ID are required');
const roomExists = await this.drawingService.existsRoom(roomId);
if (!roomExists) throw new RoomNotFoundException('Room not found');
const playerExists = await this.drawingService.existsPlayer(roomId, playerId);
if (!playerExists) throw new PlayerNotFoundException('Player not found in room');
client.data.roomId = roomId;
client.data.playerId = playerId;
client.join(roomId);
}
// 변경된 코드
async handleConnection(client: Socket) {
const roomId = client.handshake.auth.roomId;
const playerId = client.handshake.auth.playerId;
if (!roomId || !playerId) {
client.emit('error', {
code: 4000,
message: 'Room ID and Player ID are required',
});
client.disconnect();
return;
}
const roomExists = await this.drawingService.existsRoom(roomId);
if (!roomExists) {
client.emit('error', {
code: 6005,
message: 'Room not found',
});
client.disconnect();
return;
}
const playerExists = await this.drawingService.existsPlayer(roomId, playerId);
if (!playerExists) {
client.emit('error', {
code: 6006,
message: 'Player not found in room',
});
client.disconnect();
return;
}
client.data.roomId = roomId;
client.data.playerId = playerId;
client.join(roomId);
}기존에 에러를 던지는 코드에서, error 이벤트를 발생시키고, 에러 코드와 메시지를 수신받을 수 있도록 변경했다. 그리고 잘못된 연결을 하는 client의 연결을 끊도록 했다.
이에 대해 테스트 코드들도, 다음과 같이 error 이벤트를 수신해서 에러 코드와 메시지를 검증하는 방식으로 변경했다.
it('roomId 또는 playerId의 값이 존재하지 않는 경우 "Room ID and Player ID are required" 에러가 발생한다.', async () => {
const authInfo = [
{ roomId: '', playerId: '' },
{ roomId: 'room1', playerId: '' },
{ roomId: '', playerId: 'player1' },
];
for (const auth of authInfo) {
const socket = io(URL, {
auth,
});
await new Promise<void>((resolve) => {
socket.on('error', (e) => {
expect(e.code).toBe(4000);
expect(e.message).toBe('Room ID and Player ID are required');
resolve();
});
});
socket.close();
}
});실제로 console.log(e) 로 출력 결과를 확인해보면 다음과 같이 제대로 반환받는 걸 확인할 수 있다. 테스트도 통과한다!

이제 진짜로!! 제일 처음 발견한 문제를 해결할 단계가 되었다!
바로 이 코드다.
it('정상적으로 그림이 그려지는 경우', async () => {
// ...
// clientB가 연결이 완료될 때까지 기다림
await new Promise<void>((resolve) => {
clientB.on('connect', resolve)
});
console.log(clientB.connected);
// clientB가 이벤트를 수신받을 준비를 함
clientB.on('drawUpdated', (data) => {
console.log('clientB가 수신함');
expect(data).toEqual({
playerId: 'player1',
drawingData: drawingData,
});
clientB.close();
});
// clientA가 실제로 이벤트를 발생시킴
clientA.emit('draw', { drawingData });
});
clientB가 연결이 된 거까지는 확인이 되지만, 수신은 받지 못하고 있다.
clientB가 수신 받을 준비만 하고 clientA가 이벤트 발생시킨 다음에는 그대로 테스트가 끝나버려서 수신받지 못하는 것이 아닐까싶다.
정말 간단하게 바꿔보자.
이벤트 수신을 위한 Promise를 생성해 clientA가 이벤트를 발생시킨 후 해당 Promise를 실행해 제대로 값을 받고 있는지 확인해보는 코드이다.
const drawingData = {
pos: 56,
fillColor: { R: 0, G: 0, B: 0, A: 0 },
};
// ...
// drawUpdated 이벤트 수신을 위한 Promise 생성
const drawUpdatePromise = new Promise<void>((resolve) => {
clientB.on('drawUpdated', (data) => {
console.log('B가 수신함 ', data);
expect(data).toEqual({
playerId: 'player1',
drawingData: drawingData,
});
resolve();
});
});
// clientA가 실제로 이벤트를 발생시킴
clientA.emit('draw', { drawingData });
await drawUpdatePromise;
clientB.close();
내가 입력한 drawingData 값과 이벤트를 발생시킨 playerId 값을 제대로 받아오는 것을 확인할 수 있다!
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { INestApplication } from '@nestjs/common';
import { io, Socket } from 'socket.io-client';
import { RedisService } from '../redis/redis.service';
import { DrawingGateway } from './drawing.gateway';
import { DrawingService } from './drawing.service';
import { DrawingRepository } from './drawing.repository';
describe('DrawingGateway e2e 테스트', () => {
let app: INestApplication;
let redisService: RedisService;
let clientA: Socket;
const URL = 'http://localhost:3001/socket.io/drawing';
const mockConfigService = {
provide: ConfigService,
useValue: {
get: jest.fn().mockImplementation((key: string) => {
if (key === 'REDIS_HOST') return 'localhost';
if (key === 'REDIS_PORT') return '6379';
return null;
}),
},
};
beforeAll(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [DrawingGateway, DrawingService, DrawingRepository, RedisService, mockConfigService],
}).compile();
app = module.createNestApplication();
redisService = module.get<RedisService>(RedisService);
// 테스트용 서버 실행
await app.listen(3001);
});
beforeEach(async () => {
await redisService.hset('room:room1', { roomId: 'room1' });
await redisService.hset('room:room1:player:player1', { playerId: 'player1' });
clientA = io(URL, {
auth: {
roomId: 'room1',
playerId: 'player1',
},
});
await new Promise<void>((resolve) => {
clientA.on('connect', resolve);
});
});
afterEach(async () => {
clientA.close();
await redisService.flushAll();
});
afterAll(async () => {
await app.close();
redisService.quit();
});
describe('handleConnection', () => {
it('roomId 또는 playerId의 값이 존재하지 않는 경우 "Room ID and Player ID are required" 에러가 발생한다.', async () => {
const authInfo = [
{ roomId: '', playerId: '' },
{ roomId: 'room1', playerId: '' },
{ roomId: '', playerId: 'player1' },
];
for (const auth of authInfo) {
const socket = io(URL, {
auth,
});
await new Promise<void>((resolve) => {
socket.on('error', (e) => {
expect(e.code).toBe(4000);
expect(e.message).toBe('Room ID and Player ID are required');
resolve();
});
});
socket.close();
}
});
it('room이 redis 내에 존재하지 않는 경우 "Room not found" 에러가 발생한다.', async () => {
const invalidRoomClient = io(URL, {
auth: {
roomId: 'failed-room',
playerId: 'player1',
},
});
await new Promise<void>((resolve) => {
invalidRoomClient.on('error', (e) => {
expect(e.code).toBe(6005);
expect(e.message).toBe('Room not found');
resolve();
});
});
invalidRoomClient.close();
});
it('player가 redis 내에 존재하지 않는 경우 "Player not found in room" 에러가 발생한다.', async () => {
const invalidPlayerClient = io(URL, {
auth: {
roomId: 'room1',
playerId: 'failed-player',
},
});
await new Promise<void>((resolve) => {
invalidPlayerClient.on('error', (e) => {
expect(e.code).toBe(6006);
expect(e.message).toBe('Player not found in room');
resolve();
});
});
invalidPlayerClient.close();
});
it('room과 player가 정상적으로 존재하는 경우 정상적으로 연결된다.', async () => {
expect(clientA.connected).toBe(true);
});
});
describe('handleDraw', () => {
it('roomId 값이 존재하지 않는 경우 "Room ID is required" 에러가 발생한다.', async () => {
const invalidRoomClient = io(URL, {
auth: {
roomId: 'failed-room',
playerId: 'player1',
},
});
invalidRoomClient.on('connect_error', (e) => {
expect(e.message).toBe('Room ID is required');
invalidRoomClient.close();
});
});
it('정상적으로 그림이 그려지는 경우', async () => {
const drawingData = {
pos: 56,
fillColor: { R: 0, G: 0, B: 0, A: 0 },
};
/**
* client.to(roomId).emit('drawUpdated') 이므로
* 그림 그린 사람을 제외하고 다른 사람이 존재해야 이벤트를 제대로 수신받는지 확인이 가능함
* 이를 위해 clientB를 생성
*/
await redisService.hset('room:room1:player:player2', { playerId: 'player2' });
const clientB = io(URL, {
auth: {
roomId: 'room1',
playerId: 'player2',
},
});
// clientB가 연결이 완료될 때까지 기다림
await new Promise<void>((resolve) => {
clientB.on('connect', resolve);
});
// drawUpdated 이벤트 수신을 위한 Promise 생성
const drawUpdatePromise = new Promise<void>((resolve) => {
clientB.on('drawUpdated', (data) => {
expect(data).toEqual({
playerId: 'player1',
drawingData: drawingData,
});
resolve();
});
});
// clientA가 실제로 이벤트를 발생시킴
clientA.emit('draw', { drawingData });
await drawUpdatePromise;
clientB.close();
});
});
});
Exception Filter Nestjs not working on my websocket gateway
- [BE] 프로젝트 코드 읽기
- [BE] 테스트 용도의 Redis Docker 생성 및 통합 테스트 진행
- [BE] 도커 생성부터 테스트까지 스크립트 하나로 해결해보기
- [BE] e2e 테스트 진행
- [BE] Redis List 삽입 방식 변경
- [FE] Shared Worker 학습
- [FE] Shared Worker 적용(Chat Socket)
- [FE] Shared Worker 적용(Game Socket)
- [FE] Shared Worker 적용(통합 정리)
- [BE] Clova OCR로 캔버스 이미지 검사하기
- [BE] Clova Studio로 단어 간의 연관성 파악하기
- [BE] Redis pub sub을 이용해서 소켓끼리 데이터 공유하기
- [FE] 텍스트가 인식된 영역 안에 선을 제거하기
- 2025.01.06. 팀 빌딩 및 주간 계획 수립
- 2025.01.13. 2주차 주간 계획 수립
- 2025.01.20. 3주차 주간 계획 수립
- 2025.02.03. 전체 및 1주차 주간 계획 수립
- 2025.02.10. 2주차 주간 계획 수립
- 2025.02.17. 3주차 주간 계획 수립
