>_

NextJS와 스크롤 위치 복원

·18분 읽기Draft
1 of 6

다룰 내용

  • 브라우저의 기본 기능
  • 소스 코드 분석
  • App Router의 스크롤 위치 복원
  • Pages Router의 스크롤 위치 복원
  • 외부 페이지로 이동할 때, 스크롤 위치를 기억하는 방법
  • TLDR 작성

history.scrollRestoration

브라우저의 History API에 있는 프로퍼티로, 뒤로가기/앞으로가기 시 스크롤 위치를 자동 복원할지 여부를 제어한다.


값은 두 가지

동작
"auto" (기본값)브라우저가 알아서 이전 스크롤 위치로 복원
"manual"브라우저의 자동 복원을 끄고, 개발자가 직접 제어

브라우저의 기본 동작 ("auto")

일반적인 멀티페이지 웹사이트에서는 별도 코드 없이도 이렇게 동작한다:

  1. 페이지 A에서 스크롤을 내림 (ex. 500px)
  2. 링크를 클릭해서 페이지 B로 이동
  3. 뒤로가기를 누름
  4. 브라우저가 페이지 A의 스크롤 위치를 500px로 자동 복원

브라우저가 각 history entry마다 스크롤 위치를 내부적으로 저장해두고, popstate (뒤로가기/앞으로가기) 시 자동으로 적용한다.


SPA에서 문제가 되는 이유

Next.js 같은 SPA는 실제로 페이지 전체를 다시 로드하지 않고 클라이언트에서 DOM을 교체한다. 이 때 브라우저의 "auto" 복원이 타이밍 문제를 일으킨다:

  1. 뒤로가기 발생
  2. 브라우저가 즉시 스크롤 복원을 시도 (500px)
  3. 그런데 아직 콘텐츠가 렌더링되지 않아서 페이지 높이가 부족
  4. 스크롤이 제대로 복원되지 않음

그래서 Next.js를 비롯한 대부분의 SPA 프레임워크는 이렇게 한다:

js
// 브라우저의 자동 복원을 끄고
history.scrollRestoration = "manual";

// 스크롤 위치를 직접 저장/복원하는 로직을 구현

"manual"로 설정하면 브라우저는 스크롤에 손을 대지 않고, 프레임워크가 콘텐츠 렌더링이 끝난 후 적절한 타이밍에 직접 window.scrollTo()로 복원할 수 있게 된다.

소스 코드 분석

Next.js는 App Router와 Pages Router에서 완전히 다른 방식으로 스크롤 복원을 구현한다.

Pages RouterApp Router
history.scrollRestoration"manual" (실험적 플래그 필요)브라우저 기본값 "auto" 유지
스크롤 위치 저장sessionStorage저장하지 않음
뒤로가기 시 복원sessionStorage에서 읽어 window.scrollTo()브라우저 네이티브 복원에 의존
앞으로 이동 시scrollToHash()로 맨 위 또는 해시로 스크롤InnerScrollAndFocusHandler로 변경된 segment의 DOM 노드로 스크롤

App Router의 스크롤 위치 복원

App Router는 history.scrollRestoration건드리지 않는다. 브라우저 기본값인 "auto"를 그대로 둔다.

대신 이중 전략을 쓴다:

  • 앞으로 이동 (Link 클릭): Next.js가 직접 스크롤을 맨 위로 올린다
  • 뒤로/앞으로 이동 (뒤로가기 버튼): 브라우저의 네이티브 복원에 완전히 맡긴다

이 두 흐름을 소스 코드 레벨에서 하나하나 따라가 보자.


핵심 데이터 구조: FocusAndScrollRef

App Router는 스크롤을 직접 DOM API(window.scrollTo)로 바로 실행하지 않는다. 대신, "스크롤해야 한다"는 의도를 상태 객체에 기록하고, React의 렌더링 사이클이 끝난 뒤에 실제 스크롤을 수행한다.

왜 이렇게 하는가? SPA에서는 페이지 이동 시 DOM이 즉시 바뀌지 않는다. React가 새 컴포넌트를 렌더링하고 DOM을 업데이트한 뒤에야 스크롤할 대상이 존재한다. 그래서 "지금 당장 스크롤해"가 아니라, "다음 렌더링이 끝나면 스크롤해"라는 의도를 상태에 남기는 구조가 필요하다.

그 의도를 담는 객체가 FocusAndScrollRef다.

ts
type FocusAndScrollRef = {
  apply: boolean
  hashFragment: string | null
  segmentPaths: FlightSegmentPath[]
  onlyHashChange: boolean
}

네 개의 필드가 있다.

apply 는 마스터 스위치다. 이 값이 true일 때만 스크롤이 실행된다. false면 스크롤 로직 전체가 무시된다. 앞으로 이동하면 true가 되고, 스크롤이 실행된 직후 false로 돌아간다. 뒤로가기할 때는 처음부터 false인 채로 유지된다.

hashFragment 는 URL의 해시 부분을 저장한다. 예를 들어 URL이 /page#section이면, 여기에 "section"이 들어간다. 이 값이 있으면 페이지 맨 위가 아니라 해당 id를 가진 DOM 요소로 스크롤한다. 해시가 없으면 null이다.

