Ably.io 와 NestJS를 활용한 실시간 채팅

Ably.io에 대한 글을 쓰게된 계기

PCUP(가칭)이라는 sns 서비스를 만들면서 채팅 기능을 구현할 일이 생기게 되었고, 정석적으로라면 socket.ioRedis를 활용하여 채팅을 구현하는 것이 맞겠지만, Redis 서버를 사용함으로서 발생하는 비용면에서 Ably.io를 활용해보는 것이 좋겠다 라는 조언에 삽질을 하게 되었고 아아아악, Ably.io를 쓰시는 다른 분들이 삽질을 하지 않았으면 좋겠어서 이 글을 쓰게 되었다.

주의) 제가 0개 국어라 설명이 외계어처럼 느껴지실 수도 있습니다. :D

Ably.io에 대해

Ably.io채팅, GPS, Multi-User, 그래프나 차트의 실시간 업데이트를 위한 Realtime 서비스이다.
Ably.io의 사이트에 들어가보면 Publish, Subscribe로 구분되어 각각 여러 언어별로 ably 서비스를 사용하는 코드 예시가 나와있습니다. 실시간으로 업데이트를 해야되는 기능을 직접 구현하지 않고, 코드 몇 줄만을 통해 활용장점이 있지만, 이 서비스를 접하게 되면, 위의 코드 예시를 자신이 개발하고 있는 서비스의 어느 부분에 적용시켜야되는지 알기가 힘들다.

전제 조건

