딱 1년 전 이맘때 부트캠프 파이널 프로젝트를 마친 후 회고를 작성하면서 아쉬운 점으로 "무한스크롤도 써보고 싶었던 ui인데 시간 문제로 도입하지 못했다"고 꼽은 적이 있다. 그리고 그로부터 약 10개월 뒤 무한 스크롤을 원없이 사용하게 된다 😇
지난달 다른 팀원이 맡았던 파트를 인계 받았는데 그 중 메인 feature가 무한 스크롤이었다. 인계 받을 당시 프로젝트의 가장 큰 이슈는 다음과 같았다.
1. 스크롤을 위아래로 이동함에 따라 nextPage와 previousPage를 fetch하고, 서버에서 응답으로 받은 데이터를 UI를 그리는데 사용하는 배열 안에 순서에 맞게 삽입하는 게 때때로 올바르게 동작하지 않고 있었다. 가령 [... 6, 7, 8, ...] 페이지가 렌더링되어야 하는데, [... 7, 7, 8, ...]이 렌더링되어 7페이지 데이터가 중복으로 보이는 식이다.
2. scrollY가 0일 때, 즉 스크롤이 화면(스크롤 가능한 최상위 요소)의 최상단에 닿을 때 이전 페이지를 fetch한다. 한 페이지당 아이템 개수가 20개라고 할 때, 사용자가 스크롤을 위로 올려서 이전 페이지 데이터를 가져오면 새로 가져온 이전 페이지의 마지막 요소 즉 20번째 아이템이 보이고 스크롤을 또 조금 위로 올리면 19번째 아이템이 보이는 걸 기대하는데, 새로 가져온 이전 페이지의 첫번째 아이템으로 화면이 튀는 이슈가 있었다.
두 가지 이슈 모두 기본적으로 이전 페이지를 fetch 하는 데서 발생하는 문제라고 볼 수 있다. 보통 무한 스크롤을 1페이지부터 시작해서 스크롤을 아래로 내리면서 다음 페이지를 보여주는 경우가 많다. 이커머스 플랫폼의 상품 목록 페이지를 생각해보면 사용자가 상품 목록 페이지에 진입했을 때 1페이지부터 상품을 보여준다. 하지만 프로젝트의 요구사항에 따르면 어느 페이지든지 시작하는 페이지가 될 수 있다. 다시 말해 1페이지부터 시작할 수도 있지만, 마지막 페이지부터 시작할 수도 있다. 마지막 페이지부터 시작한다면 스크롤을 위쪽으로 올릴 때 이전 페이지를 요청해야 한다. 중간 페이지부터 시작하는 경우에는 사용자가 스크롤을 위로 올리면 이전 페이지를 요청하고 아래로 내리면 다음 페이지를 요청해야 한다.
결과적으로 총 세 번의 PR을 통해 위의 이슈를 해결했다. 그 과정을 간략하게 정리해 보고자 한다.
1. useInfiniteQuery 및 Intersection Observer API를 사용하여 next, previous page 데이터 요청하기
인계받을 당시 스크롤 감지 방식과 비동기 상태 관리를 다음과 같이 하고 있는 상태였다.
- 스크롤 감지: 스크롤 이벤트
스크롤 위치를 계산하여 스크롤 가능한 요소의 height 대비 현재 스크롤 위치를 0에서 1 사이의 비율로 측정해 해당 비율이 0일 때 previousPage를 요청하고, 0.9일 때 nextPage를 요청한다.
- 비동기 상태 관리:
react-query의 useInfiniteQuery hook을 사용해 이전 페이지면 UI를 그리는 배열의 앞에 데이터를 삽입하고, 다음 페이지면 해당 배열의 뒤에 삽입하기 위해 서버 요청을 스케줄링하는 액션 상태를 직접 선언하여 컨트롤 하는 중에 스크롤 이동에 데이터 패칭과 렌더링이 정상적으로 이루어지지 않는 경우들이 있었다. (초반에 기술한 첫번째 이슈)
이를 해결하기 위해 useInifiteQuery hook이 아닌 useQuery hook을 사용해 직접 pageParam, hasNextPage, hasPrevPage 등을 관리하거나 react-query의 useQuery hook을 사용하지 않고 자체적으로 구현하여 비동기 처리를 위한 상태를 보다 직접적으로 관리하려는 시도를 하고 있는 상태였다.
as-is가 위의 상태였다면 이슈 해결을 위해 to-be에 대한 계획을 다음과 같이 세웠다.
- 스크롤 감지: Intersection Observer(I/O) API 사용
취업 준비를 하면서 개인적으로 매주 기능 구현 챌린지를 하나씩 할 때 스크롤 이벤트 방식과 I/O 방식 두 가지를 각각 사용해 무한 스크롤 UI를 구현한 적이 있었다. 스크롤 이벤트는 브라우저 렌더링 시 reflow가 발생하여 연속 이벤트 실행시 성능이 떨어지기 때문에 as-is 코드에서 이벤트를 일정한 주기(설정한 ms)마다 발생하게 하는 throttling 기법을 적용하여 과다한 이벤트 실행을 방지하고 있었지만, 스크롤 위치를 감지하는 데에서 원인을 파악하지 못하는 버그가 있었다. 가령 다음 페이지를 요청하는 시점을 스크롤 위치의 비율이 0.9일 때 다음 페이지를 요청하려고 하는데 스크롤 위치가 0.9에서 0.9999...로 튀는(?) 식이었다. (해당 버그에 대한 이슈 공유를 문서로 전달 받은 게 아니어서 정확하진 않다.)
스크롤 이벤트 방식이 성능 면에서도 부담스럽고, 컨트롤 하는 데 있어서도 더 까다로운 부분이 많기 때문에 I/O 방식으로 바꿨다. as-is 코드를 작성한 팀원 분도 I/O 방식을 시도했지만 이전 페이지를 요청할 때 현재 페이지가 5페이지라면 4페이지만 요청을 해야 하는데 4페이지, 3페이지, 2페이지, 1페이지를 연속적으로 요청해 이전 페이지를 요청하면 무조건 1페이지까지 연달아 네트워크 요청을 하는 이슈를 겪고 있었다. I/O 방식 때문이 아니라 useEffect 등으로 인해 컴포넌트 렌더링이 의도와 다르게 연속적으로 발생하기 때문이었다. 비동기 상태 관리를 안정적으로 컨트롤할 때 previousPage를 연속 요청하는 문제는 발생하지 않았다.
- 비동기 상태 관리: react-query의 useInifiteQuery hook 사용
프로젝트 전반에서 react-query를 사용해 비동기 상태를 관리하고 있었기 때문에 동일한 방식을 적용하기로 했다. useInifiteQuery는 문자 그대로 무한 스크롤을 위해 만들어진 hook이기 때문에 이전 페이지와 다음 페이지를 요청하고, 서버의 응답 데이터를 컨트롤하는데 필요한 모든 리턴 값을 제공하고 있기 때문에 직접적인 상태 관리를 추가하지 않고도 데이터 패칭, UI 렌더링에 필요한 배열 데이터 관리를 할 수 있었다.
useInfiniteQuery를 호출할 때 사용한 리턴 값 :
isSuccess,
isFetchingNextPage,
hasNextPage,
fetchNextPage,
isFetchingPreviousPage,
hasPreviousPage,
fetchPreviousPage,
이 중 몇 가지만 간략히 설명해 보면,
- isSuccess 플래그 변수
처음에 false였다가 첫번째 요청에 성공해서 데이터를 렌더링할 준비가 완료되면 그 이후부터는 쭉 true이다. 즉 두번째, 세번째 데이터 요청을 한다고 해도 첫번째 요청 후에 true로 변한 플래그가 다시 false로 바뀌지 않는다. 컴포넌트 내에서 useEffect의 의존성 배열에 삽입해 렌더링 이후 동작해야 하는 함수들을 호출하는데 사용하고, 컴포넌트 리턴 부에서 무한 스크롤할 아이템을 렌더링하는 조건부로 사용했다.
- isFetchingNextPage, isFetchingPreviousPage 플래그 변수
useInfiniteQuery의 onSuccess 콜백 옵션에서 서버에서 응답으로 받아온 데이터를 UI를 렌더링하는 배열(위의 코드 예제에서 items에 해당) 요소의 순서에 맞게 삽입하는 일을 한다. 이때 previousPage 데이터인지, nextPage 데이터인지 판별하는 데에 사용했다.
useInfiniteQuery의 리턴 값을 onSuccess 콜백 함수에서 그대로 사용할 수 있다는 점이 특이하다고 생각했다.
- hasNextPage, hasPreviousPage 플래그 변수와 fetchNextPage, fetchPreviousPage 함수
이건 그다지 설명할 필요가 없을 것 같지만.. intersection observer 인스턴스를 생성할 때 첫번째 인자인 콜백 함수(handleIntersectionObserver)에서 hasPreviousPage면 fetchPreviousPage 함수를 호출하고, hasNextPage면 fetchNextPage 함수를 호출하는데 사용했다.
2. previousPage 데이터를 가져올 때 스크롤이 새로 가져온 데이터 중 첫번째 아이템으로 올라가는 이슈 원인 분석
결론부터 말하면 items 배열에 map()을 돌리면서 <Item /> 컴포넌트를 그릴 때 key 속성이 index로 되어 있었기 때문이었다. 이전 페이지 데이터를 가져올 때는 배열의 앞쪽에 요소가 새로 추가되면서 기존 아이템의 index에 변화가 일어나서 리액트가 아이템을 정렬할 때 간섭을 받은 것이었다.
key 속성에 index 대신 item.id를 넣어서 key 속성에 대해 아이템마다 고유한 값을 전달하면 무한 스크롤을 하는 아이템이 아래쪽으로 확장될 때와 마찬가지로 위쪽으로 확장될 때도 브라우저의 기본 속성인 스크롤 앵커링이 정상적으로 작동했다.
이에 대해 조금 더 자세히 설명한 내용은 별도의 포스팅으로 작성한 바 있다.
하지만 이렇게 key 속성에 index 대신 id를 넣었을 때에도 스크롤을 위쪽으로 천천히 올릴 때에는 정상적으로 동작한 반면, 스크롤을 위쪽으로 빨리 올려버리면 기존과 동일한 이슈, 즉 이전 페이지 데이터의 첫번째 아이템으로 화면이 튀는 것 같은 현상이 여전히 남아 있었다.
3. 스크롤을 위쪽으로 빠르게 올릴 때 새로 가져온 이전 페이지 데이터의 첫번째 아이템으로 화면이 튀는 이슈 해결
스크롤을 빠르게 올리든 천천히 올리든 관계 없이 사용자가 보고 있는 아이템을 화면에 유지시키기 위해서 스크롤을 직접 이동시키기로 했다. previousPage를 fetch하기 전 items 배열의 첫번째 요소를 저장해 두었다가 해당 요소로 스크롤을 이동하게 하면 새로 가져온 이전 페이지 데이터의 첫번째 아이템으로 화면이 튀는 것을 방지할 수 있었다.
이를 구현하기 위한 의사 코드는 다음과 같다.
- items 배열이 바뀔 때마다 items의 첫번째 요소의 id를 저장해 둔다. (idOfFirstElement 상태)
- previousPage fetch를 하고 렌더링이 끝나면 저장해둔 요소로 스크롤을 이동하는 함수를 실행한다.
이렇게 총 세 번의 PR을 통해 두 가지 이슈를 모두 해결할 수 있었다. 처음 인계를 받았을 때는 이슈 해결에 얼마만큼의 시간이 걸릴지 산정할 수 없었는데 결과적으로 총 4일 만에 배포까지 했다. 문제를 좁혀 나가면서 차근차근 해결해가면 된다는 자신감을 얻을 수 있는 계기가 되었다.
하지만 기존 이슈를 해결하고 나니 성능 이슈가 새롭게 부각되었다. 🥲 그 결과 위의 코드들을 모두 뒤엎고 list virtualization (또는 windowing) 기법을 적용하게 되었다. 생각보다 포스팅이 길어진 관계로 성능 개선에 대한 이야기는 별도의 포스팅을 작성해 올리도록 하겠다.
📚 참고자료
'돌멩이 하나 > 셀프 크리틱' 카테고리의 다른 글
불필요한 useEffect 없애기 (5) | 2024.06.02 |
---|---|
무한 스크롤과의 사투 (2) - tanstack/react-virtual 적용 (0) | 2024.04.15 |
id는 숫자여야 할까, 문자열이어야 할까? (0) | 2023.12.03 |
다크 모드 - 화면 깜빡임(FOUC) 이슈 해결 및 시스템 설정과 연동 (0) | 2023.06.14 |
[기능 구현 챌린지] Chart Component (1) | 2023.05.22 |