
뒤로가기를 눌렀는데 맨 위로 튀어 올라갔다 (Scroll Restoration)
쇼핑몰 목록에서 상품을 보고 뒤로가기를 눌렀는데, 스크롤이 초기화되어 사용자가 이탈했습니다. SPA와 Next.js에서 스크롤 복원(Scroll Restoration) 문제를 해결한 삽질기를 공유합니다.

쇼핑몰 목록에서 상품을 보고 뒤로가기를 눌렀는데, 스크롤이 초기화되어 사용자가 이탈했습니다. SPA와 Next.js에서 스크롤 복원(Scroll Restoration) 문제를 해결한 삽질기를 공유합니다.
로그인 화면을 만들었는데 키보드가 올라오니 노란 줄무늬 에러가 뜹니다. resizeToAvoidBottomInset부터 스크롤 뷰, 그리고 채팅 앱을 위한 reverse 팁까지, 키보드 대응의 모든 것을 정리해봤습니다.

클래스 이름 짓기 지치셨나요? HTML 안에 CSS를 직접 쓰는 기괴한 방식이 왜 전 세계 프론트엔드 표준이 되었는지 파헤쳐봤습니다.

분명히 클래스를 적었는데 화면은 그대로다? 개발자 도구엔 클래스가 있는데 스타일이 없다? Tailwind 실종 사건 수사 일지.

시각 장애인, 마우스가 고장 난 사용자, 그리고 미래의 나를 위한 배려. `alt` 태그 하나가 만드는 큰 차이.

이 문제는 특히 목록에서 상세 페이지로 이동할 때 빈번하게 발생합니다. 사용자는 자신의 위치를 잃어버리는 것을 극도로 싫어합니다.
패션 쇼핑몰 앱을 만들고 있었습니다. 사용자가 스크롤을 한참 내려서 153번째 상품을 클릭했습니다. 상세 페이지를 보고, "음 별로네" 하고 뒤로가기를 눌렀습니다.
그런데, 화면이 목록 맨 위(1번째 상품)로 초기화되어 있었습니다. 사용자는 다시 153번째까지 스크롤을 내려야 합니다. 저라면? 그냥 앱 끕니다.
이 사소한 스크롤 복원(Scroll Restoration) 실패가 서비스의 이탈률을 엄청나게 높이고 있었습니다. "브라우저가 원래 알아서 해주는 거 아니었어?" 아닙니다. SPA(Single Page Application)의 세상에서는 아무도 공짜로 해주지 않습니다.
a href 태그를 눌러 다른 페이지로 갔다가 뒤로 오면, 브라우저가 이전 스크롤 위치를 기억해 둡니다.
HTML 문서를 새로 받아오면서 브라우저 엔진이 복구해주죠.
우리는 react-router나 next/link를 씁니다.
페이지가 바뀌는 척하지만, 사실은 JavaScript로 DOM만 갈아끼우는 것입니다.
브라우저 입장에선 "페이지 이동"이 아니라 "화면 갱신"일 뿐입니다.
그래서 뒤로가기를 눌러도 브라우저는 "어디로 스크롤을 돌려놔야 하지?"라고 묻지 않습니다. 그냥 개발자가 시킨 대로(보통은 맨 위로) 렌더링할 뿐입니다.
Next.js 13+ (App Router) 혹은 Pages Router의 최신 버전에서는 아주 간단한 옵션을 제공합니다. 이걸 몰라서 3일을 삽질했습니다.
// next.config.js
module.exports = {
experimental: {
scrollRestoration: true,
},
}
이 옵션을 켜면 Next.js가 내부적으로 History API를 조작해서 스크롤 위치를 저장하고 복구해줍니다.
하지만 모든 상황에서 완벽하지는 않습니다. (특히 무한 스크롤이 있는 경우).
만약 프레임워크의 도움을 못 받거나, 아주 정밀한 제어가 필요하다면 직접 만들어야 합니다. 원리는 간단합니다. "나가기 전에 저장하고, 들어오면 복구한다."
페이지를 떠나는 순간(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]);
};
페이지에 다시 들어왔을 때(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)"거릴 수 있다는 점입니다. 데이터 로딩보다 스크롤 복구가 먼저 일어나면, 빈 화면의 맨 아래로 스크롤됐다가 데이터가 채워지는 기현상을 보게 됩니다.
쇼핑몰 목록은 보통 무한 스크롤(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가 스크롤을 복구할 때 "화면 길이"가 확보되어 있어 자연스럽게 돌아갑니다.
기능이 동작한다고 끝난 게 아닙니다. 사용자는 "내가 보던 그 위치"로 돌아가고 싶어 합니다. 이 욕구를 무시하면 사용자는 떠납니다.
개발자인 우리는 이렇게 생각해야 합니다. "뒤로가기는 시간 여행이다." 사용자를 정확히 과거의 그 시점으로 돌려보내 주는 것, 그것이 프론트엔드 개발자의 배려이자 실력입니다.
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.
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.
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)).
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).
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."
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]);
};
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.
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.
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?
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} ... />;
}
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.
scroll Event PerformanceWhile 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."
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."
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.