---
title: "데이터 기반의 프리로드 최적화 전략: 사용자 여정 분석을 통해 모바일 성능 개선하기"
description: "페이지 전환의 사용성 개선을 위해 앰플리튜드의 Journey 차트와 이벤트 로그를 활용해 프리로드 전략을 수립해보았습니다."
date: 2026-04-04
tags: [ux, performance, javascript]
sections: [technical]
draft: false
---

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

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

|                                                      극단적인 환경에서 발생한 극단적인 INP                                                       |
| :----------------------------------------------------------------------------------------------------------------------------------------------: |
| ![매우 극단적인 환경에서 발생한 매우 극단적인 INP](https://velog.velcdn.com/images/ksr20612/post/acac60e9-c967-4468-bf71-81901c29fd94/image.png) |

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

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

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

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

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

|                                     앰플리튜드의 Jorney 차트, Pathfinder                                      |
| :-----------------------------------------------------------------------------------------------------------: |
| ![Journey 차트](https://velog.velcdn.com/images/ksr20612/post/6590806a-3aec-4713-915a-5e9efed0b787/image.png) |

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

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

|                                          preload 처리할 25개 구간 선정                                          |
| :-------------------------------------------------------------------------------------------------------------: |
| ![25개 구간 선정](https://velog.velcdn.com/images/ksr20612/post/322b4ec6-36a7-47bd-a878-1545558d76ae/image.png) |

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

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

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

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

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

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

<img
 width="100%"
 src="https://velog.velcdn.com/images/ksr20612/post/f4bec87c-d3df-4a1d-b715-764825dd770e/image.png"
 />

<img
 width="100%"
 src="https://velog.velcdn.com/images/ksr20612/post/662febe5-378f-4178-b60f-cad1e7e7c82a/image.gif"
 />

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

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

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

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

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

```typescript
// 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` 훅을 통해서만 호출하게 함으로써 컨텍스트에 데이터를 추가로 전달하는 것이죠.

```typescript
// 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`에 **더 풍부한 컨텍스트**가 담겨 있기에, 프리로드 메서드에서는 좀더 다양한 동작들을 자연스럽게 사용할 수 있습니다.

```typescript
// 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차트의 신뢰도 이슈                                 |
| :------------------------------------------------------------------------------------------------------------: |
| ![기획팀의 도움](https://velog.velcdn.com/images/ksr20612/post/db6c5c54-4933-4bfd-8359-bb3463faf665/image.png) |

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

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

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

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

|                                                 데이터 수집 결과 일부                                                  |
| :--------------------------------------------------------------------------------------------------------------------: |
| ![데이터 수집 결과 일부](https://velog.velcdn.com/images/ksr20612/post/2b536be9-02f8-45a5-a48e-4df46e6446bf/image.png) |

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

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

## 마무리하며

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

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

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

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

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