
1. 들어가며
부드러운 전환, 네이티브처럼
웹뷰 프로젝트에서 디자인팀으로부터 요청을 받았습니다.
“페이지를 넘길 때 슬라이드 애니메이션을 추가해주세요.”
디자인팀에서 이런 요청을 받았습니다. 페이지를 넘어갈 때 슬라이드 애니메이션을 추가해달라는 것이었죠.

네이티브 앱에서는 ViewPager를 사용하면 이런 기능이 기본으로 제공되지만, 웹뷰에서는 framer-motion 같은 라이브러리를 사용해 직접 구현해야 했습니다. 네이티브의 스와이프 제스처와 웹의 애니메이션을 동기화하면서도 성능을 유지하는 것이 과제였습니다.
ViewPager란? 안드로이드의 ViewPager(또는 Compose의 HorizontalPager)는 스와이프 제스처로 페이지를 넘길 수 있는 UI 컴포넌트입니다. 스와이프와 함께 부드러운 슬라이드 애니메이션이 기본으로 제공되고, 화면 밖의 페이지는 자동으로 최적화됩니다.
하지만 구현 후 실제 테스트를 해보니 심각한 문제가 발견되었습니다.
2. 문제 정의
잔상이 남는 페이지 전환

Slack에 메시지가 왔습니다. 웹 성능을 mobile 4G 이하로 맞추는 경우 잔상이 남는 이슈가 발견되었다는 것이었습니다.
사용자가 스와이프를 완료해도 다음 페이지가 즉시 보이지 않았습니다. 애니메이션은 끝났지만 화면은 갱신되지 않고 이전 페이지가 계속 노출되고 있었습니다.
페이지 전환 시간을 측정해보니 총 750ms가 걸렸습니다. Chrome DevTools의 Performance 탭으로 분석한 결과, 애니메이션이 진행되는 250ms 동안 브라우저는 아무것도 하지 않고 있었습니다. 애니메이션이 끝난 후에야 리소스 로딩이 시작되는 순차적 처리 방식이 문제였습니다.
3. 해결 과정
3.1 애니메이션과 리소스 로딩 병렬화
기존 코드는 이런 구조였습니다:
// ❌ Before: 순차적 처리
const handlePageTransition = async (targetPage, direction) => {
// 애니메이션 실행 (250ms)
await animate(pageX, -direction * halfScreen, {
duration: 0.25
});
// 애니메이션 끝난 후 페이지 이동
navigate({ to: targetPage });
};
애니메이션이 진행되는 250ms 동안 브라우저가 아무것도 하지 않고 있다는 것을 발견했습니다. 이 시간을 활용해서 미리 리소스를 다운로드하면 어떨까 생각했습니다.
TanStack Router의 preloadRoute 기능을 활용해서 애니메이션과 리소스 로딩을 동시에 실행하도록 개선했습니다.
preloadRoute
란?
미리 특정 라우트의 리소스를 다운로드해두는 기능입니다. 사용자가 해당 페이지로 이동할 때 이미 준비된 상태이므로 전환 속도가 매우 빨라집니다.
// ✅ After: 병렬 처리
const handlePageTransition = async (targetPage, direction) => {
// 병렬 실행: 애니메이션 + 리소스 로드
await Promise.all([
router.preloadRoute({ to: targetPage }),
animate(pageX, -direction * halfScreen, {
duration: 0.25
})
]);
navigate({ to: targetPage });
};
사용자가 부드러운 애니메이션을 보는 동안, 브라우저는 백그라운드에서 조용히 다음 페이지를 준비하도록 만들었습니다. 이 방식으로 페이지 전환 시간을 750ms에서 350ms로 단축시킬 수 있었습니다.
3.2 빈 화면 문제
Promise가 진행되는 동안 잠시 흰색 화면이 보이는 문제가 있었습니다. 애니메이션으로 인해 x 좌표가 100%를 초과하면서 빈 화면이 노출되었습니다.
하지만 이는 기존의 “이전 페이지가 잔상으로 남는” 문제보다는 나은 UX라고 판단했습니다. 잔상이 남으면 사용자는 앱이 고장난 것처럼 느끼지만, 빈 화면은 로딩 중임을 인지할 수 있고 곧바로 새 페이지가 표시되기 때문입니다.
4. 사용자 행동 예측
4.1 행동 패턴 기반 Prefetch
350ms도 여전히 아쉬웠습니다. Google Analytics 데이터를 분석한 결과, 사용자의 95%가 Dashboard → Gallery → Inspection 순서로 이동한다는 것을 발견했습니다. 또한 Dashboard에서 평균 2.3초를 머문다는 것도 확인했습니다.
사용자가 Dashboard를 보고 있는 동안, 브라우저가 idle 상태일 때 Gallery 페이지를 미리 로드하면 어떨까 생각했습니다.
4.2 requestIdleCallback 활용
requestIdleCallback을 사용하면 브라우저의 메인 스레드가 여유로울 때 실행할 작업을 예약할 수 있습니다. 중요한 렌더링이나 사용자 인터랙션을 방해하지 않고, “틈날 때” 백그라운드 작업을 처리할 수 있는 것입니다.
// Dashboard.tsx
export function Dashboard() {
const router = useRouter();
useEffect(() => {
// 브라우저가 idle 상태일 때 Gallery prefetch
requestIdleCallback(() => {
router.preloadRoute({ to: '/gallery' });
});
}, [router]);
return <div>{/* Dashboard 내용 */}</div>;
}
사용자가 Dashboard를 보는 동안 백그라운드에서 조용히 Gallery 리소스를 로드하도록 구현했습니다. 이렇게 하면 사용자가 Gallery 버튼을 클릭할 때 이미 모든 리소스가 준비된 상태이므로 즉시 페이지가 표시됩니다.
4.3 계단식 Prefetch
Gallery에서도 같은 전략을 적용했습니다. 사용자가 Dashboard에 진입하면 2초 후 Gallery prefetch가 완료되고, Gallery 클릭 시 10ms 만에 전환됩니다. 그리고 Gallery에서 3초 후 Inspection prefetch가 완료되어, 다시 Inspection 클릭 시 10ms 만에 전환됩니다.
사용자가 따라가는 전체 여정에서 네트워크 대기 시간을 완전히 제거할 수 있었습니다.
5. 데이터 로딩 개선
loader를 통한 데이터 준비
기존에는 컴포넌트가 마운트된 후에 데이터를 요청했습니다:
// ❌ Before
export function Gallery() {
const [photos, setPhotos] = useState([]);
useEffect(() => {
fetchPhotos().then(data => setPhotos(data));
}, []);
return <div>{/* 렌더링 */}</div>;
}
TanStack Router의 loader 기능을 활용하면 라우팅 시작과 동시에 데이터를 요청할 수 있습니다:
// ✅ After
export const galleryRoute = createRoute({
path: '/gallery',
loader: async () => {
const photos = await fetchPhotos();
return { photos };
},
component: Gallery,
});
function Gallery() {
const { photos } = galleryRoute.useLoaderData();
return <div>{/* 로딩 상태 없이 바로 렌더링 */}</div>;
}
preloadRoute는 loader도 함께 실행하기 때문에, 사용자가 페이지를 보는 동안 다음 페이지의 데이터까지 미리 준비할 수 있었습니다.
6. 최종 결과
페이지 전환 시간을 750ms에서 10ms로 단축시켰습니다. 99%의 성능 개선을 달성한 것입니다. 첫 번째 전환에서는 prefetch가 적용되지 않아 350ms가 걸리지만, 그 이후부터는 모든 전환이 10ms 만에 완료됩니다.
사용자의 95%는 두 번째 전환부터 즉각적인 반응을 경험하게 되었습니다. 네트워크 요청 수는 동일하지만, 사용자가 기다리는 시간이 0ms가 된 것입니다.
7. 실패한 시도들
모든 페이지를 동시에 Prefetch
초기에는 욕심을 내서 모든 페이지를 한 번에 prefetch 하려고 했습니다:
// ❌ 나쁜 예
useEffect(() => {
requestIdleCallback(() => {
router.preloadRoute({ to: '/gallery' });
router.preloadRoute({ to: '/inspection' });
router.preloadRoute({ to: '/admin' });
router.preloadRoute({ to: '/settings' });
});
}, []);
하지만 이는 불필요한 네트워크 대역폭을 소비했고, Admin과 Settings는 5%의 사용자만 방문하기 때문에 모바일 데이터를 낭비하는 결과를 낳았습니다. 사용자 행동 패턴을 기반으로 선택적으로 prefetch 해야 한다는 것을 배웠습니다.
setTimeout으로 Prefetch 타이밍 조절
requestIdleCallback 대신 setTimeout을 사용해봤습니다:
// ❌ 나쁜 예
useEffect(() => {
setTimeout(() => {
router.preloadRoute({ to: '/gallery' });
}, 1000);
}, []);
이 방식은 브라우저가 바쁜 상태에도 실행되어 초기 페이지 렌더링을 방해했고, 사용자 기기 성능에 따라 다른 결과를 보여줬습니다. requestIdleCallback을 사용해야 브라우저가 여유로울 때만 실행된다는 것을 깨달았습니다.
8. 핵심 교훈
병렬화의 힘
애니메이션과 리소스 로딩을 병렬로 처리하는 것만으로도 엄청난 성능 개선을 이룰 수 있었습니다. 순차적으로 처리하면 250ms + 500ms = 750ms가 걸리지만, 병렬로 처리하면 max(250ms, 500ms) = 500ms로 줄어듭니다. 간단한 Promise.all 하나로 250ms를 절약한 것입니다.
사용자 행동 예측
사용자의 95%가 같은 경로를 따른다는 데이터를 발견하고 이를 적극 활용했습니다. Google Analytics를 통해 사용자 흐름을 파악하고, 그에 맞춰 페이지를 미리 준비하는 전략이 매우 효과적이었습니다.
Idle 시간의 활용
사용자가 콘텐츠를 읽는 2~3초는 브라우저 입장에서 황금 같은 시간이었습니다. requestIdleCallback으로 이 시간을 활용하면, 사용자 경험을 해치지 않으면서도 다음 페이지를 준비할 수 있었습니다.
체감 속도의 중요성
페이지 전환에 750ms가 걸리면 사용자는 앱이 느리다고 느끼지만, 백그라운드에서 미리 준비해두면 사용자는 단 10ms만 기다립니다. 체감 속도와 실제 속도는 다르다는 것을 명심해야 합니다.
9. 마치며
80줄의 코드로 750ms를 10ms로 만든 여정이었습니다. 측정하고, 가설을 세우고, 구현하고, 검증하는 과정을 반복했습니다. Chrome DevTools로 병목 구간을 파악하고, 애니메이션과 리소스 로딩을 병렬화하는 가설을 세웠습니다. Promise.all을 적용해서 750ms에서 350ms로 개선했고, Prefetch를 추가해서 350ms에서 10ms로 최종 개선했습니다.
사용자는 더 이상 기다리지 않습니다. 네이티브 앱처럼 즉각 반응하는 웹뷰를 경험합니다. 측정 없이는 개선도 없습니다. 여러분의 웹뷰도 충분히 빠를 수 있습니다.
참고자료 https://developer.mozilla.org/ko/docs/Web/API/Window/requestIdleCallback