์ด๋ฒ ํ ํ๋ก์ ํธ์์ โํ์ฌ ์์น๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ์ฌ์ฉ์์ ๋๋ค๋ฅผ ํ์ํ๋ ๊ธฐ๋ฅโ์ด ํ์ํ๋ค.
๋ง์นจ ์ง๋ ๊ธฐ๋ฅ์ด ์์ผ๋ฉด UX์ ์ผ๋ก๋ ์ข์ ๊ฒ ๊ฐ์์, ์นด์นด์ค๋งต API๋ฅผ ์ง์ ๋ถ์ฌ๋ณด๊ธฐ๋ก ํ๋ค.
์ด๋ฒ ๊ธ์์๋
๋ชจ๋ฌ ์ปดํฌ๋ํธ๋ถํฐLocationModal, ๊ทธ๋ฆฌ๊ณ์นด์นด์ค ์ง๋ API์ฐ๋๊น์ง ์ ์ฒด ๊ณผ์ ์์ฝ๋ ํ ์ค ํ ์ค ๋ฏ์ด๊ฐ๋ฉด์ ์ ๋ฆฌํ๋ค.
createPortal + SSR ๊ฐ๋์ด๋์๋ ์ฌ์ฌ์ฉ ๊ฐ๋ฅํ ๋ชจ๋ฌ ์ปจํ ์ด๋.
ํต์ฌ ํฌ์ธํธ: SSR์์ ์์ , ํฌํธ๋ก body ์ต์๋จ์ ๋ ๋, ๋ชจ๋ฌ ์ด๋ฆด ๋๋ง ๋ ๋
// components/common/Modal.tsx
'use client'
import { ComponentPropsWithRef, ReactNode, useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { cn } from '@/lib/utils'
import { Card, CardContent, CardFooter, CardHeader } from './Card'
interface ModalProps extends ComponentPropsWithRef<'div'> {
open: boolean
onClose: () => void
children?: ReactNode
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'full'
closeOnBackdrop?: boolean
}
export function Modal({
open,
onClose,
children,
size = 'md',
closeOnBackdrop = false,
className,
...props
}: ModalProps) {
// โ
SSR ๊ฐ๋: ๋ธ๋ผ์ฐ์ ์์๋ง ๋ ๋(์๋ฒ์์ document๊ฐ ์์)
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
// ์ฌ์ด์ฆ ํ๋ฆฌ์
const sizeClasses = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl': 'max-w-2xl',
'3xl': 'max-w-3xl',
full: 'max-w-full mx-4',
} as const
// โ
๋ฐฑ๋๋กญ ํด๋ฆญ ์ ๋ซ๊ธฐ (์ต์
)
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (closeOnBackdrop && e.target === e.currentTarget) onClose()
}
// โ
๋ซํ ์์ ๋/SSR์ผ ๋๋ ๋ ๋ํ์ง ์์ โ ์ด๊ธฐ ํ๋ฉด ํ๋ ค์ง ๋ฐฉ์ง
if (!mounted || !open) return null
const modalContent = (
<>
{/* ๋ฐฑ๋๋กญ(๋ท๋ฐฐ๊ฒฝ) */}
<divclassName="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
onClick={handleBackdropClick}
/>
{/* ์ค์ ์ ๋ ฌ ์ปจํ
์ด๋. ์ฌ๊ธฐ "์"์ Card(๋ณธ๋ฌธ)๊ฐ ์์ด์ผ ์ค์์ ๋ ฌ์ด ๋จน์ */}
<div className="fixed inset-0 z-50 flex items-center justify-center pointer-events-none">
<CardclassName={cn(
'relative w-full mx-4 my-8 max-h-[90vh] pointer-events-auto',
sizeClasses[size],
className
)}
{...props}
>
{children}
</Card>
</div>
</>
)
// โ
body ์๋๋ก ํฌํธ ๋ ๋ โ z-index/overflow ๊ฐ์ญ ์ต์ํ
return createPortal(modalContent, document.body)
}
export { CardHeader as ModalHeader }
export { CardContent as ModalContent }
export { CardFooter as ModalFooter }
document๊ฐ ์์ผ๋ฏ๋ก mounted ์ดํ์๋ง ๋ ๋.open={false} ๋ ๋ฐฑ๋๋กญ/๋ชจ๋ฌ DOM ์์ฒด๊ฐ ์กด์ฌํ์ง ์๋๋ก ํด์, ์ด๊ธฐ ํ๋ฉด์ด ํ๋ ค์ง๋ ๋ฌธ์ ๋ฅผ ๊ทผ๋ณธ ์ฐจ๋จ.๊ณตํต ๋ชจ๋ฌ์ โ๊ทธ๋ฆโ, LocationModal์ โ๋ด์ฉ๋ฌผโ.
์ฌ๊ธฐ์๋ ํค๋ + Kakao ์ง๋ ์ ํ๊ธฐ(LocationPicker)๋ฅผ ์กฐ๋ฆฝํ๊ณ , env ํค๋ฅผ ํ์๋ก ์ ๋ฌํฉ๋๋ค.
// components/header/LocationModal.tsx
'use client'
import { MapPinIcon } from 'lucide-react'
import { Modal, ModalHeader, ModalContent } from '@/components/common/Modal'
import LocationPicker from '@/components/location/LocationPicker'
type Props = {
open: boolean
onClose: () => void
onSelect?: (region: string, lat: number, lng: number) => void
}
export default function LocationModal({ open, onClose, onSelect }: Props) {
// โ
env์์ ์นด์นด์ค JS ํค ์ฝ์ด์ ํ์ ์ปดํฌ๋ํธ์ ๋๊น
const appKey = process.env.NEXT_PUBLIC_KAKAO_JAVASCRIPT_KEY as string
return (
<Modal open={open} onClose={onClose} size="md" closeOnBackdrop>
<ModalHeader className="flex flex-row items-center gap-2 space-y-0">
<MapPinIcon className="w-5 h-5" />
<h3 className="text-base leading-none">๋๋ค ์ค์ </h3>
</ModalHeader>
<ModalContent>
<LocationPickerappKey={appKey}
open={open}
onPick={({ lat, lng, region }) => {
// ์ง์ญ๋ช
/์ขํ๋ฅผ ์์๋ก ์ ๋ฌ (ex. ์ํ ์ ์ฅ, DB ๋ฐ์)
onSelect?.(region, lat, lng)
onClose()
}}
/>
</ModalContent>
</Modal>
)
}
process.env๋ฅผ ์ง์ ์ฝ๋ ๋์ , prop์ผ๋ก ์ฃผ์
ํ๋ฉด ํ
์คํธ/ํ๊ฒฝ ์ ํ์ด ์ฌ์์ง.์ด ํํธ๋ 2๊ฐ ํ์ผ๋ก ๊ตฌ์ฑํฉ๋๋ค.
useKakaoLoader.ts: ์นด์นด์ค ์ง๋ SDK ์คํฌ๋ฆฝํธ๋ฅผ ๋์ ์ผ๋ก ๋ก๋ํ๊ณ ์ค๋น ์๋ฃ ์ํ(ready)๋ฅผ ์ ๊ณต.LocationPicker.tsx: ์ค์ ์ง๋๋ฅผ ๋์ฐ๊ณ , ์ง๋ ํด๋ฆญ/ํ์ฌ ์์น๋ก ์ขํ๋ฅผ ๋ฐ์ โ์์ธ์ ๋์๊ตฌโ ํ์์ ๋ผ๋ฒจ์ ์์ฑ.// hooks/useKakaoLoader.ts
'use client'
import { useEffect, useState } from 'react'
const KAKAO_SDK_ID = 'kakao-maps-sdk'
export function useKakaoLoader(appKey: string) {
const [ready, setReady] = useState(false)
useEffect(() => {
if (typeof window === 'undefined') return
// ์ด๋ฏธ ๋ก๋๋์ด ์๋ค๋ฉด ์ฆ์ OK
if ((window as any).kakao?.maps) {
setReady(true)
return
}
// ๊ฐ์ ID์ ์คํฌ๋ฆฝํธ๊ฐ ์์ผ๋ฉด ์ด๋ฒคํธ๋ง ์ฐ๊ฒฐ (์ค๋ณต ์ฃผ์
๋ฐฉ์ง)
const prev = document.getElementById(KAKAO_SDK_ID) as HTMLScriptElement | null
if (prev) {
const onLoad = () => (window as any).kakao.maps.load(() => setReady(true))
prev.addEventListener('load', onLoad)
return () => prev.removeEventListener('load', onLoad)
}
// ์ ๊ท ์ฃผ์
const script = document.createElement('script')
script.id = KAKAO_SDK_ID
script.async = true
script.src = `https://dapi.kakao.com/v2/maps/sdk.js?appkey=${appKey}&libraries=services&autoload=false`
script.onload = () => (window as any).kakao.maps.load(() => setReady(true))
script.onerror = (e) => console.error('[KakaoLoader] script error', e)
document.head.appendChild(script)
}, [appKey])
return ready
}
์ค๋น๋ฌผ
- ์นด์นด์ค ๊ฐ๋ฐ์ ์ฝ์์์ โ์นด์นด์ค๋งต(์ง๋/๋ก์ปฌ)โ ์ ํ ํ์ฑํ
- JavaScript ํค ์ฌ์ฉ (REST ํค X)
- ํ๋ซํผ ๋๋ฉ์ธ ๋ฑ๋ก:
http://localhost:3000๋ฑ
ํ์ ๊ฒฝ๊ณ ๊ฐ ๋จ๋ฉด ํ๋ฒ๋ง ์ถ๊ฐ:
// types/kakao.maps.d.ts
export {}
declare global { interface Window { kakao: any } }
tsconfig.json โ "include": ["next-env.d.ts","src/types/**/*.d.ts","**/*.ts","**/*.tsx"]
// components/location/LocationPicker.tsx
'use client'
import { useEffect, useRef, useState } from 'react'
import { useKakaoLoader } from '@/hooks/useKakaoLoader'
type Props = {
appKey: string
open: boolean
onPick: (payload: { lat: number; lng: number; region: string }) => void
}
/** ์/๋ ๋ณด์ : '์์ธ' โ '์์ธ์', '๊ฒฝ๊ธฐ' โ '๊ฒฝ๊ธฐ๋', '์ธ์ข
' โ '์ธ์ข
ํน๋ณ์์น์' */
function formatSido(sido: string) {
if (/(ํน๋ณ์|๊ด์ญ์|ํน๋ณ์์น์|ํน๋ณ์์น๋|๋|์)$/.test(sido)) return sido
const metro = ['์์ธ', '๋ถ์ฐ', '๋๊ตฌ', '์ธ์ฒ', '๊ด์ฃผ', '๋์ ', '์ธ์ฐ', '์ธ์ข
']
return metro.includes(sido) ? (sido === '์ธ์ข
' ? '์ธ์ข
ํน๋ณ์์น์' : `${sido}์`) : `${sido}๋`
}
/** H(ํ์ ๋) ์ฐ์ โ "์์ธ์ ๋์๊ตฌ" ํ์ ๋ผ๋ฒจ ์์ฑ */
function pickGuLabel(res: any[]) {
const target = res.find((r: any) => r.region_type === 'H') ?? res[0]
const { region_1depth_name: sidoRaw, region_2depth_name: guRaw } = target
const sido = formatSido(sidoRaw || '')
const gu = guRaw || ''
return gu ? `${sido} ${gu}` : `${sido}`
}
export default function LocationPicker({ appKey, open, onPick }: Props) {
const ready = useKakaoLoader(appKey) // โ
SDK ์ค๋น ์ํ
const boxRef = useRef<HTMLDivElement | null>(null) // โ
์ง๋ ์ปจํ
์ด๋
const [map, setMap] = useState<any>(null)
const [marker, setMarker] = useState<any>(null)
const [label, setLabel] = useState<string>('')
// 1) ๋ชจ๋ฌ ์ด๋ฆฌ๊ณ + SDK ์ค๋น๋๋ฉด ์ง๋ ์์ฑ
useEffect(() => {
if (!open || !ready || !boxRef.current || map) return
const kakao = (window as any).kakao
const center = new kakao.maps.LatLng(37.5665, 126.9780) // ์์ธ์์ฒญ
const _map = new kakao.maps.Map(boxRef.current, { center, level: 5 })
const _marker = new kakao.maps.Marker({ position: center })
_marker.setMap(_map)
setMap(_map)
setMarker(_marker)
}, [open, ready, map])
// 2) ๋ชจ๋ฌ์ด "์ด๋ฆฐ ํ" ๋ ์ด์์ ๋ณด์ (๋ชจ๋ฌ ์จ๊น ์ํ์์ ์ด๊ธฐํํ๋ฉด ํ์ผ์ด ์ ๊ทธ๋ ค์ง)
useEffect(() => {
if (!open || !map) return
const id = setTimeout(() => {
map.relayout()
map.setCenter(map.getCenter())
}, 0)
return () => clearTimeout(id)
}, [open, map])
// 3) ์ง๋ ํด๋ฆญ โ ์ขํ โ ์ญ์ง์ค์ฝ๋ฉ โ "์์ธ์ ๋์๊ตฌ"
useEffect(() => {
if (!map || !marker) return
const kakao = (window as any).kakao
const geocoder = new kakao.maps.services.Geocoder()
const clickHandler = (e: any) => {
const latlng = e.latLng
marker.setPosition(latlng)
map.panTo(latlng)
geocoder.coord2RegionCode(
latlng.getLng(),
latlng.getLat(),
(res: any, status: any) => {
if (status === kakao.maps.services.Status.OK && res[0]) {
setLabel(pickGuLabel(res))
}
}
)
}
kakao.maps.event.addListener(map, 'click', clickHandler)
return () => kakao.maps.event.removeListener(map, 'click', clickHandler)
}, [map, marker])
// 4) ํ์ฌ ์์น ๋ฒํผ (HTTPS ํ์ / ๋ก์ปฌ ๊ฐ๋ฐ์ ์์ธ์ ์ผ๋ก ํ์ฉ)
const useCurrentLocation = () => {
if (!map || !marker) return
if (!navigator.geolocation) return alert('๋ธ๋ผ์ฐ์ ์์ ์์น๋ฅผ ์ง์ํ์ง ์์์.')
navigator.geolocation.getCurrentPosition(
({ coords }) => {
const kakao = (window as any).kakao
const geocoder = new kakao.maps.services.Geocoder()
const latlng = new kakao.maps.LatLng(coords.latitude, coords.longitude)
marker.setPosition(latlng)
map.setCenter(latlng)
geocoder.coord2RegionCode(
latlng.getLng(),
latlng.getLat(),
(res: any, status: any) => {
if (status === kakao.maps.services.Status.OK && res[0]) {
setLabel(pickGuLabel(res))
}
}
)
},
() => alert('ํ์ฌ ์์น ๊ถํ์ ํ์ฉํด ์ฃผ์ธ์.')
)
}
// 5) ์์๋ก ์ ํ ๊ฒฐ๊ณผ ์ ๋ฌ
const confirm = () => {
if (!marker) return
const pos = marker.getPosition()
onPick({ lat: pos.getLat(), lng: pos.getLng(), region: label })
}
return (
<div className="space-y-3">
{/* ์ง๋๋ ๊ณ ์ ๋์ด๊ฐ ๊ผญ ์์ด์ผ ํจ. (์์ผ๋ฉด ๋ณด์ด์ง ์์) */}
<div ref={boxRef} className="h-72 w-full rounded-xl border overflow-hidden" />
<div className="flex items-center justify-between text-sm">
<div className="text-muted-foreground">
{label ? `์ ํ๋ ์ง์ญ: ${label}` : '์ง๋๋ฅผ ํด๋ฆญํ๊ฑฐ๋ ํ์ฌ ์์น๋ฅผ ์ฌ์ฉํ์ธ์'}
</div>
<div className="flex gap-2">
<button className="h-9 rounded-md border px-3" onClick={useCurrentLocation}>
ํ์ฌ ์์น
</button>
<buttonclassName="h-9 rounded-md bg-primary px-3 text-primary-foreground disabled:opacity-50"
disabled={!label}
onClick={confirm}
>
์ ํ
</button>
</div>
</div>
</div>
)
}
coord2RegionCode ์๋ต์์ region_2depth_name(๊ตฌ/๊ตฐ) + region_1depth_name(์/๋) ์กฐํฉ..env.local
NEXT_PUBLIC_KAKAO_JAVASCRIPT_KEY=์นด์นด์ค_JavaScript_ํค
http://localhost:3000 ๋ฑ)์ ์ญ ํ์ ์ ์ธ(์ ํ):
// types/kakao.maps.d.ts
export {}
declare global { interface Window { kakao: any } }
createPortal๊ณผ SSR ๊ฐ๋๋ก ์ด๋์๋ ์์ ํ๊ฒ ๋์relayout()