서론
HospitalDetail 데이터 가져오기
상세 정보 열람 기능 구현
검색, 필터링 기능 구현
메모
이번 글에서는 사용자가 목록 뷰에서 특정 병원의 상세 정보를 열람하고, 병원 이름으로 검색하거나 상세 정보로 필터링할 수 있는 기능을 구현해보겠습니다.
우선 백엔드에서 RDS에 존재하는 병원의 상세 정보 테이블을 가져오기 위해 모델과 serializer를 구현하고, 프런트엔드에서 접근할 수 있도록 뷰와 URL을 설정해보겠습니다.
...
class HospitalDetail(models.Model):
hpid = models.CharField(max_length = 10, primary_key = True)
post_cdn1 = models.CharField(max_length = 3)
post_cdn2 = models.CharField(max_length = 3)
hvec = models.IntegerField()
hvoc = models.IntegerField()
hvcc = models.IntegerField()
hvncc = models.IntegerField()
hvccc = models.IntegerField()
hvicc = models.IntegerField()
hvgc = models.IntegerField()
duty_hayn = models.CharField(max_length = 1)
duty_hano = models.IntegerField()
duty_inf = models.CharField(max_length = 300)
duty_map_img = models.CharField(max_length = 100)
duty_eryn = models.CharField(max_length = 1)
duty_time_1c = models.CharField(max_length = 4)
duty_time_2c = models.CharField(max_length = 4)
duty_time_3c = models.CharField(max_length = 4)
duty_time_4c = models.CharField(max_length = 4)
duty_time_5c = models.CharField(max_length = 4)
duty_time_6c = models.CharField(max_length = 4)
duty_time_7c = models.CharField(max_length = 4)
duty_time_8c = models.CharField(max_length = 4)
duty_time_1s = models.CharField(max_length = 4)
duty_time_2s = models.CharField(max_length = 4)
duty_time_3s = models.CharField(max_length = 4)
duty_time_4s = models.CharField(max_length = 4)
duty_time_5s = models.CharField(max_length = 4)
duty_time_6s = models.CharField(max_length = 4)
duty_time_7s = models.CharField(max_length = 4)
duty_time_8s = models.CharField(max_length = 4)
mkioskty25 = models.CharField(max_length = 1)
mkioskty1 = models.CharField(max_length = 1)
mkioskty2 = models.CharField(max_length = 1)
mkioskty3 = models.CharField(max_length = 1)
mkioskty4 = models.CharField(max_length = 1)
mkioskty5 = models.CharField(max_length = 1)
mkioskty6 = models.CharField(max_length = 1)
mkioskty7 = models.CharField(max_length = 1)
mkioskty8 = models.CharField(max_length = 1)
mkioskty9 = models.CharField(max_length = 1)
mkioskty10 = models.CharField(max_length = 1)
mkioskty11 = models.CharField(max_length = 1)
dgid_id_name = models.CharField(max_length = 50)
hpbdn = models.IntegerField()
hpccuyn = models.IntegerField()
hpcuyn = models.IntegerField()
hperyn = models.IntegerField()
hpgryn = models.IntegerField()
hpicuyn = models.IntegerField()
hpnicuyn = models.IntegerField()
hpopyn = models.IntegerField()
class Meta:
db_table = "HOSPITAL_DETAIL_INFO"
사용자(응급구조사)가 필요로 할 수 있는 데이터는 모두 보여주는 것이 맞다고 생각해서 명세서 상에 존재하는 데이터를 모두 넣었기 때문에 컬럼이 상당히 많은 것을 확인할 수 있습니다.
...
class HospitalDetailSerializer(serializers.ModelSerializer):
class Meta:
model = HospitalDetail
fields = '__all__'
...
class HospitalDetailView(generics.ListAPIView):
queryset = HospitalDetail.objects.all()
serializer_class = HospitalDetailSerializer
...
urlpatterns = [
path('hospitals/', HospitalList.as_view()),
path('hospital_details/', HospitalDetailView.as_view()),
]
아래 사진과 같이 서버를 실행한 뒤 shell_plus로 총 522개의 상세 정보 객체를 잘 읽어왔고, Postman을 사용해 각각의 객체가 컬럼에 해당하는 값을 잘 가지고 있음을 확인할 수 있습니다.
기존에는 지도 뷰에서 특정 병원에 대한 오버레이 내에 상세 정보 열람 버튼을 만들어서 사용자가 이를 클릭하면 별도의 오버레이 창으로 보여주고자 했습니다. 다만, 회의를 거친 결과 출력해야 하는 정보가 너무 많아서 지도의 대부분을 가린다면 지도를 사용한 의미가 퇴색되기 때문에 정보를 보여주는 것은 목록 뷰에서 전담하는 것으로 로직을 수정했습니다.
function HospitalList() {
const [hospitals, setHospitals] = useState([]);
const [hospitalDetails, setHospitalDetails] = useState([]);
...
useEffect(() => {
/...
// 백엔드 API에서 병원 상세 데이터 fetch
axios.get('http://localhost:8000/api/hospital_details/')
.then(response => {
setHospitalDetails(response.data);
})
.catch(error => {
console.error('상세 데이터를 가져오는 데 실패했습니다:', error);
});
...
우선, 병원 기본 정보와 마찬가지로 axios 통신을 사용해 백엔드에서 데이터를 가져옵니다.
...
const [expandedDetail, setExpandedDetail] = useState(null);
const toggleDetailExpansion = (hospital) => {
if (expandedDetail === hospital || !activeList.includes(hospital)) {
setExpandedDetail(null);
} else {
setExpandedDetail(hospital);
}
};
...
return (
<div>
<div className='search-filter-container'>
</div>
<div className='list-container'>
{currentHospitals.map(hospital => {
// 선택된 병원의 상세 정보
const detailData = hospitalDetails.find(detail => detail.hpid === hospital.hpid);
return (
<div key={hospital.hpid} className={`list-item ${expandedDetail === hospital ? 'expanded' : ''}`}>
<div className='basic-info'>
...
{expandedDetail === hospital && (
<div className='info-title'>
<h3>상세 정보</h3>
<p>진료과목: {detailData.dgid_id_name}</p>
<table className='info-table'>
<tbody>
<tr>
<td>응급실</td>
<td>{detailData.hvec}</td>
<td>응급실</td>
<td>{detailData.mkioskty25}</td>
<td>신생아</td>
<td>{detailData.mkioskty10}</td>
</tr>
...
<tr>
<td>입원실가용여부</td>
<td>{detailData.duty_hayn === '1' ? 'Y' : 'N'}</td>
<td>응급투석</td>
<td>{detailData.mkioskty7}</td>
<td>일반중환자실</td>
<td>{detailData.hpicuyn}</td>
</tr>
...
</tbody>
</table>
</div>
)}
</div>
<div className='item-link'>
<button onClick={() => onViewChange('map', hospital.wgs_84_lat, hospital.wgs_84_lon)}>지도에서 보기</button>
<button className='detail-button' onClick={() => toggleDetailExpansion(hospital)}>
{expandedDetail === hospital ? '상세정보 닫기' : '상세정보 보기'}
</button>
</button>
</div>
</div>
);
})}
</div>
사용자가 "상세정보 보기" 버튼을 누르면 창이 확장되어 정보가 출력되고, 버튼을 한 번 더 누르면 창이 축소되는 토글 기능을 구현하기 위해 expandedDetail이라는 state를 사용했습니다. 출력해야하는 데이터가 많기 때문에 3 * 10 형태의 테이블로 구현했으며, 입원실가용여부, 응급실가용여부 필드의 값이 0 또는 1로 들어와서 조건식을 사용해 Y 또는 N을 출력했습니다.
아래 사진과 같이 상세정보 버튼을 누를 경우 창이 확장되면서 정보가 출력되고, 버튼을 다시 누를 경우 창이 축소되는 것을 확인할 수 있습니다.
이제 검색, 필터링 기능을 구현해보겠습니다. 구현 전에는 form 태그와 제출 버튼을 통해 검색, 필터링된 결과를 반환하려고 했는데, 사용자가 긴박한 상황에서 검색 결과를 빠르게 확인할 수 있도록 Javasript의 Array filter를 사용해 구현했습니다. 또한, Array의 find 메서드를 사용해 prop으로 받은 hospitalDetails에서 hospitals와 매칭되는 키값(hpid)을 갖는 객체를 찾아 필터링했습니다.
const [searchQuery, setSearchQuery] = useState('');
const [selectedCondition, setSelectedCondition] = useState('');
const [isFiltered, setIsFiltered] = useState(false);
// 검색, 필터 조건 변경 시 1페이지로 이동
useEffect(() => {
setCurrentPage(1);
}, [isFiltered, searchQuery, selectedCondition]);
// 검색, 필터링
const filteredHospitals = hospitals.filter(hospital => {
const nameMatches = hospital.duty_name.includes(searchQuery);
const detail = hospitalDetails.find(detail => detail.hpid === hospital.hpid);
const conditionMatches = detail[selectedCondition] === 'Y' || detail[selectedCondition] > 0 || selectedCondition === '';
return nameMatches && conditionMatches;
});
// 검색, 필터링 조건이 적용된 경우 filteredHospitals를, 적용되지 않은 경우 hospitals를 사용
const activeList = isFiltered ? filteredHospitals : hospitals;
const totalPages = Math.ceil(activeList.length / itemsPerPage);
const lastIndex = currentPage * itemsPerPage;
const firstIndex = lastIndex - itemsPerPage;
const currentHospitals = activeList.slice(firstIndex, lastIndex);
// 사용자로부터 검색어, 필터링 조건 받아서 설정
const handleSearchFilter = (query, condition) => {
setSearchQuery(query);
setSelectedCondition(condition);
setIsFiltered(query || condition !== null);
};
return (
<div>
<div className='search-filter-container'>
<input
type='text'
placeholder='병원 이름을 입력하세요.'
value={searchQuery}
onChange={(e) => handleSearchFilter(e.target.value, selectedCondition)}
/>
<select
value={selectedCondition}
onChange={(e) => handleSearchFilter(searchQuery, e.target.value)}
>
<option value=''>상세정보 필터링</option>
<option value='duty_eryn'>응급실</option>
<option value='hvoc'>수술실</option>
<option value='hvcc'>신경중환자</option>
<option value='hvncc'>신생중환자</option>
<option value='hvccc'>흉부중환자</option>
<option value='hvicc'>일반중환자</option>
<option value='duty_hayn'>입원실</option>
<option value='duty_hano'>병상수</option>
<option value='mkioskty1'>뇌출혈수술</option>
<option value='mkioskty2'>뇌경색의재관류</option>
<option value='mkioskty3'>심근경색의재관류</option>
<option value='mkioskty4'>복부손상의수술</option>
<option value='mkioskty5'>사지접합의수술</option>
<option value='mkioskty6'>응급내시경</option>
<option value='mkioskty7'>응급투석</option>
<option value='mkioskty8'>조산산모</option>
<option value='mkioskty9'>정신질환자</option>
<option value='mkioskty10'>신생아</option>
<option value='mkioskty11'>중증화상</option>
<option value='hpccuyn'>흉부중환자실</option>
<option value='hpcuyn'>신경중환자실</option>
<option value='hpicuyn'>일반중환자실</option>
<option value='hpnicuyn'>신생아중환자실</option>
</select>
</div>
<div className='list-container'>
{currentHospitals.map(hospital => {
...
처음 구현했을 시에는 검색, 필터링된 결과가 앞에서부터 보여지는 것이 아니라, 병원 목록의 각 페이지에서 검색, 필터링된 결과를 보여주어 의도한대로 동작하지 않았습니다. (ex. 특정 병원 이름을 입력했을 때 1페이지에 3개의 결과를 보여주고, 2페이지에는 0개, 3페이지는 2개, ...). 따라서, isFiltered라는 state를 추가해 필터링(혹은 검색)이 적용되었을 경우, activeList 상수를 filteredHospital로, 적용되지 않았을 경우 hospitals를 그대로 사용해 목록과 페이지네이션을 동적으로 구현했습니다. searchQuery, selectedCondition state를 사용해 사용자로부터 검색어와 조건을 입력받았으며, 해당 값들이 변경될 경우 1페이지로 이동하도록 구현했습니다.
병원 이름을 검색받기 위해 input 태그를, 드롭다운 형태로 필터링 조건을 검색받기 위해 select 태그를 사용했습니다. 둘 모두 handleSearchFilter()라는 메소드로 관리하고, e.target.value를 prop으로 전달해 두 기능이 동시에 적용되도록 구현했습니다. 검색은 비교적 쉽게 구현했지만, 필터링의 경우 약 30개의 필드를 일일히 추가해줘야 했습니다. 테이블에는 모든 정보를 표현하기 위해 공공데이터 API에서 받은 데이터를 모두 기입했지만, 필터링 시에는 혼동을 방지하기 위해 중복되는 필드는 제거하고, "XXX가능여부"라는 필드가 있다면 이를 우선적으로 사용했습니다.
아래의 사진들과 같이 검색, 필터링 조건이 잘 적용됨을 확인할 수 있습니다.
코드가 복잡해질수록 괄호가 많아지고, 괄호를 닫는 위치에 따라서 코드가 의도한대로 동작하지 않을 수 있으니 잘 확인해야 함
input, select 태그 둘 모두 width:100% 설정되었는데 화면에는 다른 크기로 출력됨