웹 앱에 즉각적인 탐색 및 원활한 페이지 전환 추가

1. 시작하기 전에

이 Codelab에서는 Chrome에서 기본적으로 지원하는 최신 API를 사용하여 샘플 웹 앱에 즉각적인 탐색 및 원활한 페이지 전환을 추가하는 방법을 알아봅니다.

샘플 웹 앱에서는 인기 있는 과일과 채소의 영양 정보를 확인합니다. 과일 목록과 과일 세부정보 페이지는 단일 페이지 앱(SPA)으로 빌드되었고, 채소 목록과 채소 세부정보 페이지는 기존의 여러 페이지 앱(MPA)으로 빌드되어 있습니다.

모바일 기기의 샘플 앱 스크린샷 모바일 기기의 샘플 앱 스크린샷

구체적으로 설명하자면 즉각적인 탐색을 위해 사전 렌더링, 뒤로-앞으로 캐시(bfcache), 비공개 미리 가져오기 프록시를 구현하고 원활한 페이지 전환을 위해 루트/공유 요소 전환을 구현합니다. MPA 페이지에는 사전 렌더링과 bfcache를 구현하고 SPA 페이지에는 공유 요소 전환을 구현합니다.

사이트 속도는 사용자 환경에서 항상 중요한 요소입니다. 따라서 Google에서는 실제 사용자 환경을 평가하기 위해 웹페이지의 로드 성능, 상호작용, 시각적 안정성을 측정하는 측정항목 모음인 코어 웹 바이탈을 도입했습니다. 최신 API를 사용하면 현재 운영 중인 웹사이트의 코어 웹 바이탈 점수, 특히 로드 성능 점수를 개선할 수 있습니다.

bfcache로 로드 시간이 얼마나 개선되는지 보여주는 데모 이미지

Mindvalley의 데모

또한 사용자는 모바일 네이티브 앱에서 전환을 사용하여 대단히 직관적인 탐색 및 상태 변경을 구현하는 방식에 익숙합니다. 그러나 웹에서 이러한 사용자 환경을 복제하기란 간단하지 않은 일입니다. 현재 웹 플랫폼 API로 유사한 효과를 얻는 것은 가능하지만 특히 Android 앱이나 iOS 앱의 같은 기능과 비교할 때 개발하기가 너무 어렵거나 복잡할 수 있습니다. 원활한 API는 앱과 웹 사이에서 이러한 사용자 환경과 개발자 환경의 차이를 메우기 위해 설계되었습니다.

pixiv의 Shared Element Transitions API 데모 Tokopedia의 Shared Element Transitions API 데모

pixiv Tokopedia의 데모

기본 요건

다음을 알고 있어야 합니다.

학습할 내용

다음 항목의 구현 방법

  • 사전 렌더링
  • bfcache
  • 비공개 미리 가져오기 프록시
  • 루트/공유 요소 전환

빌드할 항목

다음과 같이 즉각적이고 원활한 최신 브라우저 기능으로 강화된 Next.js로 샘플 웹 앱을 빌드해 보겠습니다.

  • 사전 렌더링을 사용한 거의 즉각적인 탐색
  • 브라우저의 뒤로 버튼과 앞으로 버튼을 사용하여 즉각적인 로드를 구현하는 bfcache
  • 비공개 미리 가져오기 프록시 또는 서명된 교환(SXG)을 사용한 교차 출처 탐색으로 앱의 멋진 첫인상 제공
  • 루트/공유 요소 전환으로 페이지 간의 원활한 전환

필요한 항목

  • Chrome 버전 101 이상

2. 시작하기

Chrome 신고 사용 설정

  1. about://flags로 이동한 다음 Prerender2documentTransition API 런타임 플래그를 사용 설정합니다.
  2. 브라우저를 다시 시작합니다.

코드 가져오기

  1. 원하는 개발 환경에서 이 GitHub 저장소의 코드를 엽니다.
