토막지식시리즈/React 토막지식

useEffect vs useLayoutEffect

GrapeMilk 2025. 1. 31. 20:55

useScrollRestoration Hook을 구현하면서 발생한 문제 기록 

useScrollRestoration훅은 특정 페이지에서 스크롤 이벤트가 발생할 때마다 scrollY값을 sessionStorage에 저장한다.

무한스크롤로 구현된 목록 페이지에서 상세페이지로 이동한뒤 다시 목록에 돌아왔을 때 scrollY값을 유지함으로써 좋은 탐색 UX를 위해 자주 사용되는 기능이다.

 

이 훅을 구현하면서 발생한 이슈가 있었는데 useEffect vs useLayoutEffect 관점에서 기록해보려고 한다.

 

useScrollRestoration훅 초기 버전

import { useEffect, useLayoutEffect } from 'react'
import { useRouter } from 'next/router'
import { throttle } from 'lodash-es'

const THROTTLE_TIME = 300

export function useScrollRestoration() {
  const router = useRouter()
  const asPath = router.asPath

  const saveScrollHeight = (asPath: string) => {
    sessionStorage.setItem(
      `scrollHeight:${asPath}`,
      window.scrollY.toString(),
    )
  }

  useEffect(() => {
    const scrollHeight = sessionStorage.getItem(`scrollHeight:${asPath}`)

    if (scrollHeight) {
      window.scrollTo(0, parseInt(scrollHeight, 10))
    }
  }, [])

  useEffect(() => {
    const saveScrollPosition = throttle(() => {
      saveScrollHeight(asPath)
    }, THROTTLE_TIME)

    window.addEventListener('scroll', saveScrollPosition)
    return () => {
      window.removeEventListener('scroll', saveScrollPosition)
    }
  }, [])
}

 

훅을 구현하고 테스트해보니 sessionStorage에 저장된 window.scrollY 값이 상세 페이지에 진입할 때 0으로 리셋되는 현상이 있었다. 의심되는 부분은saveScrollHeight를 실행하는 scroll 이벤트 부분. clean up 시점에 콘솔을 찍어보니 window.scrollY가 0으로 찍혔다.

 

console.log를 적용한 코드

  useEffect(() => {
    const saveScrollPosition = throttle(() => {
      saveScrollHeight(asPath)
    }, THROTTLE_TIME)

    window.addEventListener('scroll', saveScrollPosition)
    return () => {
      console.log(window.scrollY, " : debug") // clean up시에 0으로 찍히는 scrollY 값...
      window.removeEventListener('scroll', saveScrollPosition)
    }

 

왜 이런 현상이 발생할까?

아래 사진 처럼 useEffect의 clean up 시점은 Browser Paint가 발생한 뒤이다.

추측컨데 상세페이지로 이동하면서 Brower paint가 다시 발생했고 변경된 페이지의 scrollY는 0부터 시작하므로(왜 0부터 시작하는지는 파악해봐야 한다, next.js의 기본 동작인 것 같다) window.scrollY는 0으로 다시 그려졌을 것이다. 그 시점의 값을 useEffect의 clean up에 정의한 함수가 참조하면서 sessionStorage의 값도 0으로 바꿨을 것.

 

Browser paint 이전에 effect를 실행하는 useLayoutEffect로 변경해서 이슈를 해결했다.

...

export function useScrollRestoration() {
  ...
  useLayoutEffect(() => {
    const saveScrollPosition = throttle(() => {
      saveScrollHeight(asPath)
    }, THROTTLE_TIME)

    window.addEventListener('scroll', saveScrollPosition)
    return () => {
      window.removeEventListener('scroll', saveScrollPosition)
    }
  }, [])
}