Posts

데이터 기반의 프리로드 최적화 전략: 사용자 여정 분석을 통해 모바일 성능 개선하기

페이지 전환의 사용성 개선을 위해 앰플리튜드의 Journey 차트와 이벤트 로그를 활용해 프리로드 전략을 수립해보았습니다.

uxperformancejavascript

최근 페이지 전환 과정의 사용성 개선을 위해 앰플리튜드의 Journey 차트와 이벤트 로그를 활용해 프리로드 전략을 수립해 ⏱️째깍악어🐊에 적용했습니다. 이 글에서는 단순해 보이는 리소스 프리로드 작업에서 제가 고민했던 지점들을 정리해 보았습니다. 단순한 구현 방법보다는 의사결정 구조와 제어 전략에 대한 내용에 가깝습니다. 전략적인 최적화를 고민하시는 엔지니어분들께 도움이 되기를 바랍니다.

모바일 환경에서 화면 전환은 생각보다 더 민감한 인터랙션입니다. 특히 저사양 기기나 네트워크 환경이 좋지 않은 경우에는 짧은 지연도 꽤 선명한 버벅임으로 느껴지는 경우가 비일비재합니다. ⏱️째깍악어🐊도 이 지점을 피하기 어려웠는데요. 실제 매우 극단적인 저사양 환경에서는 INP(Interaction to Next Paint)가 500ms 수준까지도 튀는 현상이 관측되기도 했습니다.

매우 극단적인 환경에서 발생한 매우 극단적인 INP
극단적인 환경에서 발생한 극단적인 INP

위의 극단적인 상황에서는 리소스(스크립트 청크)를 다운로드받는 데 300ms 정도 시간이 소요되었고 이후 메인 스레드에서 해당 스크립트를 파싱하며 메인 스레드를 꽤나 길게 블로킹하고 있습니다(액티비티를 lazy-load하고 있거든요!). 이러한 지점들이 모여 꽤나 긴 지연감을 만들고 있었는데요, 화면 전환에 필요한 이와 같은 작업들을 미리 분산하는 프리로드을 자연스럽게 고민했고 작업하게 되었습니다.

언제나 그렇듯 프리로드는 구현 자체보다는 전략이 핵심이지 않던가요. 그래서 이번 글에서는 앰플리튜드의 사용자 데이터를 기반으로 프리로드할 범위를 선정하고 피드백 루프를 구축해 안정적으로 프리로드 전략을 운영하는 방식에 대해서 정리해 보았습니다. 물론 @stackflow를 이용하는 만큼 @stackflow 위에서의 프리로드 플러그인 확장 방향에 대해서도 살짝 다뤄보았습니다.

데이터로 프리로드 후보 압축하기

모든 리소스를 프리로드하면 될까요? 프리로드는 강력한 만큼 꽤 공격적인 전략이라고 생각하는데요, 꼭 필요하지 않은 구간에 무심코 적용하면 (1)사용하지 않을 리소스를 불필요하게 다운로드하게 되고, (2)불필요한 API 호출이 증가하며, 자연스럽게 (3)불필요한 데이터로 메모리를 점유할 수 있기 때문이죠. 데이터의 프리페치는 특히 더 신중하게 다뤄야 하는데요, 세션 상태나 인증 여부, 실시간성의 영향을 받기 때문입니다. 그래서 프리로드 전략에는 객관적인 판단 기준이 선행되어야 합니다.

그래서 ⏱️째깍악어🐊는 앰플리튜드의 Journey차트를 기반으로 프리로드 대상을 선정했습니다. 실제 사용자 이동 흐름을 분석하여 사용자들이 이동할 확률이 높은 다음 액티비티를 미리 로드하는 전략을 구축한 것이죠.

Journey 차트
앰플리튜드의 Jorney 차트, Pathfinder

앰플리튜드에서는 기본적으로 PageView라는 이벤트 로그를 수집하기에, PageView를 기준으로 Journey차트(pathfinder)를 필터링하면 쉽게 사용자 경로를 읽어낼 수 있습니다. 위 차트처럼 말이죠!

