🗓️ 2025. 01. 04
⏱️ 15

NestJS WebSocket 인증

인증... 해야겠지?

개요

양방향 통신이 필요할 때 우린 websocket을 사용합니다. 하지만 순수 websocket만으로는 편의성이 아쉽기 때문에 보통 socket.io를 일종의 websocket 프레임워크로 이용합니다.

요즘 NestJS가 express나 fastify의 wrapper 역할을 하듯이 socket.io 역시 NestJS를 통해 DI(의존성 주입)을 비롯한 여러 프레임워크 기능들을 누릴 수 있습니다.

이번 글에선 NestJS 기반의 socket.io 서버에서 어떻게 사용자 인증을 처리할 수 있는지 소개해드리려 합니다.

  • NestJS와 socket.io의 기초적인 사용법에 대해선 다루지 않습니다.
  • JWT를 기반으로 설명하지만 쿠키 방식의 인증에도 응용 가능합니다.

NestJS v10.4.9를 기준으로 작성된 글입니다.

기초 예제

Echo

글을 시작하기 전에 전반적인 예제의 뼈대가 될 코드를 작성해보겠습니다.
통신 예제는 echo로 시작하지 않으면 불법이기 때문에 echo 기능을 먼저 구현합니다.

우선 NestJS에서 socket.io 서버를 사용하기 위한 패키지를 설치해줍니다.

npm install @nestjs/websockets @nestjs/platform-socket.io

그리고 WebSocketGateway 클래스를 생성해줍니다.
예제에서는 소켓이 'echo'라는 이벤트를 발생시키면, 전달된 데이터를 그대로 소켓에게 반환하고 있습니다.

// events.gateway.ts
import { MessageBody, WebSocketGateway } from '@nestjs/websockets';

@WebSocketGateway({
	transports: ['websocket']
})
export class EventsGateway {
	@SubscribeMessage('echo')
	handleEvent(@MessageBody() data: string) {
		return data;
	}
}

생성된 클래스를 import 해줍니다.

// events.module.ts
import { Module } from '@nestjs/common';
import { EventsGateway } from './events.gateway';

@Module({ providers: [EventsGateway] })
export class EventsModule {}
// app.module.ts
import { Module } from '@nestjs/common';
import { EventsModule } from './ws/events.module';

@Module({
  imports: [EventsModule],
})
export class AppModule {}

다음으로 테스트 목적으로 사용할 클라이언트 예제를 작성해보겠습니다.
우선 socket.io-client 패키지를 설치합니다.

npm install socket.io-client

클라이언트에서 연결이 잘 됐는지, 끊겼는지, 이벤트 송수신은 정상적으로 이뤄지는지 확인하기 위해 이벤트 리스너를 작성해줍니다.

// client.ts
import { io } from 'socket.io-client';

const socket = io('http://localhost:3000', {
  transports: ['websocket'],
});

socket.on('connect', async () => {
  console.log('connected');

  const res = await socket.emitWithAck('echo', 'hello');
  console.log(`서버 응답 데이터 : ${res}`);
});

socket.on('disconnect', () => {
  console.log('disconnected');
});

클라이언트 코드 실행 시 다음과 같이 로그가 출력되면 준비 완료입니다.

$ npx ts-node client.ts

connected
서버 응답 데이터 : hello

Lifecycle hooks

NestJS로 감싸진 socket.io 서버는 http 서버에서 사용되는 Exception filter, Pipe, Guard, Interceptor 등의 기능들을 모두 사용할 수 있습니다. 하나 달라지는 게 있다면 예외를 발생시킬 때 HttpException이 아닌 WsException을 던져야 한다는 것입니다. 때문에 NestJS가 추가적으로 제공하는 Lifecycle hooks라는 기능만 알아보겠습니다.

Lifecycle hooks는 이름 그대로 Gateway의 생명주기와 관련된 이벤트들이며 인터페이스 형태로 제공됩니다.

  • OnGatewayInit
    Gateway가 생성됐을 때 afterInit 메서드가 호출됩니다.
  • OnGatewayConnection
    새로운 소켓이 연결되면 handleConnection 메서드가 호출됩니다.
  • OnGatewayDisconnect
    소켓의 연결이 끊기면 handleDisconnect 메서드가 호출됩니다.

간단한 코드로 살펴보겠습니다.

import {
  MessageBody,
  OnGatewayConnection,
  OnGatewayDisconnect,
  OnGatewayInit,
  SubscribeMessage,
  WebSocketGateway,
} from '@nestjs/websockets';
import { Socket, Server } from 'socket.io';

@WebSocketGateway({
  transports: ['websocket'],
})
export class EventsGateway
  implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect 
{
	afterInit(server: Server) {
		console.log(`gateway initialized`);
	}

	handleConnection(socket: Socket) {
		console.log(`${socket.id} connected`);
	}

	handleDisconnect(socket: Socket) {
		console.log(`${socket.id} disconnected`);
	}

	@SubscribeMessage('echo')
	handleEvent(@MessageBody() data: string) {
		return data;
	}
}