git clone -b codelab git@github.com:googlechromelabs/instant-seamless-demo.git
  1. 서버를 실행하는 데 필요한 종속 항목을 설치합니다.
npm install
  1. 포트 3000에서 서버를 시작합니다.
npm run dev
  1. 브라우저에서 http://localhost:3000으로 이동합니다.

이제 앱을 수정하고 개선할 수 있습니다. 변경할 때마다 앱이 새로고침되어 변경사항이 바로 표시됩니다.

3. 사전 렌더링 통합

이 데모에서는 임의의 서버 측 지연으로 인해 샘플 앱에 있는 채소 세부정보 페이지의 로드 시간이 매우 오래 걸립니다. 사전 렌더링으로 이러한 대기 시간을 없애 보겠습니다.

다음 안내에 따라 채소 목록 페이지에 사전 렌더링 버튼을 추가하고 사용자가 클릭하면 사전 렌더링이 트리거되도록 하세요.

  1. speculation-rules 스크립트 태그를 동적으로 삽입하는 버튼 구성요소를 만듭니다.

components/prerender-button.js

import { useContext } from 'react'
import ResourceContext from './resource-context'

// You use resource context to manage global states.
// In the PrerenderButton component, you update the prerenderURL parameter when the button is clicked.
export default function PrerenderButton() {
  const { dispatch } = useContext(ResourceContext)
  const handleClick = (e) => {
    e.preventDefault()
    e.stopPropagation()
    const parent = e.target.closest('a')
    if (!parent) {
      return
    }
    const href = parent.getAttribute('href')
    dispatch({ type: 'update', prerenderURL: href })
  }

  return (
    <button className='ml-auto bg-gray-200 hover:bg-gray-300 px-4 rounded' onClick={handleClick}>
      Prerender
    </button>
  )
}
  1. list-item.js 파일에 PrerenderButton 구성요소를 가져옵니다.

components/list-item.js

// Codelab: Add a PrerenderButton component.
import PrerenderButton from './prerender-button'

...
function ListItemForMPA({ item, href }) {
  return (
    <a href={href} className='block flex items-center'>
      <Icon src={item.image} />
      <div className='text-xl'>{item.name}</div>
      {/* Codelab: Add PrerenderButton component. */}
      <PrerenderButton />
    </a>
  )
}
  1. Speculation Rules API를 추가할 구성요소를 만듭니다.

SpeculationRules 구성요소는 앱이 prerenderURL 상태를 업데이트할 때 페이지에 스크립트 태그를 동적으로 삽입합니다.

components/speculationrules.js

import Script from 'next/script'
import { useContext, useMemo } from 'react'
import ResourceContext from './resource-context'

export default function SpeculationRules() {
  const { state } = useContext(ResourceContext)
  const { prerenderURL } = state

  return useMemo(() => {
    return (
      <>
        {prerenderURL && (
          <Script id='speculationrules' type='speculationrules'>
            {`
            {
              "prerender":[
                {
                  "source": "list",
                  "urls": ["${prerenderURL}"]
                }
              ]
            }
          `}
          </Script>
        )}
      </>
    )
  }, [prerenderURL])
}
  1. 구성요소를 앱과 통합합니다.

pages/_app.js

// Codelab: Add the SpeculationRules component.
import SpeculationRules from '../components/speculationrules'

function MyApp({ Component, pageProps }) {
  useAnalyticsForSPA()

  return (
    <ResourceContextProvider>
      <Layout>
        <Component {...pageProps} />
      </Layout>
      {/* Codelab: Add SpeculationRules component */}
      <SpeculationRules />
      <Script id='analytics-for-mpa' strategy='beforeInteractive' src='/analytics.js' />
    </ResourceContextProvider>
  )
}

export default MyApp
  1. 사전 렌더링을 클릭합니다.

