NEXT_PUBLIC_KAKAO_MAP_KEY=여기에_자바스크립트키_붙여넣기
NEXTPUBLIC로 시작해야 클라이언트에서 접근 가능
// /components/KakaoScript.tsx
"use client";
import Script from "next/script";
export default function KakaoScript() {
const appKey = process.env.NEXT_PUBLIC_KAKAO_MAP_KEY;
return (
<Script
id="kakao-maps-sdk"
src={`//dapi.kakao.com/v2/maps/sdk.js?appkey=${appKey}&autoload=false&libraries=services`}
strategy="afterInteractive"
onLoad={() => {
if (window.kakao && window.kakao.maps) {
window.kakao.maps.load(() => {
window.dispatchEvent(new Event("kakao:loaded"));
});
}
}}
/>
);
}
id : Next.js가 이 스크립트를 구분할 수 있도록 고유 ID를 붙임.src : 카카오맵 SDK URLautoload=false : 자동 실행 막고, onLoad에서 수동으로 kakao.maps.load() 호출libraries=services : 주소 → 좌표 변환(Geocoder) 기능 사용strategy="afterInteractive" : 페이지가 인터랙티브해진 뒤(클라이언트에서 준비된 후) 이 스크립트를 로드// types/global.d.ts
declare global {
interface Window {
kakao: any;
}
}
{
"compilerOptions": {
"strict": true,
"jsx": "preserve",
"moduleResolution": "node"
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "types/**/*.d.ts"]
}
npm install --save-dev @types/kakao.maps.d
// app/layout.tsx
import KakaoScript from "@/components/KakaoScript";
// ...생략
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko" className={pretendard.variable}>
<body className="font-sans">
<KakaoScript />
<AuthBootstrap />
<Providers>{children}</Providers>
<ToastProvider />
</body>
</html>
);
}
// /components/map/KakaoMap.tsx
"use client";
import { useEffect, useRef } from "react";
import { toast } from "react-toastify";
type KakaoMapProps = {
address: string;
className?: string;
level?: number;
};
export function KakaoMap({ address, className, level = 3 }: KakaoMapProps) {
const mapRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!address) return;
let cleanup = () => {};
const init = () => {
if (!mapRef.current || !window.kakao?.maps) return;
const { maps } = window.kakao;
const map = new maps.Map(mapRef.current, {
center: new maps.LatLng(37.5665, 126.978), // 기본: 서울시청
level,
});
// 주소 -> 좌표 변환
const geocoder = new maps.services.Geocoder();
/* eslint-disable @typescript-eslint/no-explicit-any */
geocoder.addressSearch(address, (result: any[], status: string) => {
if (status === maps.services.Status.OK && result[0]) {
const { x, y } = result[0];
const coords = new maps.LatLng(Number(y), Number(x));
new maps.Marker({ position: coords, map });
map.setCenter(coords);
} else {
if (process.env.NODE_ENV !== "production") {
toast.error("[KakaoMap] 주소 검색 실패:");
}
}
});
};
// SDK가 이미 로드된 경우 → 바로 init()
if (window.kakao?.maps) {
init();
} else {
// 아직 로드되지 않았다면 → "kakao:loaded" 이벤트 대기
const onLoaded = () => init();
window.addEventListener("kakao:loaded", onLoaded, { once: true });
cleanup = () => window.removeEventListener("kakao:loaded", onLoaded);
}
return () => cleanup();
}, [address, level]);
return <div ref={mapRef} className={className} aria-label="카카오 지도" />;
}
import Image from "next/image";
// Icons
import LocationIcon from "@/assets/svgs/location_icon.svg";
import { GetActivityDetailResponse } from "@/lib/types/activities";
import { KakaoMap } from "./KakaoMap";
type ActivityDescriptionProps = {
activity: GetActivityDetailResponse;
};
export function ActivityDescription({ activity }: ActivityDescriptionProps) {
return (
<article
className="flex flex-col gap-y-[30px]"
aria-labelledby="activity-description"
>
<hr className="border-nomadBlack/20" />
<section className="flex flex-col gap-y-[16px]">
<h3 className="text-xl font-bold text-nomadBlack">체험 설명</h3>
<p className="text-lg text-nomadBlack">{activity.description}</p>
</section>
<hr className="border-nomadBlack/20" />
<section className="flex flex-col gap-y-[8px]">
<KakaoMap
address={activity.address}
className="aspect-[5/2] w-full rounded-[16px] border border-gray-200"
/>
<div className="flex gap-x-[2px]">
<Image src={LocationIcon} alt="위치" width={18} height={18} />
<span className="text-lg text-nomadBlack">{activity.address}</span>
</div>
</section>
<hr className="border-nomadBlack/20" />
</article>
);
}
