Skip to content

기술공유 04. Apollo와 함께 채팅 성능 최적화하기

Aeree Cho edited this page Dec 21, 2019 · 3 revisions

Dropy 에서 채팅은 트래픽이 많이 발생하는 기능입니다 🥺

Dropy는 발표(ex 컨퍼런스 ) 상황에서 발표자(스피커 )와 청중(리스너 )간 커뮤니케이션을 돕는 어플리케이션입니다. 따라서 채널에 입장했을 때, 백명에 가까운 사용자가 실시간으로 채팅을 하고 토론할 수 있어야 합니다.

이를 수행함에 장애가 없기 위해 Dropy 개발팀이 고민하고 적용한 문제를 다음 세가지로 정리해보겠습니다.

  1. Payload를 최소화하자.
  2. DB에 채팅 로그를 읽고 쓰는 비용을 최소화 하자.
  3. 채팅 렌더링 비용을 최소화 하자.

1. Payload 를 최소화하자 🐹

@1. 간단히 구현 해보기

React 환경에서 채팅을 구현하기 가장 간단하면서, 서로의 싱크를 오류 없이 맞추는 방법은 채팅이 서버로 들어올 때마다 현재의 채팅 로그 전부를 모든 클라이언트에게 전파하는 것입니다.

이 방법을 활용하면, Client 에서는 새로 들어온 채팅을 그대로 props 로 채팅 컴포넌트에 전달해주면 되기에 클라이언트에서 구현도 간단해질 수 있고, 서로의 채팅창 싱크도 항상 맞게될 것입니다.

싱크 오류 없는 간단한 채팅 구현

@2. Payload를 최소화 시켜보자

Payload 를 최소화 시키면 바로 생각나는 방법이 PubSub 방식의 구현입니다. 서버는 단순히 채팅을 전파(Broadcast) 하는 역할만 한다면, 네트워크 상의 Payload 는 최소화 될 것입니다.

그렇다면, 단순히 채팅만 전파 받으면서 서로의 채팅 싱크를 어떻게 맞추고, 채팅 로그를 모두 렌더링 시킬 수 있을까요? 저희는 이를 위해 두가지 원칙을 세웠습니다.

  1. 모든 채팅은 DB에 먼저 기록한다.
    • DB에 기록된 채팅만 Broadcast 된다.
    • DB 기록에 실패한 채팅은 전송한 유저에게 실패 알림을 보낸다.
  2. 채널 입장 시 클라이언트 캐시에 기존 채팅들을 모두 초기화 시키고, 누적시켜 나간다.
    • Apollo Client 의 캐시기능을 활용한다.
    • client.readQuery 가 재 렌더링을 발생시키지 않는 점을 활용한다.
    • client.writeQuery 를 이용해 변경사항을 캐시에 적용하며, 이를 감지해 재 렌더링 시킨다.

Payload 를 최소화 시키자

2. DB에 읽고 쓰는 비용을 최소화 하자 💾

위의 Payload 를 최소화 하는 과정에서 세운 원칙 1번을 보면 싱크를 맞추기 위해, 채팅 전송하기 전에 항상 먼저 DB에 기록하는 과정을 거칩니다.

즉, 채팅의 전송속도를 높이기 위해서 DB에 읽고 쓰는 비용도 줄일 필요가 있다는 것이었습니다. DB 비용을 줄이기 위해 다음의 두가지 측면에서 접근했습니다.

  1. 읽고 쓰는 비용이 적은 DB 사용하기
  2. 비용을 최소화 할 수 있는 DB Schema 설계하기

@1. 읽고쓰는 비용이 적은 DB 사용하기

우선 저희는 DB를 선택하기에 앞서 대표적인 NoSQL, SQL DB 의 성능비교 지표를 찾아보았고 다음과 같은 벤치마크 결과를 찾을 수 있었습니다. (Reference Link 는 글 최하단에 있습니다)

noSQL vs SQL

지표를 통해 MongoDB가 MySqlDB 보다 빠른 속도를 보임을 알 수 있으며, 해당 벤치마크 팀에서도 일반적으로 NoSQL 이 SQL보다 빠르다고 결론 내렸습니다.