전체 차트를 훑어 preload 구간으로 25개 구간을 추렸습니다(앰플리튜드 에이전트야 고마워🙏​). 기준은 특정 화면에서 다음 화면으로 이동하는 비율이 20% 이상인 경우로 잡았습니다.

25개 구간 선정
preload 처리할 25개 구간 선정

왜 20%여야 했을까요? 이 수치는 절대적인 기준이라기보다는 트레이드오프에 가깝습니다. 프리로드에는 항상 false positive가 존재합니다(신호탐지이론). 즉, 미리 로드했지만 실제로 사용자는 이동하지 않는 경우가 존재하는데요, Journey차트에서 확인된 이동 확률 20% 이상의 경로들이 우리 서비스의 주요 경로들이었고(공고 신청 플로우, 프로그램 구매 플로우), 실제로 사용자의 이동 흐름 대부분을 커버하고 있었습니다.

다만 Journey차트의 신뢰도가 떨어질 수 있다는 한계점(특정 기간의 데이터만으로 분석, 단일 소스에 의존)이 있기 때문에 추가적인 이벤트 로깅을 통해 이 20%라는 기준을 관측, 조정하는 전략을 세웠는데요. 이 전략은 아래에서 좀더 구체적으로 설명드리겠습니다.

선별적으로 적용한 데이터 프리페치

이번 작업에서는 프리로드를 단일 개념으로 다루지 않았습니다: JS 청크 등의 리소스 프리로드와 데이터 프리페치. 리소스의 프리로드는 비교적 캐시 효율이 높으면서 실패 비용이 작습니다. 일단 미리 한번만 가져와서 저장해둬도 크게 문제되는 지점이 없기 때문이죠. 반면 도입에서 언급했다시피 데이터의 프리페치는 조금 다릅니다. 인증의 영향, stale 가능성 등 여러 지점들을 고려해 신중하게 적용되어야 하죠. 리로스의 프리로드에 비해 실패 비용이 상대적으로 크기 때문입니다. 그래서 프리페치는 위에서 선정한 25개 구간 중에서도 일부 핵심 액티비티에만 적용하는 방향으로 제어했습니다.

그렇다면 어떤 액티비티가 데이터 프리페치의 적용 대상이 되었을까요? 초기 화면 구성에 필요한 데이터의 비중이 크고, LCP에 직접적인 영향을 줄 수 있는 일부 액티비티로 액티비티를 제한했습니다. 예를 들어 아래 [째깍섬] 페이지를 볼까요?

데이터의 중요도가 높은 째깍섬 페이지 프리페치를 활용해 진입부터 데이터가 채워진 째깍섬 페이지

첫 진입에 사용자에게 노출되는 화면의 거의 모든 부분에 API 응답이 필요합니다(메인 이미지, 째깍섬 소개 문구, 하단 배너). 물론 API 응답이 pending 상태인 경우 화면이 스켈레톤으로 채워지긴 하지만 화면 전반을 스켈레톤으로 채워야 하는 상황, 사용성이 떨어지기 마련입니다. 이와 같이 API 응답값의 활용성이 높은 두 구간을 프리페치 필요 구간으로 지정, 해당 구간에만 프리페치를 적용했습니다. 좀더 전환이 빠릿하고 자연스럽지 않나요?

@stackflow 위에서 확장 가능한 프리로드 엔진

⏱️째깍악어🐊 서비스는 @stackflow 기반의 액티비티 구조를 사용하고 있습니다. @stackflow에서는 자체 preload 플러그인, @stackflow/plugin-preload (stackflow.so, 새 창)를 제공하고 있는데요. 안타깝게도 해당 플러그인을 그대로 사용하기에는 queryClient, session, preload의 호출 의도 등의 컨텍스트를 그대로 전달하기가 어려웠습니다.

