서론
사이드바 구현
목록 뷰 구현
목록 뷰에서 병원 선택 시 지도 뷰에서 해당 위치로 이동
Geolocation API
메모
이번 글에서는 사용자가 병원 목록과 병원에 대한 상세정보를 확인할 수 있도록 별도의 목록 뷰(페이지)를 구현해보겠습니다.
기존 HospitalList 컴포넌트의 경우, 단순히 백엔드에서 병원 데이터를 불러와서 MapComponent를 반환하는 역할을 했습니다. 지금부터는 사용자가 사이드바에서 지도 뷰와 목록 뷰를 선택할 수 있도록 Sidebar 컴포넌트를 구현하고, state를 사용해 사용자가 선택한 뷰를 출력하도록 HospitalList를 수정해보겠습니다.
...
function Sidebar({ onViewChange }) {
return (
<div className="sidebar">
<button className="sidebar-button" onClick={() => onViewChange('map')}>지도</button>
<button className="sidebar-button" onClick={() => onViewChange('list')}>목록</button>
</div>
);
}
export default Sidebar;
두 개의 버튼을 생성한 뒤, prop으로 받은 onViewCahnge 메서드를 onClick으로 등록합니다.
...
function HospitalList() {
...
const [selectedView, setSelectedView] = useState('map');
...
const handleViewChange = (view) => {
setSelectedView(view);
};
return (
<div className="sidebar-container">
<Sidebar onViewChange={handleViewChange} />
<div className="select-view-container">
{selectedView === 'map' ? (
<MapComponent hospitals={hospitals} selectedHospital={selectedHospital}/>
) : (
<HospitalListView hospitals={hospitals}/>
)}
</div>
</div>
);
}
export default HospitalList;
HospitalList 컴포넌트가 백엔드에서 정보를 받아와서 MapComponent, HospitalListView 컴포넌트를 map이라는 state를 사용해 선택적으로 렌더링합니다. 이와 같은 구조를 통해 백엔드에서 정보를 가져오는 로직을 중앙화함으로써 유지보수성을 증가시키고, 두 컴포넌트가 동일한 데이터를 사용한다는 것을 보장할 수 있습니다.
위의 두 코드를 통해 사용자가 "지도" 버튼을 누르면 지도 뷰를, "목록" 버튼을 누르면 목록 뷰를 보여주는 기능은 구현했지만, Sidebar 컴포넌트가 이름과는 다르게 화면 상단에 출력됩니다. 이를 해결하기 위해 아래와 같이 CSS 코드를 작성했습니다.
.sidebar-container {
display: flex;
flex-direction: row;
height: 100vh;
}
.select-view-container {
flex: 1;
padding: 20px;
}
결과적으로 아래 사진과 같이 사이드바가 화면 좌측에 배치되는 것을 확인할 수 있습니다.
이제 prop으로 받은 병원 목록을 화면에 보여주는 목록 뷰를 구현해보겠습니다.
import React, { useState } from 'react';
import './HospitalListView.css';
import MapComponent from './MapComponent';
function HospitalListView({ hospitals }) {
return (
<div>
<div className='list-container'>
{currentHospitals.map(hospital => (
<div key={hospital.hpid} className='list-item'>
<div className='item-info'>
<h3>{hospital.duty_name} {hospital.center_type === 0 ? "(응급)" : "(외상)"} </h3>
<p>{hospital.duty_addr}</p>
<p>대표: {hospital.duty_tel1}</p>
<p>응급실: {hospital.duty_tel3}</p>
</div>
</div>
))}
</div>
</div>
);
}
export default HospitalListView;
위 사진과 같이 병원 목록을 보여줄 수 있지만, 500개가 넘는 병원 정보를 한 페이지에 보여줘서 정보를 확인하기 어렵다는 문제점이 있어 페이지네이션을 적용해보겠습니다.
const itemsPerPage = 10;
const maxVisiblePages = 10;
const [currentPage, setCurrentPage] = useState(1);
const [expandedHospital, setExpandedHospital] = useState(null);
const lastIndex = currentPage * itemsPerPage;
const firstIndex = lastIndex - itemsPerPage;
const currentHospitals = hospitals.slice(firstIndex, lastIndex);
const handlePageChange = (page) => {
setCurrentPage(page);
};
const handleFirstPage = () => {
setCurrentPage(1);
};
const handleLastPage = () => {
setCurrentPage(totalPages);
};
const getPageRange = () => {
const halfVisiblePages = Math.floor(maxVisiblePages / 2);
const startPage = Math.max(currentPage - halfVisiblePages, 1);
const endPage = Math.min(startPage + maxVisiblePages - 1, totalPages);
return Array.from({ length: endPage - startPage + 1 }, (_, index) => startPage + index);
};
return (
<div>
...
<div className='list-container'>
{currentHospitals.map(hospital => {
...
</div>
<div className='pagination'>
<button onClick={handleFirstPage}>First</button>
{getPageRange().map((pageNumber, index) => (
<button
key={index}
className={pageNumber === currentPage ? 'active' : ''}
onClick={() => handlePageChange(pageNumber)}
>
{pageNumber}
</button>
))}
<button onClick={handleLastPage}>Last</button>
</div>
</div>
한 페이지 당 10개의 항목을 보여주도록 설정했고, 첫 번째 페이지와 마지막 페이지로 이동하는 버튼을 만들었으며, getPageRange() 메서드를 사용해 아래 사진과 같이 현재 페이지 번호에 따라 페이지 목록이 동적으로 변하도록 구현했습니다.
사용자가 목록 뷰에서 병원에 대한 정보를 확인한 뒤 지도 뷰에서 해당 병원의 위치를 확인할 수 있는 기능을 구현해보겠습니다. HospitalList가 백엔드에서 데이터를 가져와서 MapComponent와 HospitalListView 컴포넌트를 호출하는 현재 구조에서, HospitalListView가 MapComponent를 호출할 경우 순환 호출이 되는 문제가 발생합니다. 따라서, hospitals를 prop으로 넘긴 것과 비슷하게 HospitalListView 호출 시 handleViewChange()라는 메서드를 넘겨주었습니다.
...
const handleViewChange = (view, latitude, longitude) => {
setSelectedView(view);
setSelectedHospital({ latitude, longitude })
};
return (
<div className="sidebar-container">
...
<div className="select-view-container">
{selectedView === 'map' ? (
<MapComponent hospitals={hospitals} selectedHospital={selectedHospital} />
) : (
<HospitalListView hospitals={hospitals} hospitalDetails={hospitalDetails} onViewChange={handleViewChange}/>
)}
</div>
</div>
);
<div className='list-container'>
{currentHospitals.map(hospital => {
// 현재 병원의 상세 정보
const detailData = hospitalDetails.find(detail => detail.hpid === hospital.hpid);
return (
<div key={hospital.hpid}>
<div className='item-info'>
...
</div>
<div className='item-link'>
<button onClick={() => onViewChange('map', hospital.wgs_84_lat, hospital.wgs_84_lon)}>지도에서 보기</button>
</div>
</div>
사용자가 "지도에서 보기"라는 버튼을 클릭하면, 해당 병원의 경도와 위도를 매개변수로 사용해 onViewChange()를 호출합니다. 첫 번째 매개변수로 "map"이라는 문자열이 주어젔으므로, HospitalList에서 MapComponent를 호출합니다.
...
const MapComponent = ({ hospitals = [], selectedHospital }) => {
useEffect(() => {
...
script.onload = () => {
...
// 오버레이 닫기 기능 추가
window.kakao.maps.event.addListener(marker, 'click', function(){
overlay.setMap(map);
const tempDiv = document.createElement('div');
tempDiv.innerHTML = content;
const closeBtn = tempDiv.querySelector('.close');
closeBtn.addEventListener('click', () => {
overlay.setMap(null);
});
overlay.setContent(tempDiv);
});
// 목록 뷰에서 선택된 병원의 위치로 지도 이동
// 선택된 병원의 오버레이는 마커 클릭 없이도 자동으로 출력(닫기 기능도 별도로 구현함)
if (selectedHospital && selectedHospital.latitude === hospital.wgs_84_lat && selectedHospital.longitude === hospital.wgs_84_lon) {
const centerPosition = new window.kakao.maps.LatLng(selectedHospital.latitude, selectedHospital.longitude);
map.setCenter(centerPosition);
overlay.setMap(map);
const tempDiv = document.createElement('div');
tempDiv.innerHTML = content;
const closeBtn = tempDiv.querySelector('.close');
closeBtn.addEventListener('click', () => {
overlay.setMap(null);
});
overlay.setContent(tempDiv);
}
});
...
};
}, [hospitals, selectedHospital]);
return <div id="kakao-map" />;
};
export default MapComponent;
selectedHospital은 사용자가 선택한 병원의 경도, 위도 정보입니다. selectedHospital이 존재할 경우 목록 뷰에서 지도 뷰로 넘어온 것이고, 존재하지 않는다면 사이드바에서 선택돼서 렌더링된 것입니다. selectedHospital이 존재할 경우 지도의 중심 좌표로 설정하고, 해당 병원의 정보를 나타내기 위해 오버레이를 출력합니다. 이때 오버레이의 경우, 마커를 클릭해서 실행된 것이 아니기 때문에 window.kakao.maps.event.addListener 구문에서 닫기 기능을 추가했어도 동작하지 않습니다. 따라서, 동일한 로직을 사용해서 별도로 닫기 기능을 추가했습니다.
결과적으로 위 사진들과 같이 "지도에서 보기" 버튼을 클릭하면 지도에서 해당 병원의 위치를 잘 표시하는 것을 확인할 수 있습니다.
사용자가 처음 웹사이트에 접속했을 때 지도 뷰를 사용자 위치로 설정하기 위해 Geolocation API 사용을 시도해봤습니다. Geolocation API를 사용해 위치를 받아오기까지 시간이 좀 걸리기 때문에 아래 코드와 같이 geolocationResolved라는 state를 사용해 위치를 받아올 때까지 기다린 다음 카카오맵 API를 연동했습니다.
const MapComponent = ({ hospitals = [], selectedHospital }) => {
const [geolocationResolved, setGeolocationResolved] = useState(false);
useEffect(() => {
var centerLat = 37.5665;
var centerLon = 126.9780;
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(function(position) {
centerLat = position.coords.latitude;
centerLon = position.coords.longitude;
setGeolocationResolved(true);
});
}
if (geolocationResolved) {
console.log(centerLat, centerLon);
// 카카오맵 API 연동
...
script.onload = () => {
window.kakao.maps.load(() => {
const container = document.getElementById('kakao-map');
// 중심좌표(위도, 경도), 확대 정도 설정
const options = {
center: new window.kakao.maps.LatLng(centerLat, centerLon),
level: 5,
};
...
지도를 렌더링하기까지 대략 4~5초가 소요되었고, 좌표는 잘 받아와짐에도 10번 중에 1번 꼴로 지도가 사용자 위치로 잘 설정되고, 대부분의 경우 전혀 다른 위치로 설정되었습니다. 두 경우 출력되는 좌표값은 동일했기 때문에 카카오맵 API와 호환성이 안 좋아서 발생하는 문제인 것 같습니다.
또한, Geolocation API는 브라우저가 제공하는 기능이기 때문에 웨일의 경우 사용자 위치로 잘 설정되는 경우에도 지하철 2정거장 정도의 꽤 큰 차이가 있던 반면, 크롬의 경우 아주 정확한 위치로 설정되었습니다.
결과적으로 렌더링 시간이 지연되는 것에 비해 정확도가 떨어지기 때문에 일단은 사용을 보류했습니다. 필수적인 기능을 모두 구현해 1차 배포를 한 뒤에 확장을 고려 중입니다.
React에서 컨텐츠의 높이를 브라우저에 맞추기 위해서는 height: 100vh;
로 설정
height: 100%;
혹은 height: auto;
로 설정해도 변화 X
vh는 높이값의 1/100에 해당하기 때문에 100vh로 설정하면 브라우저가 늘어나거나 줄어들어도 유동적으로 전체 높이로 설정됨