단로그
Performance

X의 피드 성능과 스크롤 복원 따라잡기 (1)

X의 피드는 스크롤을 아무리 내려도 성능이 뛰어나고, 상세 피드를 보고 돌아와도 스크롤 위치가 바로 복원됩니다. 어떤 원리인지 예제와 함께 알아봅니다.

예제 코드는 Claude Code로 작성하여 stackblitz에서 확인할 수 있습니다.

X.com을 둘러보다보니 피드를 수천개를 보아도 성능이 뛰어나고, 돌아와도 스크롤 위치가 부드럽게 복원되는 것을 보았습니다. 여기서 궁금한 포인트는 3가지가 있습니다.

  1. 수만개의 피드를 보여도 뛰어난 피드의 성능
  2. 돌아와도 스크롤 위치가 부드럽게 복원되는 이유
  3. 피드의 높이가 모두 다른데 스크롤 위치가 정확히 복원되는 방법

직접 X의 HTML을 살펴보며 알아보도록 하겠습니다.



1. X 피드의 구조 이해

X.com 피드의 HTML 일부
<div style="position: relative; min-height: 84216.5px;">
  <div
    class="css-175oi2r"
    data-testid="cellInnerDiv"
    style="transform: translateY(66089px); position: absolute; width: 100%;"
  >
    <div class="css-175oi2r r-1adg3ll r-1ny4l3l">
      <div class="css-175oi2r">
        <article aria-labelledby="id__vxxp79pzhq id__xnblhwm42u id__jqojv72ezo id__d8
// ... 이후 내용 생략 ...

스크롤을 많이 내린 뒤 HTML을 보면 세 가지가 보입니다.

  1. 바깥 div에 min-height: 84216.5px
  2. 각 트윗은 transform: translateY(...) 로 위치가 잡혀 있음
  3. 이미 수백개의 피드를 보았지만, 실제 DOM에는 그 중 일부만 있음

min-height로 스크롤 영역을 만들고, transform으로 피드의 위치를 배치하고 있습니다.




2. 실제 피드 구현

이제 구현 내용을 살펴보며, 실제 코드 예시로 알아보도록 하겠습니다.

1. 무한 스크롤

서버 상태 동기화를 위해 tanstack/react-query를 사용하여 무한 스크롤을 구현합니다.

이후에 스크롤을 복원할 때, 이미 불러온 데이터를 보여주는 역할도 합니다.

피드 무한 스크롤 쿼리
export function useFeedQuery() {
  const query = useInfiniteQuery({
    queryKey: ['feed'],
    queryFn: ({ pageParam }) => fetchPage(pageParam),
    initialPageParam: 0,
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
  });
 
  const posts: Post[] = query.data?.pages.flatMap((p) => p.posts) ?? [];
 
  return {
    posts,
    fetchNextPage: query.fetchNextPage,
    hasNextPage: query.hasNextPage,
    isFetchingNextPage: query.isFetchingNextPage,
  };
}



2. 피드 가상화

이번에는 피드의 가상화 구현 방식을 살펴봅니다.

피드 가상화에는 tanstack/react-virtualuseVirtualizer 훅을 사용합니다. 여기서 핵심은 스크롤이 이루어지는 영역을 getScrollElement 옵션을 통해 전달하고, 가상화된 전체 영역의 높이를 minHeight: virtualizer.getTotalSize()로 설정하여 스크롤이 가능한 범위를 만들어준다는 점입니다. 앞서 설명했던 것처럼 실제로 화면에 그려지는 피드는 일부이지만, 스크롤 영역은 전체 피드 길이에 맞춰 정의됩니다.

가상화된 아이템들은 virtualizer.getVirtualItems()로 가져오고, 각각의 피드 요소는 ref={virtualizer.measureElement}를 사용해 개별 높이를 측정합니다. 그리고 각 피드의 실제 위치는 style={{ transform: 'translateY(${item.start}px)' }}처럼 transform 속성을 이용해서 정해집니다.

이러한 방식으로 불필요한 DOM 노드를 줄이면서도 자연스러운 스크롤 경험을 제공합니다.

피드 가상화 구현
export function Feed() {
  ...
  const parentRef = useRef<HTMLDivElement>(null)
 
  const { posts, fetchNextPage, hasNextPage, isFetchingNextPage } = useFeedQuery()
  ...
  const virtualizer = useVirtualizer({
    count: posts.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 140,
    overscan: 6,
    ...
  })
 
  const items = virtualizer.getVirtualItems()
  ...
 
  return (
    <div ref={parentRef} className="feed-scroller">
      ...
      <div className="feed-inner" style={{ minHeight: virtualizer.getTotalSize() }}>
        {items.map((item) => {
          const post = posts[item.index]
          return (
            <div
              key={item.key}
              data-index={item.index}
              ref={virtualizer.measureElement}
              className="feed-row"
              style={{ transform: `translateY(${item.start}px)` }}
            >
              <PostCard post={post} onOpen={() => navigate(`/post/${post.id}`)} />
            </div>
          )
        })}
      </div>
      {hasNextPage && <div className="feed-loading">Loading more…</div>}
    </div>
  )
}