segmentPaths 는 스크롤을 처리할 레이아웃 세그먼트의 경로 목록이다. App Router는 중첩 레이아웃 구조를 가지는데, 페이지 이동 시 모든 레이아웃이 아니라 실제로 변경된 세그먼트만 스크롤을 담당해야 한다. 이 배열이 비어있으면 루트 레이아웃이 처리한다.

onlyHashChange 는 같은 페이지 내에서 해시만 바뀌었는지를 나타낸다. /page#a에서 /page#b로 이동하면 true가 된다. 이 값이 true면 CSS의 scroll-behavior: smooth를 끄지 않는다. 같은 페이지 내 앵커 이동은 부드럽게 스크롤하는 게 자연스럽기 때문이다.

이 객체는 AppRouterState의 일부다:

ts
type AppRouterState = {
  tree: FlightRouterState
  cache: CacheNode
  pushRef: PushRef
  focusAndScrollRef: FocusAndScrollRef
  canonicalUrl: string
  // ...
}

AppRouterState는 App Router의 전체 상태를 담는 객체다. 라우트 트리, 캐시, 그리고 우리가 관심 있는 focusAndScrollRef가 여기에 들어있다. 이 상태가 바뀔 때마다 React가 리렌더링하고, 새로운 focusAndScrollRef가 Context를 통해 모든 레이아웃 컴포넌트에 전달된다.


초기 상태

앱이 처음 로드될 때 focusAndScrollRef는 이렇게 초기화된다:

ts
focusAndScrollRef: {
  apply: false,
  onlyHashChange: false,
  hashFragment: null,
  segmentPaths: [],
}

applyfalse다. 첫 페이지 로드(hydration) 시에는 Next.js가 스크롤에 관여하지 않는다는 뜻이다. 브라우저가 알아서 처리하도록 놔둔다.

onlyHashChangefalse다. 첫 로드는 해시 변경이 아니니까.

hashFragmentnull이다. 스크롤할 해시 타겟이 없다.

segmentPaths는 빈 배열이다. 특정 세그먼트를 지정하지 않는다.

이 초기 상태를 기억해두자. 나중에 뒤로가기 흐름에서, 이 apply: false 값이 그대로 유지되는 것이 핵심 메커니즘이 된다. 앞으로 이동이 applytrue로 올렸다가 false로 리셋하고, 뒤로가기는 그 false를 건드리지 않는다 — 이 비대칭이 App Router 스크롤 복원의 전부다.

이제 이 focusAndScrollRef가 실제로 어떻게 변하는지, 두 가지 흐름을 따라가 보자.


사용자가 <Link>를 클릭해서 다른 페이지로 이동하는 흐름이다.

tsx
const routerScroll = scroll ?? true

router[replace ? 'replace' : 'push'](href, {
  scroll: routerScroll,
})

첫 번째 줄을 보자. scroll<Link> 컴포넌트에 넘겨진 prop이다. <Link scroll={false}>처럼 명시적으로 넘기지 않으면 undefined이고, undefined ?? truetrue를 반환한다. 즉, 기본적으로 모든 Link 클릭은 스크롤을 맨 위로 올리겠다는 뜻이다.

두 번째 줄에서 router.push() 또는 router.replace()를 호출한다. replace prop이 있으면 replace, 없으면 push다. 두 번째 인자로 { scroll: true }를 넘긴다.

여기서 scroll: true라는 의도가 시작된다. 이 값은 router.push()dispatchNavigateAction() → 리듀서까지 총 세 단계를 거쳐 전달되어야 한다. 최종 목적지는 focusAndScrollRef.applytrue로 바꾸는 것이다. 지금부터 이 값이 어떻게 흘러가는지 추적해보자.

A-2. router.push()

ts
push: (href, options) => {
  startTransition(() => {
    dispatchNavigateAction(href, 'push', options?.scroll ?? true, null)
  })
}

startTransition은 React 18의 함수다. 이 안에서 발생하는 상태 업데이트는 "긴급하지 않은 전환"으로 처리된다. 사용자 입력 같은 긴급한 업데이트를 차단하지 않으면서 네비게이션을 수행하기 위해 쓴다.

dispatchNavigateAction을 호출하면서 네 개의 인자를 넘긴다. 첫 번째는 이동할 URL, 두 번째는 'push'(히스토리에 새 항목 추가), 세 번째는 스크롤 여부(true), 네 번째는 Link 인스턴스 참조(null)다.

A-3. Navigate 액션 디스패치

ts
function dispatchNavigateAction(href, navigateType, shouldScroll, linkInstanceRef) {
  const url = new URL(addBasePath(href), location.href)

  dispatchAppRouterAction({
    type: ACTION_NAVIGATE,
    url,
    isExternalUrl: isExternalURL(url),
    locationSearch: location.search,
    shouldScroll,
    navigateType,
  })
}

첫 번째 줄에서 new URL()로 전체 URL 객체를 만든다. addBasePath는 Next.js의 basePath 설정이 있으면 경로 앞에 붙여준다. 두 번째 인자 location.href는 상대 경로를 절대 경로로 변환하기 위한 기준 URL이다.