이처럼 afterInit 메서드에서는 socket.io 서버 인스턴스를 제어할 수 있고, handleConnectionhandleDisconnect 메서드에서는 관련된 socket 인스턴스를 제어할 수 있습니다.

여기까지의 기초 지식을 기반으로 인증을 구현해보겠습니다.
불필요한 부분을 생략하기 위해 예제 전체에서 jwt 관련 코드는 단순 비교로 대체했습니다.

인증

우선 서버로 인증 토큰을 전달하기 위해 클라이언트 예제의 소켓 생성부에 토큰을 넣어줍니다.

// client.ts
import { io } from 'socket.io-client';

const socket = io('http://localhost:3000', {
  transports: ['websocket'],
  auth: {
    token: 'sample-access-token', // 여기!
  },
});

Guard

인증 구현에서 가장 먼저 생각나는 방안은 Guard일 것입니다. 결론부터 말하자면 Guard는 이벤트가 발생할 때마다 인증이 필요하다면 유효하지만, 서버와 소켓간 연결 자체에 인증이 필요한 경우 적절하지 않습니다. 무슨 말인지 예제로 확인해보겠습니다.

우선 간단한 Guard 클래스를 작성합니다.

// events.guard.ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';
import { Socket } from 'socket.io';

@Injectable()
export class EventsGuard implements CanActivate {
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    const client: Socket = context.switchToWs().getClient();
    const { token } = client.handshake.auth

    return token === "sample-access-token";
  }
}

이렇게 작성된 Guard는 Gateway 클래스 전체에 적용할 수도 있고, 이벤트 핸들러 메서드에 개별적으로 적용할 수도 있습니다.

// Gateway 전체에 거는 경우
@UseGuards(EventGuard)
@WebSocketGateway({
  transports: ['websocket'],
})
export class EventsGateway {}

// 이벤트 핸들러에 개별 적용하는 경우
@WebSocketGateway({
  transports: ['websocket'],
})
export class EventsGateway {
  @UseGuards(EventsGuard)
  @SubscribeMessage('events')
  handleEvent(@MessageBody() data: string) {
    console.log(`클라이언트 요청 데이터 : ${data}`);
    return data;
  }
}

Guard 방식엔 문제가 한 가지 있는데, Guard를 Gateway 클래스 전체에 적용하더라도 @SubscribeMessage 데코레이터가 붙지 않은 메서드에는 적용되지 않는다는 것입니다.

때문에 Lifecycle hooks인 handleConnection 메서드에는 Guard가 적용되지 않아 소켓과 서버가 연결될 때는 인증이 이뤄지지 않습니다. 더군다나 WebSocket은 의도적으로 polling 방식으로 구현하지 않는 이상, 서버와 클라이언트간 연결 상태를 유지하므로 첫 연결시에만 인증이 정상적으로 이뤄진다면 신뢰 가능한 연결 상태가 유지됩니다. 하지만 Guard의 경우 정작 서버와 소켓이 연결될 때 검증이 이뤄지지 않고, 연결이 된 이후 이벤트 발생시마다 인증이 이뤄집니다.

물론 비즈니스에서 매 이벤트마다 인증이 요구된다면 Guard가 필요하겠지만 우리가 지금 구현하고자 하는 건 서버와 소켓이 '인증을 거쳐 연결'되는 상황입니다.

handleConnection

그럼 자연스레 Guard가 아니라 handleConnection 메서드 안에서 인증을 처리할 수 있겠단 생각으로 이어집니다. 아래와 같이 구현할 수 있습니다.

// events.gateway.ts
import {
  OnGatewayConnection,
  WebSocketGateway,
} from '@nestjs/websockets';
import { Socket } from 'socket.io';

@WebSocketGateway({
  transports: ['websocket'],
})
export class EventsGateway implements OnGatewayConnection {
  handleConnection(socket: Socket) {
	const { token } = socket.handshake.auth;

	if(token !== "sample-access-token") {
		socket.disconnect();
		return;
	}

	console.log(`socket ${socket.id} connected`);
  }
}

예제를 수행해보면 정상적으로 구현된 것처럼 느껴질 수 있지만, 이 역시 우리가 원하는 바는 아닙니다.
여기엔 2가지 문제가 있습니다.

  1. handleConnection은 서버와 소켓이 연결 후에 호출됩니다.
    일단 서버와 소켓을 연결시킨 뒤에 인증을 거쳐 소켓과의 연결을 끊는 방식입니다. 때문에 연결 자체를 거부하고자 하는 우리의 목적엔 부합하지 않습니다.
  2. 1번 문제로 인해 클라이언트 역시 서버의 handleConnection 메서드의 완료 여부와 상관 없이 서버와 연결 상태가 됩니다. 만약 handleConnection에서 수행되는 인증 로직이 n초 소요된다면, 그 n초 동안 클라이언트는 엄연히 서버와 연결된 상태이기 때문에 만약 n초가 되기 이전이라면 얼마든지 서버와 이벤트를 송수신할 수 있습니다. 이는 인증되지 않은 클라이언트와 일시적으로 연결될 위험이 있습니다.

