우리 서비스는 지도 기반으로 요양기관 검색 및 비교 기능을 제공하고 있습니다. 이번 업데이트에서는 사용자가 더 직관적이고 편안하게 시설을 검색할 수 있도록 검색 기능과 지도 UI 전반을 개선했습니다.
이 과정에서 1) 지도의 프로그래밍적 이동과 사용자 상호작용의 분리, 2) React의 렌더링 최적화(Empty State 및 참조 비교), 그리고 3) 모바일/데스크톱 반응형 UX 설계라는 3가지의 재미있는 기술적 챌린지가 있었습니다. 이번 글에서는 이 문제들을 어떻게 해결했는지 공유하고자 합니다.
1. 지도의 '프로그래밍적 이동'과 '사용자 드래그' 구분하기
문제 상황
사용자가 지도를 드래그하여 이동할 때만 "이 위치에서 검색" 버튼이 나타나게 하고 싶었습니다. 하지만 사용자가 검색창에 지역명(예: "강남구")을 검색해서 결과 위치로 지도를 자동으로 이동시켜줄 때도 "이 위치에서 검색" 버튼이 불필요하게 팝업되는 문제가 발생했습니다.
카카오맵 API의 idle(지도 이동 완료) 이벤트를 통해 상태를 업데이트하고 있었는데, 이 이벤트는 사용자의 개입 여부와 무관하게 지도 중심 좌표가 바뀌면 무조건 발생하기 때문입니다.
해결 방법: useRef를 활용한 Flag 도입
이 문제를 해결하기 위해 React의 useRef를 사용하여 '프로그래밍 방식의 이동'인지 추적하는 플래그(`isProgrammaticMove`)를 도입했습니다.
const isProgrammaticMove = useRef(false);
// 텍스트 검색 실행 시
const handleSearch = async () => {
const results = await fetchSearchResults(query);
if (results.length > 0) {
// 코드로 지도를 이동시키기 직전에 플래그를 true로 설정
isProgrammaticMove.current = true;
setMapCenter({ lat: results[0].lat, lng: results[0].lng });
}
};
// 지도의 중심점 이동 완료(idle) 시
const handleCenterChanged = (center) => {
// 코드로 이동한 경우라면, 상태 업데이트를 무시하고 플래그만 초기화
if (isProgrammaticMove.current) {
isProgrammaticMove.current = false;
return;
}
// 사용자가 직접 드래그한 경우에만 재검색 버튼 활성화
setHasMoved(true);
};
이러한 방식으로 이벤트 핸들러의 원천을 구분하여, 검색 결과로 인한 지도 애니메이션 중에는 불필요한 UI가 노출되지 않도록 깔끔하게 제어할 수 있었습니다.
2. '내 위치로 이동': React의 참조(Reference) Bailout 넘어서기
문제 상황
새롭게 추가된 '내 위치로 이동' 버튼이 한 번 작동한 후, 사용자가 지도를 다른 곳으로 드래그하고 다시 버튼을 누르면 작동하지 않는 현상이 있었습니다.
원인은 React의 상태 업데이트 원리(Bailout)에 있었습니다. userLocation 상태는 기기의 현재 위치를 한 번만 담고 있었는데, 상태 업데이트 함수인 setMapCenter(userLocation)를 다시 호출하면 React는 "이전과 동일한 객체 참조(Reference)가 들어왔네? 렌더링을 건너뛰어야지"라고 판단해버린 것입니다. 내부적으로 지도의 좌표는 바뀌었지만, React의 상태 객체 자체가 변하지 않아 발생한 동기화 문제였습니다.
해결 방법: 참조 강제 갱신과 타임스탬프
동일한 위치 좌표라도 새로운 객체를 생성하여 React가 상태 변화를 감지할 수 있도록 우회했습니다. 더 확실한 리렌더링 트리거를 위해 _t (타임스탬프) 속성을 추가했습니다.
const handleMyLocation = () => {
if (userLocation) {
isProgrammaticMove.current = true;
// 동일한 좌표라도 Date.now()를 통해 매번 새로운 객체 참조를 생성
const freshLoc = {
lat: userLocation.lat,
lng: userLocation.lng,
_t: Date.now()
};
setMapCenter(freshLoc);
setHasMoved(false);
}
};
이를 통해 언제든 내 위치 버튼을 누르면 즉각적이고 확정적으로 지도를 해당 위치로 이동시킬 수 있게 되었습니다.
3. Empty State 렌더링: 키보드 타이핑 깜빡임 해결
문제 상황
"검색 결과가 없습니다"를 보여주는 NoResultsView 컴포넌트가 있었습니다. 그런데 검색창에 텍스트를 입력할 때마다 이 Empty State 화면이 계속해서 처음부터 다시 나타나는(깜빡이는) 현상이 나타났습니다.
코드를 분석해보니 부모 컴포넌트 함수 내부에서 자식 컴포넌트를 선언하고 있었습니다.
// AS-IS: 컴포넌트 내부에서 함수형 컴포넌트 선언
export default function Page() {
const NoResultsView = () => (
<motion.div animate={{ opacity: 1, y: 0 }}>결과 없음</motion.div>
);
return <Fragment>{isEmpty ? <NoResultsView /> : null}</Fragment>;
}
부모가 리렌더링(타이핑으로 인한 상태 변경)될 때마다 NoResultsView 함수의 참조가 새로 생성되고, React는 이를 매번 완전히 새로운 컴포넌트로 인식하여 언마운트/리마운트를 반복하게 된 것입니다. 이로 인해 Mount 시점에 발동하는 Framer Motion 애니메이션이 무한 반복되었습니다.
해결 방법: JSX Element로 전환 (또는 외부 컴포넌트 분리)
불필요한 리마운트를 방지하기 위해 NoResultsView를 함수형 컴포넌트가 아닌, 정적 JSX 변수로 변환했습니다.
// TO-BE: JSX Element로 변수화하여 참조 재사용
export default function Page() {
const noResultsView = (
<motion.div animate={{ opacity: 1, y: 0 }}>결과 없음</motion.div>
);
return <Fragment>{isEmpty ? noResultsView : null}</Fragment>;
}
단순히 대문자 함수형 컴포넌트를 소문자 JSX 노드로 변경함으로써, React가 트리를 비교할 때 동일한 엘리먼트로 인식하게 만들어 부드러운 상태 유지가 가능해졌습니다.
마치며
이외에도 공공데이터 API를 직접 스크래핑해 전국 시군구 맵 데이터를 구축하여 필터의 정확도를 높이고, 모바일 바텀시트 UI(Vaul)를 활용해 지도와 필터 탐색의 반응형 UX를 크게 개선했습니다.
단순히 지도를 띄우고 마커를 찍는 것을 넘어서, 사용자가 "어색함"을 느끼는 찰나의 순간들(팝업이 튀어나오는 타이밍, 깜빡거리는 애니메이션, 먹통이 되는 버튼)을 프론트엔드의 구조적인 관점에서 해결해 나가는 과정이 매우 흥미로웠습니다. 앞으로도 디테일한 UX 최적화를 위해 더 깊이 고민해 보겠습니다. 감사합니다!
'사이드프로젝트' 카테고리의 다른 글
| Porkbun에서 도메인 구매 및 구글 서치콘솔 주소 변경 가이드 (0) | 2026.03.11 |
|---|---|
| Next.js로 만든 장기요양등급 자가진단 모의평가 — 설계부터 SEO까지 (0) | 2026.02.27 |
| [개선] 장기요양시설 비교 기능 개선기 — 데이터, UX, 공유까지 (0) | 2026.02.20 |
| [개선] 모바일 뷰포트 수정과 직관적인 지도 검색 (0) | 2026.02.18 |
| 커뮤니티 기능 개발 전 네이버 블로그 RSS & API로 연동하기 (0) | 2026.02.12 |