diff --git a/src/features/kakaomap/components/Map.tsx b/src/features/kakaomap/components/Map.tsx new file mode 100644 index 0000000..0af9208 --- /dev/null +++ b/src/features/kakaomap/components/Map.tsx @@ -0,0 +1,91 @@ +/** + * @file Map.tsx + * @description 카카오 지도 컨테이너 컴포넌트 + * + * 동작 흐름: + * 1.
DOM 생성 + * 2. useLayoutEffect: new kakao.maps.Map(div, options) 인스턴스 생성 (깜빡임 없음) + * 3. KakaoMapContext.Provider로 자식(MapMarker 등)에게 map 인스턴스 전달 + * 4. center/level props 변경 → SDK setter 직접 호출 (리렌더 없이 동기화) + * + * @example + * const [loading] = useKakaoLoader() + * if (loading) return + * + * return ( + * + * + * + * ) + */ + +import { useLayoutEffect, useRef, useState } from 'react' + +import { KakaoMapContext } from '../context/KakaoMapContext' +import type { KakaoMap } from '../kakaoMap.types' + +export type MapProps = { + /** 지도 중심 좌표 */ + center: { lat: number; lng: number } + /** 지도 줌 레벨 (기본값: 3) */ + level?: number + style?: React.CSSProperties + className?: string + /** 지도 인스턴스 생성 완료 콜백 — 외부에서 map 인스턴스에 직접 접근할 때 사용 */ + onCreate?: (map: KakaoMap) => void + children?: React.ReactNode +} + +export function Map({ center, level = 3, style, className, onCreate, children }: MapProps) { + const containerRef = useRef(null) + const [map, setMap] = useState(null) + + // onCreate 콜백을 ref로 안정화 — deps 변경 없이 항상 최신 참조 유지 + const onCreateRef = useRef(onCreate) + onCreateRef.current = onCreate + + // ── 최초 마운트 시 지도 인스턴스 생성 ──────────────────────── + // useLayoutEffect: DOM paint 직전 실행 → 깜빡임 없음 + useLayoutEffect(() => { + if (!containerRef.current || !window.kakao?.maps) return + + const kakaoMap = new window.kakao.maps.Map(containerRef.current, { + center: new window.kakao.maps.LatLng(center.lat, center.lng), + level, + }) + + setMap(kakaoMap) + onCreateRef.current?.(kakaoMap) + + return () => { + setMap(null) + } + // center/level 초기값은 한 번만 사용. 이후 변경은 아래 effect에서 SDK setter로 처리 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // ── center props 변경 → map.setCenter() 호출 ───────────── + useLayoutEffect(() => { + if (!map) return + map.setCenter(new window.kakao.maps.LatLng(center.lat, center.lng)) + }, [map, center.lat, center.lng]) + + // ── level props 변경 → map.setLevel() 호출 ─────────────── + useLayoutEffect(() => { + if (!map) return + map.setLevel(level) + }, [map, level]) + + return ( + +
+ {/* map 인스턴스가 준비된 후에만 자식 렌더링 */} + {map && children} + + ) +} diff --git a/src/features/kakaomap/components/MapMarker.tsx b/src/features/kakaomap/components/MapMarker.tsx new file mode 100644 index 0000000..44c225c --- /dev/null +++ b/src/features/kakaomap/components/MapMarker.tsx @@ -0,0 +1,87 @@ +/** + * @file MapMarker.tsx + * @description 카카오 지도 마커 공개 컴포넌트 + * + * Map 컴포넌트의 자식으로 사용합니다. + * position을 useMemo로 최적화하고, 실제 마커 로직은 Marker에 위임합니다. + * + * @example + * + * console.log(marker)} + * > + *
마커 내용
+ *
+ *
+ */ + +import { forwardRef, type PropsWithChildren, useMemo } from 'react' + +import { useKakaoMapContext } from '../context/KakaoMapContext' +import type { KakaoMarker } from '../kakaoMap.types' +import { Marker, type MarkerImageProp } from './Marker' + +export type MapMarkerProps = { + /** + * 마커 표시 좌표 + */ + position: { lat: number; lng: number } | { x: number; y: number } + + /** 마커 이미지 커스터마이징 */ + image?: MarkerImageProp + + /** 마커 툴팁 텍스트 */ + title?: string + + /** 드래그 가능 여부 */ + draggable?: boolean + + /** 클릭 가능 여부 */ + clickable?: boolean + + /** z-index */ + zIndex?: number + + /** 투명도 (0–1) */ + opacity?: number + + /** 마커 클릭 이벤트 */ + onClick?: (marker: KakaoMarker) => void + + /** 마커 마우스오버 이벤트 */ + onMouseOver?: (marker: KakaoMarker) => void + + /** 마커 마우스아웃 이벤트 */ + onMouseOut?: (marker: KakaoMarker) => void + + /** 마커 드래그 시작 이벤트 */ + onDragStart?: (marker: KakaoMarker) => void + + /** 마커 드래그 종료 이벤트 */ + onDragEnd?: (marker: KakaoMarker) => void + + /** 마커 생성 완료 콜백 */ + onCreate?: (marker: KakaoMarker) => void + + /** InfoWindow 옵션 */ + infoWindowOptions?: { + disableAutoPan?: boolean + removable?: boolean + zIndex?: number + } +} + +export const MapMarker = forwardRef>( + function MapMarker({ position, ...props }, ref) { + const map = useKakaoMapContext('MapMarker') + + // 복잡한 표현식을 변수로 추출해 useMemo deps를 정적으로 분석 가능하게 함 + const lat = 'lat' in position ? position.lat : position.y + const lng = 'lng' in position ? position.lng : position.x + + const markerPosition = useMemo(() => new window.kakao.maps.LatLng(lat, lng), [lat, lng]) + + return + } +) diff --git a/src/features/kakaomap/components/Marker.tsx b/src/features/kakaomap/components/Marker.tsx new file mode 100644 index 0000000..fc9446a --- /dev/null +++ b/src/features/kakaomap/components/Marker.tsx @@ -0,0 +1,207 @@ +/** + * @file Marker.tsx + * @description 카카오 마커 내부 구현 컴포넌트 + * + * MapMarker에서 위임받아 실제 kakao.maps.Marker 인스턴스를 관리합니다. + * - useMemo: 렌더 중 동기적으로 마커 인스턴스 생성 (타이밍 문제 없음) + * - useLayoutEffect: map 등록/해제 및 props setter 동기화 + * - useKakaoEvent: 이벤트 등록/해제 자동화 + * - createPortal: children을 CustomOverlay DOM에 주입 + * - forwardRef: 외부에서 마커 인스턴스에 직접 접근 가능 + */ + +import { + forwardRef, + type PropsWithChildren, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react' +import { createPortal } from 'react-dom' + +import type { + KakaoCustomOverlay, + KakaoLatLng, + KakaoMap, + KakaoMarker, + KakaoMarkerImageOptions, + KakaoSdkMarkerImage, +} from '../kakaoMap.types' +import { useKakaoEvent } from '../lib/useKakaoEvent' + +export type MarkerImageProp = { + src: string + size: { width: number; height: number } + options?: KakaoMarkerImageOptions +} + +export type MarkerProps = { + map: KakaoMap + position: KakaoLatLng + image?: MarkerImageProp + title?: string + draggable?: boolean + clickable?: boolean + zIndex?: number + opacity?: number + onClick?: (marker: KakaoMarker) => void + onMouseOver?: (marker: KakaoMarker) => void + onMouseOut?: (marker: KakaoMarker) => void + onDragStart?: (marker: KakaoMarker) => void + onDragEnd?: (marker: KakaoMarker) => void + onCreate?: (marker: KakaoMarker) => void + infoWindowOptions?: { + disableAutoPan?: boolean + removable?: boolean + zIndex?: number + } +} + +function buildMarkerImage(image: MarkerImageProp): KakaoSdkMarkerImage { + const { kakao } = window + const size = new kakao.maps.Size(image.size.width, image.size.height) + const options = image.options + ? { + ...image.options, + offset: image.options.offset + ? new kakao.maps.Point(image.options.offset.x, image.options.offset.y) + : undefined, + spriteOrigin: image.options.spriteOrigin + ? new kakao.maps.Point(image.options.spriteOrigin.x, image.options.spriteOrigin.y) + : undefined, + spriteSize: image.options.spriteSize + ? new kakao.maps.Size(image.options.spriteSize.width, image.options.spriteSize.height) + : undefined, + } + : undefined + return new kakao.maps.MarkerImage(image.src, size, options) +} + +export const Marker = forwardRef>(function Marker( + { + map, + position, + image, + title, + draggable, + clickable, + zIndex, + opacity, + onClick, + onMouseOver, + onMouseOut, + onDragStart, + onDragEnd, + onCreate, + children, + }, + ref +) { + const overlayRef = useRef(null) + const [overlayEl, setOverlayEl] = useState(null) + + const hasChildren = children != null + + // ── 마커 인스턴스 생성 ──────── + const marker = useMemo( + () => + new window.kakao.maps.Marker({ + position, + image: image ? buildMarkerImage(image) : undefined, + title, + draggable, + clickable, + zIndex, + opacity, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ) + + // 마커 인스턴스를 ref로 외부에 노출 + useImperativeHandle(ref, () => marker, [marker]) + + // ── map 등록/해제 ────────────────────────────────────────── + useLayoutEffect(() => { + marker.setMap(map) + return () => marker.setMap(null) + }, [map, marker]) + + // ── onCreate 콜백 ────────────────────────────────────────── + useLayoutEffect(() => { + onCreate?.(marker) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [marker]) + + // ── props 변경 → SDK setter 호출 ────────────────────────── + useLayoutEffect(() => { + marker.setPosition(position) + }, [marker, position]) + + useLayoutEffect(() => { + if (!image) return + marker.setImage(buildMarkerImage(image)) + }, [marker, image]) + + useLayoutEffect(() => { + if (title !== undefined) marker.setTitle(title) + }, [marker, title]) + + useLayoutEffect(() => { + if (draggable !== undefined) marker.setDraggable(draggable) + }, [marker, draggable]) + + useLayoutEffect(() => { + if (clickable !== undefined) marker.setClickable(clickable) + }, [marker, clickable]) + + useLayoutEffect(() => { + if (zIndex !== undefined) marker.setZIndex(zIndex) + }, [marker, zIndex]) + + useLayoutEffect(() => { + if (opacity !== undefined) marker.setOpacity(opacity) + }, [marker, opacity]) + + // ── 이벤트 자동 등록/해제 ───────────────────────────────── + useKakaoEvent(marker, 'click', onClick ? () => onClick(marker) : undefined) + useKakaoEvent(marker, 'mouseover', onMouseOver ? () => onMouseOver(marker) : undefined) + useKakaoEvent(marker, 'mouseout', onMouseOut ? () => onMouseOut(marker) : undefined) + useKakaoEvent(marker, 'dragstart', onDragStart ? () => onDragStart(marker) : undefined) + useKakaoEvent(marker, 'dragend', onDragEnd ? () => onDragEnd(marker) : undefined) + + // ── children → CustomOverlay + createPortal ─────────────── + useLayoutEffect(() => { + if (!hasChildren) return + + const { kakao } = window + const container = document.createElement('div') + + const overlay = new kakao.maps.CustomOverlay({ + position, + content: container, + map, + yAnchor: 1, + }) + + overlayRef.current = overlay + setOverlayEl(container) + + return () => { + overlay.setMap(null) + overlayRef.current = null + setOverlayEl(null) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [map, hasChildren]) + + // overlay position 동기화 + useLayoutEffect(() => { + overlayRef.current?.setPosition(position) + }, [position]) + + if (!overlayEl || !hasChildren) return null + return createPortal(children, overlayEl) +}) diff --git a/src/features/kakaomap/components/ZoomControl.tsx b/src/features/kakaomap/components/ZoomControl.tsx new file mode 100644 index 0000000..3b7dcc3 --- /dev/null +++ b/src/features/kakaomap/components/ZoomControl.tsx @@ -0,0 +1,48 @@ +/** + * @file ZoomControl.tsx + * @description 카카오 지도 줌 컨트롤 컴포넌트 + * + * 내부에서 사용하며, KakaoMapContext로 map 인스턴스를 획득해 + * 줌 컨트롤을 추가/제거합니다. + * + * @example + * + * + * + */ + +import { forwardRef, useImperativeHandle, useLayoutEffect, useMemo } from 'react' + +import { useKakaoMapContext } from '../context/KakaoMapContext' +import type { KakaoControl } from '../kakaoMap.types' + +type KakaoControlPositionKey = keyof Window['kakao']['maps']['ControlPosition'] + +export type ZoomControlProps = { + /** ZoomControl 표시 위치 (기본값: 'RIGHT') */ + position?: number | KakaoControlPositionKey +} + +export const ZoomControl = forwardRef(function ZoomControl( + { position: _position = 'RIGHT' }, + ref +) { + const map = useKakaoMapContext('ZoomControl') + + const position = + typeof _position === 'string' ? window.kakao.maps.ControlPosition[_position] : _position + + const zoomControl = useMemo(() => new window.kakao.maps.ZoomControl(), []) + + useImperativeHandle(ref, () => zoomControl, [zoomControl]) + + useLayoutEffect(() => { + map.addControl(zoomControl, position) + + return () => { + map.removeControl(zoomControl) + } + }, [map, position, zoomControl]) + + return null +}) diff --git a/src/features/kakaomap/components/index.ts b/src/features/kakaomap/components/index.ts new file mode 100644 index 0000000..dc1cec3 --- /dev/null +++ b/src/features/kakaomap/components/index.ts @@ -0,0 +1,8 @@ +export type { MapProps } from './Map' +export { Map } from './Map' +export type { MapMarkerProps } from './MapMarker' +export { MapMarker } from './MapMarker' +export type { MarkerImageProp, MarkerProps } from './Marker' +export { Marker } from './Marker' +export type { ZoomControlProps } from './ZoomControl' +export { ZoomControl } from './ZoomControl' diff --git a/src/features/kakaomap/context/KakaoMapContext.ts b/src/features/kakaomap/context/KakaoMapContext.ts new file mode 100644 index 0000000..80231f2 --- /dev/null +++ b/src/features/kakaomap/context/KakaoMapContext.ts @@ -0,0 +1,27 @@ +/** + * @file KakaoMapContext.ts + * @description 카카오 map 인스턴스를 자식 컴포넌트에 전달하는 Context + * + * 컴포넌트가 Provider가 되고, 자식의 마커·인포윈도우 등이 + * useContext(KakaoMapContext)로 map 인스턴스를 획득합니다. + */ + +import { createContext, useContext } from 'react' + +import type { KakaoMap } from '../kakaoMap.types' + +export const KakaoMapContext = createContext(null) + +/** + * map 인스턴스가 필요한 자식 컴포넌트에서 사용합니다. + * 컴포넌트 내부가 아니라면 Error를 발생시킵니다. + */ +export function useKakaoMapContext(componentName?: string): KakaoMap { + const map = useContext(KakaoMapContext) + if (!map) { + throw new Error( + `${componentName ? componentName + ' Component' : 'useKakaoMapContext'} must exist inside Map Component!` + ) + } + return map +} diff --git a/src/features/kakaomap/context/index.ts b/src/features/kakaomap/context/index.ts new file mode 100644 index 0000000..878377f --- /dev/null +++ b/src/features/kakaomap/context/index.ts @@ -0,0 +1 @@ +export { KakaoMapContext, useKakaoMapContext } from './KakaoMapContext' diff --git a/src/features/kakaomap/hooks/index.ts b/src/features/kakaomap/hooks/index.ts new file mode 100644 index 0000000..7080059 --- /dev/null +++ b/src/features/kakaomap/hooks/index.ts @@ -0,0 +1,3 @@ +export { useKakaoLoader } from './useKakaoLoader' +export type { UseKakaoPlaceSearchOptions } from './useKakaoPlaceSearch' +export { useKakaoPlaceSearch } from './useKakaoPlaceSearch' diff --git a/src/features/kakaomap/hooks/useKakaoLoader.ts b/src/features/kakaomap/hooks/useKakaoLoader.ts new file mode 100644 index 0000000..6340f24 --- /dev/null +++ b/src/features/kakaomap/hooks/useKakaoLoader.ts @@ -0,0 +1,48 @@ +/** + * @file useKakaoLoader.ts + * @description 카카오 Maps SDK 로드 상태를 관리하는 훅 + * + * 내부적으로 KakaoMapApiLoader.load()를 호출하고 [loading, error] 상태를 반환합니다. + * loading이 false가 된 후에 컴포넌트를 렌더링해야 합니다. + * + * @example + * const [loading, error] = useKakaoLoader() + * if (loading) return + * if (error) return + * return + */ + +import { useEffect, useState } from 'react' + +import { KakaoMapApiLoader } from '../lib/kakaoMapApiLoader' + +const appkey = import.meta.env.VITE_KAKAO_MAP_KEY + +export function useKakaoLoader(): [loading: boolean, error: Error | null] { + const [state, setState] = useState<[loading: boolean, error: Error | null]>( + appkey ? [true, null] : [false, new Error('VITE_KAKAO_MAP_KEY 환경변수가 설정되지 않았습니다.')] + ) + + useEffect(() => { + if (!appkey) return + + const loader = KakaoMapApiLoader.getInstance({ + appkey, + libraries: ['services'], + }) + + loader + .load() + .then(() => { + setState([false, null]) + }) + .catch((err: unknown) => { + setState([ + false, + err instanceof Error ? err : new Error('카카오 지도 SDK 로드에 실패했습니다.'), + ]) + }) + }, []) + + return state +} diff --git a/src/features/kakaomap/hooks/useKakaoPlaceSearch.ts b/src/features/kakaomap/hooks/useKakaoPlaceSearch.ts new file mode 100644 index 0000000..1f1c25f --- /dev/null +++ b/src/features/kakaomap/hooks/useKakaoPlaceSearch.ts @@ -0,0 +1,81 @@ +/** + * @file useKakaoPlaceSearch.ts + * @description Kakao Places API 검색 로직 훅 + * + * initializeMap() 완료 후 호출해야 window.kakao.maps.services가 존재합니다. + */ + +import { useCallback, useRef, useState } from 'react' + +import type { KakaoPlace, KakaoPlacesService } from '../kakaoMap.types' + +export type UseKakaoPlaceSearchOptions = { + /** 검색 성공 콜백 */ + onSearchSuccess?: (places: KakaoPlace[]) => void + /** 검색 오류 콜백 */ + onSearchError?: (message: string) => void +} + +export function useKakaoPlaceSearch({ + onSearchSuccess, + onSearchError, +}: UseKakaoPlaceSearchOptions = {}) { + const [places, setPlaces] = useState([]) + const [error, setError] = useState(null) + + const placesServiceRef = useRef(null) + + const search = useCallback( + (searchKeyword: string) => { + const trimmedKeyword = searchKeyword.trim() + if (!trimmedKeyword) return false + + const { kakao } = window + if (!kakao?.maps?.services) { + console.warn('[카카오 장소 검색] SDK가 아직 준비되지 않았습니다.') + return false + } + + if (!placesServiceRef.current) { + placesServiceRef.current = new kakao.maps.services.Places() + } + + const ps = placesServiceRef.current + + ps.keywordSearch(trimmedKeyword, (data, status) => { + if (status === kakao.maps.services.Status.OK) { + setError(null) + setPlaces(data) + onSearchSuccess?.(data) + } else if (status === kakao.maps.services.Status.ZERO_RESULT) { + setError(null) + setPlaces([]) + onSearchSuccess?.([]) + } else { + const message = '검색 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.' + setError(message) + setPlaces([]) + onSearchError?.(message) + console.error('[카카오 장소 검색] 오류 발생 - status:', status) + } + }) + + return true + }, + [onSearchSuccess, onSearchError] + ) + + /** 검색 상태 초기화 */ + const reset = useCallback(() => { + setPlaces([]) + setError(null) + placesServiceRef.current = null + }, []) + + return { + places, + error, + search, + reset, + } +} diff --git a/src/features/kakaomap/index.ts b/src/features/kakaomap/index.ts new file mode 100644 index 0000000..6cbac28 --- /dev/null +++ b/src/features/kakaomap/index.ts @@ -0,0 +1,31 @@ +// Types +export type { + KakaoCustomOverlay, + KakaoCustomOverlayOptions, + KakaoInfoWindow, + KakaoLatLng, + KakaoLatLngBounds, + KakaoMap, + KakaoMarker, + KakaoMarkerImageOptions, + KakaoPlace, + KakaoPlacesService, + KakaoPoint, + KakaoSdkMarkerImage, + KakaoSearchMeta, + KakaoSearchParams, + KakaoSearchResponse, + KakaoSize, +} from './kakaoMap.types' + +// Context +export * from './context' + +// Lib +export * from './lib' + +// Hooks +export * from './hooks' + +// Components +export * from './components' diff --git a/src/features/kakaomap/kakaoMap.types.ts b/src/features/kakaomap/kakaoMap.types.ts new file mode 100644 index 0000000..1c8b527 --- /dev/null +++ b/src/features/kakaomap/kakaoMap.types.ts @@ -0,0 +1,230 @@ +/** + * @file kakaoMap.types.ts + * @description 카카오 Maps SDK 타입 정의 + * @note 외부 API 응답 스펙을 따르기 위해 snake_case 사용 + */ + +/* eslint-disable @typescript-eslint/naming-convention */ + +// ─── 카카오 장소 검색 응답 타입 ────────────────────────────────────────── + +/** 카카오 장소 검색 응답 문서 타입 */ +export type KakaoPlace = { + /** 장소명, 업체명 */ + place_name: string + /** 전체 지번 주소 */ + address_name: string + /** 전체 도로명 주소 */ + road_address_name: string + /** X 좌표값, 경도(longitude) */ + x: string + /** Y 좌표값, 위도(latitude) */ + y: string + /** 장소 ID */ + id: string + /** 카테고리 그룹 코드 */ + category_group_code: string + /** 카테고리 그룹명 */ + category_group_name: string + /** 카테고리 이름 */ + category_name: string + /** 전화번호 */ + phone: string + /** 장소 상세페이지 URL */ + place_url: string + /** 중심좌표까지의 거리 (단, x,y 파라미터를 준 경우에만 존재) */ + distance?: string +} + +/** 카카오 장소 검색 API 응답 메타 정보 */ +export type KakaoSearchMeta = { + total_count: number + pageable_count: number + is_end: boolean + same_name?: { + region: string[] + keyword: string + selected_region: string + } +} + +/** 카카오 장소 검색 API 응답 타입 */ +export type KakaoSearchResponse = { + documents: KakaoPlace[] + meta: KakaoSearchMeta +} + +/** 카카오 장소 검색 API 요청 파라미터 */ +export type KakaoSearchParams = { + query: string + category_group_code?: string + x?: string + y?: string + radius?: number + page?: number + size?: number + sort?: 'distance' | 'accuracy' +} + +// ─── 카카오 Maps SDK 내부 타입 ──────────────────────────────────────────── + +export interface KakaoLatLng { + getLat(): number + getLng(): number +} + +export interface KakaoLatLngBounds { + extend(latlng: KakaoLatLng): void + isEmpty(): boolean +} + +/** 카카오 SDK Size 객체 */ +export interface KakaoSize { + width: number + height: number +} + +/** 카카오 SDK Point 객체 */ +export interface KakaoPoint { + x: number + y: number +} + +/** kakao.maps.MarkerImage 인스턴스 */ +export type KakaoSdkMarkerImage = object + +/** MarkerImage 생성 옵션 */ +export interface KakaoMarkerImageOptions { + alt?: string + coords?: string + offset?: KakaoPoint + shape?: 'default' | 'rect' | 'circle' | 'poly' + spriteOrigin?: KakaoPoint + spriteSize?: KakaoSize +} + +export interface KakaoMarker { + setMap(map: KakaoMap | null): void + getPosition(): KakaoLatLng + setPosition(latlng: KakaoLatLng): void + setImage(image: KakaoSdkMarkerImage): void + setTitle(title: string): void + setDraggable(draggable: boolean): void + setClickable(clickable: boolean): void + setZIndex(zIndex: number): void + setOpacity(opacity: number): void +} + +export interface KakaoCustomOverlay { + setMap(map: KakaoMap | null): void + setContent(content: HTMLElement | string): void + setPosition(latlng: KakaoLatLng): void + getContent(): HTMLElement | string +} + +export interface KakaoCustomOverlayOptions { + map?: KakaoMap + position: KakaoLatLng + content?: HTMLElement | string + zIndex?: number + /** 마커 기준 Y 오프셋 (0: 하단, 1: 중앙, 2: 상단) */ + yAnchor?: number + xAnchor?: number +} + +export interface KakaoInfoWindow { + open(map: KakaoMap, marker: KakaoMarker): void + close(): void + setContent(content: string): void +} + +export interface KakaoMap { + setCenter(latlng: KakaoLatLng): void + setLevel(level: number): void + setBounds(bounds: KakaoLatLngBounds): void + relayout(): void + addControl(control: KakaoControl, position: number): void + removeControl(control: KakaoControl): void +} + +export type KakaoControl = object + +export interface KakaoMapOptions { + center: KakaoLatLng + level?: number +} + +export interface KakaoMarkerOptions { + position: KakaoLatLng + map?: KakaoMap + image?: KakaoSdkMarkerImage + title?: string + draggable?: boolean + clickable?: boolean + zIndex?: number + opacity?: number +} + +export interface KakaoInfoWindowOptions { + zIndex?: number + content?: string +} + +export interface KakaoPlacesService { + keywordSearch( + keyword: string, + callback: (data: KakaoPlace[], status: string) => void, + options?: Partial + ): void +} + +// ─── window.kakao 글로벌 타입 선언 ──────────────────────────────────────── + +declare global { + interface Window { + kakao: { + maps: { + Map: new (container: HTMLElement, options: KakaoMapOptions) => KakaoMap + LatLng: new (lat: number, lng: number) => KakaoLatLng + LatLngBounds: new () => KakaoLatLngBounds + Size: new (width: number, height: number) => KakaoSize + Point: new (x: number, y: number) => KakaoPoint + MarkerImage: new ( + src: string, + size: KakaoSize, + options?: KakaoMarkerImageOptions + ) => KakaoSdkMarkerImage + Marker: new (options: KakaoMarkerOptions) => KakaoMarker + CustomOverlay: new (options: KakaoCustomOverlayOptions) => KakaoCustomOverlay + InfoWindow: new (options?: KakaoInfoWindowOptions) => KakaoInfoWindow + MapTypeControl: new () => KakaoControl + ZoomControl: new () => KakaoControl + ControlPosition: { + TOPRIGHT: number + RIGHT: number + TOP: number + TOPLEFT: number + LEFT: number + BOTTOMLEFT: number + BOTTOM: number + BOTTOMRIGHT: number + } + event: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + addListener(target: object, type: string, handler: (...args: any[]) => void): void + // eslint-disable-next-line @typescript-eslint/no-explicit-any + removeListener(target: object, type: string, handler: (...args: any[]) => void): void + } + services: { + Places: new () => KakaoPlacesService + Status: { + OK: string + ZERO_RESULT: string + ERROR: string + } + } + load(callback: () => void): void + } + } + } +} diff --git a/src/features/kakaomap/lib/index.ts b/src/features/kakaomap/lib/index.ts new file mode 100644 index 0000000..dae0914 --- /dev/null +++ b/src/features/kakaomap/lib/index.ts @@ -0,0 +1,3 @@ +export type { KakaoMapLoaderOptions } from './kakaoMapApiLoader' +export { KakaoMapApiLoader } from './kakaoMapApiLoader' +export { useKakaoEvent } from './useKakaoEvent' diff --git a/src/features/kakaomap/lib/kakaoMapApiLoader.ts b/src/features/kakaomap/lib/kakaoMapApiLoader.ts new file mode 100644 index 0000000..99bdb87 --- /dev/null +++ b/src/features/kakaomap/lib/kakaoMapApiLoader.ts @@ -0,0 +1,202 @@ +/** + * @description KakaoMapApiLoader 클래스 + * + * - 동일 appkey/libraries 옵션이면 단일 인스턴스 재사용 + * - 옵션이 달라지면 에러 throw + * - 이미 window.kakao가 존재하면 즉시 resolve + * - 실패 시 지수 백오프(exponential backoff) 방식으로 자동 재시도 + */ + +type LoadState = 'INITIALIZED' | 'LOADING' | 'SUCCESS' | 'FAILURE' + +export type KakaoMapLoaderOptions = { + appkey: string + libraries?: string[] + /** 최대 재시도 횟수 (기본값: 3) */ + maxRetries?: number + /** 초기 재시도 대기 시간 ms (기본값: 500) */ + retryDelay?: number +} + +const KAKAO_STATUS_MESSAGES: Record = { + 400: '잘못된 요청입니다. API에 필요한 필수 파라미터를 확인해주세요. (400 Bad Request)', + 401: '인증 오류입니다. 앱키(VITE_KAKAO_MAP_KEY)가 올바른지 확인해주세요. (401 Unauthorized)', + 403: '권한 오류입니다. 앱 등록 및 도메인 설정을 확인해주세요. (403 Forbidden)', + 429: '쿼터를 초과했습니다. 정해진 사용량이나 초당 요청 한도를 초과했습니다. (429 Too Many Request)', + 500: '카카오 서버 내부 오류입니다. 잠시 후 다시 시도해주세요. (500 Internal Server Error)', + 502: '카카오 게이트웨이 오류입니다. 잠시 후 다시 시도해주세요. (502 Bad Gateway)', + 503: '카카오 서비스 점검 중입니다. 잠시 후 다시 시도해주세요. (503 Service Unavailable)', +} + +export class KakaoMapApiLoader { + private static instance: KakaoMapApiLoader | null = null + + private state: LoadState = 'INITIALIZED' + private promise: Promise | null = null + + private readonly appkey: string + private readonly libraries: string[] + private readonly maxRetries: number + private readonly retryDelay: number + + private constructor({ + appkey, + libraries = [], + maxRetries = 3, + retryDelay = 500, + }: KakaoMapLoaderOptions) { + this.appkey = appkey + this.libraries = libraries + this.maxRetries = maxRetries + this.retryDelay = retryDelay + } + + /** + * 싱글톤 인스턴스 반환 + * - 처음 호출 시 인스턴스 생성 + * - 이후 호출 시 options 없이 기존 인스턴스 반환 가능 + * - options가 달라지면 에러 throw + */ + static getInstance(options?: KakaoMapLoaderOptions): KakaoMapApiLoader { + if (!KakaoMapApiLoader.instance) { + if (!options) { + throw new Error('[KakaoMapLoader] 처음 호출 시 options가 필요합니다.') + } + KakaoMapApiLoader.instance = new KakaoMapApiLoader(options) + return KakaoMapApiLoader.instance + } + + if (options) { + const isSameKey = KakaoMapApiLoader.instance.appkey === options.appkey + const isSameLibs = + JSON.stringify(KakaoMapApiLoader.instance.libraries.sort()) === + JSON.stringify((options.libraries ?? []).sort()) + + if (!isSameKey || !isSameLibs) { + throw new Error( + '[KakaoMapLoader] appkey 또는 libraries 옵션이 기존 인스턴스와 다릅니다. 앱에서 동일한 옵션을 사용해야 합니다.' + ) + } + } + + return KakaoMapApiLoader.instance + } + + /** 테스트 등에서 인스턴스 초기화 시 사용 */ + static reset(): void { + KakaoMapApiLoader.instance = null + } + + /** SDK 스크립트 URL 생성 */ + private buildScriptUrl(): string { + const libs = this.libraries.join(',') + const libsQuery = libs ? `&libraries=${libs}` : '' + return `https://dapi.kakao.com/v2/maps/sdk.js?appkey=${this.appkey}&autoload=false${libsQuery}` + } + + /** 지수 백오프 대기 */ + private wait(attempt: number): Promise { + const delay = this.retryDelay * Math.pow(2, attempt) + return new Promise((resolve) => setTimeout(resolve, delay)) + } + + /** script 태그 삽입 후 로드 시도 (단일 시도) */ + private tryLoad(): Promise { + return new Promise((resolve, reject) => { + const url = this.buildScriptUrl() + const script = document.createElement('script') + script.src = url + script.async = true + + script.onload = () => { + try { + window.kakao.maps.load(() => resolve()) + } catch { + script.remove() + reject(new Error('카카오 지도 SDK 초기화에 실패했습니다.')) + } + } + + script.onerror = () => { + script.remove() + // fetch로 실제 HTTP 상태 코드 확인 + fetch(url) + .then((res) => { + if (res.ok) { + reject( + new Error( + '카카오 지도 SDK 로드에 실패했습니다. 일시적인 네트워크 오류일 수 있으니 잠시 후 다시 시도해주세요.' + ) + ) + return + } + const message = + KAKAO_STATUS_MESSAGES[res.status] ?? + `카카오 지도 SDK 로드에 실패했습니다. (HTTP ${res.status})` + reject(new Error(message)) + }) + .catch(() => { + reject(new Error('카카오 지도 SDK를 로드할 수 없습니다. 네트워크 연결을 확인해주세요.')) + }) + } + + document.head.appendChild(script) + }) + } + + /** + * SDK 로드 (지수 백오프 재시도 포함) + * - 이미 SUCCESS 상태면 즉시 resolve + * - LOADING 중이면 동일 Promise 반환 + * - INITIALIZED / FAILURE 상태에서 새로 시도 + */ + load(): Promise { + // 이미 window.kakao가 존재하면 즉시 resolve + if (window.kakao?.maps) { + this.state = 'SUCCESS' + return Promise.resolve() + } + + if (this.state === 'SUCCESS') { + return Promise.resolve() + } + + // LOADING 중이면 동일 Promise 반환 + if (this.state === 'LOADING' && this.promise) { + return this.promise + } + + this.state = 'LOADING' + + this.promise = (async () => { + for (let attempt = 0; attempt <= this.maxRetries; attempt++) { + try { + await this.tryLoad() + this.state = 'SUCCESS' + return + } catch (err) { + const isLastAttempt = attempt === this.maxRetries + const message = err instanceof Error ? err.message : '알 수 없는 오류' + console.error( + `[KakaoMapLoader] 로드 실패 (시도 ${attempt + 1}/${this.maxRetries + 1}):`, + message + ) + + if (isLastAttempt) { + this.state = 'FAILURE' + this.promise = null + throw err + } + + await this.wait(attempt) + } + } + })() + + return this.promise + } + + get currentState(): LoadState { + return this.state + } +} diff --git a/src/features/kakaomap/lib/useKakaoEvent.ts b/src/features/kakaomap/lib/useKakaoEvent.ts new file mode 100644 index 0000000..da83745 --- /dev/null +++ b/src/features/kakaomap/lib/useKakaoEvent.ts @@ -0,0 +1,39 @@ +/** + * @file useKakaoEvent.ts + * @description 카카오 이벤트 자동 등록/해제 훅 + * + * 마운트 시 addListener, 언마운트 시 자동 removeListener + * 핸들러를 ref로 안정화하여 target이 바뀔 때만 재등록합니다. + * + * @example + * useKakaoEvent(marker, 'click', () => onClick?.(marker)) + */ + +import { useLayoutEffect, useRef } from 'react' + +export function useKakaoEvent( + target: T | null, + type: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + handler: ((...args: any[]) => void) | undefined +) { + // 핸들러를 ref로 안정화 — target이 바뀔 때만 이벤트를 재등록하고, 핸들러 변경은 ref를 통해 반영 + const handlerRef = useRef(handler) + useLayoutEffect(() => { + handlerRef.current = handler + }, [handler]) + + useLayoutEffect(() => { + if (!target || !handlerRef.current) return + + const { kakao } = window + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const stableHandler = (...args: any[]) => handlerRef.current?.(...args) + + kakao.maps.event.addListener(target, type, stableHandler) + + return () => { + kakao.maps.event.removeListener(target, type, stableHandler) + } + }, [target, type]) +} diff --git a/src/features/meetings/components/MapModal.tsx b/src/features/meetings/components/MapModal.tsx deleted file mode 100644 index c9f5581..0000000 --- a/src/features/meetings/components/MapModal.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type { MeetingLocation } from '@/features/meetings/meetings.types' -import { Modal, ModalBody, ModalContent, ModalHeader, ModalTitle } from '@/shared/ui' - -interface MapModalProps { - open: boolean - onOpenChange: (open: boolean) => void - location: MeetingLocation -} - -export default function MapModal({ open, onOpenChange, location }: MapModalProps) { - return ( - - - - {location.name} - - -
- 지도 API 연동 예정 -
-
-
-
- ) -} diff --git a/src/features/meetings/components/MeetingDetailHeader.tsx b/src/features/meetings/components/MeetingDetailHeader.tsx index ab2d34e..6c59a90 100644 --- a/src/features/meetings/components/MeetingDetailHeader.tsx +++ b/src/features/meetings/components/MeetingDetailHeader.tsx @@ -9,7 +9,10 @@ type ProgressBadge = { text: '약속 전' | '약속 중' | '약속 후' color: 'yellow' | 'blue' | 'red' } -export function MeetingDetailHeader({ children, progressStatus }: MeetingDetailHeaderProps) { +export default function MeetingDetailHeader({ + children, + progressStatus, +}: MeetingDetailHeaderProps) { const progressStatusLabelMap: Record = { PRE: { text: '약속 전', color: 'yellow' }, ONGOING: { text: '약속 중', color: 'red' }, diff --git a/src/features/meetings/components/MeetingDetailInfo.tsx b/src/features/meetings/components/MeetingDetailInfo.tsx index 3710ea6..d19896f 100644 --- a/src/features/meetings/components/MeetingDetailInfo.tsx +++ b/src/features/meetings/components/MeetingDetailInfo.tsx @@ -18,7 +18,7 @@ interface MeetingDetailInfoProps { meeting: GetMeetingDetailResponse } -export function MeetingDetailInfo({ meeting }: MeetingDetailInfoProps) { +export default function MeetingDetailInfo({ meeting }: MeetingDetailInfoProps) { const leader = meeting.participants.members.find((member) => member.role === 'LEADER') const members = meeting.participants.members.filter((member) => member.role === 'MEMBER') const displayedMembers = members.slice(0, MAX_DISPLAYED_AVATARS) diff --git a/src/features/meetings/components/PlaceList.tsx b/src/features/meetings/components/PlaceList.tsx index aede7a6..6af8c68 100644 --- a/src/features/meetings/components/PlaceList.tsx +++ b/src/features/meetings/components/PlaceList.tsx @@ -3,51 +3,47 @@ * @description 장소 검색 결과 목록 컴포넌트 */ -import type { KakaoPlace } from '../kakaoMap.types' +import type { KakaoPlace } from '@/features/kakaomap' +import { Button } from '@/shared/ui' export type PlaceListProps = { /** 장소 목록 */ places: KakaoPlace[] - /** 장소 클릭 핸들러 */ + /** li 클릭 시 지도 포커스 핸들러 */ + onPlaceFocus: (place: KakaoPlace) => void + /** 선택 버튼 클릭 핸들러 */ onPlaceClick: (place: KakaoPlace) => void - /** 장소 hover 핸들러 */ - onPlaceHover?: (place: KakaoPlace, index: number) => void - /** 장소 hover 종료 핸들러 */ - onPlaceHoverEnd?: () => void } -export default function PlaceList({ - places, - onPlaceClick, - onPlaceHover, - onPlaceHoverEnd, -}: PlaceListProps) { - if (places.length === 0) { - return ( -
-

검색 결과가 없습니다

-
- ) - } - +export default function PlaceList({ places, onPlaceFocus, onPlaceClick }: PlaceListProps) { return ( -
- {places.map((place, index) => ( - +
+

{place.place_name}

+ {place.category_group_name} +
+

{place.road_address_name}

+ +
+ +
+ ))} -
+ ) } diff --git a/src/features/meetings/components/PlaceListSkeleton.tsx b/src/features/meetings/components/PlaceListSkeleton.tsx new file mode 100644 index 0000000..f7cc107 --- /dev/null +++ b/src/features/meetings/components/PlaceListSkeleton.tsx @@ -0,0 +1,19 @@ +type PlaceListSkeletonProps = { + count?: number +} + +export default function PlaceListSkeleton({ count = 5 }: PlaceListSkeletonProps) { + return ( +
    + {[...Array(count).keys()].map((i) => ( +
  • +
    +
    +
    +
    +
    +
  • + ))} +
+ ) +} diff --git a/src/features/meetings/components/PlaceSearchModal.tsx b/src/features/meetings/components/PlaceSearchModal.tsx index b5a995a..0dc5d2c 100644 --- a/src/features/meetings/components/PlaceSearchModal.tsx +++ b/src/features/meetings/components/PlaceSearchModal.tsx @@ -1,33 +1,17 @@ /** * @file PlaceSearchModal.tsx - * @description 카카오 장소 검색 모달 컴포넌트 + * @description 장소 검색 모달 컴포넌트 + * + * UI는 searchState 기준으로만 화면을 분기합니다. + * 모든 상태 관리와 비동기 로직은 usePlaceSearch 훅에서 처리합니다. */ -import { AlertCircle, Search } from 'lucide-react' -import { useEffect, useRef } from 'react' - -import { - Button, - Input, - Modal, - ModalBody, - ModalContent, - ModalFooter, - ModalHeader, - ModalTitle, -} from '@/shared/ui' - -import { useKakaoMap } from '../hooks/useKakaoMap' -import { useKakaoPlaceSearch } from '../hooks/useKakaoPlaceSearch' -import type { KakaoPlace } from '../kakaoMap.types' -import PlaceList from './PlaceList' - -declare global { - interface Window { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - kakao: any - } -} +import { Map, MapMarker, ZoomControl } from '@/features/kakaomap' +import PlaceList from '@/features/meetings/components/PlaceList' +import PlaceListSkeleton from '@/features/meetings/components/PlaceListSkeleton' +import { usePlaceSearch } from '@/features/meetings/hooks' +import { cn } from '@/shared/lib/utils' +import { Modal, ModalBody, ModalContent, ModalHeader, ModalTitle, SearchField } from '@/shared/ui' export type PlaceSearchModalProps = { /** 모달 열림 상태 */ @@ -48,71 +32,21 @@ export default function PlaceSearchModal({ onOpenChange, onSelectPlace, }: PlaceSearchModalProps) { - // 지도 관리 - const { - mapElement, - isInitialized, - error: mapError, - initializeMap, - renderMarkers, - setCenter, - cleanup, - } = useKakaoMap() - - // 장소 검색 관리 - const keywordRef = useRef(null) const { + searchState, + errorMessage, places, - error: searchError, - search, - reset, - } = useKakaoPlaceSearch({ - onSearchSuccess: renderMarkers, - }) - - // 모달 열릴 때 지도 초기화 - useEffect(() => { - if (open && !isInitialized) { - initializeMap() - } - }, [open, isInitialized, initializeMap]) - - // 검색 실행 - const handleSearch = () => { - const keyword = keywordRef.current?.value || '' - search(keyword) - } - - // Enter 키 처리 - const handleKeyUp = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault() - handleSearch() - } - } - - // 장소 선택 - const handlePlaceClick = (place: KakaoPlace) => { - setCenter(Number(place.y), Number(place.x)) - - onSelectPlace({ - name: place.place_name, - address: place.road_address_name || place.address_name, - latitude: Number(place.y), - longitude: Number(place.x), - }) - - onOpenChange(false) - reset() - cleanup() - } - - // 모달 닫기 - const handleClose = () => { - onOpenChange(false) - reset() - cleanup() - } + isMapMounted, + isMapVisible, + hoveredPlaceId, + keywordRef, + setMapInstance, + setHoveredPlaceId, + handleKeyDown, + handlePlaceClick, + handlePlaceFocus, + handleClose, + } = usePlaceSearch({ open, onOpenChange, onSelectPlace }) return ( @@ -122,64 +56,68 @@ export default function PlaceSearchModal({ -
- - -
- -
- {/* 지도 영역 */} -
-
- - {/* SDK 로드 에러 오버레이 */} - {mapError && ( -
-
- -

{mapError}

-
-
- )} + + + {/* 지도 + 리스트 영역 + isMapMounted: 첫 검색 전 / 에러는 마운트하지 않음 + isMapVisible: noResults일 때 Map 인스턴스를 유지한 채 CSS로만 숨김 */} + {isMapMounted && ( +
+ + + {places.map((place) => ( + setHoveredPlaceId(place.id)} + onMouseOut={() => setHoveredPlaceId(null)} + > + {hoveredPlaceId === place.id && ( +
+ {place.place_name} +
+ )} +
+ ))} +
+ +
+ {searchState === 'searching' ? ( + + ) : ( + + )} +
+
+ )} - {/* 검색 전 안내 메시지 오버레이 */} - {!isInitialized && !mapError && ( -
-
- -

장소를 검색하면

-

지도에 표시됩니다

-
-
- )} + {/* 검색 결과 없음 */} + {searchState === 'noResults' && ( +
+

검색 결과가 없습니다

+ )} - {/* 장소 리스트 */} -
- {searchError && ( -
- - {searchError} -
- )} - + {/* SDK 오류 또는 검색 오류 */} + {searchState === 'error' && ( +
+

{errorMessage}

-
+ )} - - - - ) diff --git a/src/features/meetings/components/index.ts b/src/features/meetings/components/index.ts index dbb1aa8..68df722 100644 --- a/src/features/meetings/components/index.ts +++ b/src/features/meetings/components/index.ts @@ -1,9 +1,9 @@ -export { default as MapModal } from './MapModal' export { default as MeetingApprovalItem } from './MeetingApprovalItem' export { default as MeetingApprovalList } from './MeetingApprovalList' export { default as MeetingApprovalListSkeleton } from './MeetingApprovalListSkeleton' export { default as MeetingDetailButton } from './MeetingDetailButton' -export { MeetingDetailHeader } from './MeetingDetailHeader' -export { MeetingDetailInfo } from './MeetingDetailInfo' +export { default as MeetingDetailHeader } from './MeetingDetailHeader' +export { default as MeetingDetailInfo } from './MeetingDetailInfo' export { default as PlaceList } from './PlaceList' +export { default as PlaceListSkeleton } from './PlaceListSkeleton' export { default as PlaceSearchModal } from './PlaceSearchModal' diff --git a/src/features/meetings/hooks/index.ts b/src/features/meetings/hooks/index.ts index 34d21ae..615edca 100644 --- a/src/features/meetings/hooks/index.ts +++ b/src/features/meetings/hooks/index.ts @@ -4,10 +4,9 @@ export * from './useConfirmMeeting' export * from './useCreateMeeting' export * from './useDeleteMeeting' export * from './useJoinMeeting' -export * from './useKakaoMap' -export * from './useKakaoPlaceSearch' export * from './useMeetingApprovals' export * from './useMeetingDetail' export * from './useMeetingForm' +export * from './usePlaceSearch' export * from './useRejectMeeting' export * from './useUpdateMeeting' diff --git a/src/features/meetings/hooks/useKakaoMap.ts b/src/features/meetings/hooks/useKakaoMap.ts deleted file mode 100644 index 08f01fd..0000000 --- a/src/features/meetings/hooks/useKakaoMap.ts +++ /dev/null @@ -1,179 +0,0 @@ -/** - * @file useKakaoMap.ts - * @description Kakao Maps 지도 및 마커 관리 훅 - */ - -import { useRef, useState } from 'react' - -import type { KakaoPlace } from '../kakaoMap.types' -import { loadKakaoSdk } from '../loadKakaoSdk' - -export type UseKakaoMapOptions = { - /** 초기 중심 좌표 */ - initialCenter?: { lat: number; lng: number } - /** 초기 줌 레벨 */ - initialLevel?: number -} - -export function useKakaoMap({ initialCenter, initialLevel = 3 }: UseKakaoMapOptions = {}) { - const [mapElement, setMapElement] = useState(null) - const [isInitialized, setIsInitialized] = useState(false) - const [error, setError] = useState(null) - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const mapRef = useRef(null) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const markersRef = useRef([]) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const infowindowRef = useRef(null) - - const defaultCenter = useRef(initialCenter ?? { lat: 37.566826, lng: 126.9786567 }) - - // 마커 제거 - const clearMarkers = () => { - markersRef.current.forEach((marker) => { - marker.setMap(null) - }) - markersRef.current = [] - } - - // 인포윈도우 닫기 - const closeInfoWindow = () => { - infowindowRef.current?.close() - } - - // HTML escape 유틸리티 - const escapeHtml = (text: string) => { - const div = document.createElement('div') - div.textContent = text - return div.innerHTML - } - - // 인포윈도우 열기 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const openInfoWindow = (marker: any, title: string) => { - if (!mapRef.current || !infowindowRef.current) return - const escapedTitle = escapeHtml(title) - infowindowRef.current.setContent(`
${escapedTitle}
`) - infowindowRef.current.open(mapRef.current, marker) - } - - // 지도 수동 초기화 - const initializeMap = async () => { - if (!mapElement) { - console.warn('Map element not ready') - return false - } - - if (mapRef.current) { - // 이미 초기화된 경우 relayout만 실행 - mapRef.current.relayout() - return true - } - - try { - await loadKakaoSdk() - } catch (err) { - const message = err instanceof Error ? err.message : '카카오 지도 SDK 로드에 실패했습니다.' - setError(message) - return false - } - - const kakao = window.kakao - - if (!kakao?.maps) { - const message = '카카오 지도 SDK가 로드되지 않았습니다.' - console.error('[카카오 지도]', message) - setError(message) - return false - } - - // 지도 생성 - const map = new kakao.maps.Map(mapElement, { - center: new kakao.maps.LatLng(defaultCenter.current.lat, defaultCenter.current.lng), - level: initialLevel, - }) - - mapRef.current = map - - infowindowRef.current = new kakao.maps.InfoWindow({ zIndex: 1 }) - setError(null) - setIsInitialized(true) - - // Portal/Modal에서 사이즈 계산 이슈 방지 - setTimeout(() => { - map.relayout() - map.setCenter(new kakao.maps.LatLng(defaultCenter.current.lat, defaultCenter.current.lng)) - }, 0) - - return true - } - - // 지도 정리 - const cleanup = () => { - clearMarkers() - closeInfoWindow() - mapRef.current = null - infowindowRef.current = null - setIsInitialized(false) - setError(null) - } - - // 장소 목록에 대한 마커 렌더링 - const renderMarkers = (places: KakaoPlace[]) => { - if (!mapRef.current || !window.kakao) return - - const kakao = window.kakao - const map = mapRef.current - - clearMarkers() - closeInfoWindow() - - const bounds = new kakao.maps.LatLngBounds() - - places.forEach((place) => { - const position = new kakao.maps.LatLng(Number(place.y), Number(place.x)) - - const marker = new kakao.maps.Marker({ - position, - map, - }) - - // 마커 hover 이벤트 - kakao.maps.event.addListener(marker, 'mouseover', () => { - openInfoWindow(marker, place.place_name) - }) - kakao.maps.event.addListener(marker, 'mouseout', () => { - closeInfoWindow() - }) - - markersRef.current.push(marker) - bounds.extend(position) - }) - - // 마커들이 모두 보이도록 bounds 조정 - if (places.length > 0) { - map.setBounds(bounds) - } - } - - // 특정 좌표로 지도 중심 이동 - const setCenter = (lat: number, lng: number) => { - if (!mapRef.current || !window.kakao) return - const kakao = window.kakao - const position = new kakao.maps.LatLng(lat, lng) - mapRef.current.setCenter(position) - } - - return { - mapElement: setMapElement, - isInitialized, - error, - initializeMap, - renderMarkers, - closeInfoWindow, - openInfoWindow, - setCenter, - cleanup, - } -} diff --git a/src/features/meetings/hooks/useKakaoPlaceSearch.ts b/src/features/meetings/hooks/useKakaoPlaceSearch.ts deleted file mode 100644 index 0bf9e44..0000000 --- a/src/features/meetings/hooks/useKakaoPlaceSearch.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * @file useKakaoPlaceSearch.ts - * @description Kakao Places API 검색 로직 훅 - */ - -import { useRef, useState } from 'react' - -import type { KakaoPlace } from '../kakaoMap.types' - -export type UseKakaoPlaceSearchOptions = { - /** 검색 성공 콜백 */ - onSearchSuccess?: (places: KakaoPlace[]) => void - /** 검색 오류 콜백 */ - onSearchError?: (message: string) => void -} - -export function useKakaoPlaceSearch({ - onSearchSuccess, - onSearchError, -}: UseKakaoPlaceSearchOptions = {}) { - const [places, setPlaces] = useState([]) - const [error, setError] = useState(null) - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const placesServiceRef = useRef(null) - - // 검색 실행 - const search = (searchKeyword: string) => { - if (!searchKeyword.trim()) { - return false - } - - const kakao = window.kakao - if (!kakao?.maps?.services) { - return false - } - - // Places 서비스 - if (!placesServiceRef.current) { - placesServiceRef.current = new kakao.maps.services.Places() - } - - const ps = placesServiceRef.current - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ps.keywordSearch(searchKeyword, (data: KakaoPlace[], status: any) => { - if (status === kakao.maps.services.Status.OK) { - setError(null) - setPlaces(data) - onSearchSuccess?.(data) - } else if (status === kakao.maps.services.Status.ZERO_RESULT) { - setError(null) - setPlaces([]) - onSearchSuccess?.([]) - } else { - // Status.ERROR: 네트워크 오류, 서버 오류 등 다양한 원인으로 발생 - const message = '검색 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.' - setError(message) - setPlaces([]) - onSearchError?.(message) - console.error('[카카오 장소 검색] 오류 발생 - status:', status) - } - }) - - return true - } - - // 검색 상태 초기화 - const reset = () => { - setPlaces([]) - setError(null) - placesServiceRef.current = null - } - - return { - places, - error, - search, - reset, - } -} diff --git a/src/features/meetings/hooks/usePlaceSearch.ts b/src/features/meetings/hooks/usePlaceSearch.ts new file mode 100644 index 0000000..6386dd2 --- /dev/null +++ b/src/features/meetings/hooks/usePlaceSearch.ts @@ -0,0 +1,168 @@ +/** + * @file usePlaceSearch.ts + * @description 장소 검색 모달의 상태 관리 훅 + * + * SDK 로드, 지도 초기화, 장소 검색 API 등 모든 비동기 로직과 에러를 + * 하나의 searchState로 추상화하여 UI가 선언적으로 상태를 수신할 수 있도록 합니다. + * + * searchState: + * - 'idle' 초기 화면 (검색 전) + * - 'searching' 검색 중 (리스트 스켈레톤 표시) + * - 'hasResults' 검색 결과 있음 (지도 + 리스트 표시) + * - 'noResults' 일치하는 결과 없음 + * - 'error' SDK 로드 실패 또는 검색 API 오류 + */ + +import { useCallback, useEffect, useRef, useState } from 'react' + +import type { KakaoMap, KakaoPlace } from '@/features/kakaomap' +import { useKakaoLoader, useKakaoPlaceSearch } from '@/features/kakaomap' + +export type PlaceSearchState = 'idle' | 'searching' | 'hasResults' | 'noResults' | 'error' + +type SelectedPlace = { + name: string + address: string + latitude: number + longitude: number +} + +export type UsePlaceSearchOptions = { + open: boolean + onOpenChange: (open: boolean) => void + onSelectPlace: (place: SelectedPlace) => void +} + +export function usePlaceSearch({ open, onOpenChange, onSelectPlace }: UsePlaceSearchOptions) { + const [sdkLoading, sdkError] = useKakaoLoader() + + const [searchState, setSearchState] = useState('idle') + const [mapInstance, setMapInstance] = useState(null) + const [hoveredPlaceId, setHoveredPlaceId] = useState(null) + + // 카카오 Map SDK는 마운트 시점의 컨테이너 크기로 지도를 초기화합니다. + // display:none 상태에서 마운트되면 크기가 0으로 계산되어 지도가 깨지므로, + // 첫 검색이 실행되어 지도 영역이 화면에 보이는 시점에 처음 마운트합니다. + const [hasBeenSearched, setHasBeenSearched] = useState(false) + + const keywordRef = useRef(null) + + const { + places, + error: searchError, + search, + reset, + } = useKakaoPlaceSearch({ + onSearchSuccess: (results) => { + setSearchState(results.length > 0 ? 'hasResults' : 'noResults') + }, + onSearchError: () => { + setSearchState('error') + }, + }) + + // SDK 로드 실패 시 error 상태로 전환 + const effectiveSearchState: PlaceSearchState = sdkError ? 'error' : searchState + + // places 또는 mapInstance가 준비되면 지도 범위를 자동 조정 + // (첫 검색 시 places가 먼저 오거나 mapInstance가 먼저 올 수 있으므로 둘 다 dep에 포함) + useEffect(() => { + if (!mapInstance || places.length === 0) return + + const { kakao } = window + const bounds = new kakao.maps.LatLngBounds() + places.forEach((p) => bounds.extend(new kakao.maps.LatLng(Number(p.y), Number(p.x)))) + mapInstance.setBounds(bounds) + }, [mapInstance, places]) + + const resetState = useCallback(() => { + setSearchState('idle') + setHoveredPlaceId(null) + setHasBeenSearched(false) + setMapInstance(null) + reset() + }, [reset]) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key !== 'Enter') return + // 한국어 등 IME 입력 중 composition 이벤트는 무시 + if (e.nativeEvent.isComposing) return + e.preventDefault() + + const keyword = keywordRef.current?.value.trim() ?? '' + if (!keyword) return + + // SDK 로드 실패 상태에서는 검색 불가 + if (sdkError) return + + // SDK 아직 로드 중이라면 무시 + if (sdkLoading) return + + reset() + setHoveredPlaceId(null) + setSearchState('searching') + setHasBeenSearched(true) + search(keyword) + }, + [sdkLoading, sdkError, reset, search] + ) + + const handlePlaceClick = useCallback( + (place: KakaoPlace) => { + if (mapInstance) { + mapInstance.setCenter(new window.kakao.maps.LatLng(Number(place.y), Number(place.x))) + } + onSelectPlace({ + name: place.place_name, + address: place.road_address_name || place.address_name, + latitude: Number(place.y), + longitude: Number(place.x), + }) + onOpenChange(false) + resetState() + }, + [mapInstance, onSelectPlace, onOpenChange, resetState] + ) + + const handlePlaceFocus = useCallback( + (place: KakaoPlace) => { + if (!mapInstance) return + mapInstance.setLevel(4) + mapInstance.setCenter(new window.kakao.maps.LatLng(Number(place.y), Number(place.x))) + }, + [mapInstance] + ) + + const handleClose = useCallback(() => { + onOpenChange(false) + resetState() + }, [onOpenChange, resetState]) + + // error 상태에서 노출할 메시지 — SDK 오류 우선 + const errorMessage = sdkError?.message ?? searchError ?? '오류가 발생했습니다. 다시 시도해주세요.' + + // Map 컴포넌트를 DOM에 마운트할지 여부 + // error 상태는 인스턴스 보존이 불필요하므로 unmount (다음 검색 시 새로 초기화) + const isMapMounted = open && hasBeenSearched && effectiveSearchState !== 'error' + + // 지도 영역을 화면에 표시할지 여부 (isMapMounted가 true일 때만 유의미) + // noResults에서는 Map 인스턴스를 유지한 채 CSS로만 숨김 → 재검색 시 재초기화 없이 재사용 + const isMapVisible = effectiveSearchState === 'searching' || effectiveSearchState === 'hasResults' + + return { + searchState: effectiveSearchState, + errorMessage, + places, + isMapMounted, + isMapVisible, + hoveredPlaceId, + keywordRef, + setMapInstance, + setHoveredPlaceId, + handleKeyDown, + handlePlaceClick, + handlePlaceFocus, + handleClose, + } +} diff --git a/src/features/meetings/index.ts b/src/features/meetings/index.ts index 1e5b581..deccde9 100644 --- a/src/features/meetings/index.ts +++ b/src/features/meetings/index.ts @@ -11,12 +11,6 @@ export * from './lib' export * from './meetings.api' // Types -export type { - KakaoPlace, - KakaoSearchMeta, - KakaoSearchParams, - KakaoSearchResponse, -} from './kakaoMap.types' export type { ConfirmMeetingResponse, CreateMeetingRequest, diff --git a/src/features/meetings/kakaoMap.types.ts b/src/features/meetings/kakaoMap.types.ts deleted file mode 100644 index 2f13f71..0000000 --- a/src/features/meetings/kakaoMap.types.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * @file kakao.types.ts - * @description 카카오 로컬 API 관련 타입 정의 - * @note 외부 API 응답 스펙을 따르기 위해 snake_case 사용 - */ - -/* eslint-disable @typescript-eslint/naming-convention */ - -/** - * 카카오 장소 검색 응답 문서 타입 - */ -export type KakaoPlace = { - /** 장소명, 업체명 */ - place_name: string - /** 전체 지번 주소 */ - address_name: string - /** 전체 도로명 주소 */ - road_address_name: string - /** X 좌표값, 경도(longitude) */ - x: string - /** Y 좌표값, 위도(latitude) */ - y: string - /** 장소 ID */ - id: string - /** 카테고리 그룹 코드 */ - category_group_code: string - /** 카테고리 그룹명 */ - category_group_name: string - /** 카테고리 이름 */ - category_name: string - /** 전화번호 */ - phone: string - /** 장소 상세페이지 URL */ - place_url: string - /** 중심좌표까지의 거리 (단, x,y 파라미터를 준 경우에만 존재) */ - distance?: string -} - -/** - * 카카오 장소 검색 API 응답 메타 정보 - */ -export type KakaoSearchMeta = { - /** 검색된 문서 수 */ - total_count: number - /** total_count 중 노출 가능 문서 수 */ - pageable_count: number - /** 현재 페이지가 마지막 페이지인지 여부 */ - is_end: boolean - /** 질의어의 지역 및 키워드 분석 정보 */ - same_name?: { - /** 질의어에서 인식된 지역의 리스트 */ - region: string[] - /** 질의어에서 지역 정보를 제외한 키워드 */ - keyword: string - /** 인식된 지역 리스트 중, 현재 검색에 사용된 지역 정보 */ - selected_region: string - } -} - -/** - * 카카오 장소 검색 API 응답 타입 - */ -export type KakaoSearchResponse = { - /** 검색 결과 문서 리스트 */ - documents: KakaoPlace[] - /** 응답 관련 정보 */ - meta: KakaoSearchMeta -} - -/** - * 카카오 장소 검색 API 요청 파라미터 - */ -export type KakaoSearchParams = { - /** 검색을 원하는 질의어 (필수) */ - query: string - /** 카테고리 그룹 코드 (선택) */ - category_group_code?: string - /** 중심 좌표의 X 혹은 경도(longitude) */ - x?: string - /** 중심 좌표의 Y 혹은 위도(latitude) */ - y?: string - /** 중심 좌표부터의 반경거리. 미터(m) 단위 */ - radius?: number - /** 결과 페이지 번호 (1~45, 기본값: 1) */ - page?: number - /** 한 페이지에 보여질 문서의 개수 (1~15, 기본값: 15) */ - size?: number - /** 결과 정렬 순서 (distance: 거리순, accuracy: 정확도순) */ - sort?: 'distance' | 'accuracy' -} diff --git a/src/features/meetings/loadKakaoSdk.ts b/src/features/meetings/loadKakaoSdk.ts deleted file mode 100644 index 18b994a..0000000 --- a/src/features/meetings/loadKakaoSdk.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * @file loadKakaoSdk.ts - * @description 카카오 Maps SDK 싱글톤 로더 - * - * - 앱 전체에서 SDK를 한 번만 로드 (중복 script 삽입 방지) - * - 지도를 실제로 사용할 때만 로드 (불필요한 토큰/쿼터 소모 방지) - * - autoload=false + maps.load() 콜백으로 초기화 완료 시점 보장 - */ - -let kakaoSdkPromise: Promise | null = null - -export function loadKakaoSdk(): Promise { - // 이미 초기화 완료된 경우 - if (window.kakao?.maps) { - return Promise.resolve() - } - - // 이미 로드 중인 경우 동일 Promise 반환 (중복 요청 방지) - if (kakaoSdkPromise) { - return kakaoSdkPromise - } - - kakaoSdkPromise = new Promise((resolve, reject) => { - const script = document.createElement('script') - script.src = `https://dapi.kakao.com/v2/maps/sdk.js?appkey=${ - import.meta.env.VITE_KAKAO_MAP_KEY - }&autoload=false&libraries=services` - script.async = true - - script.onload = () => { - try { - window.kakao.maps.load(() => resolve()) - } catch (err) { - kakaoSdkPromise = null - const message = '카카오 지도 SDK 초기화에 실패했습니다.' - console.error('[카카오 지도]', message, err) - reject(new Error(message)) - } - } - - script.onerror = () => { - // 실패 시 Promise 초기화하여 재시도 가능하게 - kakaoSdkPromise = null - - // fetch로 실제 HTTP 상태 코드 확인 후 카카오 공식 상태 메시지 사용 - fetch(script.src) - .then((res) => { - if (res.ok) { - const message = - '카카오 지도 SDK 로드에 실패했습니다. 일시적인 네트워크 오류일 수 있으니 잠시 후 다시 시도해주세요.' - console.error('[카카오 지도] SDK 로드 실패 (진단 불일치):', message) - reject(new Error(message)) - return - } - - const kakaoStatusMessages: Record = { - 400: '잘못된 요청입니다. API에 필요한 필수 파라미터를 확인해주세요. (400 Bad Request)', - 401: '인증 오류입니다. 앱키(VITE_KAKAO_MAP_KEY)가 올바른지 확인해주세요. (401 Unauthorized)', - 403: '권한 오류입니다. 앱 등록 및 도메인 설정을 확인해주세요. (403 Forbidden)', - 429: '쿼터를 초과했습니다. 정해진 사용량이나 초당 요청 한도를 초과했습니다. (429 Too Many Request)', - 500: '카카오 서버 내부 오류입니다. 잠시 후 다시 시도해주세요. (500 Internal Server Error)', - 502: '카카오 게이트웨이 오류입니다. 잠시 후 다시 시도해주세요. (502 Bad Gateway)', - 503: '카카오 서비스 점검 중입니다. 잠시 후 다시 시도해주세요. (503 Service Unavailable)', - } - const message = - kakaoStatusMessages[res.status] ?? - `카카오 지도 SDK 로드에 실패했습니다. (HTTP ${res.status})` - const error = new Error(message) - console.error('[카카오 지도] SDK 로드 실패:', message) - reject(error) - }) - .catch(() => { - const message = '카카오 지도 SDK를 로드할 수 없습니다. 네트워크 연결을 확인해주세요.' - console.error('[카카오 지도] SDK 로드 실패 (네트워크 오류):', message) - reject(new Error(message)) - }) - } - - document.head.appendChild(script) - }) - - return kakaoSdkPromise -} diff --git a/src/features/topics/topics.api.ts b/src/features/topics/topics.api.ts index 926758d..3770397 100644 --- a/src/features/topics/topics.api.ts +++ b/src/features/topics/topics.api.ts @@ -230,7 +230,7 @@ export const confirmTopics = async ( // 🚧 임시: 로그인 기능 개발 전까지 목데이터 사용 // TODO: 로그인 완료 후 아래 주석을 해제하고 목데이터 로직 제거 - if (USE_MOCK_DATA) { + if (USE_MOCK) { // 실제 API 호출을 시뮬레이션하기 위한 지연 await new Promise((resolve) => setTimeout(resolve, 500)) return getMockConfirmTopics(meetingId, topicIds) diff --git a/src/shared/ui/SearchField.tsx b/src/shared/ui/SearchField.tsx index 390527a..7078b7e 100644 --- a/src/shared/ui/SearchField.tsx +++ b/src/shared/ui/SearchField.tsx @@ -15,31 +15,35 @@ type SearchFieldProps = Omit, 'type'> * setQuery(e.target.value)} /> * ``` */ -function SearchField({ className, disabled, ...props }: SearchFieldProps) { - return ( -
- - ( + ({ className, disabled, ...props }, ref) => { + return ( +
-
- ) -} + > + + +
+ ) + } +) +SearchField.displayName = 'SearchField' export { SearchField }