Skip to content

Commit 11c2efd

Browse files
authored
[feat]카카오맵 feature 모듈 분리 및 장소 선택 모달 디자인 변경 (#77)
* feat: 카카오맵 feature 모듈 분리 및 장소 검색 기능 개선 (#76) - meetings feature에 산재된 카카오맵 관련 코드를 kakaomap feature로 분리. - Map, MapMarker, Marker, ZoomControl 컴포넌트와 useKakaoLoader, useKakaoMap, useKakaoPlaceSearch 훅을 독립 모듈로 구성. - PlaceListSkeleton 컴포넌트와 usePlaceSearch 훅을 추가하여 장소 검색 UX 개선. - 장소 검색 모달 디자인 수정 * refactor: 카카오맵 모듈 구조 개선 및 컨텍스트 타입 안전성 강화 (#76) - useKakaoMapContext가 null 대신 Error를 throw하도록 변경하여 Map 컴포넌트 외부 사용 시 명확한 에러 발생. - Marker 인스턴스 관리를 useRef에서 useMemo로 전환하여 타이밍 문제 해결. - components/context/hooks/lib 각 디렉토리에 barrel export index 추가. - useKakaoMap, MapModal 불필요 파일 삭제. - useKakaoLoader 환경변수 사전 체크 로직 개선. - usePlaceSearch 모달 닫기 시 mapInstance null 초기화. * refactor : SDK 에러와 검색 에러 분리(#76) * style:프리티어 수정(#76) * refactor: Loader 클래스를 KakaoMapApiLoader로 이름 변경 (#76)
1 parent 1fff012 commit 11c2efd

30 files changed

Lines changed: 1441 additions & 672 deletions
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* @file Map.tsx
3+
* @description 카카오 지도 컨테이너 컴포넌트
4+
*
5+
* 동작 흐름:
6+
* 1. <div ref> DOM 생성
7+
* 2. useLayoutEffect: new kakao.maps.Map(div, options) 인스턴스 생성 (깜빡임 없음)
8+
* 3. KakaoMapContext.Provider로 자식(MapMarker 등)에게 map 인스턴스 전달
9+
* 4. center/level props 변경 → SDK setter 직접 호출 (리렌더 없이 동기화)
10+
*
11+
* @example
12+
* const [loading] = useKakaoLoader()
13+
* if (loading) return <Spinner />
14+
*
15+
* return (
16+
* <Map
17+
* center={{ lat: 37.566826, lng: 126.9786567 }}
18+
* level={3}
19+
* style={{ width: '100%', height: '350px' }}
20+
* onCreate={setMap}
21+
* >
22+
* <MapMarker position={{ lat: 37.566826, lng: 126.9786567 }} />
23+
* </Map>
24+
* )
25+
*/
26+
27+
import { useLayoutEffect, useRef, useState } from 'react'
28+
29+
import { KakaoMapContext } from '../context/KakaoMapContext'
30+
import type { KakaoMap } from '../kakaoMap.types'
31+
32+
export type MapProps = {
33+
/** 지도 중심 좌표 */
34+
center: { lat: number; lng: number }
35+
/** 지도 줌 레벨 (기본값: 3) */
36+
level?: number
37+
style?: React.CSSProperties
38+
className?: string
39+
/** 지도 인스턴스 생성 완료 콜백 — 외부에서 map 인스턴스에 직접 접근할 때 사용 */
40+
onCreate?: (map: KakaoMap) => void
41+
children?: React.ReactNode
42+
}
43+
44+
export function Map({ center, level = 3, style, className, onCreate, children }: MapProps) {
45+
const containerRef = useRef<HTMLDivElement>(null)
46+
const [map, setMap] = useState<KakaoMap | null>(null)
47+
48+
// onCreate 콜백을 ref로 안정화 — deps 변경 없이 항상 최신 참조 유지
49+
const onCreateRef = useRef(onCreate)
50+
onCreateRef.current = onCreate
51+
52+
// ── 최초 마운트 시 지도 인스턴스 생성 ────────────────────────
53+
// useLayoutEffect: DOM paint 직전 실행 → 깜빡임 없음
54+
useLayoutEffect(() => {
55+
if (!containerRef.current || !window.kakao?.maps) return
56+
57+
const kakaoMap = new window.kakao.maps.Map(containerRef.current, {
58+
center: new window.kakao.maps.LatLng(center.lat, center.lng),
59+
level,
60+
})
61+
62+
setMap(kakaoMap)
63+
onCreateRef.current?.(kakaoMap)
64+
65+
return () => {
66+
setMap(null)
67+
}
68+
// center/level 초기값은 한 번만 사용. 이후 변경은 아래 effect에서 SDK setter로 처리
69+
// eslint-disable-next-line react-hooks/exhaustive-deps
70+
}, [])
71+
72+
// ── center props 변경 → map.setCenter() 호출 ─────────────
73+
useLayoutEffect(() => {
74+
if (!map) return
75+
map.setCenter(new window.kakao.maps.LatLng(center.lat, center.lng))
76+
}, [map, center.lat, center.lng])
77+
78+
// ── level props 변경 → map.setLevel() 호출 ───────────────
79+
useLayoutEffect(() => {
80+
if (!map) return
81+
map.setLevel(level)
82+
}, [map, level])
83+
84+
return (
85+
<KakaoMapContext.Provider value={map}>
86+
<div ref={containerRef} style={style} className={className} />
87+
{/* map 인스턴스가 준비된 후에만 자식 렌더링 */}
88+
{map && children}
89+
</KakaoMapContext.Provider>
90+
)
91+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/**
2+
* @file MapMarker.tsx
3+
* @description 카카오 지도 마커 공개 컴포넌트
4+
*
5+
* Map 컴포넌트의 자식으로 사용합니다.
6+
* position을 useMemo로 최적화하고, 실제 마커 로직은 Marker에 위임합니다.
7+
*
8+
* @example
9+
* <Map center={{ lat: 37.566826, lng: 126.9786567 }} level={3}>
10+
* <MapMarker
11+
* position={{ lat: 37.566826, lng: 126.9786567 }}
12+
* onClick={(marker) => console.log(marker)}
13+
* >
14+
* <div style={{ color: '#000' }}>마커 내용</div>
15+
* </MapMarker>
16+
* </Map>
17+
*/
18+
19+
import { forwardRef, type PropsWithChildren, useMemo } from 'react'
20+
21+
import { useKakaoMapContext } from '../context/KakaoMapContext'
22+
import type { KakaoMarker } from '../kakaoMap.types'
23+
import { Marker, type MarkerImageProp } from './Marker'
24+
25+
export type MapMarkerProps = {
26+
/**
27+
* 마커 표시 좌표
28+
*/
29+
position: { lat: number; lng: number } | { x: number; y: number }
30+
31+
/** 마커 이미지 커스터마이징 */
32+
image?: MarkerImageProp
33+
34+
/** 마커 툴팁 텍스트 */
35+
title?: string
36+
37+
/** 드래그 가능 여부 */
38+
draggable?: boolean
39+
40+
/** 클릭 가능 여부 */
41+
clickable?: boolean
42+
43+
/** z-index */
44+
zIndex?: number
45+
46+
/** 투명도 (0–1) */
47+
opacity?: number
48+
49+
/** 마커 클릭 이벤트 */
50+
onClick?: (marker: KakaoMarker) => void
51+
52+
/** 마커 마우스오버 이벤트 */
53+
onMouseOver?: (marker: KakaoMarker) => void
54+
55+
/** 마커 마우스아웃 이벤트 */
56+
onMouseOut?: (marker: KakaoMarker) => void
57+
58+
/** 마커 드래그 시작 이벤트 */
59+
onDragStart?: (marker: KakaoMarker) => void
60+
61+
/** 마커 드래그 종료 이벤트 */
62+
onDragEnd?: (marker: KakaoMarker) => void
63+
64+
/** 마커 생성 완료 콜백 */
65+
onCreate?: (marker: KakaoMarker) => void
66+
67+
/** InfoWindow 옵션 */
68+
infoWindowOptions?: {
69+
disableAutoPan?: boolean
70+
removable?: boolean
71+
zIndex?: number
72+
}
73+
}
74+
75+
export const MapMarker = forwardRef<KakaoMarker, PropsWithChildren<MapMarkerProps>>(
76+
function MapMarker({ position, ...props }, ref) {
77+
const map = useKakaoMapContext('MapMarker')
78+
79+
// 복잡한 표현식을 변수로 추출해 useMemo deps를 정적으로 분석 가능하게 함
80+
const lat = 'lat' in position ? position.lat : position.y
81+
const lng = 'lng' in position ? position.lng : position.x
82+
83+
const markerPosition = useMemo(() => new window.kakao.maps.LatLng(lat, lng), [lat, lng])
84+
85+
return <Marker map={map} position={markerPosition} {...props} ref={ref} />
86+
}
87+
)
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
/**
2+
* @file Marker.tsx
3+
* @description 카카오 마커 내부 구현 컴포넌트
4+
*
5+
* MapMarker에서 위임받아 실제 kakao.maps.Marker 인스턴스를 관리합니다.
6+
* - useMemo: 렌더 중 동기적으로 마커 인스턴스 생성 (타이밍 문제 없음)
7+
* - useLayoutEffect: map 등록/해제 및 props setter 동기화
8+
* - useKakaoEvent: 이벤트 등록/해제 자동화
9+
* - createPortal: children을 CustomOverlay DOM에 주입
10+
* - forwardRef: 외부에서 마커 인스턴스에 직접 접근 가능
11+
*/
12+
13+
import {
14+
forwardRef,
15+
type PropsWithChildren,
16+
useImperativeHandle,
17+
useLayoutEffect,
18+
useMemo,
19+
useRef,
20+
useState,
21+
} from 'react'
22+
import { createPortal } from 'react-dom'
23+
24+
import type {
25+
KakaoCustomOverlay,
26+
KakaoLatLng,
27+
KakaoMap,
28+
KakaoMarker,
29+
KakaoMarkerImageOptions,
30+
KakaoSdkMarkerImage,
31+
} from '../kakaoMap.types'
32+
import { useKakaoEvent } from '../lib/useKakaoEvent'
33+
34+
export type MarkerImageProp = {
35+
src: string
36+
size: { width: number; height: number }
37+
options?: KakaoMarkerImageOptions
38+
}
39+
40+
export type MarkerProps = {
41+
map: KakaoMap
42+
position: KakaoLatLng
43+
image?: MarkerImageProp
44+
title?: string
45+
draggable?: boolean
46+
clickable?: boolean
47+
zIndex?: number
48+
opacity?: number
49+
onClick?: (marker: KakaoMarker) => void
50+
onMouseOver?: (marker: KakaoMarker) => void
51+
onMouseOut?: (marker: KakaoMarker) => void
52+
onDragStart?: (marker: KakaoMarker) => void
53+
onDragEnd?: (marker: KakaoMarker) => void
54+
onCreate?: (marker: KakaoMarker) => void
55+
infoWindowOptions?: {
56+
disableAutoPan?: boolean
57+
removable?: boolean
58+
zIndex?: number
59+
}
60+
}
61+
62+
function buildMarkerImage(image: MarkerImageProp): KakaoSdkMarkerImage {
63+
const { kakao } = window
64+
const size = new kakao.maps.Size(image.size.width, image.size.height)
65+
const options = image.options
66+
? {
67+
...image.options,
68+
offset: image.options.offset
69+
? new kakao.maps.Point(image.options.offset.x, image.options.offset.y)
70+
: undefined,
71+
spriteOrigin: image.options.spriteOrigin
72+
? new kakao.maps.Point(image.options.spriteOrigin.x, image.options.spriteOrigin.y)
73+
: undefined,
74+
spriteSize: image.options.spriteSize
75+
? new kakao.maps.Size(image.options.spriteSize.width, image.options.spriteSize.height)
76+
: undefined,
77+
}
78+
: undefined
79+
return new kakao.maps.MarkerImage(image.src, size, options)
80+
}
81+
82+
export const Marker = forwardRef<KakaoMarker, PropsWithChildren<MarkerProps>>(function Marker(
83+
{
84+
map,
85+
position,
86+
image,
87+
title,
88+
draggable,
89+
clickable,
90+
zIndex,
91+
opacity,
92+
onClick,
93+
onMouseOver,
94+
onMouseOut,
95+
onDragStart,
96+
onDragEnd,
97+
onCreate,
98+
children,
99+
},
100+
ref
101+
) {
102+
const overlayRef = useRef<KakaoCustomOverlay | null>(null)
103+
const [overlayEl, setOverlayEl] = useState<HTMLDivElement | null>(null)
104+
105+
const hasChildren = children != null
106+
107+
// ── 마커 인스턴스 생성 ────────
108+
const marker = useMemo(
109+
() =>
110+
new window.kakao.maps.Marker({
111+
position,
112+
image: image ? buildMarkerImage(image) : undefined,
113+
title,
114+
draggable,
115+
clickable,
116+
zIndex,
117+
opacity,
118+
}),
119+
// eslint-disable-next-line react-hooks/exhaustive-deps
120+
[]
121+
)
122+
123+
// 마커 인스턴스를 ref로 외부에 노출
124+
useImperativeHandle(ref, () => marker, [marker])
125+
126+
// ── map 등록/해제 ──────────────────────────────────────────
127+
useLayoutEffect(() => {
128+
marker.setMap(map)
129+
return () => marker.setMap(null)
130+
}, [map, marker])
131+
132+
// ── onCreate 콜백 ──────────────────────────────────────────
133+
useLayoutEffect(() => {
134+
onCreate?.(marker)
135+
// eslint-disable-next-line react-hooks/exhaustive-deps
136+
}, [marker])
137+
138+
// ── props 변경 → SDK setter 호출 ──────────────────────────
139+
useLayoutEffect(() => {
140+
marker.setPosition(position)
141+
}, [marker, position])
142+
143+
useLayoutEffect(() => {
144+
if (!image) return
145+
marker.setImage(buildMarkerImage(image))
146+
}, [marker, image])
147+
148+
useLayoutEffect(() => {
149+
if (title !== undefined) marker.setTitle(title)
150+
}, [marker, title])
151+
152+
useLayoutEffect(() => {
153+
if (draggable !== undefined) marker.setDraggable(draggable)
154+
}, [marker, draggable])
155+
156+
useLayoutEffect(() => {
157+
if (clickable !== undefined) marker.setClickable(clickable)
158+
}, [marker, clickable])
159+
160+
useLayoutEffect(() => {
161+
if (zIndex !== undefined) marker.setZIndex(zIndex)
162+
}, [marker, zIndex])
163+
164+
useLayoutEffect(() => {
165+
if (opacity !== undefined) marker.setOpacity(opacity)
166+
}, [marker, opacity])
167+
168+
// ── 이벤트 자동 등록/해제 ─────────────────────────────────
169+
useKakaoEvent(marker, 'click', onClick ? () => onClick(marker) : undefined)
170+
useKakaoEvent(marker, 'mouseover', onMouseOver ? () => onMouseOver(marker) : undefined)
171+
useKakaoEvent(marker, 'mouseout', onMouseOut ? () => onMouseOut(marker) : undefined)
172+
useKakaoEvent(marker, 'dragstart', onDragStart ? () => onDragStart(marker) : undefined)
173+
useKakaoEvent(marker, 'dragend', onDragEnd ? () => onDragEnd(marker) : undefined)
174+
175+
// ── children → CustomOverlay + createPortal ───────────────
176+
useLayoutEffect(() => {
177+
if (!hasChildren) return
178+
179+
const { kakao } = window
180+
const container = document.createElement('div')
181+
182+
const overlay = new kakao.maps.CustomOverlay({
183+
position,
184+
content: container,
185+
map,
186+
yAnchor: 1,
187+
})
188+
189+
overlayRef.current = overlay
190+
setOverlayEl(container)
191+
192+
return () => {
193+
overlay.setMap(null)
194+
overlayRef.current = null
195+
setOverlayEl(null)
196+
}
197+
// eslint-disable-next-line react-hooks/exhaustive-deps
198+
}, [map, hasChildren])
199+
200+
// overlay position 동기화
201+
useLayoutEffect(() => {
202+
overlayRef.current?.setPosition(position)
203+
}, [position])
204+
205+
if (!overlayEl || !hasChildren) return null
206+
return createPortal(children, overlayEl)
207+
})

0 commit comments

Comments
 (0)