Middleware

이를 해결하려면 socket.io의 Middleware를 이용해야 합니다. Middleware를 등록하려면 socket.io 서버 인스턴스가 필요한데, Lifecycle hooks의 afterInit 메서드나 @WebSocketServer 데코레이터로 인스턴스를 얻을 수 있습니다. 예제에서는 afterInit 메서드를 이용해보겠습니다.

// events.gateway.ts
import {
  OnGatewayInit,
  WebSocketGateway,
} from '@nestjs/websockets';
import { Socket, Server } from 'socket.io';

@WebSocketGateway({
  transports: ['websocket'],
})
export class EventsGateway implements OnGatewayInit {
  afterInit(server: Server) {
    server.use((socket, next) => {
      try {
	    // socket.request.headers.cookie;
	    const { token } = socket.handshake.auth;
	    
		if (token !== 'sample-access-token') {
			throw new Error('Invalid token');
		}
		
		next();
	  } catch (err) {
	    next(err);
	  }
    });
  }
}

Middleware에서는 소켓과의 연결 자체를 다룰 수 있기 때문에 만약 인증에 문제가 있어 에러 객체를 next 함수에 넘겼다면 소켓 인스턴스의 disconnect 메서드를 호출할 필요도 없이 연결이 거부됩니다.

Gateway 클래스에는 비즈니스 로직만 남겨두고 인증 로직을 따로 분리하고 싶다면 다음과 같이 리팩토링할 수 있습니다.

// gateway-auth.service.ts
import { Injectable } from '@nestjs/common';
import { Server } from 'socket.io';

type WsMiddleware = Parameters<Server['use']>[0];

@Injectable()
export class GatewayAuthService {
  auth(): WsMiddleware {
    return (socket, next) => {
      try {
        const { token } = socket.handshake.auth;

        if (token !== 'sample-access-token') {
          throw new Error('Invalid token');
        }

        next();
      } catch (err) {
        next(err);
      }
    };
  }
}
// events.gateway.ts
import {
  OnGatewayInit,
  WebSocketGateway,
} from '@nestjs/websockets';
import { Socket, Server } from 'socket.io';
import { GatewayAuthService } from './gateway-auth.service';

@WebSocketGateway({
  transports: ['websocket'],
})
export class EventsGateway implements OnGatewayInit {
  constructor(private readonly gatewayAuthService: GatewayAuthService) {}

  afterInit(server: Server) {
    server.use(this.gatewayAuthService.auth());
  }
}
// events.module.ts
import { Module } from '@nestjs/common';
import { EventsGateway } from './events.gateway';
import { GatewayAuthService } from './gateway-auth.service';

@Module({ providers: [EventsGateway, GatewayAuthService] })
export class EventsModule {}

거기다 Middleware로 인증을 관리하면 클라이언트측 소켓의 특수 이벤트를 활용할 수 있습니다.
socket.io-client의 소켓에는 connect, disconnect 이벤트뿐만 아니라 connect_error라는 특수 이벤트가 있습니다. 이 이벤트는 Middleware에서 연결이 거부된 경우에만 발생하며, 서버측으로부터 연결 자체가 거부됐기 때문에 재연결을 시도하지 않습니다. 애초에 연결이 거부된 클라이언트이니 재연결을 시도할 필요가 없으니까요.

// client.ts
socket.on('connect_error', (err: Error) => {
	console.log(err.message); // Invalid Token
});

이렇게 우리가 원하는 바를 달성하면서도 공식적인 '연결 실패' 이벤트로 처리되는 것도 마음에 드네요!

응용

Middleware를 조금만 더 응용해보겠습니다.
다음은 유저의 중복 연결 방지와 소켓에 커스텀 데이터를 추가하는 예제입니다.

// gateway-auth.service.ts
@Injectable()
export class GatewayAuthService {
  private readonly clientMap = new Map<string, User>();

  constructor(private readonly authService: AuthService) {}

  auth(): WsMiddleware {
    return async (socket, next) => {
      try {
        const { token } = socket.handshake.auth;
        const user = await this.authService.verify(token);

		// 연결된 클라이언트 관리
        if (this.clientMap.has(user.id)) {
          throw new Error('이미 연결된 유저입니다.');
        }

        this.clientMap.set(user.id, user);

		socket.on('disconnect', () => {
			this.clientMap.delete(user.id);
		});

		// 커스텀 데이터 추가
        socket.data = {
          user,
          connectedAt: new Date(),
        };

        next();
      } catch (err) {
        next(err);
      }
    };
  }
}

번외 - 쿠키의 경우

쿠키를 통해 인증이 이뤄지는 경우에도 크게 다르지 않습니다. Middleware를 사용하는 것은 마찬가지이며, 클라이언트측에서 소켓을 생성할 때 withCredentials 옵션을 주면 됩니다.

const socket = io('http://localhost:3000', {
  transports: ['websocket'],
  credentials: true
});

더 자세한 내용은 Socket.IO 공식 문서를 참고하시기 바랍니다.

참고

돌아가기
© 2025 VERYCOSY.