[JS] 무한스크롤


무한스크롤 구현

일반적인 데이터 리스트를 보여주는 무한스크롤과는 약간의 차이를 주어 데이터의 처음과 끝에서도 스크롤이 가능하도록 구현이 필요했다.

유튜브 쇼츠, 인스타그램 릴스처럼 스크롤당 게시물 전환을 구현해야 했다. 다만 2개 이상의 데이터만 존재해도 무조건적인 스크롤이 필요했기에 백과 프론트 모두 적용이 불가피했다.

이에 서버에서 받아오는 데이터의 경우 2개 이상의 데이터가 존재할 경우 previous, current, next 의 객체배열이 함께 내려와 무한스크롤처럼 보이도록 했다.

1. 초기 블록 구성

  useEffect(() => {
    if (initialData) {
      const items = [...initialData.previous, initialData.current, ...initialData.next]
      setCurrentItems(items)
      setCurrentIndex(3)
    }
}, [initialData])
  • 동작: Api응답을 7개의 배열로 재구성
  • 배열 구조: [prev2, prev1, prev0, CURRENT, next0, next1, next2]
  • 인덱스: [0, 1, 2, 3, 4, 5, 6]

리스트 상 해당 데이터를 클릭 후 변경된 url에서 id값을 받아와 데이터를 호출하게 되는 흐름을 고려해 초기 데이터 호출 후 최초 7개의 블록을 설정해 가운데(index 3)에 현재의 게시물이 보이도록 설정했다.

모바일 디바이스만을 이용해 접근이 가능한 웹사이트 특성으로 각기 상이한 디바이스의 크기에 맞추어 화면에 꽉찬 렌더링을 위해 아이템의 높이와 위치를 계산해야 했다.
하지만 해당 과정에서 게시물이 먼저 보인 후 계산된 화면에 알맞게 조정되는 과정이 UX를 해쳐 수정이 필요했다.

2. 초기 렌더링 및 위치설정

const updateItemMeasurements = useCallback(() => {
  const heights: number[] = []
  const positions: number[] = []
  let currentPosition = 0

  itemRefs.current.forEach((ref, index) => {
    const height = ref?.offsetHeight || window.innerHeight
    heights[index] = height
    positions[index] = currentPosition
    currentPosition += height
  })

  setItemHeights(heights)
  setItemPositions(positions)
  
  //초기 렌더링 시 애니메이션 제거
  if (isInitialRender && positions[3] !== undefined && containerRef.current) {
    containerRef.current.style.transition = 'none'
    containerRef.current.style.transform = `translateY(-${positions[3]}px)`
    
    requestAnimationFrame(() => {
      if (containerRef.current) {
        containerRef.current.style.transition = 'transform 0.3s ease-out'
      }
      setIsInitialRender(false)
    })
  }
}, [isInitialRender])
  • 동작: 컨테이너를 인덱스3으로 이동(애니메이션X)
  • 목적: 사용자에게 현재 아이템(인덱스3)이 화면에 보이도록 함


3. 사용자 네비게이션

3-1. 다음 아이템 이동

일반적인 스크롤로 인덱스 3 -> 4 -> 5 순으로 이동하는 경우

const navigateNext = useCallback(async () => {
    // ...일반적인 다음 이동
    const newIndex = currentIndex + 1
    setCurrentIndex(newIndex)

    if (containerRef.current && itemPositions[newIndex] !== undefined) {
      containerRef.current.style.transform = `translateY(-${itemPositions[newIndex]}px)`
    }
    // ...
    },[])
  • 동작: currentIndex +1, 해당 위치로 스크롤
  • ex: translateY(-${positions[4]px})

3-1. 프리로딩 트리거

인덱스5 에 도달하면 preloadNextData()를 통해 백그라운드에서 다음 데이터 프리로드를 시작하여 인덱스6 에 도달하기 전에 새 데이터를 준비하도록 한다.

if (newIndex === 5 && !preloadedNextItems && !isPreloadingNext) {
        preloadNextData()
      }

4. 경계 감지(인덱스0,6) 및 데이터 전환

본 스크롤링 구현에 있어 가장 핵심이 되는 로직으로 0, 6인덱스에 도달하게 되면 데이터 전환이 시작되게 한다.

4-1. 인덱스 6 도달

// 현재 인덱스 6이고 다음 데이터가 준비됐다면 전환
if (currentIndex === 6) {
  if (preloadedNextItems) {
    await transitionToNext()
  } else if (!isPreloadingNext) {
    // 데이터 로드 시작하고 완료되면 자동 전환
    preloadNextData()
  }
}

