취준하고 취업하고 일한다고 오랜만에 글을 작성해 보네요..
앞으로는 종종 작성해 보도록 노력해 보겠습니다. (보장은 못..ㅎㅎ)
이번 글에서는 회사 업무를 다루며 발생한 NextJS의 Router 문제 해결 방안에 대해 서술해보려고 합니다.
기본 플로우
지금 맡고있는 프로젝트에서 동영상에 접근하기 위한 기본적인 url로 /video/${videoId}를 사용합니다.
여기서 하단 BottomSheet를 클릭하면 /video/${videoId}#해시값으로 url이 변경되면서 BottomSheet가 열리고 닫히는 무한 루프가 발생했습니다.
문제점
문제는 바로 여기서 발생합니다.
첫 번째 동영상 페이지를 /video/71으로 가정하고, 두 번째 동영상 페이지를 /video/109로 가정하겠습니다.
첫 번째 동영상 시청의 경우 이전에 시청한 동영상이 없기 때문에 뒤로 가기 버튼이 존재하지 않습니다.
하지만 두 번째 동영상 시청 이후부터는 이전에 시청한 동영상이 history에 남습니다.
두 번째 동영상 페이지에서 BottomSheet를 열고 닫은 후 첫 번째 동영상 페이지로 이동하기 위해 뒤로 가기 버튼을 클릭합니다.
이 뒤로가기 버튼에는 router.back() 함수가 걸려있습니다.
하지만 router history에는 이미 /video/109#해시가 쌓여있기 때문에 BottomSheet가 다시 열리는 문제가 발생합니다.
이를 해결하기 위해 replace를 사용해보기도 하고, localstorage에 이전에 접속했던 videoId를 저장해보기도 했습니다.
replace는 history에 기록이 남지 않는다고 하여 사용했지만 원하는 대로 작동하지 않았습니다.
localstorage의 경우 저장은 잘되나 새로운 동영상 페이지로 이동하게 되면 새로운 페이지의 videoId로 갱신해버리는 문제가 발생했습니다.
해결 방안
결론부터 말하자면 각 동영상 페이지에 접속할 때마다 url에서 videoId를 분리해 배열에 저장하는 것이었습니다.
우선 videoId를 분리하기 위해서는 히스토리들을 모아야하기 때문에 커스텀 히스토리 훅(useVideoStore with zustand)을 만들었습니다.
useVideoStore
export const useVideoStore = create<VideoStore>((set, get) => ({
// 상태 관리
videoIds: [], // ['videoA', 'videoB', 'videoC'] 형태로 저장
currentIndex: -1, // 현재 보고 있는 비디오의 인덱스
}));
addVideoId - url에서 videoId를 추출하여 추가
addVideoId: (url: string) => {
const videoId = extractVideoId(url);
if (videoId && !get().videoIds.includes(videoId)) {
set((state) => ({
// 불변성 유지를 위해 spread operator 사용
videoIds: [...state.videoIds, videoId],
// 새로 추가된 비디오를 현재 위치로 설정
currentIndex: state.videoIds.length
}));
}
},
작동 방식
URL Parsing
예를 들어, localhost:3000/video/abc123 이라면 abc123을 추출합니다.
이후, 중복 검사를 통해 배열에 존재하는 videoId는 추가하지 않고, 배열 끝에 push 방식으로 추가합니다.
addVideoId는 비디오 간 이동 시 history를 누적합니다. 또, 중복방지로 깔끔한(?) history를 유지합니다.
여기서 "깔끔한"이란, #와 같은 해시값이 들어가지 않은 url만 그리고 중복되지 않은 url만 넣는다는 것을 의미합니다.
addVideoIdFromPath - videoId를 직접 받아서 추가
addVideoIdFromPath: (videoId: string) => {
if (videoId && !get().videoIds.includes(videoId)) {
// 케이스 1: 새로운 비디오
set((state) => ({
videoIds: [...state.videoIds, videoId],
currentIndex: state.videoIds.length
}));
} else if (videoId) {
// 케이스 2: 이미 방문한 비디오로 돌아가는 경우
const index = get().videoIds.indexOf(videoId);
set({ currentIndex: index });
}
},
addVideoFromPath는 두 가지 시나리오를 처리합니다.
- 새로운 비디오 : 배열에 추가하고 index를 설정합니다.
- 기존 비디오 : index만 업데이트 합니다.
예를 들어, 현재 상태 : ['1', '2', '3'], currentIndex : 2라면, '2'로 이동 시 currentIndex만 1로 변경합니다.
getPreviousVideoId - 이전 videoId 반환
getPreviousVideoId: (currentVideoId: string) => {
const { videoIds } = get();
const currentIndex = videoIds.indexOf(currentVideoId);
if (currentIndex > 0) {
return videoIds[currentIndex - 1];
}
return null; // 첫 번째 비디오면 null 반환
},
getPreviousVideoId는 가장 중요한 함수인데요, 현재 video의 index를 찾습니다.
만약 index가 0보다 크면 이전 비디오가 존재한다는 것을 의미합니다.
따라서 videoIds[currentIndex - 1]를 반환합니다.
getPreviousVideoId는 router.back() 대신 사용되기 때문에 반드시 필요한 함수입니다.
clearVideoIds - history 배열 초기화
clearVideoIds: () => {
set({ videoIds: [], currentIndex: -1 });
},
clearVideoIds 함수도 중요한 함수 중 하나인데요, 이게 없으면 새로고침을 하더라도 이전에 저장했던 배열들이 존재하여
실제 사용자가 접근하지 않은 페이지들이 배열에 쌓여있게 됩니다.
때문에 저는 clearVideoIds 함수를 만들어, /video를 포함하지 않은 url에서는 배열을 초기화합니다. (아래 참조)
export default function PageMain({ /* props */ }) {
const pathname = usePathname();
const { clearVideoIds } = useVideoStore();
useEffect(() => {
if (!pathname.includes('/video/')) {
clearVideoIds();
// 이제 videoIds = [], currentIndex = -1
}
}, [pathname, clearVideoIds]);
}
상위페이지, 즉 Main에서 history 초기화 관리를 실행했습니다.
적용 전
기존 코드에서는 다음과 같이 작성되어 있었습니다.
<div onBackClick={
pathName !== originVideoUrl && !props.isFullScreen && !props.isShowPopup
? router.back()
: undefined
}
</div>
적용 후
const handleGoToPreviousVideo = (e?: React.MouseEvent) => {
// 이벤트 버블링 방지 (상위 요소의 클릭 이벤트 차단)
e?.preventDefault();
e?.stopPropagation();
if (props.currentVideoId) {
// 스토어에서 이전 비디오 조회
const previousVideoId = getPreviousVideoId(props.currentVideoId);
if (previousVideoId) {
// 케이스 1: 이전 비디오가 있음
// router.push()로 새로운 네비게이션 (router.back() 사용 X)
router.push(`/video/${previousVideoId}`);
} else {
// 케이스 2: 첫 번째 비디오 (이전 비디오 없음)
if (originVideoUrl) {
// 2-1: 비디오 페이지 진입 전 URL이 있으면 그곳으로
// 예: /main → /video/A에서 뒤로가기 → /main
router.push(originVideoUrl);
} else {
// 2-2: originVideoUrl도 없으면 홈으로
router.push('/');
}
}
}
};
먼저 handleGoToPreviousVideo라는 커스텀 훅을 만듭니다.
다른 훅들과 섞이면 동작이 불가능하기 때문에 이벤트 버블링 방지를 위해 preventDefault와 stopPropagation을 설정해 줍니다.
작동 방식
- 현재 videoId를 확인합니다.
- store에서 이전 videoId를 조회합니다.
- 경우에 따라 적절한 페이지로 이동합니다.
<div onBackClick={
pathName !== originVideoUrl && !props.isFullScreen && !props.isShowPopup
? handleGoToPreviousVideo
: undefined
}
</div>
결론
router.back() 동작 방식에 문제가 있어 커스텀 훅을 만들게 되었습니다.
참 많은 방법들을 사용해 봤는데 결국엔 array에 url들을 넣어서 구현하게 되었는데요, 기능마다 자료구조를 다르게 설정하여 구현했으면 더 좋은 성능을 기대했을 수 있지 않을까 싶습니다.
예를 들어, 사실 video history의 경우에 사용자가 한 번 서비스를 체험할 때 그리 많은 영상을 보지 않기 때문에 기존 array로 했을 경우 시간복잡도가 O(n)이기 때문이죠.
중복 체크의 경우 Set을 사용하고, 조회는 Map을 사용했더라면 각각 O(1) 시간복잡도를 사용하기 때문에 더 빨랐을 것 같습니다.
하지만 단점으로는 3개의 자료구조를 사용하기 때문에 메모리 사용량이 증가했겠죠?
서비스가 그리 큰 웹사이트는 아니기 때문에 array가 적합한 것 같습니다.
지금까지 읽어주셔서 감사합니다 (_ _)