로드 시간이 상당히 개선된 것을 확인할 수 있습니다. 실제 사용 사례에서는 사용자가 다음에 방문할 가능성이 높은 페이지에 대해 휴리스틱 방식으로 사전 렌더링이 트리거됩니다.

샘플 앱의 사전 렌더링 데모 동영상

분석

기본적으로 샘플 웹 앱의 analytics.js 파일은 DOMContentLoaded 이벤트가 발생할 때 페이지 조회 이벤트를 전송합니다. 이는 적절한 동작이 아닙니다. 이 이벤트가 사전 렌더링 단계에서 발생하기 때문입니다.

document.prerenderingprerenderingchange 이벤트를 도입하여 문제를 해결하려면 다음 안내를 따르세요.

  • analytics.js 파일을 다시 작성합니다.

public/analytics.js

  const sendEvent = (type = 'pageview') => {
    // Codelab: Make analytics prerendering compatible.
    // The pageshow event could happen in the prerendered page before activation.
    // The prerendered page should be handled by the prerenderingchange event.
    if (document.prerendering) {
      return
    }
    console.log(`Send ${type} event for MPA navigation.`)
    fetch(`/api/analytics?from=${encodeURIComponent(location.pathname)}&type=${type}`)
  }
  ...

  // Codelab: Make analytics prerendering compatible.
  // The prerenderingchange event is triggered when the page is activated.
  document.addEventListener('prerenderingchange', () => {
    console.log('The prerendered page was activated.')
    sendEvent()
  })
  ...

잘하셨습니다. 분석이 사전 렌더링과 호환되도록 수정되었습니다. 이제 브라우저 콘솔에서 적절한 타이밍에 페이지 조회 로그를 확인할 수 있습니다.

4. bfcache 차단기 삭제

unload 이벤트 핸들러 삭제

불필요한 unload 이벤트 발생은 매우 흔히 보이는 실수이며 이제 이런 일은 없어야 합니다. 이 이벤트는 bfcache의 작동을 방해할 뿐 아니라 불안정하게 발생하기 때문입니다. 예를 들어 모바일Safari에서는 이벤트가 발생하지 않는 경우도 있습니다.

unload 이벤트 대신 pagehide 이벤트를 사용하세요. 이 이벤트는 unload 이벤트가 발생하는 모든 경우는 물론 페이지가 bfcache에 배치될 때도 발생합니다.

unload 이벤트 핸들러를 삭제하는 방법은 다음과 같습니다.

  • analytics.js 파일에서 unload 이벤트 핸들러의 코드를 pagehide 이벤트 핸들러의 코드로 바꿉니다.

public/analytics.js

// Codelab: Remove the unload event handler for bfcache.
// The unload event handler prevents the content from being stored in bfcache. Use the pagehide event instead.
window.addEventListener('pagehide', () => {
  sendEvent('leave')
})

cache-control 헤더 업데이트

Cache-control: no-store HTTP 헤더로 게재되는 페이지는 브라우저의 bfcache 기능을 활용할 수 없으므로 이 헤더는 신중하게 사용하는 것이 좋습니다. 특히 로그인 상태와 같은 맞춤 정보나 중요한 정보가 포함되지 않은 페이지라면 굳이 Cache-control: no-store HTTP 헤더로 게재하지 않아도 될 것입니다.

샘플 앱의 cache-control 헤더를 업데이트하려면 다음 안내를 따르세요.

  • getServerSideProps 코드를 수정합니다.

pages/vegetables/index.js