그 다음 dispatchAppRouterAction을 호출해서 액션을 디스패치한다. 이 액션 객체를 하나하나 보자:

  • type: ACTION_NAVIGATE — 이건 "앞으로 이동" 액션이다. 뒤로가기는 ACTION_RESTORE를 쓴다. 이 구분이 나중에 결정적인 차이를 만든다.
  • url — 이동할 URL 객체.
  • isExternalUrl — 외부 사이트 URL인지 여부. 외부면 SPA 네비게이션이 아니라 전체 페이지 이동을 한다.
  • locationSearch — 현재 URL의 쿼리스트링. 이전 URL과 비교하는 데 쓴다.
  • shouldScrollLink에서 시작된 true 값이 여기까지 전달되었다. 이 값이 최종적으로 focusAndScrollRef.apply를 결정한다.
  • navigateType'push' 또는 'replace'.

A-4. Navigate Reducer → completeSoftNavigation()

dispatchAppRouterAction이 호출되면, App Router의 상태 관리 시스템(React의 useReducer와 유사한 구조)이 이 액션을 받아 처리한다. ACTION_NAVIGATE 타입의 액션은 navigate reducer가 담당한다. 이 리듀서가 하는 일은 크게 두 가지다: 새 라우트 트리와 캐시를 계산하는 것, 그리고 우리가 추적하고 있는 focusAndScrollRef를 구성하는 것이다.

여러 내부 함수를 거치지만, 스크롤과 관련된 최종 목적지는 completeSoftNavigation()이다. shouldScroll: true가 여기까지 전달된다. 이 함수가 새로운 focusAndScrollRef를 구성한다.

ts
const onlyHashChange =
  url.pathname === oldUrl.pathname &&
  url.search === oldUrl.search &&
  url.hash !== oldUrl.hash

이 세 줄은 "해시만 바뀌었는지"를 판단한다.

첫 번째 조건: 경로(pathname)가 같은지. /blog에서 /blog로 이동하면 true. 두 번째 조건: 쿼리스트링(search)이 같은지. ?page=1에서 ?page=1이면 true. 세 번째 조건: 해시(hash)가 다른지. #a에서 #b로 바뀌었으면 true.

세 조건이 모두 true면, 같은 페이지에서 해시만 바뀐 것이다. 예를 들어 /blog#intro에서 /blog#conclusion으로의 이동이 이에 해당한다.

ts
const segmentPathsToScrollTo =
  onlyHashChange || !shouldScroll
    ? []
    : scrollableSegments !== null
      ? scrollableSegments
      : oldState.focusAndScrollRef.segmentPaths

스크롤할 대상 세그먼트를 결정하는 로직이다. 삼항 연산자가 중첩되어 있으니 분기별로 보자.

onlyHashChange || !shouldScrolltrue인 경우: 빈 배열 []을 반환한다. 해시만 바뀌었거나, <Link scroll={false}>로 스크롤을 명시적으로 끈 경우다. 빈 배열이면 특정 세그먼트를 지정하지 않으므로, 루트 레이아웃이 스크롤을 담당하게 된다.

그렇지 않고 scrollableSegments !== null인 경우: scrollableSegments를 사용한다. 이 값은 어디서 온 걸까? completeSoftNavigation이 호출되기 전에, startPPRNavigation이라는 함수가 실행된다. 이 함수는 이전 라우트 트리와 새 라우트 트리를 비교해서, 실제로 변경된 세그먼트만 골라낸다.

예를 들어 /blog/a에서 /blog/b로 이동하면, /blog 레이아웃은 그대로고 하위 세그먼트(ab)만 바뀐다. scrollableSegments에는 이 바뀐 하위 세그먼트의 경로만 들어간다. 이렇게 하면 루트 레이아웃이 아니라, 실제로 콘텐츠가 바뀐 세그먼트의 InnerScrollAndFocusHandler만 스크롤을 실행한다.

둘 다 아닌 경우: 이전 상태의 segmentPaths를 그대로 쓴다. 폴백이다.

ts
focusAndScrollRef: {
  apply: shouldScroll
    ? segmentPathsToScrollTo !== null
      ? true
      : oldState.focusAndScrollRef.apply
    : oldState.focusAndScrollRef.apply,

  onlyHashChange,

  hashFragment:
    shouldScroll && url.hash !== ''
      ? decodeURIComponent(url.hash.slice(1))
      : oldState.focusAndScrollRef.hashFragment,

  segmentPaths: segmentPathsToScrollTo,
}

새로운 focusAndScrollRef 객체를 만드는 부분이다. 필드별로 보자.

apply 필드:

바깥 삼항: shouldScrolltrue인가? → true면 안쪽 삼항으로 간다: segmentPathsToScrollTo !== null인가? → segmentPathsToScrollTo는 위에서 빈 배열 []이거나 세그먼트 배열이다. 배열은 null이 아니므로, 이 조건은 항상 true다. → 따라서 applytrue 가 된다.

shouldScrollfalse면? 이전 상태의 apply 값을 그대로 유지한다. 스크롤을 끈 Link라면 기존 스크롤 상태를 건드리지 않는다.

정리하면: shouldScrolltrueapplytrue가 된다. 이것이 스크롤의 트리거다.

