메시지 페이징 - boostcamp-2020/Project12-B-Slack-Web GitHub Wiki

메시지 페이징을 생각하게 된 이유

슬랙에는 다양한 채널이 존재합니다. 또 그 안에서 다양한 메시지와 쓰레드를 전달받게 되는데요. 많은 팀원들이 한 채널에서 많은 이야기를 나눈다고 생각했을 때 메시지 페이징을 고려하지 않는다면 초기 로딩 속도가 굉장히 느릴 것입니다. 이러한 점을 고려하여 효율적인 메시지 관리 방법을 도입해야겠다는 생각이 들었습니다.

Back-End에 대한 고민

먼저 메시지 페이징을 진행하면서 팀에서 생각했던 다양한 고민들에 대해서 이야기를 해보려고합니다. 메시지 페이징을 하려면 API를 어떻게 호출을 해야할 지 기준이 있어야 한다고 생각했습니다.

알고 있던 방식 중 게시판에서 paging을 고려했던 경험에 대해서 생각을 해봤습니다. 게시판은 offset으로 페이지 번호를 보내주면 limit에 따라 데이터를 보내주게 됩니다. 하지만 슬랙 서비스에서는 페이지 번호인 1, 2, 3으로 offset을 주어지게 된다면 새롭게 메시지가 생성되었을 때 순서가 꼬일 수 있다는 생각을 하게 되었습니다.

그래서 브레인스토밍을 통해 2가지의 방식을 생각했습니다.

1. 마지막 메시지의 시간을 offset으로 보내자
2. 마지막 메시지의 ID를 offset으로 보내자

첫 번째 방식인 마지막 메시지의 시간을 offset으로 보내는 것에 대해서 생각을 해봤을 때 혹시나 시간에 대해서 겹치게 되는 버그가 발생을 한다면 데이터의 값을 불러오지 못할 수도 있다고 생각했습니다. 또한 Date 타입의 큰 크기를 보내주는 것이 요청에 있어서 좋지 않을 것 같다는 생각을 했습니다.

두 번째 방식으로는 마지막 메시지의 ID를 기준으로 auto increment로 설정해둔 아이디 값을 두었을 때 그 기준으로의 메시지를 보내주면 좋겠다는 생각을 했습니다. 하지만 이렇게 진행을 한다고 했을 때 최초의 메시지를 가져올 때의 기준이 되는 메시지 ID가 없기 때문에 문제가 생기지 않을까라는 생각을 하게 되었습니다.

Back-End에 대한 결론

여러가지 자료조사를 하면서 실제 텔레그램에서는 어떻게 offset에 대한 기준을 정하는 지에 대한 API 문서를 보게 되었습니다.

Pagination in the API

텔레그램에서는 마지막으로 보낸 메시지를 offset_id로 채택하여 사용하고 있었고 결과가 실시간 데이터의 경우에는 offset 값을 전달하지 않는다는 글이 쓰여 있었습니다.

팀에서 생각하기에도 offset_id는 number의 타입을 가지고 있고 auto increment로 고유의 Primary key를 가지기에 메시지를 가져오는 기준에 적합하다는 생각을 하게 되었습니다.

또 최초의 메시지를 가져오기 위해 기준이 되는 메시지가 없을 때에 대한 처리를 생각했을 때 Query를 통해 offset_id 유무를 파악해 분기를 고려하게 되었습니다.

/* 최초의 메시지 생성 */
api/messages/{chatroomId}

/* 다음 메시지 로딩 */
api/messages/{chatroomId}?offset={offset_id}

Front에 대한 고민의 시작

Front에서는 어떻게 상태를 관리하고 Scroll Event를 잘 활용하여 Infinite Scroll에 대한 부분을 구현할 지 생각을 해보게 되었습니다.

1. react에서는 Front scroll event를 어떻게 구현하는 것이 좋을까?
2. 스크롤의 위치는 어떻게 구할 수 있을까?
3. react-redux를 통한 상태 관리는 어떻게 진행되지?
4. 마지막 메시지에서의 요청을 어떻게 관리할까?
5. 채팅방 event에 대한 타입을 관리하면 좋을 것 같은데... 어떻게 관리하면 좋을까?