3. 스크롤 복원

좋은 사용자 경험을 위해서는 피드를 보다가 글 하나를 눌러 상세 페이지로 갔다가 돌아왔을 때, 그 위치가 그대로 복원되어야 합니다.

여기서 가장 먼저 떠올리기 쉬운 방법은 스크롤 픽셀 값(scrollTop)을 그대로 저장했다가 복원하는 것입니다.

하지만 가상화된 피드에서는 이 방법이 통하지 않습니다. 복원 직후 화면의 글들이 measureElement로 다시 측정되면서 위쪽 글들의 높이가 바뀌면, 같은 픽셀 위치라도 그 자리에 있던 글이 달라지기 때문입니다.

그래서 픽셀이 아니라 “어떤 글을 보고 있었는지(index)”“그 글 안으로 얼마나 스크롤했는지(delta)” 를 저장합니다. 이 앵커 정보를 이용해서 위쪽 글들의 높이가 재측정으로 조금씩 바뀌더라도 항상 같은 자리에 다시 고정할 수 있습니다.

스크롤 복원 상태 인터페이스
interface FeedScrollState {
  index: number;
  delta: number;
  measurements?: VirtualItem[];
}

저장은 Feed의 unmount 시점에 한 번만 실행합니다. 이때 virtualizer.getVirtualItems()로 현재 화면에 그려진 항목 중, 뷰포트 최상단에 걸쳐 있는 항목을 앵커로 고릅니다.

스크롤 복원 저장
// 캐시로 사용될 유니크 키
const FEED_KEY = 'home-feed';
...
  useEffect(() => {
    return () => {
      const offset = virtualizer.scrollOffset ?? 0
      const items = virtualizer.getVirtualItems()
      const anchor = items.find((it) => it.end > offset) ?? items[0]
      saveFeedScroll(FEED_KEY, {
        index: anchor?.index ?? 0,
        delta: anchor ? offset - anchor.start : 0,
        measurements: virtualizer.measurementsCache,
      })
    }
  }, [virtualizer])
...

anchor : 즉 화면 맨 위에 걸쳐 보이는 글의 지점

delta는 그 글의 시작점에서 얼마나 더 내려갔는지를 의미합니다.

measurements : 측정값을 저장해두고, 복원 시에 useVirtualizer에서 사용합니다.

복원은 두 단계로 이루어집니다.

① 측정 캐시를 다시 적용하여, 첫 렌더부터 전체 스크롤 높이가 이전과 같도록 만듭니다.

캐시된 값이 없으면 estimateSize: 140으로 측정되어 피드의 위치가 변경될 수 있습니다.

캐시 적용
const virtualizer = useVirtualizer({
  count: posts.length,
  getScrollElement: () => parentRef.current,
  estimateSize: () => 140,
  overscan: 6,
  initialMeasurementsCache: saved?.measurements,
});

② 저장된 앵커 위치로 스크롤을 다시 맞춥니다. 복원 직후에도 화면에 보이는 글들이 재측정되며 위치가 미세하게 흔들릴 수 있어서, 몇 프레임에 걸쳐 반복 적용합니다.

useLayoutEffect(() => {
  if (!saved) return;
  let raf = 0;
  let tries = 0;
  const pin = () => {
    const m = virtualizer.measurementsCache[saved.index];
    if (m) virtualizer.scrollToOffset(m.start + saved.delta);
    if (++tries < 6) raf = requestAnimationFrame(pin);
  };
  pin();
  return () => cancelAnimationFrame(raf);
}, []);

measurementsCache[saved.index]로 앵커 글의 현재 시작 위치(start)를 구하고, 여기에 저장해둔 delta를 더해 스크롤 위치를 맞춥니다. requestAnimationFrame으로 6프레임 동안 반복 호출하는 이유는 한 번의 호출로는 위치가 고정되지 않기 때문입니다.


3. 마무리

실제 예제로 살펴보니 min-heighttransform의 사용이 더 눈에 들어오는 것 같습니다.

2편에서는 이번 편에서 다루지 않은 Masonry 피드의 스크롤 복원 방법을 알아보겠습니다.

감사합니다.