VirtualizedList(무한스크롤 리스트) 리팩토링 개발 이야기
‘페이지 재방문 시 스크롤 복귀’ 오늘의집은 어떻게 해결했을까?
2024년 9월 11일Ressi

안녕하세요, 저는 오늘의집 Software Engineer 렛시입니다. 지난해(2023년) 상반기에 개발했던 VirtualizedList 컴포넌트의 리팩토링 후기를 적어보려 해요. :)

VirtualizedList 란?

VirtualizedList(가상 리스트)란 무엇인지를 먼저 알아봅시다.

VirtualizedList(가상 리스트)는 나열된 리스트의 아이템 중 화면에 실제로 보이는 아이템만 렌더링하는 기술입니다. 위의 영상처럼 유저가 스크롤을 내리면 화면에 보이는 아이템의 범위가 바뀌고, 그에 따라 렌더링되는 아이템도 바뀌게 됩니다.


위 그림에서 Item 1, 2, 99, 100은 화면을 벗어난 곳에 위치해 있기에 현재 시점에는 렌더링 되지 않지만, 유저가 스크롤을 내리거나 올려 위치를 변경하면 렌더링 될 수 있습니다. 이로 인해 유저는 마치 리스트가 처음부터 끝까지 차례로 구성된 것처럼 느끼게 되는데요. 바로 이 점 때문에 Virtualized List(가상 리스트)라는 이름이 붙게 되었어요. 화면에 없는 아이템을 실제로 존재하는 것처럼 생각하게 만드는 것이죠.

가상 리스트는 현재 가지고 있는 아이템의 끝에 다다르게 되면, 서버에서 다시 데이터를 받아오면서 계속 스크롤을 내릴 수 있도록 구현되는데요. 이 모습이 마치 무한히 리스트가 있는 것처럼 느껴지기에 Infinite Scrolling이라 불리기도 해요. 가상 리스트와 함께 자주 쓰이는 개념이랍니다.


기존 가상 리스트를 바꾸게 된 배경

그렇다면 오늘의집이 왜 기존에 사용하던 가상 리스트 컴포넌트를 바꿔야 했는지 말씀드릴게요.

기술 블로그의 초창기 글에도 언급했듯이, 당시의 가상리스트 라이브러리들은 ‘반응형으로 한 row(행)에 보여 줄 아이템의 개수를 변경하지 못하는 문제’가 있어서 모든 기능을 직접 구현하여 해당 컴포넌트를 만들었습니다. 하지만 해당 컴포넌트의 문서화가 제대로 되어 있지 않고 코드가 길다 보니 유지보수에 어려움을 겪곤 했어요.

현재는 관련 라이브러리들이 꾸준히 업데이트 되면서 이전에 우려했던 문제가 해결되었고, 그 결과 복잡한 코드를 라이브러리로 일부 대체할 수 있겠다는 판단을 하게 되었습니다. 그래서 기본적인 기능은 가상 리스트 라이브러리를 이용하고, 부가적으로 필요한 기능들은 wrapping하여 커버하는 몽키 패칭 기법으로 리팩토링을 진행하게 되었습니다.


라이브러리 선택

개발 당시의 ▲다운로드 수, ▲업데이트 시점, ▲원하는 기능을 커버할 수 있는 정도의 3가지 조건을 고려해서 저희는 virtuoso 라이브러리를 선택했습니다.

▲ 데이터 출처: <strong>npm trends</strong>
▲ 데이터 출처: npm trends


다만 현재 글 작성 시점에서 npm 라이브러리 비교 사이트인 npm trends를 통해 비교해 보니, virtuoso 대비 tanstack의 virtual-core의 점유율이 급상승한 것을 확인할 수 있었습니다.

tanstack-query 의 사용이 많아지면서 덩달아 점유율이 올라간 것으로 보이는데요. 역시 FE 업계에서 기술 선호는 빠르게 바뀌는 것 같습니다.🧐


페이지 재방문 시 스크롤 복귀

대부분의 기능은 virtuoso로 구현할 수 있었는데요. 개발 과정에서 가장 어려웠던 기능인 ‘페이지 재방문 시 스크롤 복귀’에 대해 자세히 설명해 보겠습니다.

스크롤 복귀 위치를 저장하는 방식

