Ayden's journal

11. webSocketGateway를 사용한 알림 기능 구현

웹 소켓은 클라이언트와 서버 간에 지속적인 연결을 유지하여 실시간 양방향 통신을 가능하게 하는 프로토콜로, 일반적인 HTTP 요청과는 달리 지속적인 연결을 통해 빠른 데이터 송수신이 가능하다. 알림 기능을 구현하는 데 있어서 webSocket 보다는 SSE가 더 알맞다는 의견도 다수 접했지만, Express.js에서 webSocket을 다뤄본 적이 있다보니 Nest.js에서도 webSocket을 사용하기로 했다.

Nest.js는 gateway 클래스를 사용하여 webSocket 서버를 설정하고 관리한다. @WebSocketGateway는 클라이언트와 서버 사이에 실시간 양방향 통신을 가능하게 해주며, 이를 통해 클라이언트의 연결, 메세지 처리, 연결 종료 등의 다양한 이벤트를 ─ 상속의 형태로 ─ 다룰 수 있게 한다.

 

WebSocket 서버 설정

@WebSocketGateway 데코레이터는 최대 두 개의 인자를 받는데, 첫 번째는 Nest.js와 다른 포트를 사용하고 싶은 경우 포트 번호를 지정해주게 되고, 두 번째에는 gateway를 설정하기 위한 옵션 객체를 받는다. 만약 따로 포트 번호를 지정하지 않아도 된다면 아래와 같이 첫 번째 인자로 옵션 객체를 제공하고 두 번째 인자를 제공하지 않을 수도 있다.

gateway 클래스는 OnGatewayInit, OnGatewayConnection등의 인터페이스를 구현할 수 있는데, 나는 필요에 의해 OnGatewayDisconnect 인터페이스만을 구현하고 있다.

// notification.gateway.ts
@WebSocketGateway({
  namespace: "notification",
  cors: {
    origin: [localhost:3000, localhost:3001],
    credentials: true,
  },
})
export class NotificationGateway implements OnGatewayDisconnect {
  @WebSocketServer()
  server: Server;
}

 

따로 포트를 여는 것이 아니라 기존의 통신을 업그레이드 하고자 하는 경우 main.ts에서 useWebSocketAdapter 메소드를 사용해 기본 어댑터를 설정해주면 된다.

// main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  
  app.useWebSocketAdapter(new IoAdapter(app));
  await app.listen(3000);
}
bootstrap();

 

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

프론트엔드도 그렇지만, 백엔드도 뭐 하나 마음대로 할 수 있는 게 없다. 모든 것은 프론트엔드와 긴요하게 소통하며 정해야 하고, 내 경우에는 프론트엔드를 담당하는 ayden과 백엔드를 담당하는 ayden이 기나긴 회의 끝에 웹소켓 연결 방식을 정했다. 클라이언트에서는 웹소켓에 연결하자마자 'userConnect'라는 이벤트로 유저 정보를 보내면, 백엔드에서는 이를 받아 Map 객체에 소켓 id와 유저 id를 저장한다. 그리고 소켓 연결이 종료되면 백엔드는 Map 객체에서 해당 유저를 지워버리는 것이다.

// notification.gateway.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) {
    // 클라이언트에 심어놓은 유저 정보를 통해 Map 객체에서 유저 삭제
    const { userId } = client.data;

    if (userId) {
      this.clients.delete(userId);
      console.log('userId ' + userId + ' disconnected');
    }
  }
}

 

메세지 송신

sendMessage라는 메소드를 만들어 클라이언트로 메세지를 보낼 수 있도록 했다. 여기에는 소켓 연결을 특정하기 위한 userId와 함께 클라이언트로 보낼 메세지 그 자체인 notification이 포함된다. 클라이언트는 이 메세지를 알아서 가공해서 쓸 것이다.

// notification.gateway.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도 일종의 프로바이더 취급이기 때문에 모듈에서 export 해주면 다른 모듈에서 이를 import하여 사용할 수 있다. 따라서 메세지를 보내주어야 하는 각각의 모듈에서 아래와 같이 sendMessage 메소드를 호출하게 된다.

@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 어댑터를 통해 이를 지원하고 있다. 아쉽게도 나는 아직 백엔드 서버를 추가 운용할 생각이 없다. 더군다나 Redis에 대한 지식이라고는 "세션 id를 관리하는 존나 빠른 DB" 수준에 불과하다.

당장 Redis와 Redis 어댑터를 사용하여 여러 백엔드 서버를 묶어 웹소켓을 구현할 수는 없겠지만, 언젠가는 이를 시도해볼 생각이다.

블로그의 정보

Ayden's journal

Beard Weard Ayden

활동하기