1. 사용자가 욕하며 떠나는 이유
이 문제는 특히 목록에서 상세 페이지로 이동할 때 빈번하게 발생합니다. 사용자는 자신의 위치를 잃어버리는 것을 극도로 싫어합니다.
패션 쇼핑몰 앱을 만들고 있었습니다. 사용자가 스크롤을 한참 내려서 153번째 상품을 클릭했습니다. 상세 페이지를 보고, "음 별로네" 하고 뒤로가기를 눌렀습니다.
그런데, 화면이 목록 맨 위(1번째 상품)로 초기화되어 있었습니다. 사용자는 다시 153번째까지 스크롤을 내려야 합니다. 저라면? 그냥 앱 끕니다.
이 사소한 스크롤 복원(Scroll Restoration) 실패가 서비스의 이탈률을 엄청나게 높이고 있었습니다. "브라우저가 원래 알아서 해주는 거 아니었어?" 아닙니다. SPA(Single Page Application)의 세상에서는 아무도 공짜로 해주지 않습니다.
2. 왜 SPA에선 스크롤이 까먹나?
전통적인 웹 (MPA)
a href 태그를 눌러 다른 페이지로 갔다가 뒤로 오면, 브라우저가 이전 스크롤 위치를 기억해 둡니다.
HTML 문서를 새로 받아오면서 브라우저 엔진이 복구해주죠.
현대적인 웹 (SPA / Next.js)
우리는 react-router나 next/link를 씁니다.
페이지가 바뀌는 척하지만, 사실은 JavaScript로 DOM만 갈아끼우는 것입니다.
브라우저 입장에선 "페이지 이동"이 아니라 "화면 갱신"일 뿐입니다.
그래서 뒤로가기를 눌러도 브라우저는 "어디로 스크롤을 돌려놔야 하지?"라고 묻지 않습니다. 그냥 개발자가 시킨 대로(보통은 맨 위로) 렌더링할 뿐입니다.
3. 해결책 1 - Next.js의 마법 (Config 설정)
Next.js 13+ (App Router) 혹은 Pages Router의 최신 버전에서는 아주 간단한 옵션을 제공합니다. 이걸 몰라서 3일을 삽질했습니다.
// next.config.js
module.exports = {
experimental: {
scrollRestoration: true,
},
}
이 옵션을 켜면 Next.js가 내부적으로 History API를 조작해서 스크롤 위치를 저장하고 복구해줍니다.
하지만 모든 상황에서 완벽하지는 않습니다. (특히 무한 스크롤이 있는 경우).
4. 해결책 2 - 수동 구현 (The Hard Way)
만약 프레임워크의 도움을 못 받거나, 아주 정밀한 제어가 필요하다면 직접 만들어야 합니다. 원리는 간단합니다. "나가기 전에 저장하고, 들어오면 복구한다."
1) 저장하기 (SessionStorage)
페이지를 떠나는 순간(beforeUnload 혹은 라우터 이벤트), 현재 window.scrollY를 저장합니다.
// useScrollSave.ts
import { useEffect } from 'react';
import { useRouter } from 'next/router';
export const useScrollSave = () => {
const router = useRouter();
useEffect(() => {
const handleRouteChangeStart = () => {
sessionStorage.setItem(`scroll_${router.asPath}`, window.scrollY.toString());
};
router.events.on('routeChangeStart', handleRouteChangeStart);
return () => {
router.events.off('routeChangeStart', handleRouteChangeStart);
};
}, [router]);
};
2) 복구하기
페이지에 다시 들어왔을 때(useEffect), 저장된 값이 있으면 거기로 점프합니다.
// useScrollRestore.ts
import { useEffect } from 'react';
import { useRouter } from 'next/router';
export const useScrollRestore = () => {
const router = useRouter();
useEffect(() => {
const scrollPos = sessionStorage.getItem(`scroll_${router.asPath}`);
if (scrollPos) {
window.scrollTo(0, parseInt(scrollPos));
}
}, [router]);
};
이 방식의 치명적인 단점은 "화면이 번쩍(Flash)"거릴 수 있다는 점입니다. 데이터 로딩보다 스크롤 복구가 먼저 일어나면, 빈 화면의 맨 아래로 스크롤됐다가 데이터가 채워지는 기현상을 보게 됩니다.
5. 해결책 3 - React Query와의 환상적인 조합 (Feat. 무한 스크롤)
쇼핑몰 목록은 보통 무한 스크롤(Infinite Scroll)입니다.
데이터가 page=1, 2, 3... 이렇게 순차적으로 로딩되어야 스크롤 위치를 복구할 수 있습니다.
TanStack Query(React Query)를 쓰면 데이터 캐싱이 되므로 스크롤 복원과 찰떡궁합입니다.
// React Query는 데이터를 메모리에 들고 있습니다.
// 뒤로가기를 해도 API를 다시 호출하지 않고 캐시된 데이터를 즉시 보여줍니다.
const { data } = useInfiniteQuery({
queryKey: ['products'],
// ...
staleTime: 1000 * 60 * 5, // 5분 동안은 신선함 유지
});
데이터가 즉시 렌더링되므로, 브라우저나 Next.js가 스크롤을 복구할 때 "화면 길이"가 확보되어 있어 자연스럽게 돌아갑니다.
6. 마무리 - 디테일이 명품을 만든다
기능이 동작한다고 끝난 게 아닙니다. 사용자는 "내가 보던 그 위치"로 돌아가고 싶어 합니다. 이 욕구를 무시하면 사용자는 떠납니다.
개발자인 우리는 이렇게 생각해야 합니다. "뒤로가기는 시간 여행이다." 사용자를 정확히 과거의 그 시점으로 돌려보내 주는 것, 그것이 프론트엔드 개발자의 배려이자 실력입니다.
I Pressed Back, and It Jumped to the Top (Scroll Restoration)
1. Why Users Rage-Quit
I was building a fashion e-commerce app. A user scrolled way down and clicked on the 153rd item. Checked the details, thought "Meh," and hit Back.
Boom. The screen reset to the top (1st item). The user has to scroll down to the 153rd item again. If it were me? I'd just close the app.
This trivial Scroll Restoration failure was skyrocketing our bounce rate. "Doesn't the browser handle this automatically?" No. In the world of SPI (Single Page Applications), nothing is free.
2. Why Does SPA Forget Scroll?
Traditional Web (MPA)
When you click an a href and come back, the browser remembers the previous scroll position.
It re-renders the HTML and restores the offset natively.
Modern Web (SPA / Next.js)
We use react-router or next/link.
Navigation looks real, but it's just JavaScript swapping DOM elements.
To the browser, it's not a "page navigation," just a "screen update."
So when you hit Back, the browser doesn't ask "Where should I scroll?" It just renders whatever the code says (usually starting at (0,0)).
3. Solution 1: Next.js Magic (Config)
Next.js 13+ (App Router) or recent Pages Router versions provide a simple config. I wasted 3 days not knowing this.
// next.config.js
module.exports = {
experimental: {
scrollRestoration: true,
},
}
This makes Next.js manipulate the History API to save and restore scroll positions.
However, it's not perfect for all cases (especially with infinite scrolling lists).
4. Solution 2: Manual Implementation (The Hard Way)
If you can't rely on the framework or need precise control, do it yourself. The principle is simple: "Save on Exit, Restore on Enter."
1) Save (SessionStorage)
Save window.scrollY when leaving the route (beforeUnload or router event).
// useScrollSave.ts
import { useEffect } from 'react';
import { useRouter } from 'next/router';
export const useScrollSave = () => {
const router = useRouter();
useEffect(() => {
const handleRouteChangeStart = () => {
sessionStorage.setItem(`scroll_${router.asPath}`, window.scrollY.toString());
};
router.events.on('routeChangeStart', handleRouteChangeStart);
return () => {
router.events.off('routeChangeStart', handleRouteChangeStart);
};
}, [router]);
};
2) Restore
On re-entry (useEffect), jump to the saved position.
// useScrollRestore.ts
import { useEffect } from 'react';
import { useRouter } from 'next/router';
export const useScrollRestore = () => {
const router = useRouter();
useEffect(() => {
const scrollPos = sessionStorage.getItem(`scroll_${router.asPath}`);
if (scrollPos) {
window.scrollTo(0, parseInt(scrollPos));
}
}, [router]);
};
The fatal flaw here is the "Flash." If scroll restoration happens before data loading, the user jumps to the bottom of an empty screen, then content pops in.
5. Solution 3: React Query Combo (Feat. Infinite Scroll)
Shopping lists are usually Infinite Scroll.
Data page=1, 2, 3... must be loaded sequentially to restore the scroll position.
TanStack Query (React Query) is the perfect partner because of caching.
// React Query holds data in memory.
// Hitting 'Back' doesn't refetch API; it shows cached data instantly.
const { data } = useInfiniteQuery({
queryKey: ['products'],
// ...
staleTime: 1000 * 60 * 5, // Keep fresh for 5 mins
});
Since data renders instantly, the page has the necessary "height" for the browser/Next.js to restore the scroll position naturally.
6. The Virtualization Trap (react-window)
Modern lists are often Virtual. We use libraries like react-window or react-virtuoso to render only the items currently in the viewport.
This destroys native scroll restoration.
Why?
- User scrolls to item #1000 (Height: 50,000px).
- User clicks item.
- User hits "Back".
- Browser tries to scroll to 50,000px.
- But the list is empty! Virtual lists only render items after scroll events. The initial height is 0.
- Browser sees document height is 0, so it resets scroll to 0.
The Fix:
You must store the Scroll Offset in global state (like Redux or Zustand) or Context before unmounting the list.
When the list remounts, pass that offset to the virtual list's initialScrollOffset prop.
// Example with react-window
const List = () => {
const listRef = useRef();
const savedOffset = useStore(state => state.scrollOffset);
useEffect(() => {
if (savedOffset) {
listRef.current.scrollTo(savedOffset);
}
return () => {
// Save offset on unmount
saveOffset(listRef.current.state.scrollOffset);
}
}, []);
return <FixedSizeList ref={listRef} ... />;
}
7. Advanced: History API Deep Dive
If you want full control, you must understand history.scrollRestoration.
By default, it is set to 'auto'. The browser tries to be helpful.
But in SPAs, its help is often wrong.
// Disable browser's native attempt
if ('scrollRestoration' in history) {
history.scrollRestoration = 'manual';
}
Once set to manual, the browser stops interfering. Now YOU are the god of scroll.
You can listen to popstate events and restore scroll exactly when you want (e.g., after your API data has fully loaded).
It's tedious, but for high-end UX (like Instagram or Twitter feeds), manual control is the only way to get that "smooth, non-jumpy" feel.
8. Common Pitfall: The scroll Event Performance
While we are discussing scroll, a warning: Never attach a raw scroll listener.
// BAD: Fires 100+ times per second
window.addEventListener('scroll', handleScroll);
If you try to save scroll position on every pixel change, you will kill the main thread.
Always use Throttling (e.g., lodash.throttle) or requestAnimationFrame.
// GOOD: Fires at most once every 100ms
const saveScroll = throttle(() => {
sessionStorage.setItem('scrollY', window.scrollY);
}, 100);
window.addEventListener('scroll', saveScroll);
This ensures that your "restoration logic" doesn't destroy the "scrolling experience."
9. Conclusion: Details Make the Product
It works ≠ It's done. Users want to return to "exactly where I was." If they lose their place, they lose their flow. And if they lose their flow, they leave your app.
Implement scroll restoration. It’s the difference between a "School Project" and a "Pro App."
Final Thought: The Time Traveler
As developers, we must think: "The Back button is Time Travel." Sending the user back to that exact moment in the past—that is the courtesy and skill of a frontend developer.