먼저 유저가 페이지에 진입했을 때 1) 스크롤 복귀가 필요한지, 2) 저장된 스크롤 위치 정보가 현재 페이지와 같은지를 판단하기 위해 같은 페이지인지 구분하는 uniqueId 를 생성해야 합니다. 여기서 언급한 ‘같은 페이지’의 기준이 조금 모호해서 보다 명확히 하기 위해 몇 가지 예를 들어볼게요.

  1. 가상 리스트를 사용하는 A 페이지에서 스크롤을 내린 후, B 페이지로 이동한 다음 뒤로 가기 버튼을 눌러서 A 페이지로 돌아왔을 때 (결과: A 페이지에서 보던 스크롤 위치로 복귀되어 있어야 함)
  2. 가상 리스트를 사용하는 A 페이지에서 스크롤을 내린 후, 새로고침을 했을 때 (결과: A 페이지에서 보던 스크롤 위치로 복귀되어 있어야 함)
  3. 가상 리스트를 사용하는 A 페이지에서 스크롤을 내린 후, B 페이지로 이동한 다음, 네비게이션의 메뉴를 클릭하여 A 페이지로 돌아왔을 때 (결과: A 페이지의 스크롤은 초기화되어 있어야 함)

결국 ‘새로고침’이나 ‘뒤로 가기’와 같이 A 페이지를 보고 있다가 되돌아왔다는 것이 보장된 상황에서만 ‘저장된 스크롤 위치를 받아와 이동’하는 스크롤 복귀를 수행해야 합니다. 이 기술의 구현은 history API와 연관이 있어요.

1) state 넣기  2) 새로고침 / 뒤로가기 시  3) 페이지 재 진입 시
1) state 넣기 2) 새로고침 / 뒤로가기 시 3) 페이지 재 진입 시

먼저 홈 화면에서 history.pushState 함수를 실행해서 history.state에 특정 값을 세팅해 줍니다. 이후 새로고침하여 history.state 값을 체크해보면, 처음에 세팅한 값이 그대로 남아있는 것을 확인할 수 있습니다. 하지만 다른 페이지로 이동한 후 로고를 클릭해서 다시 홈으로 돌아오면 세팅한 값이 날아가 버립니다.

이러한 history API의 특성을 이용하여 페이지에서 벗어날 때 생성한 uniqueId를 history.state에 저장해두고, 페이지 접근 시 똑같은 ID가 history.state에 있는지 확인하는 방식으로 스크롤 복귀 로직을 구성했습니다.

이제 페이지를 벗어날 때 uniqueId를 이용해 보고 있던 스크롤 위치의 정보를 저장하고, 페이지에 재진입할 때 해당 정보를 다시 받아올 수 있게 되었습니다.


렌더링이 완료된 후 스크롤 이동

그런데 만약 스크롤 할 위치를 알고 있더라도 해당 위치까지 페이지가 렌더링 되어 있지 않다면, 원하는 위치로 이동할 수 없게 됩니다.😵 이는 2가지 방법으로 해결할 수 있습니다.

  1. 큰 padding을 먼저 렌더링하고, 스크롤 복귀 이후 원래 아이템으로 렌더링하는 방식
  2. 스크롤 위치에 도달할 때까지 반복적인 스크롤 이동을 시도하는 방식

1번 방식을 이용하면 스크롤 복귀가 확실하게 이루어지지만, 유저가 잠시동안 아무것도 없는 빈 공간을 볼 수 있고, 또 원래 아이템으로 바꿔 렌더링하는 시점을 찾기가 어렵습니다. 2번 방식을 이용하면 UI가 자연스럽지만, 스크롤 위치가 정확하지 않을 수 있고 무한 루프에 빠질 수 있다는 단점이 존재합니다.

처음에는 1번 방식을 시도했으나, 원래 아이템으로 복귀하는 시점을 찾기가 쉽지 않았습니다. 그래서 2번 방식을 이용하되 반복 횟수에 조건을 걸고 여러 번 스크롤 이동을 시도하는 방식으로 구현했습니다.


스크롤 복귀 위치의 데이터 불러오기

이제 스크롤 위치로 이동할 수 있게 되었지만, 이동한 곳에서 유저에게 아이템이 한참 후에야 뜨는 현상이 발생합니다. 왜 그럴까요? 첫 번째 데이터부터 순차적으로 받아오면서 복귀한 위치에 있는 아이템의 데이터는 위쪽의 데이터가 모두 받아진 후에야 받아오기 때문입니다. 이 문제를 해결하기 위해서는 서버에서 클라이언트에 infinite scroll을 지원하는 방식에 대한 이해가 필요합니다. 크게 2가지 방식으로 나뉘는데요.

  1. pageOffset 방식
  2. cursor 방식

pageOffset 방식은 (page, per) ⇒ items 의 구조를 가지고 있습니다. 원하는 페이지와 페이지별 아이템의 개수를 넘기면 해당 페이지의 아이템들을 내려주는 방식입니다. 클라이언트는 데이터를 1번 페이지부터 불러올 필요 없이 중간 page도 한 번에 요청할 수 있다는 장점이 있습니다.