4-2. 다음 데이터로 전환 과정(transitionToNext)

전환 시작과 함께 높이를 계산한다.

setIsTransitioning(true)
const removedItemsHeight = itemHeights.slice(0, 3).reduce((sum, h) => sum + h, 0)
  • 동작: 제거될 앞 3개 아이템(인덱스 0,1,2)의 총 높이 계산

4-3. 새 배열 구성

const itemsToKeep = currentItems.slice(3)      // [3,4,5,6] 유지
const itemsToAdd = preloadedNextItems.slice(4) // 새 데이터의 [4,5,6] 추가
const newItems = [...itemsToKeep, ...itemsToAdd] // 새로운 7개 배열
  • 배열 갱신: [old3, old4, old5, old6, new4, new5, new6]

4-4. 위치 보정 및 최종 이동

제거된 높이만큼 위로 이동해 시각적 점프를 방지하며 새로운 인덱스 4위치로 부드럽게 전환하여 사용자는 현재 보던 아이템의 다음 아이템을 자연스럽게 보게 한다.

// 즉시 위치 보정
container.style.transition = 'none'
container.style.transform = `translateY(${currentY + removedItemsHeight}px)`

// 새로운 인덱스 4(원래 6이었던 아이템의 다음)로 이동
requestAnimationFrame(() => {
  const targetIndex = 4
  container.style.transition = 'transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)'
  container.style.transform = `translateY(-${targetOffset}px)`
  setCurrentIndex(targetIndex)
})

4-5. 인덱스 0 도달

// 현재 인덱스 0이고 이전 데이터가 준비됐다면 전환
if (currentIndex === 0) {
  if (preloadedPrevItems) {
    await transitionToPrevious()
  }
}

4-6. 이전 데이터로 전환 과정(transitionToPrevious)

새로운 데이터 프리로딩을 위해 새로운 배열을 앞쪽에 추가하여 구성한다.

const itemsToKeep = currentItems.slice(0, 4)      // [0,1,2,3] 유지  
const itemsToAdd = preloadedPrevItems.slice(0, 3) // 새 데이터의 [0,1,2] 추가
const newItems = [...itemsToAdd, ...itemsToKeep]  // 새로운 7개 배열
  • 새로운 갱신: [new0, new1, new2, old0, old1, old2, old3]

4-7. 위치 보정 및 최종 이동

// 추가된 높이만큼 아래로 이동하여 시각적 점프 방지
const estimatedNewItemsHeight = window.innerHeight * 3
container.style.transform = `translateY(${currentTransform - estimatedNewItemsHeight}px)`

// 새로운 인덱스 2(원래 0이었던 아이템의 이전)로 이동
const targetIndex = 2
container.style.transform = `translateY(-${targetOffset}px)`
setCurrentIndex(targetIndex)

5. 백그라운드 프리로딩 시스템

인덱스 1 또는 5에 도달하면 데이터가 프리로드 되도록 조건을 설정하고, 현재 마지막 아이템을 기준으로 새로운 데이터 세트를 로드한다.

const preloadNextData = useCallback(async () => {
  const lastItem = currentItems[6]
  const response = await //data 호출
  const newItems = [...response.previous, response.current, ...response.next]
  setPreloadedNextItems(newItems)
})

6. 터치/키보드 인터렉션

앞서 설명한대로 모바일 웹에서만 접근이 가능한 웹의 특성상 터치(스크롤) 제스쳐를 감지하여 동작에 제한을 주지 않으면 게시물을 보기 힘들 정도로 UX가 저하되었다.
이를 개선하기 위해 터치 제스처를 감지하고, 키보드 액션을 추가해주어 동작에 불편함을 제거했다.

6-1. 터치 제스처 감지

const handleTouchEnd = useCallback((e: React.TouchEvent) => {
  const deltaY = endY - startYRef.current
  const velocity = velocityRef.current
  
  const shouldScroll = Math.abs(deltaY) > 50 || Math.abs(velocity) > 0.5
  
  if (shouldScroll) {
    if (deltaY > 0 || velocity > 0.5) {
      navigatePrevious() // 위로 스와이프 = 이전
    } else if (deltaY < 0 || velocity < -0.5) {
      navigateNext()     // 아래로 스와이프 = 다음
    }
  }
})

6-2. 키보드 네비게이션

const handleKeyDown = (e: KeyboardEvent) => {
  if (e.key === 'ArrowUp') {
    navigatePrevious()
  } else if (e.key === 'ArrowDown') {
    navigateNext()
  }
}

댓글남기기