export const getServerSideProps = middleware(async (ctx) => {
  const { req, res } = ctx
  // Codelab: Modify the cache-control header.
  res.setHeader('Cache-Control', 'public, s-maxage=10, stale-while-revalidate=59')
  ...

pages/vegetables/[name].js

export const getServerSideProps = middleware(async (ctx) => {
  const { req, res, query } = ctx
  // Codelab: Modify the cache-control header.
  res.setHeader('Cache-Control', 'public, s-maxage=10, stale-while-revalidate=59')
  ...

페이지가 bfcache에서 복원되는지 확인

pageshow 이벤트는 페이지가 처음 로드될 때 load 이벤트 직후에 발생하며 언제든 페이지가 bfcache에서 복원될 때 발생합니다. pageshow 이벤트에는 persisted 속성이 있습니다. 이 속성은 페이지가 bfcache에서 복원된 경우 true이고 그렇지 않은 경우 false입니다. persisted 속성을 사용하여 일반 페이지 로드와 bfcache 복원을 구분할 수 있습니다. 주요 분석 서비스는 bfcache를 인식하지만, 페이지가 bfcache에서 복원된 것인지 직접 확인하고 수동으로 이벤트를 전송할 수도 있습니다.

페이지가 bfcache에서 복원된 것인지 확인하는 방법은 다음과 같습니다.

  • 이 코드를 analytics.js 파일에 추가합니다.

public/analytics.js

  // Codelab: Use the pageshow event handler for bfcache.
  window.addEventListener('pageshow', (e) => {
    // If the persisted flag exists, the page was restored from bfcache.
    if (e.persisted) {
      console.log('The page was restored from bfcache.')
      sendEvent()
    }
  })

웹페이지 디버그

Chrome 개발자 도구를 사용하면 테스트를 통해 페이지가 bfcache에 최적화되어 있는지 확인하고 bfcache 사용을 가로막을 가능성이 있는 문제를 식별할 수 있습니다.

특정 페이지를 테스트하려면 다음 안내를 따르세요.

  1. Chrome에서 페이지로 이동합니다.
  2. Chrome 개발자 도구에서 애플리케이션 > 뒤로-앞으로 캐시 > 테스트 실행을 클릭합니다.

Chrome 개발자 도구는 페이지가 bfcache에서 복원될 수 있는지 확인하기 위해 페이지에서 나갔다가 다시 돌아오려고 합니다.

49bf965af35d5324.png

성공하면 뒤로-앞으로 캐시에서 페이지가 복원되었다는 메시지가 패널에 표시됩니다.

47015a0de45f0b0f.png

실패하면 페이지가 복원되지 않았다는 메시지와 함께 그 이유가 패널에 표시됩니다. 개발자가 해결할 수 있는 문제가 원인인 경우 그 내용도 표시됩니다.

dcf0312c3fc378ce.png

5. 크로스 사이트 미리 가져오기 사용 설정

미리 가져오기는 사용자가 탐색할 때 이미 브라우저에 데이터가 존재하도록 가져오기를 일찍 시작하여 탐색을 가속화합니다. 이를 통해 코어 웹 바이탈을 손쉽게 개선하고 탐색하기 전에 일부 네트워크 활동을 건너뛸 수 있습니다. 미리 가져오기를 사용하면 최대 콘텐츠 렌더링 시간(LCP)이 바로 가속화되며 탐색 시 최초 입력 반응 시간(FID)과 레이아웃 변경 횟수(CLS)가 더욱 개선될 여지도 있습니다.

비공개 미리 가져오기 프록시는 크로스 사이트 미리 가져오기를 사용 설정하지만 사용자 개인 정보가 대상 서버에 공개되지는 않습니다.

비공개 미리 가져오기 프록시 작동 방식

비공개 미리 가져오기 프록시를 사용하여 크로스 사이트 미리 가져오기 사용 설정

웹사이트 소유자는 잘 알려진 traffic-advice 리소스(웹 크롤러의 /robots.txt와 유사함)를 통해 미리 가져오기를 제어할 수 있습니다. 이렇게 하면 HTTP 서버에서 구현 에이전트가 해당 조언을 적용해야 한다고 선언할 수 있게 됩니다. 현재 웹사이트 소유자는 에이전트에 네트워크 연결을 허용하지 않거나 제한하도록 조언할 수 있습니다. 향후 다른 조언이 추가될 수 있습니다.

traffic-advice 리소스를 호스팅하는 방법은 다음과 같습니다.

  1. JSON과 유사한 다음 파일을 추가합니다.

public/.well-known/traffic-advice

[
  {
    "user_agent": "prefetch-proxy",
    "google_prefetch_proxy_eap": {
      "fraction": 1
    }
  }
]

google_prefetch_proxy_eap 필드는 사전 체험 프로그램을 위한 특수 필드이며 fraction 필드는 비공개 미리 가져오기 프록시에서 전송하는 요청된 미리 가져오기 부분을 제어하는 필드입니다.

트래픽 조언은 application/trafficadvice+json MIME 유형으로 반환되어야 합니다.

  1. next.config.js 파일에서 응답 헤더를 구성합니다.

next.config.js

const nextConfig = {
  // Codelab: Modify content-type for traffic advice file.
  async headers() {
    return [
      {
        source: '/.well-known/traffic-advice',
        headers: [
          {
            key: 'Content-Type',
            value: 'application/trafficadvice+json',
          },
        ],
      },
    ]
  },
}

module.exports = nextConfig

6. Shared Element Transitions API 통합

사용자가 웹의 한 페이지에서 다른 페이지로 이동하면 첫 번째 페이지가 사라지고 새 페이지가 나타나면서 콘텐츠가 갑작스럽고 예기치 않게 변경됩니다. 이렇게 이어지는 단절된 사용자 환경은 사용자를 혼란스럽게 만들고 인지 부하가 커지는 결과를 낳습니다. 사용자가 새 페이지에 도착하기까지의 과정을 머릿속에서 끼워 맞춰야 하기 때문입니다. 또한 이러한 환경에서는 원하는 대상이 로드되기를 기다리는 동안 사용자가 인지하는 페이지 로딩 시간도 더 길게 느껴집니다.

부드럽게 로드되는 애니메이션은 사용자가 페이지 사이를 이동하는 동안 맥락이 유지되므로 인지 부하가 감소하며, 인지되는 로드 지연 시간도 줄어듭니다. 그동안 흥미롭고 재미있는 콘텐츠가 표시되기 때문입니다. 이러한 이유로 대부분의 플랫폼에서는 개발자가 Android, iOS, MacOS, Windows와 같은 원활한 전환을 빌드할 수 있도록 사용하기 쉬운 프리미티브를 제공하고 있습니다.

Shared Element Transitions API는 문서 간(MPA) 전환이든 문서 내(SPA) 전환이든 상관없이 웹에서 동일한 기능을 개발자에게 제공합니다.

pixiv의 Shared Element Transitions API 데모 Tokopedia의 Shared Element Transitions API 데모

pixiv Tokopedia의 데모

샘플 앱의 SPA 부분용 Shared Element Transitions API를 통합하려면 다음 안내를 따르세요.

  1. use-page-transition.js 파일에서 전환을 관리하기 위한 맞춤 후크를 만듭니다.

utils/use-page-transition.js

import { useEffect, useContext, useRef, useCallback } from 'react'
import ResourceContext from '../components/resource-context'

// Call this hook on this first page before you start the page transition. For Shared Element Transitions, you need to call the transition.start() method before the next page begins to render, and you need to do the Document Object Model (DOM) modification or setting of new shared elements inside the callback so that this hook returns the promise and defers to the callback resolve.
export const usePageTransitionPrep = () => {
  const { dispatch } = useContext(ResourceContext)

  return (elm) => {
    const sharedElements = elm.querySelectorAll('.shared-element')
    // Feature detection
    if (!document.createDocumentTransition) {
      return null
    }

    return new Promise((resolve) => {
      const transition = document.createDocumentTransition()
      Array.from(sharedElements).forEach((elm, idx) => {
        transition.setElement(elm, `target-${idx}`)
      })
      transition.start(async () => {
        resolve()
        await new Promise((resolver) => {
          dispatch({ type: 'update', transition: { transition, resolver } })
        })
      })
    })
  }
}

// Call this hook on the second page. Inside the useEffect hook, you can refer to the actual DOM element and set them as shared elements with the transition.setElement() method. When the resolver function is called, the transition is initiated between the captured images and newly set shared elements.
export const usePageTransition = () => {
  const { state, dispatch } = useContext(ResourceContext)
  const ref = useRef(null)
  const setRef = useCallback((node) => {
    ref.current = node
  }, [])

  useEffect(() => {
    if (!state.transition || !ref.current) {
      return
    }
    const { transition, resolver } = state.transition
    const sharedElements = ref.current.querySelectorAll('.shared-element')
    Array.from(sharedElements).forEach((elm, idx) => {
      transition.setElement(elm, `target-${idx}`)
    })
    resolver()
    return () => {
      dispatch({ type: 'update', transition: null })
    }
  })

  return setRef
}
  1. 목록 페이지에서 usePageTransitionPrep() 맞춤 후크를 호출한 다음 비동기 함수를 호출하여 click 이벤트 내에서 transition.start() 메서드를 트리거합니다.

함수 내에서 shared-element 클래스 요소를 수집하여 공유 요소로 등록합니다.

components/list-item.js

// Codelab: Add the Shared Element Transitions API.
import { usePageTransitionPrep } from '../utils/use-page-transition'
...

function ListItemForSPA({ item, href }) {
  // Codelab: Add Shared Element Transitions.
  const transitionNextState = usePageTransitionPrep()
  const handleClick = async (e) => {
    const elm = e.target.closest('a')
    await transitionNextState(elm)
  }
  return (
    <Link href={href}>
      <a className='block flex items-center' onClick={handleClick}>
        <Icon src={item.image} name={item.name} className='shared-element' />
        <div className='text-xl'>{item.name}</div>
      </a>
    </Link>
  )
}
  1. 세부정보 페이지에서 usePageTransition() 후크를 호출하여 transition.start() 콜백 함수를 완료합니다.

세부정보 페이지의 공유 요소도 이 콜백에서 등록됩니다.

pages/fruits/[name].js

// Codelab: Add the Shared Element Transitions API.
import { usePageTransition } from '../../utils/use-page-transition'

const Item = ({ data }) => {
  const { name, image, amountPer, nutrition } = data
  // Codelab: Add the Shared Element Transitions API.
  const ref = usePageTransition()

  return (
    <div className={'flex flex-col items-center justify-center py-4 px-4 sm:flex-row'} ref={ref}>
      <div className='flex flex-col items-center sm:w-2/4'>
        <Image
          className='object-cover border-gray-100 border-2 rounded-full shared-element'
          src={image}
          width='240'
          height='240'
          alt={`picture of ${name}`}
        />
        <h1 className='text-4xl font-bold mt-4'>{name}</h1>
      </div>

      <div className='sm:w-2/4 w-full'>
        <Nutrition amountPer={amountPer} nutrition={nutrition} />
      </div>
    </div>
  )
...
}

이제 이미지 요소가 목록 및 세부정보 페이지에서 공유되고 페이지 전환에서 원활하게 연결되는 것을 확인할 수 있습니다. CSS pseudo-elements로 애니메이션을 맞춤설정하여 더 멋지게 만들 수도 있습니다.

공유 요소 전환이 없는 샘플 앱 데모 동영상 공유 요소 전환이 적용된 샘플 앱 데모 동영상

7. 축하합니다

축하합니다. 더 매끄럽고 매력적이며 직관적인 사용자 환경을 갖춘 즉각적이고 원활한 웹 앱을 만들었습니다.

자세히 알아보기

사전 렌더링

bfcache

크로스 사이트 미리 가져오기

서명된 교환

루트/공유 요소 전환

이러한 API는 아직 개발 초기 단계이므로 crbug.com에서 의견을 공유하거나 각 API의 GitHub 저장소에서 문제를 보고해 주시기 바랍니다.