-
Notifications
You must be signed in to change notification settings - Fork 3
기술공유 04. Apollo와 함께 채팅 성능 최적화하기
Dropy는 발표(ex 컨퍼런스 ) 상황에서 발표자(스피커 )와 청중(리스너 )간 커뮤니케이션을 돕는 어플리케이션입니다. 따라서 채널에 입장했을 때, 백명에 가까운 사용자가 실시간으로 채팅을 하고 토론할 수 있어야 합니다.
이를 수행함에 장애가 없기 위해 Dropy 개발팀이 고민하고 적용한 문제를 다음 세가지로 정리해보겠습니다.
- Payload를 최소화하자.
- DB에 채팅 로그를 읽고 쓰는 비용을 최소화 하자.
- 채팅 렌더링 비용을 최소화 하자.
React 환경에서 채팅을 구현하기 가장 간단하면서, 서로의 싱크를 오류 없이 맞추는 방법은 채팅이 서버로 들어올 때마다 현재의 채팅 로그 전부를 모든 클라이언트에게 전파하는 것입니다.
이 방법을 활용하면, Client 에서는 새로 들어온 채팅을 그대로 props 로 채팅 컴포넌트에 전달해주면 되기에 클라이언트에서 구현도 간단해질 수 있고, 서로의 채팅창 싱크도 항상 맞게될 것입니다.
Payload 를 최소화 시키면 바로 생각나는 방법이 PubSub 방식의 구현입니다. 서버는 단순히 채팅을 전파(Broadcast) 하는 역할만 한다면, 네트워크 상의 Payload 는 최소화 될 것입니다.
그렇다면, 단순히 채팅만 전파 받으면서 서로의 채팅 싱크를 어떻게 맞추고, 채팅 로그를 모두 렌더링 시킬 수 있을까요? 저희는 이를 위해 두가지 원칙을 세웠습니다.
- 모든 채팅은 DB에 먼저 기록한다.
- DB에 기록된 채팅만 Broadcast 된다.
- DB 기록에 실패한 채팅은 전송한 유저에게 실패 알림을 보낸다.
- 채널 입장 시 클라이언트 캐시에 기존 채팅들을 모두 초기화 시키고, 누적시켜 나간다.
- Apollo Client 의 캐시기능을 활용한다.
-
client.readQuery
가 재 렌더링을 발생시키지 않는 점을 활용한다. -
client.writeQuery
를 이용해 변경사항을 캐시에 적용하며, 이를 감지해 재 렌더링 시킨다.
위의 Payload 를 최소화 하는 과정에서 세운 원칙 1번을 보면 싱크를 맞추기 위해, 채팅 전송하기 전에 항상 먼저 DB에 기록하는 과정을 거칩니다.
즉, 채팅의 전송속도를 높이기 위해서 DB에 읽고 쓰는 비용도 줄일 필요가 있다는 것이었습니다. DB 비용을 줄이기 위해 다음의 두가지 측면에서 접근했습니다.
- 읽고 쓰는 비용이 적은 DB 사용하기
- 비용을 최소화 할 수 있는 DB Schema 설계하기
우선 저희는 DB를 선택하기에 앞서 대표적인 NoSQL, SQL DB 의 성능비교 지표를 찾아보았고 다음과 같은 벤치마크 결과를 찾을 수 있었습니다. (Reference Link 는 글 최하단에 있습니다)
지표를 통해 MongoDB가 MySqlDB 보다 빠른 속도를 보임을 알 수 있으며, 해당 벤치마크 팀에서도 일반적으로 NoSQL 이 SQL보다 빠르다고 결론 내렸습니다.
따라서 저희팀은 MongoDB가 더 실시간 통신에 더 적합한 DB라고 판단하고 사용했습니다. (현재 사용이 익숙한 DB가 이 두개 뿐이라 두가지 옵션만 우선 고려했습니다)
채팅에서 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();
VanillaJS 로 작업을 했다면 렌더링 비용을 최소화 하는 것은 조금 더 편했을지 모르지만 저희 팀은 리액트를 사용하고 있었습니다. 따라서 리액트의 내부 코드를 모두 알지 않는 상태에서 공식문서를 참고하고, '이런식으로 동작하지 않을까'라고 추측을 하며 최적화한 부분이라는 것을 미리 말씀드립니다. (벤치마킹을 해볼 시간은 없었습니다)
공식문서에 따르면 키값은 해당 DOM 을 접근하는 index 처럼 사용되는 듯합니다. '좋아요, 채팅추가' 등과 같은 데이터에 변화가 생겼을 시 빠른 채팅 DOM 탐색을 위해 index 를 key값으로 사용하지 않고 각 채팅 데이터의 고유한 id값을 key로 부여 했습니다.
{chatLogs.map(({
id,
...,
}) => (
<ChatLog key={`chat-log-${id}`}>
<ChatCard {...} />
</ChatLog>
))}
리액트에서 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주차 스프린트 때 시간상 아직은 반영되지 않았지만, 추후 반영될 부분입니다. 채팅이 100개, 200개가 쌓이면 브라우저 렌더링 측면에서 overflow 스크롤이 깔끔하게 이동하지 않습니다.
이를 개선하기 위해 채팅을 20개씩만 렌더링 시키고, 아폴로 캐시와 페이징 시스템을 연결 및 설계해서 무한스크롤을 구현하는 방법을 고민하고 있습니다.
© BoostCamp 김김이조.
Members
'김'도현 (happydhKim) | '김'재원 (load0ne) | '이'미림 (always-awake) | '조'애리 (aereeeee)
-
Plans
-
Rules
-
Style Guides
-
Sprint Meeting Logs