PUBLISHED

11. webSocketGateway를 사용한 알림 기능

작성일: 2024.11.11

11. webSocketGateway를 사용한 알림 기능

웹소켓은 클라이언트와 서버 간에 지속적인 연결을 유지함으로써 실시간 양방향 통신을 가능하게 하는 프로토콜이다. 일반적인 HTTP 요청이 요청–응답 단위로 연결을 맺고 끊는 방식이라면, 웹소켓은 한 번 연결을 맺은 뒤 그 연결을 계속 유지하며 데이터를 주고받는다. 이 덕분에 알림, 채팅과 같은 실시간 기능을 구현하기에 적합하다.

알림 기능의 경우 WebSocket보다는 SSE(Server-Sent Events) 가 더 적합하다는 의견도 종종 접했다. 실제로 단방향 알림만 필요하다면 SSE가 구조적으로 더 단순한 선택일 수 있다. 다만 Express.js 환경에서 이미 WebSocket을 다뤄본 경험이 있었고, 그 연장선에서 Nest.js에서도 WebSocket을 사용하기로 결정했다.

Nest.js에서는 Gateway 클래스를 통해 WebSocket 서버를 설정하고 관리한다. @WebSocketGateway 데코레이터를 사용하면 클라이언트와 서버 간의 실시간 통신을 손쉽게 구성할 수 있으며, 클라이언트 연결, 메시지 수신, 연결 해제와 같은 이벤트를 인터페이스 구현을 통해 처리할 수 있다.

WebSocket 서버 설정

@WebSocketGateway 데코레이터는 최대 두 개의 인자를 받는다.

별도의 포트를 열 필요가 없다면, 아래처럼 옵션 객체만 전달해도 된다.

untitled
TS
@WebSocketGateway({
  namespace: 'notification',
  cors: {
    origin: ['http://localhost:3000', 'http://localhost:3001'],
    credentials: true,
  },
})
export class NotificationGateway implements OnGatewayDisconnect {
  @WebSocketServer()
  server: Server;
}

Gateway 클래스는 OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect 같은 인터페이스를 구현할 수 있다. 내 경우에는 연결 해제 시점의 처리가 필요했기 때문에 OnGatewayDisconnect만 구현했다.

기존 HTTP 서버의 연결을 WebSocket으로 업그레이드하고 싶다면, main.ts에서 WebSocket 어댑터를 설정해주면 된다.

untitled
JS
async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.useWebSocketAdapter(new IoAdapter(app));
  await app.listen(3000);
}
bootstrap();

클라이언트 연결 및 연결 해제

프론트엔드와 백엔드 모두 마음대로 정할 수 있는 건 없다. 결국 모든 것은 양쪽의 합의다.
내 경우에는 프론트엔드를 담당하는 ayden과 백엔드를 담당하는 ayden이 긴 회의 끝에 다음과 같은 규칙을 정했다.

untitled
TS
export class NotificationGateway implements OnGatewayDisconnect {
  @WebSocketServer()
  server: Server;

  private clients: Map<string, Socket> = new Map();

  @SubscribeMessage('userConnect')
  handleUserConnect(client: Socket, { userId }: { userId: string }) {
    client.data.userId = userId;
    this.clients.set(userId, client);

    console.log(`userId ${userId} connected`);
    return 'webSocket Connected';
  }

  handleDisconnect(client: Socket) {
    const { userId } = client.data;

    if (userId) {
      this.clients.delete(userId);
      console.log(`userId ${userId} disconnected`);
    }
  }
}

client.data를 활용해 소켓에 사용자 정보를 심어두면, 연결 해제 시점에도 비교적 깔끔하게 정리할 수 있다.

메시지 송신

알림을 전송하기 위해 sendMessage 메서드를 만들었다. 이 메서드는 userId를 기준으로 연결된 소켓을 찾고, 해당 클라이언트에게 알림 메시지를 전송한다.

untitled
TS
export class NotificationGateway implements OnGatewayDisconnect {
  @WebSocketServer()
  server: Server;

  private clients: Map<string, Socket> = new Map();

  sendMessage(userId: string, notification: Notification) {
    const client = this.clients.get(userId);

    if (client) {
      console.log(`emit ${notification.type} to ${userId}`);
      client.emit('notification', notification);
    } else {
      console.log(`Client not connected: ${userId}`);
    }
  }
}

Gateway 역시 Nest.js에서는 프로바이더로 취급된다. 따라서 모듈에서 export 해주면, 다른 모듈에서도 이를 주입받아 사용할 수 있다.

untitled
TS
@Controller('comments')
export class CommentsController {
  constructor(
    private reportService: ReportService,
    private commentService: CommentService,
    private notificationService: NotificationService,
    private notificationGateway: NotificationGateway,
  ) {}

  async sendNotification(parentId, userId) {
    const report = await this.reportService.getReport(parentId);

    if (report.user.id !== userId) {
      const noti = await this.notificationService.createNotification(
        report.user.id,
        {
          senderId: userId,
          reportId: parentId,
          type: 'NEW_COMMENT_ON_REPORT',
        },
      );

      this.notificationGateway.sendMessage(report.user.id, noti);
    }
  }
}

단일 서버 전제와 한계

현재 나는 단 하나의 백엔드 서버만 운영하고 있기 때문에, 소켓 연결 관리를 위해 Map 객체를 사용해도 문제가 없다. 모든 연결 정보가 온메모리에 존재하고, 서버가 하나뿐이니 동기화 이슈도 없다.

하지만 서버를 여러 대로 확장하는 순간 이 방식은 곧바로 한계를 드러낸다. Map은 프로세스 메모리에 존재하므로, 서버 간에 상태를 공유할 수 없기 때문이다.

이런 경우 일반적으로는 Redis를 사용해 소켓 연결 정보를 중앙에서 관리한다. Nest.js 역시 Redis 어댑터를 통해 다중 서버 환경에서의 WebSocket 구성을 지원한다.

다만 지금의 나는 “Redis는 세션 id 관리하는 존나 빠른 DB” 정도의 지식만 가지고 있고, 아직 백엔드 서버를 여러 대로 운영할 계획도 없다. 언젠가 서버를 확장하게 된다면, Redis와 Redis 어댑터를 사용한 WebSocket 구성도 한 번쯤은 제대로 다뤄볼 생각이다. 지금은 여기까지로 충분하다.