AI 도구의 도움으로 헤드리스 반응형 그리드 가상 스크롤 구현하기

가상 스크롤을 구현하자

최근에 여러 카드들을 나열하는 UI를 구현해야 하는 요구사항을 받았습니다.
처음에는 단순히 모든 아이템들에 대해 map 을 사용해 카드 UI를 렌더링했으나, 성능 최적화를 위해 가상 스크롤을 구현하고 싶었습니다.
옛날에 가상 스크롤을 직접 구현했던 적이 있었는데, 생각보다 깔끔하게 구현하지 못해서 이리저리 많이 고생했던 적이 있었습니다. 그래서 이번에는 AI 도구의 도움을 받아 반응형 그리드 가상 스크롤을 구현해봤습니다.

GPT를 사용해보자

처음에는 tanstack virtual 을 이용해서 구현을 했습니다만, 하나의 행에 하나의 아이템 밖에 렌더링할 수 없어서 아쉬웠습니다. 왜냐면 요구사항에서는 하나의 행에 여러개의 카드가 렌더링되어야 했기 때문이였죠.
[그림자료 첨부]
저는 다음과 같은 것들을 원했습니다.
  1. 화면 너비에 따라 열의 수가 유동적으로 변하는 그리드 스타일
  1. 가상 스크롤
  1. 헤드리스
그래서 GPT한테 다음과 같은 질문을 하여 대략적인 코드를 뽑았습니다.
안녕, 반응형 Grid 가상 스크롤 훅을 구현하고 싶어. 일단 생각중인 개요는 다음과 같아.
  1. 해당 훅은 스크롤링 이벤트, 레이아웃 및 위치 계산, 컨테이너 스크롤에 따라 보여져야 할 페이지 정도만 관리하고
  1. 실질적인 렌더링 책임은 훅 사용자에게 있음
