6. 드롭다운은 클라이언트컴포넌트인데 서버 컴포넌트에서 사용해도 되나?
2025년 12월 12일
Detail 페이지에서 다른 범죄로 이동할 수 있는 드롭다운을 추가했습니다. 대시보드로 돌아가지 않고 바로 전환할 수 있도록 했습니다. 원 과제는 대시보드에서 드롭다운을 통해 날짜나, 연도를 옮기면서 갈 수 있는 드롭다운을 구현해야 하나, 제 데이터가 연도가 정해져 있고, 대시보드는 전체 범죄에 대한 데이터를 설명하므로, 디테일 페이지에서 다른 범죄들로 이동할 수 있는 드롭다운을 구현하고자 합니다.
구현 단계
1. CrimeSelector 컴포넌트 생성
범죄 선택 드롭다운을 재사용 가능한 컴포넌트로 분리했습니다.
파일 위치: components/CrimeSelector.tsx
"use client"로 클라이언트 컴포넌트 지정 (상태와 라우팅 사용)useState로 드롭다운 열림/닫힘 상태 관리useRouter로 페이지 이동 처리
"use client"; import { useRouter } from 'next/navigation'; import { useState } from 'react'; interface CrimeSelectorProps { crimes: Array<{ categoryMajor: string; categoryMiddle: string; }>; currentCrime: string; }
2. 데이터 그룹화
대분류별로 범죄를 그룹화해 UI를 개선했습니다.
const groupedCrimes = crimes.reduce((acc, crime) => { if (!acc[crime.categoryMajor]) { acc[crime.categoryMajor] = []; } acc[crime.categoryMajor].push(crime.categoryMiddle); return acc; }, {} as Record<string, string[]>);
reduce로 범죄 목록을 대분류별로 분류- 예:
{ "강력범죄": ["살인", "강도"], "절도범죄": ["건물절도", "차량절도"] }
3. 드롭다운 토글
버튼 클릭으로 열림/닫힘을 제어합니다.
const [isOpen, setIsOpen] = useState(false); <button type="button" onClick={() => setIsOpen(!isOpen)} className="..." > <span>{currentCrime}</span> <svg className={`... ${isOpen ? 'transform rotate-180' : ''}`}> {/* 화살표 아이콘 */} </svg> </button>
- 현재 선택된 범죄를 버튼에 표시
- 화살표 아이콘 회전으로 상태 표시
4. 외부 클릭 감지
외부 클릭 시 드롭다운을 닫습니다.
{isOpen && ( <> {/* 오버레이 */} <div className="fixed inset-0 z-10" onClick={() => setIsOpen(false)} /> {/* 드롭다운 메뉴 */} <div className="absolute z-20 ..."> {/* 메뉴 내용 */} </div> </> )}
- 고정 오버레이(
fixed inset-0)로 전체 화면 덮기 - z-index로 레이어 구분 (오버레이 z-10, 메뉴 z-20)
- 오버레이 클릭 시 닫힘
5. 범죄 선택 핸들러
선택 시 해당 상세 페이지로 이동합니다.
const handleSelect = (crimeMiddle: string) => { router.push(`/detail/${encodeURIComponent(crimeMiddle)}`); setIsOpen(false); };
useRouter로 클라이언트 사이드 네비게이션- URL 인코딩으로 한글 처리
- 선택 후 드롭다운 닫기
6. 드롭다운 메뉴 렌더링
대분류별 그룹화된 목록을 표시합니다.
<div className="absolute z-20 mt-2 w-full sm:w-80 bg-white border border-gray-200 rounded-lg shadow-lg max-h-96 overflow-auto"> <div className="py-1"> {Object.entries(groupedCrimes).map(([categoryMajor, crimeList]) => ( <div key={categoryMajor}> {/* 대분류 헤더 */} <div className="px-4 py-2 text-xs font-semibold text-gray-500 uppercase tracking-wider bg-gray-50 sticky top-0"> {categoryMajor} </div> {/* 중분류 목록 */} {crimeList.map((crimeMiddle) => ( <button key={crimeMiddle} onClick={() => handleSelect(crimeMiddle)} className={`... ${ crimeMiddle === currentCrime ? 'bg-gray-100 text-gray-900 font-medium' : 'text-gray-700' }`} > {crimeMiddle} </button> ))} </div> ))} </div> </div>
주요 기능:
sticky top-0로 대분류 헤더 고정max-h-96 overflow-auto로 긴 목록 스크롤- 현재 선택 항목 하이라이트
- 반응형 너비 (
w-full sm:w-80)
7. Detail 페이지에 통합
서버 컴포넌트인 Detail 페이지에 클라이언트 컴포넌트를 통합했습니다.
파일 위치: app/detail/[category_middle]/page.tsx
// 범죄 목록 데이터 준비 (드롭다운용) const crimeListData = convertCrimeRecordsToTableData(records); // 헤더 영역에 드롭다운 배치 <div className="flex items-center justify-between mb-4"> <Link href="/dashboard">← 대시보드로 돌아가기</Link> <CrimeSelector crimes={crimeListData} currentCrime={categoryName} /> </div>
- 서버에서 데이터 준비 후 클라이언트 컴포넌트에 전달
- 기존 "대시보드로 돌아가기" 링크와 함께 배치
기술적 고려사항
클라이언트/서버 컴포넌트 분리
- Detail 페이지는 서버 컴포넌트(데이터 페칭)
- CrimeSelector는 클라이언트 컴포넌트(상호작용, 라우팅)
TypeScript 타입 안전성
CrimeSelectorProps로 props 타입 명확화Record<string, string[]>로 그룹화 결과 타입 지정
접근성
button타입 명시focus:outline-none focus:ring-2로 포커스 표시- 키보드 네비게이션 고려
반응형 디자인
w-full sm:w-auto: 모바일은 전체 너비, 데스크톱은 자동sm:w-80: 데스크톱에서 최대 너비 320px
결론
재사용 가능한 컴포넌트로 분리하고, 데이터 그룹화, 외부 클릭 처리 등을 추가해 Detail 페이지 내 범죄 전환을 쉽게 만들었습니다. 클라이언트/서버 컴포넌트 분리와 TypeScript로 타입 안전성을 확보했습니다.
크라이언트컴포넌트인 드롭다운 컴포넌트를 서버컴포넌트인 디테일페이지에서 사용할때, 직렬화 가능한 데이터만 전달하고 또한 클라이언트컴포넌트가 서버 함수를 호출하지 않으며, 데이터 변환은 서버에서 완료된 상태에서 안전하게 import해서 사용하기 때문에, 문제가 생기지 않습니다.