채팅 기능 부분의 TypeORM Entity가 Rooms, 그리고 Messages로 구분되어있어야한다.
Entity 코드는 다음과 같다.

  • Messages Entity
  • import { Column, Entity, ManyToOne } from 'typeorm'; import { TypeOrmEntity } from '../common/typeorm.entity'; import { User } from '../users/user.entity'; import { Room } from '../rooms/room.entity'; @Entity() export class Message extends TypeOrmEntity { @Column() message: string; @Column() userId: number; @Column() roomId: string; @ManyToOne(() => Room, (room) => room.messages) room: Room; @ManyToOne(() => User, (user) => user.messages) user: User; }
  • Rooms Entity
  • import { Column, CreateDateColumn, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn, Unique, UpdateDateColumn, } from 'typeorm'; import { Message } from '../messages/message.entity'; import { User } from '../users/user.entity'; @Entity() @Unique(['ownerId', 'joinerId']) export class Room { @PrimaryGeneratedColumn('uuid') // RoomId를 int형으로 사용할 경우, 보안적인 이슈가 발생할 수 있으므로 uuid를 활용합니다. id: string; @CreateDateColumn() createdAt: Date; @UpdateDateColumn() updatedAt: Date; @Column() ownerId: number; @Column() joinerId: number; @OneToMany(() => Message, (message) => message.room) messages: Message[]; @ManyToOne(() => User, (user) => user.rooms) owner: User; @ManyToOne(() => User, (joiner) => joiner) joiner: User; }

Ably.io 코드 예시 방법은 해당 사이트 docs에서 보면 1.1 Basic Authentication에 해당한다.
Basic Authentication의 방법을 사용할 시, 프론트엔드에서 Ably.io에서 발급받은 ABLY_KEY를 도난당할 위험성이 있으므로 JWTABLY_KEY 기반으로 발급받은 토큰을 포함하는 2.3(a) Ably-compatible token is embedded in a External JWT from your server and passed to clients 방식을 사용하여 개발하였다. 코드를 보기 전에 Ably.io JWT Auth 방식에 대해 소개하겠다.

Ably JWT Auth 방식

개발하고 있는 서비스에 로그인 혹은 서비스 내부의 채팅창에 접속할 때, 백엔드에서 ABLY_KEY를 통해 Ably.io에 토큰(ably token)을 요청하게됩니다. (위의 그림에서 1, 2의 단계에 해당)
리턴된 ably token을 payload에 {"x-ably-token" : 리턴된 ably 토큰 값}의 형식으로 담아 JWT 토큰을 발급하여 유저에게 리턴합니다. 유저는 이때 리턴된 토큰 값을 바탕으로 ably platform과 통신합니다.(위의 그림에 순서대로 3, 4, 5 단계에 해당)

백엔드

  • auth.service.ts
import { ConflictException, Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { hash } from 'bcryptjs';
import { User } from 'src/users/user.entity';
import { UsersService } from 'src/users/users.service';
@Injectable()
export class AuthService {
  constructor(
    private readonly usersService: UsersService,
    private readonly jwtService: JwtService,
  ) {}
...... 중략 ......
  async login(userId: number) {
    const user = await this.usersService.findById(userId);
    const Ably = require('ably');
    var ably = new Ably.Rest.Promise({ key: process.env.ABLY_SECRET_KEY });
    const token = await ably.auth.requestToken({
      clientId: user.email,
    });
    const payload = { sub: userId, 'x-ably-token': token.token };
    return { token: this.jwtService.sign(payload) };
  }
  async reAbly() {
    const Ably = require('ably');
    var ably = new Ably.Rest.Promise({ key: process.env.ABLY_SECRET_KEY });
    const token = await ably.auth.requestToken();
    return token;
  }
}
  • auth.controller.ts
@Public()
@Get('/re-create')
  async reCreate() {
    return this.service.reAbly();
  } /// 해당 코드를 Auth Controller안에 추가
  • messages.service.ts
import { Injectable } from '@nestjs/common';
import { TypeOrmService } from '../common/typeorm.service';
import { Message } from './message.entity';
@Injectable()
export class MessagesService extends TypeOrmService(Message) {
 ..... 중략 ......
  async createMessage(userId: number, roomId: string, message: string) {
    this.publishMessage(userId, roomId, message);
    return this.repository.save({ roomId, userId, message });
  }
  publishMessage(userId: number, roomId: string, chat: string) {
    const Ably = require('ably');
    const ably = new Ably.Rest(process.env.ABLY_SECRET_KEY);
    const channel = ably.channels.get(roomId);
    channel.publish(userId.toString(), chat);
  }
}

프론트엔드

  • ChatPage.tsx 혹은 채팅창 컴포넌트 or 페이지
import {
  BackButton,
  BottomFixedView,
  Section,
  TextField,
  TopNavbar,
} from '@entropyparadox/reusable-react';
import React, { useEffect, useState } from 'react';
import { ReactComponent as SendIcon } from '../asset/svg/sendmsg-icon.svg';
import { useParams } from 'react-router-dom';
import { useQuery } from 'react-query';
import { fetcher } from '../plugins/react-query';
import { ChatUser } from '../components/ChatUser';
import { api } from '../plugins/axios';
import { MomentFormat, utcToFormat } from '../plugins/moment';
import { Message } from '../types';
import Ably from 'ably/promises';
import { Types } from 'ably';
import map from 'lodash/map';
export const ChatPage = () => {
  const { id } = useParams<{ id: string }>();
  const [client, setClient] = useState<Ably.Realtime>();
  const [channel, setChannel] = useState<Types.RealtimeChannelPromise>();
  const [chat, setChat] = useState<string>('');
  const [messages, setMessages] = useState<Types.Message[]>([]);
  const [prevMessages, setPrevMessages] = useState<any>([]);
  ...... 중략 ......
  const date = new Date();

  useEffect(() => {
    if (client) return;
    const _client = new Ably.Realtime({
      token: localStorage.getItem('token') ?? '',
      authUrl: `${process.env.REACT_APP_API_URL}auth/re-create`,
      transports: ['web_socket'],
    });
    setClient(_client);
  }, [client]);

  const _initAbly = async () => {
    if (!client || channel || !room) return;
    const _channel = await client.channels.get(room.id);
    const Scroll = require('react-scroll');
    const scroll = Scroll.animateScroll;
    _channel.subscribe('chat', (message) => {
      messages.push(message);
      setMessages([...messages]);
      scroll.scrollToBottom();
    });
    await setChannel(_channel);
  };

  useEffect(() => {
    setPrevMessages(ExpiredMessages);
  }, [prevMessages]);

  useEffect(() => {
    _initAbly();
    return () => channel?.unsubscribe('chat');
  }, [client, channel, room]);
  ...... 중략 ......

Realtime()의 옵션에 대해서 간략하게 설명해보면
token: localStorage.getItem('token') ?? ' '부분은 발급받은 토큰을 불러오고 없을시 ' '로 처리하는 옵션
authUrl: ${process.env.REACT_APP_API_URL}auth/re-create은 ably 토큰이 만료될 시, 재발급 받는 옵션
transports: ['web_socket']ably-transport-preference을 web_socket으로 고정시키는 옵션이다.

위와 같이 코드를 넣으면 Ably token 방식으로 ably 채팅 서비스가 작동하는 것을 확인할 수 있다.

마치면서

Ably.io 라는 리얼타임 서비스를 이번에 PCUP 서비스를 만들면서 처음 써보았는데 한국어로 된 기술 블로그도 없고, 공식 문서가 생략해버린 부분이 꽤 있었지만, 이러한 새로운 서비스를 활용해보는 경험 또한 값진 것 같다. 추후 채팅을 제외한 iot, 혹은 GPS, Live-Update 등을 활용할 기회가 있다면 따로 추가할 예정이다.