따라서 저희팀은 MongoDB가 더 실시간 통신에 더 적합한 DB라고 판단하고 사용했습니다. (현재 사용이 익숙한 DB가 이 두개 뿐이라 두가지 옵션만 우선 고려했습니다)

@2. DB Schema 설계하기

채팅에서 DB 정규화를 통해 중복성을 줄일 시 Join 과정을 거쳐야하는 비용이 존재했습니다. 저희 팀은 채팅은 로그성 데이터이기에 사용자 정보 변화에 즉각적으로 대응할 필요가 없다고 생각했습니다.

/* 중복성을 줄일시 Join 과정을 거쳐야 한다. */
const ChatSchema = new Schema({
  channelId: String,
  userId: String,
  message: String,
  ...
});

const UserSchema = new Schema({
  userId: String,
  displayName: String,
  ...
});

const chat = await Chat.findById(...);
const author = await User.findById(chat.authorId);
const chatPayload = { ...chat, author };
...

따라서 채팅 스키마에 작성자 정보를 모두 넣는 방식으로 Join 비용을 줄였습니다.

/* 다음과 같이 필요한 데이터를 모두 Chat Schema에 지정한다. */
const ChatSchema = new Schema({
  channelId: String,
  userId: String,
  displayName: String,
  message: String,
  ...
});

const UserSchema = new Schema({
  userId: String,
  displayName: String,
  ...
});

const chat = await Chat.findById(...);
const chatPayload = chat.toPayload();

3. 렌더링 비용을 최소화 하자 🖌

VanillaJS 로 작업을 했다면 렌더링 비용을 최소화 하는 것은 조금 더 편했을지 모르지만 저희 팀은 리액트를 사용하고 있었습니다. 따라서 리액트의 내부 코드를 모두 알지 않는 상태에서 공식문서를 참고하고, '이런식으로 동작하지 않을까'라고 추측을 하며 최적화한 부분이라는 것을 미리 말씀드립니다. (벤치마킹을 해볼 시간은 없었습니다)

@1. 리스트의 키값을 Unique한 채팅 ID로 주자

공식문서에 따르면 키값은 해당 DOM 을 접근하는 index 처럼 사용되는 듯합니다. '좋아요, 채팅추가' 등과 같은 데이터에 변화가 생겼을 시 빠른 채팅 DOM 탐색을 위해 index 를 key값으로 사용하지 않고 각 채팅 데이터의 고유한 id값을 key로 부여 했습니다.

{chatLogs.map(({
  id,
  ...,
}) => (
  <ChatLog key={`chat-log-${id}`}>
    <ChatCard {...} />
  </ChatLog>
))}

@2. 렌더링의 횟수 자체를 최소화 하자

리액트에서 render 함수가 불린다는 것이 바로 DOM 조작으로 이어지는 것은 아니지만, 그래도 렌더링의 횟수 자체를 최소화 하는 것이 좋다고 생각했습니다. (아마도) 비교연산을 하고, 계속해서 동적 객체를 할당하는 비용(많은 비용은 아니라고 생각되지만)을 줄이려 했습니다.

따라서, 재 렌더링을 발생시키는 로직들 useState, useQuery, ... 상태관련 로직을 분리시켜서 채팅의 캐시 변화에만 반응하도록 customHook 을 만들고 이를 통해 채팅 리스트를 재 렌더링 시켰습니다.

const ChatCards = (props) => {
  const { logs } = useGetChatsCached();
  
  return (
      <S.Scroller>
        {chatLogs.map(({
          id,
          ...
        }) => (
          <S.ChatLog key={`chat-log-${id}`}>
            <ChatCard {...} />
          </S.ChatLog>
        ))}
      </S.Scroller>
  );
};

@3. 무한 스크롤을 통해 DOM 렌더링 자체를 줄이는 UI를 만들자

이 부분은 3주차 스프린트 때 시간상 아직은 반영되지 않았지만, 추후 반영될 부분입니다. 채팅이 100개, 200개가 쌓이면 브라우저 렌더링 측면에서 overflow 스크롤이 깔끔하게 이동하지 않습니다.

이를 개선하기 위해 채팅을 20개씩만 렌더링 시키고, 아폴로 캐시와 페이징 시스템을 연결 및 설계해서 무한스크롤을 구현하는 방법을 고민하고 있습니다.

Reference

Clone this wiki locally