const-tommy.dev
기록을 불러오는 중입니다
muroom 프로젝트에서 기술적 도전 과제가 가장 컸던 지점은 단연 지도 기반의 통합 검색 시스템이었다. 가격, 크기, 옵션 등 20여 개의 필터와 지도 좌표를 실시간으로 동기화하는 작업은 결코 만만치 않았다. 이는 단순히 상태를 관리하는 문제를 넘어, 성능 최적화와 사용자 경험(UX) 사이에서 발생하는 기술적 트레이드오프를 해결하고 가장 효율적인 데이터 흐름을 설계하는 과정이었다.
지도 기반 서비스의 핵심은 사용자가 보고 있는 영역과 필터 조건에 맞는 데이터를 즉시, 그리고 정확하게 보여주는 것이다. 하지만 muroom 개발 과정에서 마주한 데이터의 양은 상상을 초월했다. 단순히 '탐색' 기능이라 부르기엔 관리해야 할 상태값이 너무나 방대했기 때문이다.
구체적으로 한 화면에서 동기화되어야 할 데이터는 다음과 같았다.
minLat, maxLat, minLng, maxLngkeyword)와 데이터 정렬 기준(sort)처음에는 익숙한 Zustand 같은 메모리 기반 전역 상태를 떠올릴 수도 있었지만, 다음과 같은 명확한 한계가 존재했다.
useEffect와 예외 처리 로직이 필요해지며 이는 곧 코드의 복잡도 상승으로 이어진다.결국 이 방대한 데이터를 '어떻게 유실 없이 관리하고, 수많은 파라미터 변화에도 성능을 유지할 것인가' 가 이번 설계의 가장 큰 숙제였다.
nuqs)였을까?나는 모든 상태의 Single Source of Truth 를 주소창(URL)으로 설정했다. 여기에는 세 가지 전략적 이유가 있었다.
queryKey가 자동으로 변경되어 데이터를 다시 불러오는 구조를 택함으로써, 불필요한 명령형(useEffect) 로직을 최소화했다.20개가 넘는 파라미터를 주소창에 넣을 때 가장 큰 적은 '렌더링 오버헤드' 와 '히스토리 꼬임' 이다. 이를 해결하기 위해 두 가지 핵심 최적화를 적용했다.
nuqs의 shallow: true 옵션을 적극 활용했다. URL이 바뀔 때마다 Next.js가 서버로 페이지 전체를 요청하는 대신, 브라우저의 주소창만 기민하게 업데이트하도록 설정했다.

주소창은 바뀌지만 'Doc' 타입의 요청 없이 API 통신만 일어나는 모습.
지도 영역이 바뀔 때 좌표값 4개(minLat, maxLat 등)를 각각 따로 업데이트하면 브라우저 히스토리에 불필요한 스택이 쌓이고 주소창이 덜컥거리는 현상이 발생한다. 이를 방지하기 위해 useQueryStates를 사용하여 여러 파라미터를 단 한 번의 업데이트로 묶어서 처리하는 배치 방식을 도입했다.
// 단 한 번의 업데이트로 7개의 지도 파라미터를 동시에 반영 (Batching)
setParams({
center: state.center,
zoom: state.zoom,
studioId: state.studioId,
minLatitude: state.bounds?.minLat ?? null,
// ...나머지 좌표 데이터
});
지도가 멈추는 순간 7개의 데이터가 한 묶음으로 깔끔하게 처리되는 로그.
물리적인 성능 최적화만큼 중요했던 것은 데이터를 불러오는 동안 사용자가 느끼는 '체감 속도' 였다.
keepPreviousData: 부드러운 화면 전환의 비밀데스크톱의 페이지네이션이나 모바일의 무한 스크롤 환경에서, 새 데이터를 가져올 때마다 화면이 하얗게 비워진다면 사용자는 큰 피로감을 느낀다. 나는 TanStack Query의 keepPreviousData 옵션을 활용해 이 문제를 해결했다.
![]() | ![]() |
|---|---|
| 데스크톱 페이지네이션 | 모바일 무한 스크롤 |
로딩 중에도 이전 리스트를 그대로 유지하여 깜빡임 없는 사용자 경험을 제공한다.
muroom은 하나의 커스텀 훅(useStudiosMapListInfiniteQuery)에서 모바일의 무한 스크롤과 데스크톱의 페이지네이션을 동시에 대응하도록 설계했다. 핵심은 placeholderData를 통해 데이터 공백을 메우는 것이다.
const useStudiosMapListInfiniteQuery = (
params: StudiosMapSearchRequestProps | undefined,
config: { page: number; size: number; isMobile: boolean; }
) => {
return useInfiniteQuery({
queryKey: [
'studios', 'map-list',