특히 해당 플러그인은 등록된 프리로드 함수를 액티비티 진입 직전과 preload 함수의 호출 시점 모두에서 호출되는 구조이기 때문에 최초의 SSR 시점 등에서도 불필요하게 프리로드 함수가 호출되는 이슈가 있었습니다. ⏱️째깍악어🐊 서비스는 @stackflow의 보완을 위해 자체 SSR 서버를 구축, 활용하고 있었기에 해당 플러그인을 그대로 사용하기에는 어려움이 있었습니다.

그래서 데이터 프리페칭에 필요한 컨텍스트를 추가로 전달할 수 있도록 플러그인을 아래처럼 확장했습니다.

// preloadPlugin.ts
export type MyLoader = {}> = (
 args: Omit<Parameters<LoaderStackflow >[0], 'activityContext'> & {
 activityContext: {
 viaHook?: boolean; // ** 의도적인 프리로드인 경우 true
 queryClient?: QueryClient; // tanstack/react-query의 쿼리 클라이언트
 session?: SessionState; // 세션 정보(예: 인증 여부 등)
 [key: string]: unknown;
 };
 }
) => unknown;

@stackflow의 activityContext를 확장하여 viaHook, queryClient, session 객체를 추가적으로 컨텍스트에 넘길 수 있도록 했습니다. 여기서 viaHook은 훅을 통해 의도적으로 preload 함수를 호출하였는지의 여부를 의미합니다. 이 값이 true인 경우가 코드를 통해 직접 프리로드를 실행해야 하는 분기인 것이죠.

그렇다면 이와 같은 값들은 어떻게 주입할 수 있었을까요? 별도의 커스텀 래핑 훅, usePreloader를 추가해 값들을 preload 호출 시점에 컨텍스트로 넘기는 방식을 사용했습니다. @stackflow의 preload를 각 액티비티에서 직접 호출하지 않고, usePreloader 훅을 통해서만 호출하게 함으로써 컨텍스트에 데이터를 추가로 전달하는 것이죠.

// usePreloader.ts
function usePreloader() {
  const { preload } = usePreloaderStackflow(); // @stackflow/plugin-preload
  const queryClient = useQueryClient();
  const [session] = useSession();

  const preloadCustomized = useCallback(
    (...args: Parameters) => {
      const [activityName, activityParams, options] = args;
      preload(activityName, activityParams, {
        ...options,
        activityContext: {
          ...options?.activityContext,
          viaHook: true,
          queryClient,
          session,
        },
      });
    },
    [preload, queryClient, session],
  );

  return {
    preload: preloadCustomized,
  };
}

그리고 위와 같은 구조로 호출하는 프리로드 로직은 로더의 각 액티비티 프리로드 메서드를 내부적으로 호출합니다. 파라미터로 전달되는 activityContex에 더 풍부한 컨텍스트가 담겨 있기에, 프리로드 메서드에서는 좀더 다양한 동작들을 자연스럽게 사용할 수 있습니다.

// preloadPlugin.ts
const loaders: Loaders = {
 HomeActivity: ({ activityContext: { viaHook, queryClient, session } }) => {
 if (viaHook) {
 // 훅을 통한 호출인 경우에만 다음 액티비티를 프리로드할 수 있습니다.
 import('@activities/island/activity');
 }

 if (queryClient && session?.status) {
 // queryClient 객체의 prefetch를 사용할 수 있습니다. 인증 여부에 따라 다른 API가 필요한 경우에도 대응이 가능합니다.
 prefetchHome(queryClient, session.status);
 }
 },
 ...
};

지속적으로 관측하며 보완하는 피드백 루프

단일 데이터 소스인 Journey차트에만 의존하여 프리로드 기준점을 잡는 것은 다소 위험한 판단일 수 있기에, 운영 환경에서 관측 가능한 데이터를 심어두고 이 기준점을 조정할 수 있도록 피드백 루프를 구성했습니다. 즉, 추가 데이터를 통해 현재 프리로드 전략이 실제 사용자 흐름과 어느 정도 일치하고 있는지 관측할 수 있는 구조를 추가한 것입니다.

