들어가며
모달 창을 열었다. 그리고 이제 모달 창을 닫는다. 아니, 나는 뒤로가기를 눌러 모달을 닫고 싶다. 어떻게 사용자가 뒤로가기를 눌렀을 때 모달을 닫히도록 할 수 있을까? 이를 어떻게 감지할 수 있을까? 그리고 어떻게 페이지를 새로 불러오지 않고 url만을 변경할 수 있을까?
이번 글에서는 History API를 활용한 위 문제의 해결 방법에 대해 다루고자 한다.
라우팅 관련 Web API
History API
History API는 브라우저의 세션 기록(방문 기록)을 조작할 수 있게 해주는 웹 API이다. 이를 통해 브라우저의 앞으로 가기, 뒤로 가기와 같은 탐색 동작을 프로그래밍 방식으로 제어할 수 있으며, URL을 변경하면서도 페이지를 새로고침하지 않는 동작을 구현할 수 있다. 특히 SPA(Single Page Application)에서 사용자 경험을 향상시키는 데 매우 유용한 API이다.
실제로 Next.js의 useRouter와 React Router의 useNavigate는 내부적으로 History API를 이용해 구현이 되어있다.
History API에는 다음과 같은 메서드가 있다.
pushState(),replaceState(): URL을 변경할 때 사용하는 메서드
pushState는 브라우저의 세션 기록에 새로운 항목을 추가하고, replaceState는 현재 세션 기록을 새로운 항목으로 대체한다. 두 메서드 모두 페이지를 새로고침하지 않고 URL을 변경할 수 있다는 특징이 있다.
forward(),back(),go(): 브라우저의 세션 기록을 탐색할 때 사용하는 메서드
forward()는 앞으로 가기, back()은 뒤로 가기, 그리고 go()는 특정 위치로 이동할 수 있게 해준다. go() 메서드는 매개변수로 정수를 받아 양수면 앞으로, 음수면 뒤로 이동한다.
Navigation API
Navigation API는 브라우저의 탐색 동작을 더욱 강력하게 제어할 수 있게 해주는 새로운 웹 API이다. History API와 달리 Navigation API는 더 현대적이고 선언적인 방식으로 페이지 탐색을 처리할 수 있으며 탐색 이벤트의 인터셉트와 프리로딩과 같은 고급 기능을 제공한다. 하지만 아직 실험적인 기능이며 브라우저 지원이 제한적이라는 단점이 있다.
현재 전세계 사용자의 76%브라우저에서 지원 중이다.
Location
Location은 현재 URL의 정보를 가져오고 조작할 수 있게 해주는 Web API 중 하나이다.
Location은 현재 URL에 관한 다양한 프로퍼티를 제공한다.
href: 전체 URL을 나타내는 문자열protocol: URL의 프로토콜 부분 (예: "http:", "https:")host: URL의 호스트명과 포트 번호hostname: URL의 호스트명port: URL의 포트 번호pathname: URL의 경로 부분search: URL의 쿼리스트링 부분 ("?"를 포함)hash: URL의 프래그먼트 식별자 부분 ("#"를 포함)
위 프로퍼티를 이용해 페이지 이동도 가능하다. 단, 페이지를 이동할 때 새로고침을 한다.
location.href = 'https://www.web-memo.site'
location.pathname = '/setting'
location.search = '?id=5'
Location의 메서드는 다음과 같다.
assign(): 새로운 URL로 이동하며 현재 페이지를 새로고침한다.
이는 location.href = url과 동일한 동작이다.
replace(): 현재 페이지를 새로운 URL로 대체하며 페이지를 새로고침한다.reload(): 현재 페이지를 새로고침한다.
Location은 현재 url에 대한 정보를 얻을 때, 그리고 외부 페이지로 이동할 때 유용하다. 그 외에는 현재 웹사이트에서 다른 페이지로 이동할 때는 매 번 새로고침을 하기에 적절하지 않다. 그래서 React Router 같은 SPA 라우팅을 사용하는 것이 사용자 경험 측면에서 좋다.
왜 Location만 API가 아닌가요 ?
Location은 History API, Navigation API와 달리 인터페이스이다.
Location은 초기 웹 브라우저에서 존재했던 기본적인 기능 중 하나이기 때문이다. History는 Location과 같이 BOM의 일부이다. 초기부터 존재하던 Location과 달리 하지만 History API는 HTML5(2014년)에서 새롭게 추가되었다.
BOM이 뭔가요 ?
BOM은 브라우저와 소통하기 위해 만들어진 모델로, 웹 페이지 내용을 제외한 웹 브라우저 창에 포함된 모든 객체 요소를 말한다.
종류는 다음과 같다.
window: 최상위 객체로 각 프레임 별로 하나씩 존재location: url 주소에 대한 정보를 제공document: 현재 문서에 대한 정보navigator: 브라우저의 정보 제공history: 브라우저의 방문 기록 정보를 제공screen: 브라우저의 외부 환경에 대한 정보를 제공
popstate
popstate는 브라우저의 세션 기록(방문 기록)이 변경될 때 발생하는 이벤트이다. 사용자가 브라우저의 뒤로 가기나 앞으로 가기 버튼을 클릭할 때 또는 history.back(), history.forward(), history.go() 메서드를 호출할 때 발생한다. 단 pushState()나 replaceState() 메서드를 호출할 때는 popstate 이벤트가 발생하지 않는다는 점에 주의해야 한다.
Next.js에서의 라우팅
Next.js 공식 문서에서는 페이지 리로딩 없이 브라우저의 히스토리 스택을 바꾸고 싶다면 History API를 사용할 것을 권장한다. pushState와 replaceState는 Next.js 라우터와 통합이 되며 usePathname, useSearchParams와도 동기화가 된다.
위 내용은 App Routing기준이며, Page Routing에서 히스토리 스택을 바꾸고 싶다면 useRouter의 세 번째 인자에 shallow : true를 전달하면 된다.
History API를 이용해 모달창 열고 닫기
Navigation API는 최신의 크롬 브라우저(102이상)에서만 지원하며, 간단한 히스토리 스택 조작과 url 수정 기능이 필요했기에 History API를 사용하기에 충분할 것이라 판단했다.
설계
자, 그럼 History API를 이용해 모달창과 통합해보자. 도식화를 하면 다음과 같다.
- 사용자가 모달을 연다.
- history.push()로 스택을 쌓는다. 이 때 세 번째 인자에 search params를 변경한 url을 전달해 url을 수정한다.
- 사용자가 모달을 닫는다.
- 사용자가 X버튼이나 모달 바깥 부분을 눌러 모달을 닫는다.
⇒ X버튼 및 모달 바깥 부분의 click 이벤트에 콜백 함수를 등록한다 : history.back()으로 이전 상태로 돌아간다.
2. 사용자가 뒤로가기를 눌러 모달을 닫는다.
⇒ popstate 이벤트 리스너에 콜백 함수를 등록한다 : 모달을 닫는 로직을 작성한다.
코드
자, 이제 코드로 이를 옮겨보자.
- 사용자가 모달을 연다.
// MemoGrid.tsx
export default function MemoGrid() {
const [dialogMemoId, setDialogMemoId] = useState<number | null>();
// ..
return (
<>
<MemoItem
// ..
setDialogMemoId={setDialogMemoId}
/>
{dialogMemoId && <MemoDialog setDialogMemoId={setDialogMemoId} />}
</>
)
}
// MemoItem.tsx
export default function MemoItem({
setDialogMemoId,
}) {
const searchParams = useSearchParams();
const handleMemoItemClick = (event: MouseEvent<HTMLElement>) => {
searchParams.set('id', id);
setDialogMemoId(Number(id));
history.pushState({ openedMemoId: Number(id) }, '', searchParams.getUrl());
}
};
메모 아이템을 누르면 dialogItemId 상태가 변경된다. 이 때 history.pushState()를 이용해 search params를 변경하며 히스토리 스택을 쌓는다. 또한 상태로 openedMemoId를 전달한다. 이는 사용자가 뒤로가기를 누르거나, 앞으로 가기를 눌렀을 때 어떤 모달이 열려야할지 알려주는 역할을 한다. 즉 히스토리 스택의 상태라고 보면 된다.
- 사용자가 모달을 닫는다.
- 사용자가 X버튼이나 모달 바깥 부분을 눌러 모달을 닫는다.
// MemoDialog.tsx
export default function MemoDialog({ lng, memoId, setDialogMemoId }: MemoDialog) {
const closeDialog = () => {
setDialogMemoId(null);
const hasPreviousPage = history.state?.openedMemoId === memoId;
if (hasPreviousPage) history.back();
};
// ..
}
history.back()을 이용해 1번에서 추가한 히스토리 스택을 제거한다.
이 때 주의할 점이 있다. 위의 코드를 보면 hasPreviousPage라는 플래그가 있는 것을 확인할 수 있다. 그리고 history.state로 현재 히스토리 스택의 상태를 확인한다. 이는, 사용자가 모달을 열어서 이 페이지에 접속했는지 혹은 url을 입력해서 이 페이지에 접속했는지 체크하기 위함이다. 전자의 경우 1번에서 history.pushState에서 상태를 설정했기에 히스토리 스택에 상태가 존재할 것이고, 후자의 경우 존재하지 않을 것이다. 히스토리 스택이 쌓인 전자만 hisotry.back()으로 히스토리 스택을 제거한다.
2. 사용자가 뒤로가기를 눌러 모달을 닫는다.
// MemoGrid.tsx
useEffect(function closeDialogOnPopState() {
const handlePopstate = () => {
setDialogMemoId(history.state?.openedMemoId);
};
window.addEventListener('popstate', handlePopstate);
return () => {
window.removeEventListener('popstate', handlePopstate);
};
}, []);
사용자의 뒤로가기 혹은 앞으로 가기를 감지하기 위해서는 popstate 이벤트를 활용할 수 있다. 이 때 1번에서 히스토리 스택을 쌓을 때 첫 번쨰 인자로 넘긴 히스토리 스택의 상태를 이용해, 해당 상태로 모달을 설정한다. 이를 통해 히스토리 스택의 상태를 이용해 모달의 상태를 동기화할 수 있다.
- 처음 페이지에 접속했을 때 search params 여부에 따라 모달을 보여주거나 보여주지 않는다.
useEffect(function updateDialogId() {
const currentDialogId = searchParams.get('id');
if (!currentDialogId) return;
setDialogMemoId(Number(currentDialogId));
}, []);
꿀팁
framer-motion의 layoutId를 이용한 자연스러운 애니메이션
만약 위 예시와 같이 여러 아이템들 중 하나를 클릭하면 해당 요소가 확대되는 기능이라면 framer-motion의 layoutId를 활용하면 자연스러운 애니메이션을 사용자에게 보여줄 수 있다.
코드
- container
<motion.div
layoutId={`card-container-${id}`}
>
- card-header
<motion.div
layoutId={`card-header-${id}`}
>
- card-body
<motion.div
layoutId={`card-body-${id}`}
>
- card-footer
<motion.div
layoutId={`card-footer-${id}`}
>
위와 같이 전체 컨테이너, 내부 헤더, 바디, 푸터 각각에 layoutId를 설정한다. 이를 모달과 메모 아이템 컴포넌트에서 동일한 layoutId를 사용하면 메모 아이템이 모달로 확대되는 과정을 자연스러운 애니메이션으로 표현할 수 있다.
이는 특히 next/navigation의 useRouter를 사용하지 않고 History API를 이용한 CSR에서 특히 효과적이다. 페이지 전체를 리로드하지 않고도 URL을 변경할 수 있기 때문이다. useRouter를 사용할 때는 다음과 같이 뚝뚝 끊기는 것을 확인할 수 있다.
마무리
이번 글에서는 History API를 활용해 모달을 자연스럽게 열고 닫는 방법에 대해 살펴보았다. History API를 이용하면 페이지 새로고침 없이 URL을 변경할 수 있고, 사용자의 브라우저 뒤로가기/앞으로가기 동작도 자연스럽게 처리할 수 있다. 여기에 framer-motion의 layoutId를 활용하면 더욱 부드러운 사용자 경험을 제공할 수 있다.
특히 Next.js의 App Router 환경에서도 공식 문서에서 권장하는 방식이니만큼, SPA에서 모달을 구현할 때 History API 활용을 고려해보면 좋을 것 같다.
웹사이트에서 히스토리 스택은 사용자 경험과도 밀접한 관련이 있다. 뒤로가기를 눌렀을 때 이 모달이 닫히거나, 창이 닫히는 것을 기대했는데 닫히지 않고 다른 페이지로 이동한다면 사용자는 혼란스러워할 것이다. 이는 웹에서만 한정되는 것이 아니다. 앱에서도 네비게이션 스택을 쌓으며 페이지를 이동하고는 한다. 앱은 ‘탭’이라는 개념이 존재하기에 조금 더 고민해야할 것이 많을 수 있다.
앞으로도 히스토리 스택과 같은 사소한 것이여도 사용자 경험과 관련이 있다면 항상 관심을 기울이고 ‘사용자는 이 액션에 무엇을 기대할까?’를 고민하는 프론트엔드 개발자가 되어야겠다고 생각했다.