onlyHashChange 필드:

위에서 계산한 값을 그대로 넣는다. 해시만 바뀌었으면 true, 아니면 false.

hashFragment 필드:

shouldScrolltrue이고 URL에 해시가 있으면(url.hash !== ''), 해시 값을 디코딩해서 저장한다. url.hash"#section"처럼 #을 포함하므로, .slice(1)#을 제거해서 "section"만 남긴다. decodeURIComponent는 한글이나 특수문자가 인코딩된 경우를 처리한다.

해시가 없거나 shouldScrollfalse면 이전 값을 유지한다.

segmentPaths 필드:

위에서 결정한 segmentPathsToScrollTo를 그대로 넣는다.

이 새로운 상태가 반환되면, React가 리렌더링을 시작한다.

A-5. Context를 통해 레이아웃으로 전달

ts
const globalLayoutRouterContext = useMemo(() => ({
  tree,
  focusAndScrollRef,
  nextUrl,
  previousNextUrl,
}), [tree, focusAndScrollRef, nextUrl, previousNextUrl])

app-router.tsx에서 상태가 변경되면, useMemo로 새 Context 값을 만든다. focusAndScrollRef는 이제 apply: true인 상태다. 이 값이 GlobalLayoutRouterContext.Provider를 통해 하위 모든 레이아웃 컴포넌트에 전달된다.

각 레이아웃 세그먼트에는 ScrollAndFocusHandler가 감싸져 있다:

tsx
function ScrollAndFocusHandler({ segmentPath, children }) {
  const context = useContext(GlobalLayoutRouterContext)

  return (
    <InnerScrollAndFocusHandler
      segmentPath={segmentPath}
      focusAndScrollRef={context.focusAndScrollRef}
    >
      {children}
    </InnerScrollAndFocusHandler>
  )
}

이 컴포넌트는 Context에서 focusAndScrollRef를 꺼내서 InnerScrollAndFocusHandler에 prop으로 넘긴다. segmentPath는 이 레이아웃 세그먼트의 경로다. 예를 들어 /blog/[slug] 레이아웃이면 해당 경로 정보가 들어있다.

정리하면, 데이터 흐름은 이렇다:

  1. completeSoftNavigation()이 새 focusAndScrollRef (apply: true)를 포함한 상태를 반환
  2. app-router.tsx가 이 상태로 리렌더링, GlobalLayoutRouterContext에 새 값을 제공
  3. 각 레이아웃 세그먼트의 ScrollAndFocusHandler가 Context에서 새 값을 읽음
  4. InnerScrollAndFocusHandler가 새 props를 받아 componentDidUpdate 실행

이제 마지막 단계다. InnerScrollAndFocusHandlerapply: true를 보고 실제 DOM 스크롤을 수행한다.

A-6. InnerScrollAndFocusHandler — 실제 스크롤 실행

이 클래스 컴포넌트가 실제로 DOM 스크롤을 수행한다. handlePotentialScroll 메서드를 단계별로 본다.

1단계: apply 확인