기획팀의 도움
기획팀에서 지적해주신 Journey차트의 신뢰도 이슈

그래서 이번 작업에서는 프리로드 대상 액티비티에 실제 진입하였는지의 여부를 추적할 수 있도록 preload-hit과 preload-requested 이벤트를 추가로 수집할 수 있도록 로깅 로직을 수정했습니다. 프리로드가 발생한 경우 preload-requested를, 이후 실제로 해당 액티비티로 이동한 경우에 preload-hit를 기록하는 방식입니다.

// preload_requested
{
 "from": "/",
 "to": "/island"
}

// preload_hit
{
 "from": "/",
 "to": "/island"
}

이를 통해 프리로드 전략이 실제 사용자 흐름과 크게 어긋나고 있지는 않은지, 특정 프리로드가 과도한 리소스 낭비로 이어지고 있지는 않은지 운영 환경에서 확인할 수 있도록 구성했습니다. 다만 현실적인 이유로 이 피드백 루프를 지속적으로 유지할 수는 없기 때문에, 배포 후 2주 동안만 데이터를 수집했습니다.

데이터 수집 결과 일부
데이터 수집 결과 일부

데이터 관측 결과 Journey 차트 기반으로 잡은 20%라는 기준이 생각보다는 조금 공격적이었다는 결론으로 이어졌습니다. 의미 있어 보였던 일부 구간들이 실제 운영 환경에서는 기대보다 낮은 적중률을 보였기 때문이었습니다(사용자의 다음 행동이 비교적 예측 가능한 구간-째깍섬 센터 선택, 예약 플로우 따라가기-에서는 꽤나 효율적이긴 했습니다!).

그래서 실제 운영 데이터 기준 효율이 낮은 일부 액티비티(예: / → /user-guide/care-type)는 프리로드 대상에서 제거했습니다. 다만 적중률 자체가 높지는 않았지만 화면 자체가 무거워 저사양 기기에서의 INP 발생 가능성이 높은 일부 액티비티는 기존의 프리로드를 유지했습니다(예: / → /teacher/[id]).

마무리하며

📳​ 테스트 환경: AOS, 갤럭시 S21, 네트워크 스로틀링(fast 4G)

프리로드 적용 이전 프리로드(프리페치) 적용 이후

이번 작업은 단순한 최적화라기보다는 사용자의 다음 행동을 어디까지 예측할 수 있는가에 대한 고민에 더 가까웠던 것 같습니다. 프리로드는 결국 사용자 행동을 확률적으로 예측하는 전략이기 때문에 반드시 false positive와 리소스 낭비 가능성을 함께 동반합니다. 실제 운영 환경에서도 적중률이 화면의 성격과 사용자 여정 구조에 따라 꽤 다르게 나타났고, 비용 대비 효율이 기대보다 낮은 케이스들도 일부 확인할 수 있었습니다.

그럼에도 이번 작업이 의미 있었다고 느꼈던 이유는 사용자가 실제로 체감할 수 있는 액티비티 전환 과정의 버벅임을 꽤 유의미하게 줄일 수 있었기 때문입니다. 프리로드 이후에는 화면 전환 직전의 js 청크 다운로드를 위한 대기 시간이 대부분 제거되었고, 이후 파싱 작업 등이 더 빠르게 이어지면서 전환 체감도 함께 개선할 수 있었습니다.

여담입니다만, 이번 작업에서는 앰플리튜드 Journey차트 분석 과정에서 기획팀과 마케팅팀의 큰 도움을 받았습니다. 특히 /[id]와 같은 동적 경로들은 적절한 필터링 설정 없이는 정확한 전환 흐름을 확인하기 어려웠는데, 직접 샘플 차트까지 만들어 전달해주셔 많은 도움이 되었습니다. 다시 한번 좋은 서비스는 혼자 만드는 것이 아니라는 점을 느꼈던 순간이었습니다 ☺️.

Dongyeon.log