이런 느낌으로 구현하고 싶어. 도와줄래?
💡
참고로 헤드리스 개념은 tanstack virtual에 영감을 받았습니다.
import { useEffect, useState, useCallback } from 'react' interface UseVirtualGridProps { itemCount: number containerRef: React.RefObject<HTMLElement> itemHeight: number getColumnCount: () => number overscan?: number } export function useVirtualGrid({ itemCount, containerRef, itemHeight, getColumnCount, overscan = 1, }: UseVirtualGridProps) { const [scrollTop, setScrollTop] = useState(0) const [containerHeight, setContainerHeight] = useState(0) const onScroll = useCallback(() => { if (containerRef.current) { setScrollTop(containerRef.current.scrollTop) } }, [containerRef]) useEffect(() => { const el = containerRef.current if (!el) return const handleResize = () => { setContainerHeight(el.clientHeight) } handleResize() el.addEventListener('scroll', onScroll) window.addEventListener('resize', handleResize) return () => { el.removeEventListener('scroll', onScroll) window.removeEventListener('resize', handleResize) } }, [containerRef, onScroll]) const columnCount = getColumnCount() const rowCount = Math.ceil(itemCount / columnCount) const totalHeight = rowCount * itemHeight const startRow = Math.floor(scrollTop / itemHeight) const visibleRowCount = Math.ceil(containerHeight / itemHeight) const start = Math.max(0, (startRow - overscan) * columnCount) const end = Math.min(itemCount, (startRow + visibleRowCount + overscan) * columnCount) return { visibleRange: { start, end }, totalHeight, scrollTop, } }
생각보다 코드를 꽤 잘 뽑아주네요. 스크롤 이벤트도 관리하고, 리사이즈도 대응이 되어있고, 행과 열을 포함해서 렌더링에 필요한 정보를 계산해주고 있습니다.
이 훅을 사용하는 측에서는 다음과 같이 사용하면 된다고 합니다.
const gridRef = useRef<HTMLDivElement>(null) const itemHeight = 200 const getColumnCount = () => Math.floor(window.innerWidth / 300) || 1 const { visibleRange, totalHeight } = useVirtualGrid({ itemCount: items.length, containerRef: gridRef, itemHeight, getColumnCount, }) return ( <div ref={gridRef} style={{ overflowY: 'auto' }}> <div style={{ height: totalHeight, position: 'relative' }}> {items.slice(visibleRange.start, visibleRange.end).map((item, idx) => { const index = visibleRange.start + idx const row = Math.floor(index / getColumnCount()) const col = index % getColumnCount() return ( <div key={item.id} style={{ position: 'absolute', top: row * itemHeight, left: `${(100 / getColumnCount()) * col}%`, width: `${100 / getColumnCount()}%`, height: itemHeight, }} > {item.content} </div> ) })} </div> </div> )
의도한 대로 코드가 잘 뽑혔습니다. 다만 몇가지 보완해야 할 점이 보이네요.
getColumnCount 함수를 인자로 받는게 아니라, containerRef 와 그리드 요소 아이템의 너비와 높이, 그리고 gap 값을 받아서 훅 내부에서 계산하게 할 수 있어?
import { useEffect, useState, useCallback } from 'react' interface UseVirtualGridProps { itemCount: number containerRef: React.RefObject<HTMLElement> itemWidth: number itemHeight: number gap: number overscan?: number } export function useVirtualGrid({ itemCount, containerRef, itemWidth, itemHeight, gap, overscan = 1, }: UseVirtualGridProps) { // ... // column 개수 계산 const columnCount = Math.max( 1, Math.floor((containerSize.width + gap) / (itemWidth + gap)), ) const rowCount = Math.ceil(itemCount / columnCount) const totalHeight = rowCount * itemHeight + (rowCount - 1) * gap const startRow = Math.floor(scrollTop / (itemHeight + gap)) const visibleRowCount = Math.ceil(containerSize.height / (itemHeight + gap)) const start = Math.max(0, (startRow - overscan) * columnCount) const end = Math.min(itemCount, (startRow + visibleRowCount + overscan) * columnCount) return { visibleRange: { start, end }, totalHeight, columnCount, scrollTop, } }
getColumnCount 를 받아서 호출하던 기존 방식에서, 레이아웃을 기반으로 열의 수를 직접 계산하도록 바꿨습니다.
{items.slice(visibleRange.start, visibleRange.end).map((item, idx) => { const index = visibleRange.start + idx const row = Math.floor(index / columnCount) const col = index % columnCount return ( <div key={item.id} style={{ position: 'absolute', top: row * (itemHeight + gap), left: col * (itemWidth + gap), width: itemWidth, height: itemHeight, }} > {item.content} </div> ) })}
그리고 훅을 사용하는 쪽에서는 이렇게 top, left 를 계산헤서 렌더링을 하면 됩니다.

다만 아직도 몇가지 보완해야 할 점이 있는데요, 첫번째로 성능 향상을 위해 topleft 대신 transform 을 이용해서 위치를 조절해줘야 합니다.
style={{ position: "absolute", top: 0, left: 0, width: itemWidth, height: itemHeight, transform: `translate(${col * (itemWidth + gap)}px, ${row * (itemHeight + gap)}px)`, }}
위 코드와 같이 요렇게 간단하게 수정을 완료했습니다.
두번째로, 첫 렌더링 시에는 레이아웃을 제대로 계산하지 못하는 단점이 있었습니다. 아무래도 훅이 호출되는 시점에는 containerRef 에 DOM 요소가 제대로 할당되지 않아서 그런 것 같았습니다.
const [mounted, setMounted] = useState(false); const intervalIdRef = useRef<number>(0); useEffect(() => { intervalIdRef.current = setInterval(() => { if (containerRef.current) { setMounted(true); clearInterval(intervalIdRef.current); } }, 0); return () => { clearInterval(intervalIdRef.current); }; }, [containerRef]);
간단한 상태를 하나 추가하여 문제를 해결하긴 했습니다만, 더 좋은 방법은 없을지 고민이네요. 🤔

이렇게 해서 간단하게 헤드리스 반응형 그리드 가상 스크롤을 구현해봤습니다. 이게 바로 바이브 코딩일까요.
사실 아직도 더 개선할 부분은 남아있습니다. 원래 제가 구상했던 것은 아이템 컨테이너에 grid 스타일을 적용한 “진짜 그리드 스타일” 가상 스크롤이였거든요.
글을 쓰는 지금 간단하게 머릿속으로 생각해보면, 각 아이템마다 행과 열의 위치만 잘 잡아주면 될 것 같긴 합니다. 다만 이렇게 수정하게 되면 현재 화면의 breakpoint를 JS 차원에서 관찰을 하고, 이에 대응하는 열의 수를 훅에다가 주입해줘야 한다는 점이 조금.. 걸리네요.
뭐 여튼 이정도만 해도 충분히 요구사항은 만족하기 때문에 이쯤에서 구현은 마무리를 했습니다.
개발을 할 때 AI 도구의 도움을 종종 받는데요, 이번에도 도구를 활용해보니 개발 시간을 엄청나게 단축했습니다. ‘AI 도구 활용 능력도 중요하다’는 말이 더욱 공감이 되었던 경험이었네요.