ts
handlePotentialScroll = () => {
  const { focusAndScrollRef, segmentPath } = this.props

  if (focusAndScrollRef.apply) {

props에서 focusAndScrollRefsegmentPath를 꺼낸다. focusAndScrollRef.applytrue인지 확인한다. false면 이 메서드는 여기서 끝난다. 아무 스크롤도 하지 않는다.

앞으로 이동에서는 applytrue이므로 안으로 들어간다. 뒤로가기에서는 false이므로 여기서 바로 빠진다. 이게 두 흐름의 갈림길이다.

2단계: 이 세그먼트가 스크롤 대상인지 확인

왜 이 검사가 필요한가? 레이아웃 트리를 생각해보자. /blog/hello 페이지는 이런 구조다:

RootLayout → InnerScrollAndFocusHandler (루트)
  └── BlogLayout → InnerScrollAndFocusHandler (/blog)
        └── PostLayout → InnerScrollAndFocusHandler (/blog/hello)

세 개의 핸들러가 모두 같은 focusAndScrollRef를 보고 있다. 셋 다 스크롤을 실행하면 세 번 스크롤이 일어난다. 그래서 "이 세그먼트가 스크롤을 담당해야 하는가?"를 확인해야 한다.

ts
    if (
      focusAndScrollRef.segmentPaths.length !== 0 &&
      !focusAndScrollRef.segmentPaths.some((scrollRefSegmentPath) =>
        segmentPath.every((segment, index) =>
          matchSegment(segment, scrollRefSegmentPath[index])
        )
      )
    ) {
      return
    }

바깥 조건 focusAndScrollRef.segmentPaths.length !== 0부터 본다. segmentPaths가 빈 배열이면 이 조건이 false이므로 if 블록 전체를 건너뛴다. 빈 배열은 "특정 세그먼트를 지정하지 않음"을 의미하므로, 아무 핸들러나 스크롤을 처리할 수 있다.

segmentPaths에 값이 있으면, .some()으로 이 핸들러의 segmentPath가 목록에 포함되는지 확인한다. .every()matchSegment로 경로의 각 부분을 하나씩 비교한다. 매칭되는 경로가 하나도 없으면(!some(...)true) return해서 빠진다. 이 세그먼트는 스크롤 대상이 아니다.

3단계: 스크롤할 DOM 노드 찾기

ts
    let domNode = null
    const hashFragment = focusAndScrollRef.hashFragment

    if (hashFragment) {
      domNode = getHashFragmentDomNode(hashFragment)
    }

    if (!domNode) {
      domNode = findDOMNode(this)
    }

domNode 변수를 null로 초기화한다.

hashFragment가 있으면(URL에 #section이 있었으면), 먼저 해시로 DOM 노드를 찾는다. getHashFragmentDomNode는 아래에서 설명한다.

해시로 노드를 못 찾았거나 해시가 없으면, findDOMNode(this)를 호출한다. 이건 React의 레거시 API로, 이 클래스 컴포넌트가 렌더링한 DOM 노드를 반환한다. 즉, 이 레이아웃 세그먼트의 루트 DOM 요소를 가져온다.

getHashFragmentDomNode 함수:

ts
function getHashFragmentDomNode(hashFragment: string) {
  if (hashFragment === 'top') {
    return document.body
  }
  return (
    document.getElementById(hashFragment) ??
    document.getElementsByName(hashFragment)[0]
  )
}

hashFragment"top"이면 document.body를 반환한다. #top은 페이지 최상단으로 스크롤하라는 관례적 의미다.

그 외에는 document.getElementById로 해당 id를 가진 요소를 찾는다. 예를 들어 #section이면 <h2 id="section">을 찾는다.

??는 nullish coalescing 연산자다. getElementByIdnull을 반환하면(해당 id가 없으면), document.getElementsByName으로 name 속성을 가진 요소를 찾는다. 이건 <a name="section">같은 오래된 HTML 앵커 패턴을 지원하기 위해서다.

4단계: 유효한 스크롤 타겟 찾기

ts
    if (!(domNode instanceof Element)) {
      return
    }

    while (!(domNode instanceof HTMLElement) || shouldSkipElement(domNode)) {
      if (domNode.nextElementSibling === null) {
        return
      }
      domNode = domNode.nextElementSibling
    }

먼저 domNodeElement 인스턴스인지 확인한다. findDOMNode이 텍스트 노드를 반환할 수도 있고, null일 수도 있다. Element가 아니면 스크롤할 수 없으니 포기한다.

다음은 while 루프다. 두 가지 조건 중 하나라도 해당하면 다음 형제 요소로 넘어간다:

  • !(domNode instanceof HTMLElement): SVG 요소 같은 non-HTML 요소는 스킵한다.
  • shouldSkipElement(domNode): 스크롤 대상으로 부적절한 요소는 스킵한다.

다음 형제 요소가 없으면(nextElementSibling === null) 포기한다. 있으면 그 형제로 이동해서 다시 검사한다.

shouldSkipElement 함수:

ts
function shouldSkipElement(element: HTMLElement) {
  if (['sticky', 'fixed'].includes(getComputedStyle(element).position)) {
    return true
  }
  const rect = element.getBoundingClientRect()
  return rectProperties.every((item) => rect[item] === 0)
}

첫 번째 조건: getComputedStyle(element).position으로 요소의 CSS position 값을 가져온다. sticky이거나 fixed이면 true를 반환한다. 이런 요소들은 스크롤과 무관하게 화면에 고정되어 있으므로, 스크롤 타겟으로 쓸 수 없다. 예를 들어 상단에 고정된 네비게이션 바가 여기에 해당한다.

두 번째 조건: getBoundingClientRect()로 요소의 크기와 위치를 가져온다. top, right, bottom, left, width, height 등이 전부 0이면 보이지 않는 요소다. 숨겨진 요소로 스크롤하는 건 의미가 없으니 스킵한다.

5단계: apply를 false로 리셋

ts
    focusAndScrollRef.apply = false
    focusAndScrollRef.hashFragment = null
    focusAndScrollRef.segmentPaths = []

이 세 줄이 매우 중요하다.

applyfalse로 바꾼다. 이것은 React의 불변 상태 업데이트가 아니라, 객체를 직접 변경하는 뮤테이션이다. 의도적인 설계다.

왜 뮤테이션인가? 레이아웃 트리에는 여러 InnerScrollAndFocusHandler가 동시에 존재한다. 이들은 모두 같은 focusAndScrollRef 객체를 참조하고 있다. 첫 번째로 실행된 핸들러가 apply = false로 바꾸면, 같은 렌더 사이클에서 실행되는 나머지 핸들러들은 applyfalse인 것을 보고 아무것도 하지 않는다.

"먼저 잡는 놈이 임자" 방식이다. 중복 스크롤을 방지한다.

hashFragmentsegmentPaths도 초기화한다. 이 스크롤 이벤트는 소비되었다.

이 뮤테이션이 앞으로 이동과 뒤로가기, 두 흐름을 연결하는 다리다. 앞으로 이동에서 applytrue로 올라갔다가, 스크롤 실행 후 여기서 false로 내려간다. 이후 뒤로가기가 발생하면, completeTraverseNavigation은 이 false 상태를 그대로 패스스루한다. 즉, 앞으로 이동이 "스스로 뒷정리"를 해놓기 때문에, 뒤로가기에서 별도 로직 없이도 apply가 자연스럽게 false로 유지되는 것이다.

6단계: 스크롤 실행

ts
    disableSmoothScrollDuringRouteTransition(
      () => {
        if (hashFragment) {
          ;(domNode as HTMLElement).scrollIntoView()
          return
        }

        const htmlElement = document.documentElement
        const viewportHeight = htmlElement.clientHeight

        if (topOfElementInViewport(domNode as HTMLElement, viewportHeight)) {
          return
        }

        htmlElement.scrollTop = 0

        if (!topOfElementInViewport(domNode as HTMLElement, viewportHeight)) {
          ;(domNode as HTMLElement).scrollIntoView()
        }
      },
      {
        dontForceLayout: true,
        onlyHashChange: focusAndScrollRef.onlyHashChange,
      }
    )

disableSmoothScrollDuringRouteTransition은 콜백 함수를 감싸는 래퍼다. CSS에 scroll-behavior: smooth가 있으면 일시적으로 끄고, 콜백을 실행한 뒤 다시 켠다. 이건 아래에서 따로 설명한다.

콜백 안의 로직을 보자. 두 가지 경우로 나뉜다.

해시가 있는 경우 (hashFragment가 truthy):

domNode.scrollIntoView()를 호출한다. 3단계에서 해시로 찾은 DOM 노드로 스크롤한다. 그리고 return으로 함수를 종료한다. 해시 이동은 맨 위로 스크롤할 필요가 없으니까.

줄 앞의 세미콜론(;)은 ASI(Automatic Semicolon Insertion) 문제를 방지하기 위한 것이다. 이전 줄의 코드와 (domNode...)가 함수 호출로 해석되는 걸 막는다.

해시가 없는 경우 (일반 페이지 이동):

3단계 폴백 로직이 실행된다.

먼저 document.documentElement(즉, <html> 요소)와 뷰포트 높이를 가져온다.

topOfElementInViewport를 호출해서, 대상 DOM 노드의 상단이 이미 뷰포트 안에 보이는지 확인한다. 보이고 있으면 return한다. 이미 보이는데 스크롤할 필요가 없으니까.

보이지 않으면 htmlElement.scrollTop = 0으로 페이지를 맨 위로 스크롤한다. 대부분의 경우 이것으로 충분하다.

그런데 맨 위로 스크롤한 뒤에도 대상 노드가 보이지 않을 수 있다. 대상 세그먼트가 페이지 최상단이 아닌 경우다. 예를 들어 고정 헤더 아래에 있거나, 중첩 레이아웃의 하위 세그먼트일 수 있다. 그래서 한 번 더 topOfElementInViewport를 확인하고, 여전히 안 보이면 domNode.scrollIntoView()로 해당 요소까지 스크롤한다.

topOfElementInViewport 함수:

ts
function topOfElementInViewport(element: HTMLElement, viewportHeight: number) {
  const rect = element.getBoundingClientRect()
  return rect.top >= 0 && rect.top <= viewportHeight
}

getBoundingClientRect()는 요소의 뷰포트 기준 위치를 반환한다. rect.top은 요소의 상단 가장자리가 뷰포트 상단에서 얼마나 떨어져 있는지를 픽셀로 나타낸다.

rect.top >= 0: 요소가 뷰포트 상단 위에 있지 않다. (위로 스크롤해야 보이는 상태가 아님) rect.top <= viewportHeight: 요소가 뷰포트 하단 아래에 있지 않다. (아래로 스크롤해야 보이는 상태가 아님)

두 조건을 모두 만족하면, 요소의 상단이 화면에 보이고 있다는 뜻이다.

7단계: 포커스 설정

ts
    focusAndScrollRef.onlyHashChange = false
    domNode.focus()
  }
}

onlyHashChangefalse로 리셋한다. 이 네비게이션 이벤트의 처리가 끝났으므로.

domNode.focus()로 해당 요소에 포커스를 준다. 이건 접근성(a11y)을 위한 것이다. 스크린 리더 사용자가 키보드 네비게이션으로 페이지를 이동했을 때, 포커스가 새 콘텐츠의 시작점에 있어야 한다.

라이프사이클 메서드:

ts
componentDidMount() {
  this.handlePotentialScroll()
}

componentDidUpdate() {
  if (this.props.focusAndScrollRef.apply) {
    this.handlePotentialScroll()
  }
}

render() {
  return this.props.children
}

componentDidMount: 이 컴포넌트가 DOM에 처음 마운트된 직후 호출된다. 네비게이션으로 인해 새 레이아웃 세그먼트가 생성될 때, 마운트 시점에 스크롤을 확인한다.

componentDidUpdate: 이미 마운트된 컴포넌트가 새 props를 받아 리렌더링된 후 호출된다. applytrue일 때만 handlePotentialScroll을 호출한다. applyfalse면 불필요한 작업을 피한다.

render: 자식을 그대로 렌더링한다. 이 컴포넌트 자체는 아무 DOM도 추가하지 않는다. 순수하게 스크롤 로직만 담당하는 wrapper다.

A-6 보충: disableSmoothScrollDuringRouteTransition

ts
function disableSmoothScrollDuringRouteTransition(fn, options) {
  if (options.onlyHashChange) {
    fn()
    return
  }

  const htmlElement = document.documentElement
  const hasDataAttribute = htmlElement.dataset.scrollBehavior === 'smooth'

  if (!hasDataAttribute) {
    fn()
    return
  }

  const existing = htmlElement.style.scrollBehavior
  htmlElement.style.scrollBehavior = 'auto'
  fn()
  htmlElement.style.scrollBehavior = existing
}

이 함수의 목적은 페이지 전환 시 CSS scroll-behavior: smooth를 일시적으로 끄는 것이다.

사이트에 scroll-behavior: smooth가 설정되어 있으면, scrollTop = 0이나 scrollIntoView()가 부드러운 애니메이션으로 실행된다. 페이지가 완전히 바뀌었는데 이전 스크롤 위치에서 맨 위까지 부드럽게 올라가는 건 어색하다. 콘텐츠는 이미 바뀌었는데 스크롤만 천천히 올라가니까.

첫 번째 분기: onlyHashChangetrue면 smooth scroll을 끄지 않고 그냥 실행한다. 같은 페이지 내 해시 이동(예: 목차 클릭)은 부드럽게 스크롤하는 게 자연스럽기 때문이다.

두 번째 분기: <html> 요소에 data-scroll-behavior="smooth" 속성이 없으면, smooth scroll을 쓰지 않는 사이트라고 판단하고 그냥 실행한다. 이 속성은 Next.js에 "이 사이트는 smooth scroll을 쓰니까, 네비게이션 시 신경 써달라"고 알려주는 신호다.

세 번째 분기: smooth scroll을 쓰는 사이트다. 현재 scroll-behavior 값을 existing에 저장하고, 'auto'로 바꾼다. 'auto'는 즉시 스크롤(점프)을 의미한다. 콜백 fn()을 실행해서 스크롤을 수행한 뒤, 원래 값으로 복원한다.


앞으로 이동의 전체 흐름을 따라왔다. applytrue로 올라가서 스크롤이 실행되고, 다시 false로 내려오는 사이클을 봤다.

이제 사용자가 뒤로가기 버튼을 누르면 어떻게 되는지 보자. 결론부터 말하면, 놀라울 정도로 단순하다. Next.js는 거의 아무것도 하지 않는다.


흐름 B: 뒤로가기 (Back 버튼 → 브라우저가 스크롤 복원)

사용자가 브라우저의 뒤로가기 버튼을 누르는 흐름이다.

B-1. popstate 이벤트 핸들러

ts
const onPopState = (event: PopStateEvent) => {
  if (!event.state) {
    return
  }

  if (!event.state.__NA) {
    window.location.reload()
    return
  }

  startTransition(() => {
    dispatchTraverseAction(
      window.location.href,
      event.state.__PRIVATE_NEXTJS_INTERNALS_TREE
    )
  })
}

window.addEventListener('popstate', onPopState)

브라우저의 뒤로가기/앞으로가기 버튼을 누르면 popstate 이벤트가 발생한다.

첫 번째 if: event.state가 없으면 무시한다. 히스토리 엔트리에 상태가 저장되지 않은 경우다. 초기 페이지 로드 엔트리가 이에 해당할 수 있다.

두 번째 if: event.state.__NA가 없으면 전체 페이지를 새로고침한다. __NA는 "Next.js App"의 약자로, App Router가 생성한 히스토리 엔트리임을 나타내는 마커다. 이 마커가 없으면, 외부 사이트에서 돌아왔거나 Pages Router가 만든 히스토리일 수 있다. 이 경우 SPA 네비게이션을 시도하지 않고 브라우저에게 완전히 맡긴다.

세 번째 분기: App Router의 히스토리 엔트리다. dispatchTraverseAction을 호출한다. 두 개의 인자를 넘긴다:

  • window.location.href: 현재 URL. 뒤로가기가 발생했으므로 브라우저가 이미 URL을 변경한 상태다.
  • event.state.__PRIVATE_NEXTJS_INTERNALS_TREE: App Router가 pushState 할 때 저장해둔 라우트 트리 정보다. 이전 페이지의 레이아웃 구조가 들어있다.

B-2. dispatchTraverseAction

ts
function dispatchTraverseAction(href, historyState) {
  dispatchAppRouterAction({
    type: ACTION_RESTORE,
    url: new URL(href),
    historyState,
  })
}

ACTION_RESTORE 액션을 디스패치한다. 앞으로 이동의 ACTION_NAVIGATE와 비교해 보자:

ACTION_NAVIGATE에는 shouldScroll 필드가 있었다. ACTION_RESTORE에는 없다. 스크롤 제어를 Next.js가 하지 않겠다는 의도적인 설계다.

B-3. Restore Reducer

ts
function restoreReducer(state, action) {
  const treeToRestore = action.historyState?.tree ?? state.tree

  const task = startPPRNavigation(
    now, currentUrl, state.renderedSearch, state.cache, state.tree,
    restoreSeed.routeTree, ...,
    FreshnessPolicy.HistoryTraversal,
    ...
  )

  if (task === null) {
    return completeHardNavigation(state, restoredUrl, 'replace')
  }

  return completeTraverseNavigation(
    state, restoredUrl, renderedSearch,
    task.node, task.route, restoredNextUrl
  )
}

treeToRestore는 히스토리에 저장된 라우트 트리를 가져온다. 뒤로가기로 돌아가려는 페이지의 레이아웃 구조다.

startPPRNavigation은 캐시에서 이전 페이지의 데이터를 찾고, 필요하면 서버에 요청을 보낸다. FreshnessPolicy.HistoryTraversal은 뒤로가기 전용 캐싱 정책으로, 앞으로 이동보다 캐시를 더 적극적으로 활용한다.

tasknull이면 클라이언트에서 복원이 불가능하다. completeHardNavigation으로 전체 페이지를 새로고침한다.

task가 있으면 completeTraverseNavigation을 호출한다. 앞으로 이동에서는 completeSoftNavigation을 호출했었다. 이름이 다른 것은 우연이 아니다. 이 두 함수의 결정적 차이는 단 하나 — focusAndScrollRef를 어떻게 다루는가다.

completeSoftNavigation은 새 focusAndScrollRef 객체를 만들면서 apply: true를 설정했다. completeTraverseNavigation은? 지금부터 보자.

B-4. completeTraverseNavigation() — 결정적 차이

ts
function completeTraverseNavigation(state, url, renderedSearch, cache, tree, nextUrl) {
  return {
    canonicalUrl: createHrefFromUrl(url),
    renderedSearch,
    pushRef: {
      pendingPush: false,
      mpaNavigation: false,
      preserveCustomHistoryState: true,
    },
    focusAndScrollRef: state.focusAndScrollRef,
    cache,
    tree,
    nextUrl,
    previousNextUrl: null,
    debugInfo: null,
  }
}

이 함수가 App Router 스크롤 복원의 핵심이다.

focusAndScrollRef: state.focusAndScrollRef

이 한 줄이 전부다. 기존 상태의 focusAndScrollRef그대로 패스스루한다. 새로 만들지 않는다. applytrue로 설정하지 않는다. 아무것도 바꾸지 않는다.

completeSoftNavigationfocusAndScrollRef를 새 객체로 만들면서 apply: true를 설정했다. completeTraverseNavigation은 그냥 기존 값을 넘긴다.

기존 값의 apply는 무엇인가? 이전 앞으로 이동에서 InnerScrollAndFocusHandler가 5단계에서 apply = false로 뮤테이션했다. 그 이후로 아무도 true로 바꾸지 않았다. 따라서 applyfalse 다.

이 상태로 React가 리렌더링하면:

  1. InnerScrollAndFocusHandler.componentDidUpdate()가 호출된다.
  2. this.props.focusAndScrollRef.applyfalse다.
  3. handlePotentialScroll()이 호출되지 않는다.
  4. Next.js는 스크롤에 아무 관여도 하지 않는다.

그러면 스크롤은 누가 복원하는가? 브라우저다.

App Router는 history.scrollRestoration을 건드리지 않았으므로 기본값 "auto"가 유지되고 있다. 브라우저는 "auto" 모드에서 popstate 발생 시 자동으로 이전 스크롤 위치를 복원한다. Next.js가 관여하지 않으니, 브라우저의 네이티브 복원이 자연스럽게 동작한다.

pushRef도 살펴보자:

  • pendingPush: false: 히스토리에 새 항목을 추가하지 않는다. 브라우저가 이미 뒤로가기로 히스토리를 이동했으니까.
  • preserveCustomHistoryState: true: 현재 히스토리 상태를 덮어쓰지 않는다. 뒤로가기한 페이지의 히스토리 상태를 보존한다.

설계 요약

App Router는 스크롤을 두 가지로 나누어 처리한다:

앞으로 이동은 Next.js가 직접 제어한다. focusAndScrollRef.applytrue로 설정하고, InnerScrollAndFocusHandler가 DOM 렌더링 후 적절한 타이밍에 scrollTop = 0 또는 scrollIntoView()를 호출한다. 이렇게 하면 콘텐츠 렌더링이 완료된 후에 스크롤이 실행되므로 타이밍 문제가 없다.

뒤로/앞으로 이동은 브라우저에게 맡긴다. focusAndScrollRef를 건드리지 않고 apply: false를 유지한다. history.scrollRestoration"auto"이므로 브라우저의 네이티브 복원이 동작한다.

이 설계의 장점:

  1. bfcache 호환: history.scrollRestoration = "manual"을 설정하면 일부 브라우저(특히 iOS Safari)에서 bfcache가 깨진다. App Router는 이 API를 건드리지 않으므로 bfcache와 충돌하지 않는다.

  2. 단순함: 스크롤 위치를 sessionStorage에 저장하고 읽는 코드가 필요 없다. 브라우저가 내부적으로 관리하는 스크롤 위치를 그대로 활용한다.

  3. 안정성: 브라우저의 네이티브 스크롤 복원은 렌더링 파이프라인과 통합되어 있어서, JS에서 window.scrollTo()를 호출하는 것보다 타이밍이 안정적이다.