Front Scroll Event 구현

프론트에서 Scroll을 이벤트로 등록하기 위한 2가지 방식을 사용했습니다.

useEffect(() => {
  window.addEventListener('scroll', getCurrentScroll());
  return () => window.removeEventListener('scroll', getCurrentScroll);
});
<Tag onScroll={getCurrentScroll} />
useEffect와 addEventListener를 통해 scroll에 대한 이벤트를 등록해주었습니다. 하지만 계속적으로 컴포넌트가 렌더링이 되면서 브라우저 도구로 확인했을 때 event가 계속적으로 생기는 모습을 알 수 있었습니다. 따라서 removeEventListener를 통해 등록했던 eventListener를 제거했습니다. 태그에서 정의하는 onScroll Event 또한 같은 방식으로 진행되기에 사용하게 되었습니다.

Scroll 위치 구하기

Infinite Scroll의 구현에 있어서 그리고 Scroll에 좋은 사용자 경험을 만들기 위해서는 2가지의 로직이 필요했습니다.
/* 메시지를 언제 불러오는가? */
e.target.scrolltop <= e.target.scrollHeight / 4

/* 스크롤을 바닥으로 어떻게 움직이는가? */
const { scrollHeight, clientHeight } = El.current;
const maxScrollTop = scrollHeight - clientHeight;
El.current.scrollTop - maxScrollTop > 0 ? maxScrollTop : 0;

먼저, 메시지를 언제 불러올 지에 대한 생각으로 위로 스크롤을 올렸을 때 1/4지점을 넘어서면 메시지를 호출을 하게 되었습니다. 이는 지속적인 데모 발표를 통해서 사용자가 가장 편안한 Scroll을 느끼는 임계값으로 선정하게 되었습니다.

두 번째로 스크롤의 바닥을 구하기 위해서는 scrollTop을 가장 큰 값으로 만들 필요가 있었습니다. 따라서 scrollHeight에서 clientHeight의 값을 뺀 값을 설정해주었습니다.

Chatroom Event Type 관리

메시지를 새로 받아오거나 메시지를 새로 작성하는 등 사용자의 시나리오에 따른 스크롤에 대한 이벤트는 다양했습니다. 따라서 프로젝트에서 이러한 Event Type을 정의하고 따로 관리를 하는 것이 좋다고 판단하게 되었습니다.

export const scrollEventType = {
  COMMON: 'Common',
  LOADING: 'Loading',
  COMPLETELOADING: 'Complete loading',
  INPUTTEXT: 'Input Text'
}
해당 타입을 정의해서 사용함으로 유저의 어떤 상황이 있는지 한눈에 확인이 가능했으며 해당 상황을 useState를 통해 저장하게 되면서 다양한 상황에 대한 처리가 가능했습니다.

느낀 점

이렇게 메시지 페이징에 대한 작업을 진행하면서 실시간 데이터에 대해서 처리하는 방식을 자세히 탐구해볼 수 있어서 굉장히 뜻깊은 경험이었습니다. 아직은 Event에 대한 state를 계속적으로 바꿔주지만 조금은 비효율적인 스크롤이라고 생각합니다. 하지만 쓰레틀링이나 Intersection Observer에 대해서도 더 공부해보고 더 좋은 스크롤에 대한 Best Practice를 찾아나가 개선을 시켜나가야겠다는 생각이 들었습니다.

마치며

메시지 페이징에 대해서 고려해볼 점은 아직도 많다고 생각합니다. 어떻게 하면 더 좋은 방식으로 프로젝트에 맞는 페이징을 할 수 있을까 고민하는 과정에서 조금이나마 성능을 고려한 결과물을 얻어낼 수 있었다고 생각합니다. 프로젝트를 진행하면서 충분한 고민과 생각을 거친 뒤 개발을 진행하는 것의 중요함을 알게 되었습니다. 앞으로도 계속적으로 생각하고 고민하는 개발자가 되어야겠다는 생각을 가지게 해준 경험이었습니다.