cursor 방식은 (token, per) ⇒ (items, nextToken) 구조를 가지고 있습니다. 이전에 불러온 데이터의 nextToken 값을 기준으로 다음 데이터를 요청합니다. cursor 방식은 일반적으로 단방향으로 작동하기 때문에 이전 데이터를 불러오지 못하고 다음 데이터만 불러올 수 있습니다. 따라서 클라이언트가 스크롤 복귀 위치의 아이템을 불러올 token 값을 알고 있어도, 스크롤을 위로 올리는 순간 데이터를 불러올 수 없는 문제가 생겨버리죠.

이를 극복하기 위해 양방향 cursor 방식을 도입할 수 있는데요. 바로 (token, per) ⇒ (items, prevToken, nextToken) 구조로 prevToken을 추가하는 방식입니다. 이렇게 구현하면 데이터를 유지보수하기 쉬운 cursor 방식을 사용하면서도, 스크롤 복귀 시 위쪽으로 스크롤을 올려도 데이터를 받아올 수 있어요. 비록 아직 양방향 cursor 방식을 구현하진 않았지만, 이는 향후 개선 과제로 남겨두었습니다.

이렇게 우여곡절 끝에 스크롤 복귀 기능이 완성되었습니다.


기존에 없던 기능 추가

리팩토링을 하면서 아이템과 다른 UI를 중간에 삽입하는 기능도 추가했습니다. 예를 들어 배너와 같은 컴포넌트인데요.

중간에 주입할 item은 InsertedItem의 배열 형태로 받으며, 내부에는 주입할 행을 나타내는 rowIndex 와 주입할 아이템인 component 가 있습니다.


이 배열을 이용해서 VirtualizedList 내부에서 insertedItem이 원하는 rowIndex에 렌더링될 수 있도록 구현하고, 해당 아이템 이후에 위치하는 기존 행들은 rowIndex가 1씩 증가하는 방식으로 구현합니다.

이를 간단히 코드를 표현해 보자면 다음과 같습니다.

추후 개선점

  • 양방향 cursor 방식 도입
  • row의 시작 위치가 상이한 레이아웃 구현
  • 중간에 다른 요소를 행 단위(한 줄 삽입)가 아닌 (행, 열) 단위로 삽입할 수 있도록 구현

위와 같은 기능들은 향후 필요한 기획이 생기면 추가로 구현할 예정입니다.


현재 적용된 범위 / 효과

오늘 설명드렸던 컴포넌트는 현재 오늘의집 내 다음과 같은 페이지에서 사용되고 있습니다.

프로젝트를 진행하며 가장 뿌듯했던 점은 컴포넌트의 기능 설명과 의사결정 히스토리를 개발과 병행하며 꼼꼼히 문서화한 것인데요. VirtualizedList와 관련된 동료들의 수정 제안을 받으며, 이 컴포넌트가 유지보수 가능한 코드로 자리 잡았음을 실감할 수 있었습니다.

또한 이번 리팩토링에서 의미 있었던 부분을 꼽자면, 리스트 중간에 아이템과 다른 UI 요소를 넣는 기능을 추가한 것인데요. 이전에는 컴포넌트 구조를 파악하기 어려워 이 기능을 추가하지 못했으나, 이번 기회를 통해 성공적으로 구현할 수 있었기에 더 나은 서비스 환경을 제공할 수 있게 되었습니다.

오늘의집 프론트엔드 팀은 당연해 보이는 기능들도 유저 관점에서 깊이 고민하며 문제를 해결하기 위해 노력하고 있습니다. 이번 개발 과정에서도 치열한 논의를 거치며 그 중요성을 다시금 실감했는데요. 앞으로도 유저의 진짜 문제를 해결하고, O! Moment를 만들어갈 수 있기를 기대하며 이만 마칩니다. 💪

오늘의집에서 당신을 찾고 있습니다!
Technical Lead & Manager, BackendTechnical Lead & Manager, GrowthTechnical Lead & Manager, AndroidTechnical Lead & Manager, Site Reliability EngineerSenior Technical Program ManagerSenior Software Engineer, BackendSenior Software Engineer, FrontendSenior Software Engineer, Machine LearningSenior Software Engineer, Machine Learning, XRSoftware Engineer, BackendSoftware Engineer, Backend, XRSoftware Engineer, Backend, AdsSoftware Engineer, DataSoftware Engineer, FrontendSoftware Engineer, Frontend, XRSoftware Engineer, AndroidSoftware Engineer, Machine LearningSite Reliability EngineerDatabase AdministratorQA Engineer, Automation
목록으로 돌아가기