71. Google Map API Settings
구글맵 설정
작성일
5/3/2025작성
김범준수정일
5/3/2025구글맵
주요기능
- 구글맵 기본 컴포넌트
- 공인 아이피를 기반으로 위치 정보 및 기타 주요 정보 가져오기
- 주소입력을 바탕으로 지도 정보 가져오기
- 대한민국 주요 시군구를 통하여 지도 정보 가져오기
- 지도 마커 (새로운 버전)
코드
// -- google-map -- //
'use client'
import VivGoogleMap from "@/components/VivGoogleMap";
import VivTitle from "@/components/VivTitle";
import GpsFixedOutlinedIcon from '@mui/icons-material/GpsFixedOutlined';
import LocationDisabledOutlinedIcon from '@mui/icons-material/LocationDisabledOutlined';
import PlaceOutlinedIcon from '@mui/icons-material/PlaceOutlined';
import { getIpInfomations } from '@/lib/fetchIpInfo';
import { IIpInfo } from '@/interfaces/i-ip-info';
import { IPosData } from '@/interfaces/i-pos-data';
import FormSection from '../ip-address/FormSection'; // FormSection 경로는 실제 프로젝트에 맞게 확인 필요
import { ILocationInfo } from '@/interfaces/i-location-info';
import { useCallback, useEffect, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { ButtonGroup, FilledInput, FormControl, FormHelperText, IconButton, InputLabel, MenuItem, Select, SelectChangeEvent, Tooltip } from "@mui/material";
interface AddressInfo {
lat: number;
lng: number;
jibunAddress?: string;
roadAddress?: string;
}
const GoogleMapPage = (
) => {
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
const [initialIpInfo, setInitialIpInfo] = useState<IIpInfo | undefined>();
const [posData, setPosData] = useState<IPosData | undefined>(); // 지도의 중심 좌표 상태 (undefined 가능)
const [locationInfos, setLocationInfos] = useState<ILocationInfo[]>([]); // 초기값을 빈 배열로 설정
const [locationId, setLocationId] = useState<string>(''); // Select 컴포넌트 value는 string을 기대하므로 string으로 관리
const [isLoadingIp, setIsLoadingIp] = useState(true); // 초기 IP 로딩 상태
const [isLoadingLocations, setIsLoadingLocations] = useState(true);
const {
control,
handleSubmit,
formState: { errors },
reset,
setValue,
} = useForm<IPosData>({
defaultValues: {
latitude: '',
longitude: ''
},
mode: 'onTouched'
});
// ? IP 정보(IIpInfo)를 받아서 posData 상태를 업데이트하는 함수
// ? 이 함수는 이제 setPosData만 호출함. 폼 업데이트는 별도 useEffect에서 처리.
const updatePosDataFromIpInfo = useCallback((ipInfo: IIpInfo | null) => {
let newPosData: IPosData | undefined = undefined;
if (ipInfo?.location) {
const locationParts = ipInfo.location.split(',');
if (locationParts.length === 2) {
const lat = locationParts[0].trim();
const lng = locationParts[1].trim();
if (lat && lng) {
newPosData = { latitude: lat, longitude: lng };
} else {
console.warn('⚠️ Parsed latitude or longitude is empty after splitting.');
}
} else {
console.warn(`⚠️ Invalid location format received: ${ipInfo.location}`);
}
} else {
console.log('ℹ️ No location data found in the received IP Info.');
}
setPosData(newPosData); // posData 상태 업데이트 (undefined일 수도 있음)
}, []); // setPosData는 안정적이므로 의존성 배열에 넣지 않아도 됨 (또는 [setPosData] 추가)
// ? 컴포넌트 마운트 시 초기 IP 정보 가져오기
useEffect(() => {
const getInitialIp = async () => {
setIsLoadingIp(true);
try {
const ip = await getIpInfomations();
setInitialIpInfo(ip);
updatePosDataFromIpInfo(ip); // 가져온 IP 정보로 posData 설정
} catch (error) {
console.error("Error fetching initial IP info:", error);
// 초기 IP 로딩 실패 시 posData를 설정하지 않거나 기본값 설정 가능
setPosData(undefined);
} finally {
setIsLoadingIp(false);
}
}
getInitialIp();
}, [updatePosDataFromIpInfo]); // updatePosDataFromIpInfo는 useCallback으로 감싸져 안정적
// ? 컴포넌트 마운트 시 장소 목록(LocationInfo) 가져오기
useEffect(() => {
const fetchData = async () => {
setIsLoadingLocations(true);
const url = `${apiUrl}/api/locationinfo`;
try {
const response = await fetch(url, {
method: 'GET',
headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) {
throw new Error(`데이터 목록 가져오기 실패: ${response.status}`);
}
const result: ILocationInfo[] = await response.json();
setLocationInfos(result || []); // 결과가 null/undefined일 경우 빈 배열
} catch (e: any) {
console.error("Error fetching location infos:", e);
setLocationInfos([]); // 에러 발생 시 빈 배열로 설정
} finally {
setIsLoadingLocations(false);
}
}
fetchData();
}, [apiUrl]); // apiUrl이 변경될 가능성이 낮다면 []로 해도 무방
// ? posData 상태가 변경될 때마다 폼 필드 업데이트
useEffect(() => {
if (posData && posData.latitude && posData.longitude) {
setValue('latitude', posData.latitude, { shouldValidate: true, shouldDirty: true });
setValue('longitude', posData.longitude, { shouldValidate: true, shouldDirty: true });
// posData에 해당하는 locationId를 찾아서 Select 값도 업데이트 (선택 사항)
const foundLocation = locationInfos.find(loc =>
loc.latitude.toString() === posData.latitude?.toString() &&
loc.longitude.toString() === posData.longitude?.toString()
);
setLocationId(foundLocation ? foundLocation.id.toString() : '');
} else {
setValue('latitude', '', { shouldDirty: true });
setValue('longitude', '', { shouldDirty: true });
setLocationId(''); // Select 값도 초기화
}
}, [posData, setValue, locationInfos]); // posData나 locationInfos가 변경되면 실행
// 사무실 위치 설정 핸들러
const onSetOffice = () => {
const office: IPosData = {
latitude: 37.7110454443427,
longitude: 127.116683580175
};
setPosData(office); // posData만 업데이트 -> useEffect가 폼 필드 업데이트
};
// 폼 제출 핸들러
const onSubmit = (data: IPosData) => {
// 유효한 숫자 형태인지 추가 검증 가능
if (data.latitude && data.longitude) {
setPosData(data); // posData 업데이트 -> useEffect가 폼 필드 업데이트 (필요시)
} else {
console.warn("onSubmit: Invalid latitude or longitude in form data", data);
}
};
// 장소 선택 변경 핸들러
const handleChange = (event: SelectChangeEvent<string>) => { // Select value 타입 string으로 변경
const id = event.target.value;
setLocationId(id); // 선택된 ID 업데이트 (string)
if (id) {
const location = locationInfos?.find((x) => x.id === Number(id));
if (location) {
const pos: IPosData = {
latitude: location.latitude,
longitude: location.longitude
};
setPosData(pos); // posData 업데이트 -> useEffect가 폼 필드 업데이트
}
} else {
// '선택 안함' 또는 빈 값 선택 시 처리
setPosData(undefined); // 지도 위치 초기화
}
};
// 폼 및 지도 초기화 핸들러
const handleReset = () => {
reset({ latitude: '', longitude: '' }); // 폼 필드 초기화
setPosData(undefined); // 지도 위치 상태 초기화
setLocationId(''); // Select 값 초기화
};
return (
<div className="w-full min-h-screen flex flex-col items-center">
<VivTitle title="내 지도" />
<div className="w-full">
<VivGoogleMap params={posData!} locations={locationInfos} />
</div>
{/* 아이피 정보 가져오기 및 입력 섹션 */}
{/* 로딩 상태 또는 초기 IP 정보에 따라 표시 */}
{isLoadingIp && <div>Loading initial IP information...</div>}
{!isLoadingIp && initialIpInfo && (
<FormSection
initialIpInfo={initialIpInfo}
onIpInfoFetched={updatePosDataFromIpInfo} // IP 검색 시 호출될 콜백 (posData 업데이트용)
/>
)}
{!isLoadingIp && !initialIpInfo && <div>Could not load initial IP information.</div>}
{/* 위도/경도 입력 폼 */}
<form autoComplete='off'
className='flex flex-col gap-8 items-center p-4 w-[400px]' // 반응형 너비 조절
onSubmit={handleSubmit(onSubmit)} >
<div className='flex flex-col sm:flex-row gap-4 w-full justify-center'> {/* 반응형 레이아웃 */}
{/* 위도, Latitude */}
<FormControl className='w-full sm:w-[250px]'> {/* 반응형 너비 */}
<InputLabel htmlFor="latitude">위도 (Latitude)</InputLabel>
<Controller
name='latitude'
control={control}
rules={{ required: '위도를 입력하여 주세요' }}
render={({ field }) => (
<FilledInput
{...field}
error={!!errors.latitude}
autoComplete='off'
id='latitude'
name='latitude'
type="number" // 숫자 입력에 더 적합할 수 있음
inputProps={{ step: "any" }} // 소수점 허용
/>
)}
/>
{errors.latitude &&
<FormHelperText className='!text-red-400'>
{errors.latitude?.message}
</FormHelperText>}
</FormControl>
{/* 경도, Longitude */}
<FormControl className='w-full sm:w-[250px]'> {/* 반응형 너비 */}
<InputLabel htmlFor="longitude">경도 (Longitude)</InputLabel> {/* 오타 수정: logitude -> longitude */}
<Controller
name='longitude'
control={control}
rules={{ required: '경도를 입력하여 주세요' }} // 메시지 수정
render={({ field }) => (
<FilledInput
{...field}
error={!!errors.longitude}
autoComplete='off'
id='longitude'
name='longitude'
type="number" // 숫자 입력에 더 적합할 수 있음
inputProps={{ step: "any" }} // 소수점 허용
/>
)}
/>
{errors.longitude &&
<FormHelperText className='!text-red-400'>
{errors.longitude?.message}
</FormHelperText>}
</FormControl>
</div>
<ButtonGroup variant='outlined'>
<Tooltip title='Clear Location' placement='bottom'>
<IconButton type='button' onClick={handleReset}> {/* 초기화 핸들러 사용 */}
<LocationDisabledOutlinedIcon />
</IconButton>
</Tooltip>
<Tooltip title='Set Office Location' placement='bottom'>
<IconButton type='button' onClick={onSetOffice}>
<PlaceOutlinedIcon />
</IconButton>
</Tooltip>
<Tooltip title='Update Map from Input' placement='bottom'>
<IconButton type='submit'> {/* 폼 제출 버튼 */}
<GpsFixedOutlinedIcon />
</IconButton>
</Tooltip>
</ButtonGroup>
</form>
{/* 장소 선택 드롭다운 */}
<FormControl sx={{ m: 1, minWidth: 200, maxWidth: '80%' }} size="small"> {/* 너비 조절 */}
<InputLabel id="location-select-label">장소 선택</InputLabel>
<Select
labelId="location-select-label"
id="location-select"
value={locationId} // 상태값 바인딩 (string)
label="장소 선택"
onChange={handleChange}
disabled={isLoadingLocations} // 로딩 중 비활성화
>
<MenuItem value="">
<em>선택 안함</em>
</MenuItem>
{locationInfos?.map((info) => (
<MenuItem key={info.id} value={info.id.toString()}> {/* value는 string으로 */}
{info?.name || `Location ${info.id}`} {/* 이름 없으면 기본 텍스트 */}
</MenuItem>
))}
</Select>
{isLoadingLocations && <FormHelperText>Loading locations...</FormHelperText>}
</FormControl>
</div>
);
}
export default GoogleMapPage;
연관코드
// -- VivGoogleMap.tsx --//
'use client';
import { ILocationInfo } from '@/interfaces/i-location-info';
import { IPosData } from '@/interfaces/i-pos-data';
import { GoogleMap, useJsApiLoader } from '@react-google-maps/api';
import { useEffect, useRef, useState } from 'react';
interface MapComponentProps {
params: IPosData;
locations: ILocationInfo[]
}
const GOOGLE_MAP_LIBRARIES: ("marker")[] = ["marker"]; // 컴포넌트 밖!
export default function VivGoogleMap({ params, locations }: MapComponentProps) {
const [address, setAddress] = useState<string | null>();
const mapRef = useRef<google.maps.Map | null>(null);
const markerRefs = useRef<google.maps.marker.AdvancedMarkerElement[]>([]);
const { latitude, longitude }: IPosData = params || { latitude: 37.5665, longitude: 126.9780 };
const mapOptions = {
center: { lat: Number(latitude), lng: Number(longitude) },
zoom: 18,
mapId: process.env.NEXT_PUBLIC_GOOGLE_MAP_ID
};
const { isLoaded } = useJsApiLoader({
googleMapsApiKey: process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY!,
language: 'ko', // 지도 UI 텍스트, 범례 등이 한글로 표현됨
region: 'KR', // 장소 이름의 지역적 표현을 한국식으로 유도 함.
libraries: GOOGLE_MAP_LIBRARIES // ✅ 이제 더 이상 매번 새 배열 아님!
});
useEffect(() => {
if (!mapRef.current || !isLoaded) return;
(async () => {
const { AdvancedMarkerElement } = await google.maps.importLibrary("marker") as google.maps.MarkerLibrary;
// 1. 중앙 마커 추가
const centerMarker = new AdvancedMarkerElement({
map: mapRef.current,
position: { lat: Number(latitude), lng: Number(longitude) },
title: '+'
})
markerRefs.current.push(centerMarker);
// 2. 위치 목록 마커들 추가
locations.forEach((l) => {
const div = document.createElement('div');
div.innerHTML = `<div style="background: #007bff;
color: white; padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
white-space: nowrap;
transform: translateY(-180%); /* 👈 요게 핵심! */
position: relative;
z-index: 1000;
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
">${l.name}</div>`;
const advancedMarker = new AdvancedMarkerElement({
map: mapRef.current!,
position: { lat: l.latitude, lng: l.longitude },
content: div,
});
markerRefs.current.push(advancedMarker);
});
})();
}, [isLoaded, latitude, locations, longitude]);
const handleAddressSearch = async () => {
if (!address) return;
const geocoder = new google.maps.Geocoder();
geocoder.geocode({ address }, (results, status) => {
if (status === 'OK' && results && results[0]) {
const location = results[0].geometry.location;
const lat = location.lat();
const lng = location.lng();
// 지도 중심 이동
mapRef.current?.panTo({ lat, lng });
// 마커 추가 or 이동
const centerMarker = new google.maps.marker.AdvancedMarkerElement({
map: mapRef.current,
position: { lat, lng },
title: '검색된 위치',
});
markerRefs.current.push(centerMarker);
} else {
alert('주소를 찾을 수 없습니다!');
}
});
};
if (!isLoaded) return <div>지도를 불러오는 중입니다...</div>;
return (
<>
<GoogleMap
mapContainerStyle={{
width: 'calc(100%-2rem)',
height: '400px',
border: 'solid 5px #A6BBCF',
borderRadius: '1rem',
margin: '0 1rem'
}}
id='VIV-MAP-ID'
center={{ lat: Number(latitude), lng: Number(longitude) }}
onLoad={(map) => { mapRef.current = map }}
options={mapOptions} />
<div className='flex gap-4 justify-center items-center p-4 text-slate-400'>
<input
type="text"
placeholder="주소를 입력하세요"
className='px-4 py-3 border-2 rounded-2xl'
onChange={(e) => setAddress(e.target.value)}
value={address ?? ''}
/>
<button
onClick={handleAddressSearch}
className='bg-sky-400
px-4 py-3 rounded-xl
cursor-pointer hover:bg-sky-700 text-white'
>
지도 이동
</button>
</div>
</>
);
}
CONCLUSION
Google MAP API with next.js settings
// src/app/etc/ip-address/FormSection.tsx
'use client';
import { IIpInfo } from '@/interfaces/i-ip-info';
import { Box, IconButton, Stack, TextField } from '@mui/material';
import React, { useState } from 'react';
import DeleteIcon from '@mui/icons-material/Delete';
import TouchAppOutlinedIcon from '@mui/icons-material/TouchAppOutlined';
const api = process.env.NEXT_PUBLIC_IPINFO_URL2;
interface FormSectionProps {
initialIpInfo: IIpInfo;
onIpInfoFetched?: (ipInfo: IIpInfo) => void; // 부모에게 IP 정보를 전달할 함수 타입 정의
}
async function getInfoAsync(ip: string): Promise<IIpInfo> {
const response = await fetch(`${api}/api/ip/${ip}`);
if (!response.ok) {
throw new Error(`Failed to fetch IP info: ${response.statusText}`);
}
const data: IIpInfo = await response.json();
return data;
}
const FormSection: React.FC<FormSectionProps> = ({ initialIpInfo, onIpInfoFetched }) => {
const [ipAddress, setIpAddress] = useState<string>('');
const [info, setInfo] = useState<IIpInfo>(initialIpInfo);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
// 부모로부터 받은 initialIpInfo가 변경될 경우 내부 info 상태 업데이트 (선택적)
// 만약 부모가 초기 IP 외에 다른 이유로 initialIpInfo를 변경할 가능성이 있다면 필요
const handleDelete = () => {
setIpAddress('');
setError(null);
};
const handleGetIpInfo = async () => {
if (ipAddress && !isLoading) {
setIsLoading(true);
setError(null); // 이전 에러 초기화
try {
const fetchedIpInfo = await getInfoAsync(ipAddress);
setInfo(fetchedIpInfo); // 자식 컴포넌트 내부 상태 업데이트 (표시용)
// ? 핵심,성공적으로 IP 정보를 가졍왔으면 부모로 부터 받은 콜백 함수 호출 하여 데이터 전달
if (onIpInfoFetched)
onIpInfoFetched(fetchedIpInfo);
// ? ------------------------
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch IP info. Please try again.';
setError(errorMessage);
console.error('Error fetching IP info: ', error);
// 에러 발생 시 부모에게 알릴 필요가 있다면, onIpInfoFetched(null) 등을 호출할 수도 있음
const nullIp: IIpInfo = {}
if (onIpInfoFetched)
onIpInfoFetched(nullIp); // 예시: 에러 시 null 전달
} finally {
setIsLoading(false);
}
}
}
const handleBlur = () => {
if (ipAddress && !/^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/.test(ipAddress)) {
setError('Invalid IP format');
} else if (error === 'Invalid IP format') { // 유효한 형식으로 바뀌면 에러 제거
setError(null);
}
}
const handleIpAddress = (e: React.ChangeEvent<HTMLInputElement>) => {
setIpAddress(e.target.value);
if (error) { // 입력 시 에러 메시지 초기화 (선택적)
setError(null);
}
};
const infoItems = [
{ label: 'IP', value: info.ip },
{ label: 'City', value: info.city },
{ label: 'Region', value: info.region },
{ label: 'Country', value: info.country },
{ label: 'Location', value: info.location },
{ label: 'ISP', value: info.isp },
];
return (
<div className='h-auto'>
<Box
component="form"
sx={{ '& > :not(style)': { m: 1, width: '25ch' } }}
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center'
}}
noValidate
autoComplete="off"
>
<h1 className="text-slate-400 text-center w-96 mx-auto mb-4">{info.ip}</h1>
<TextField id="ipaddress" name='ipaddress'
label="Enter IP Address"
variant="standard"
value={ipAddress}
onBlur={handleBlur}
onChange={handleIpAddress}
helperText={error}
disabled={isLoading}
/>
<span className='text-xs'>
{error && <p className="text-red-400 text-center">{error}</p>}
</span>
<Stack direction="row" spacing={1} style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center'
}}>
<IconButton
color="warning"
disabled={isLoading}
onClick={handleDelete}>
<DeleteIcon />
</IconButton>
<div className='flex-1'></div>
<IconButton color="primary"
disabled={!ipAddress || isLoading || !!error}
onClick={handleGetIpInfo}>
<TouchAppOutlinedIcon />
</IconButton>
</Stack>
</Box>
<div className="border-2 rounded-2xl border-slate-400 p-4 flex flex-col mx-auto w-[500px]">
<dl style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '0.5rem' }}>
{infoItems.map((item) => (
<React.Fragment key={item.label}>
<dt style={{ fontWeight: 'bold', marginRight: '2em' }}>{item.label}</dt>
<dd>{item.value}</dd>
</React.Fragment>
))}
</dl>
<span>
{isLoading && <p className="text-blue-500 text-xs text-center">Loading...</p>}
{error && <p className="text-red-500 text-center">{error}</p>}
</span>
</div>
</div>
);
};
export default FormSection;
삭제권한 확인 중...
수정 권한 확인 중...