개발일기
[내일배움캠프] 최종 프로젝트 - 구글 맵 API: 선택 국가만 구글 맵 선택 가능하도록 클릭, 검색 시 마커 제한 설정(country 국가명 싹 뽑아내기) 및 SpotMap 컴포넌트 분리 / useRef와 useState의 비교 본문
[내일배움캠프] 최종 프로젝트 - 구글 맵 API: 선택 국가만 구글 맵 선택 가능하도록 클릭, 검색 시 마커 제한 설정(country 국가명 싹 뽑아내기) 및 SpotMap 컴포넌트 분리 / useRef와 useState의 비교
코찡 2023. 9. 9. 14:38오늘의 기억할 거리
1. useRef와 useState의 특성을 고려한 사용 (선택한 국가 country 전달 과정에서)
useRef는 컴포넌트의 리렌더링 없이 항상 최신 country 값을 참조하게 하기 위해, useState는 selectedCountry 값의 변경에 따라 컴포넌트를 재렌더링하기 위해 사용했습니다.
WriteTemplate 컴포넌트에서 props로 받아온 country 값이 바뀔 때마다, useRef는 컴포넌트의 리렌더링 없이 해당 country 값의 최신 값을 지속해서 유지하고, useState는 업데이트된 selectedCountry 상태를 자식 컴포넌트인 AddressSearch 컴포넌트로 전달합니다.
1) 상황
스팟 공유 글 작성하는 WriteTemplate 컴포넌트에서 dropdown으로 국가 선택 시, location 값은 [region, country] 배열입니다.
선택한 국가만 구글 맵 내에서 검색/클릭 가능하도록 설정해주기 위해, location[1]의 형태로 구글 맵 SpotMap 컴포넌트에 선택된 '국가명(country)'을 props로 전달합니다.
// components 폴더 하위 spotShare 폴더 하위 spotShareTemplate 폴더 하위 WriteTemplate.tsx
// return 부분에서 SpotMap 컴포넌트 import해서 사용 (props로 dropdown에서 선택한 국가명 country 전달)
<SpotMap setLatitude={setLatitude} setLongitude={setLongitude} address={address} setAddress={setAddress} country={location[1]} />
SpotMap 컴포넌트는 장소를 검색하는 AddressSearch 컴포넌트를 자식 컴포넌트로 import해 사용합니다.
* countryRef를 왜 사용했는지?
useRef는 "컴포넌트의 리렌더링 없이도 최신 country 값의 변경된 값을 참조"할 수 있으며, country 값이 변경되더라도 컴포넌트를 리렌더링 하지 않고, 컴포넌트가 리렌더링 되더라도 최신 값을 유지하기 때문입니다.
따라서, dropdown 국가 선택 후 SpotMap 컴포넌트가 리렌더링 되지 않더라도 props로 받아온 해당 국가값을 변경할 수 있으며, addMarker 함수 처럼 여기 저기 지도를 클릭하면서 SpotMap 컴포넌트가 리렌더링되더라도 가장 최근에 선택된 country값을 참조하도록 하기 위함입니다.
// components 폴더 하위 spotShare 폴더 하위 map 폴더 하위 SpotMap.tsx
import { useEffect, useRef } from 'react';
import { AddressSearch } from './AddressSearch';
// dropdown에서 선택된 국가 props로 받아옵니다.
type SpotMapProps = {
country: string;
};
const SpotMap = ({ country }: SpotMapProps) => {
const countryRef = useRef<string | undefined>(country);
// 선택한 국가(country) 변경 시, countryRef를 업데이트해줍니다.
useEffect(() => {
countryRef.current = country;
console.log('SpotMap에서 찍은 countryRef.current', countryRef.current);
}, [country]);
// 선택된 국가(country) 정보 내에 한정해서 구글 맵 장소 검색이 가능하도록 제한하기 위해
// 선택 국가 정보(countryRef.current)를 AddressSearch 컴포넌트에 전달해주었습니다. selectedCountry={countryRef.current}
return (
<div style={{ marginTop: '50px' }}>
<AddressSearch map={map} setLatitude={setLatitude} setLongitude={setLongitude} setAddress={setAddress} address={address} selectedCountry={countryRef.current} setMarkerOnMap={setMarkerOnMap} />
<div ref={mapRef} style={{ width: '100%', height: '50vh', marginTop: '20px' }} />
</div>
);
};
export default SpotMap;
2) 문제 발생
SpotMap 컴포넌트에서의 countryRef.current는 dropdown에서 국가를 선택하는 순간 해당 country 값이 들어와 찍히지만,
'selectedCountry=countryRef.current' 형태로 AddressSearch 컴포넌트에 전달된 selectedCountry는 기존 국가 선택 전인 undefined 상태에서 countryRef.current로 전달받은 selectedCountry의 상태 변경 사항이 바로 반영되지 않았습니다. 즉, 의존성 배열에 있는 selectedCountry 값이 undefined에서 '선택한 국가'로 바뀌지 않아 콘솔에 찍히지 않았습니다. 따라서 선택한 국가 '대한민국'에 해당하는 '서울'을 검색했음에도 선택된 국가 정보가 없다는 오류 메시지가 뜨는 문제가 발생했습니다.
3) 문제 원인
useRef는 DOM 노드 또는 React 요소에 액세스하는 방법을 제공합니다. useRef에 대해 알아야 할 중요한 사항 중 하나는 useRef 데이터가 변경될 때 컴포넌트가 다시 렌더링되지 않는다는 것입니다. 따라서 countryRef.current가 어딘가에서 업데이트되지만 상위 컴포넌트에서 다시 렌더링되지 않으면, 업데이트된 값이 자식 AddressSearch 컴포넌트에 props로 전달되지 않습니다.
따라서 SpotMap에서 선택한 국가의 country값을 전달받아 countryRef.current 값이 업데이트 되더라도, SpotMap 컴포넌트가 렌더링 된 것은 아니므로 AddressSearch에는 해당 변경된 값이 전달되지 않았던 것입니다.
4) 문제 해결
useState를 사용하여 'selectedCountry' 값의 상태가 변경되면 컴포넌트가 다시 렌더링되게 함으로써, 업데이트된 값을 자식 컴포넌트인 AddressSearch에 props로 전달합니다.
이제 dropdown에서 국가명 선택 후, 장소를 검색하면 '검색 컴포넌트에서 찍은 selectedCountry'값이 선택된 국가 값으로 정상 반영되면서 선택한 국가에 해당한 지역을 검색한 경우, 해당 장소에 마커가 표시됩니다.
// components 폴더 하위 spotShare 폴더 하위 map 폴더 하위 SpotMap.tsx
import { useEffect, useRef, useState } from 'react';
import { AddressSearch } from './AddressSearch';
type SpotMapProps = {
country: string;
};
const SpotMap = ({ country }: SpotMapProps) => {
const countryRef = useRef<string | undefined>(country);
// countryRef를 상태 변경 사항으로 관리하기 위해 useState 생성
const [selectedCountry, setSelectedCountry] = useState<string | undefined>();
// 선택한 국가(country) 변경 시, countryRef를 업데이트하고, selectedCountry 상태에 담아줍니다.
useEffect(() => {
countryRef.current = country;
setSelectedCountry(countryRef.current);
console.log('SpotMap에서 찍은 countryRef.current', countryRef.current);
}, [country]);
// 선택 국가 정보(country)를 selectedCountry라는 상태 관리 값에 담아서 AddressSearch 컴포넌트에 전달해주었습니다. selectedCountry={selectedCountry}
return (
<div style={{ marginTop: '50px' }}>
<AddressSearch map={map} setLatitude={setLatitude} setLongitude={setLongitude} setAddress={setAddress} address={address} selectedCountry={selectedCountry} setMarkerOnMap={setMarkerOnMap} />
<div ref={mapRef} style={{ width: '100%', height: '50vh', marginTop: '20px' }} />
</div>
);
};
export default SpotMap;
1. 구글 맵 API: 선택 국가만 구글 맵 선택 가능하도록 클릭, 검색 시 마커 제한 설정
1) SpotMap 컴포넌트 -> 구글 맵 표시하는 메인 컴포넌트 (상태 관리 및 초기화)
// components 폴더 하위 spotShare 폴더 하위 map 폴더 하위 SpotMap.tsx
import { useEffect, useRef, useState } from 'react';
import { AddressSearch } from './AddressSearch';
import { addMarker, createAndSetMarker } from './createMarker';
type SpotMapProps = {
setLatitude: (lat: number | null) => void;
setLongitude: (lng: number | null) => void;
address: string | null;
setAddress: (address: string | null) => void;
country: string;
};
const SpotMap = ({ setLatitude, setLongitude, address, setAddress, country }: SpotMapProps) => {
// 지도 표시할 영역 지정하는 Ref
const mapRef = useRef<HTMLDivElement>(null);
// marker 관리할 Ref
const markerRef = useRef<google.maps.Marker | null>(null);
// dropdown에서 선택된 국가 관리하는 Ref
const countryRef = useRef<string | undefined>(country);
const [map, setMap] = useState<google.maps.Map | null>(null);
const [selectedCountry, setSelectedCountry] = useState<string | undefined>();
// 선택한 국가 변경 시, countryRef 및 selectedCountry 상태 업데이트
useEffect(() => {
countryRef.current = country;
setSelectedCountry(countryRef.current);
console.log('SpotMap에서 찍은 countryRef.current', countryRef.current);
}, [country]);
// Google Maps API가 로드되었는지 확인하고 지도 초기화
useEffect(() => {
const checkIfGoogleIsLoaded = () => {
return typeof window.google === 'object' && typeof window.google.maps === 'object';
};
const initializeMap = () => {
if (mapRef.current) {
const seoul = { lat: 37.5642135, lng: 127.0016985 };
const map = new google.maps.Map(mapRef.current, {
zoom: 12,
center: seoul,
});
setMap(map);
}
};
if (checkIfGoogleIsLoaded()) {
initializeMap();
} else {
const googleMapScript = document.querySelector('script[src*="googleapis"]');
googleMapScript?.addEventListener('load', initializeMap);
}
}, []);
// 지도 클릭 시, 마커 추가하는 이벤트 핸들러
useEffect(() => {
if (map) {
map.addListener('click', (e) => {
addMarker(map, e.latLng, setLatitude, setLongitude, setAddress, countryRef, markerRef);
});
}
}, [map]);
// 검색 시, 지도 중심 조정 및 마커 추가하는 함수
const setMarkerOnMap = (location: google.maps.LatLng) => {
if (!map) return;
map.setCenter(location);
map.setZoom(17);
createAndSetMarker(map, location, setLatitude, setLongitude, setAddress, markerRef);
};
// 검색 컴포넌트 분리
return (
<div style={{ marginTop: '50px' }}>
<AddressSearch map={map} setLatitude={setLatitude} setLongitude={setLongitude} setAddress={setAddress} address={address} selectedCountry={selectedCountry} setMarkerOnMap={setMarkerOnMap} />
<div ref={mapRef} style={{ width: '100%', height: '50vh', marginTop: '20px' }} />
</div>
);
};
export default SpotMap;
2) AddressSearch 컴포넌트 -> 구글 맵: 검색 컴포넌트 분리
// components 폴더 하위 spotShare 폴더 하위 map 폴더 하위 AddressSearch.tsx
import { useEffect, useState } from 'react';
import { AlertError } from '../../common/modal/alert';
import { processCountryComponent } from './convert';
import * as St from './style';
type AddressSearchProps = {
map: google.maps.Map | null;
setLatitude: (lat: number | null) => void;
setLongitude: (lng: number | null) => void;
setAddress: (address: string | null) => void;
address: string | null;
selectedCountry: string | undefined;
setMarkerOnMap: (location: google.maps.LatLng) => void;
};
export const AddressSearch = ({ map, setLatitude, setLongitude, setAddress, address, selectedCountry, setMarkerOnMap }: AddressSearchProps) => {
const [inputValue, setInputValue] = useState<string>('');
// dropdown에서 선택한 국가 내에서의 검색만 가능하도록 하기위해 받아온 props
useEffect(() => {
console.log('검색 컴포넌트에서 찍은 selectedCountry', selectedCountry);
}, [selectedCountry]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
// 장소 검색 (enter 키 쳤을 때도 실행되도록)
const handleEnterToSearch = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSearch();
}
};
// 장소 검색 ('검색' 버튼 클릭했을 때)
const handleSearch = (e?: React.MouseEvent<HTMLButtonElement>) => {
e?.preventDefault();
if (!map) return;
if (!selectedCountry) {
AlertError({ text: '국가 정보가 없습니다. 국가를 먼저 선택해주세요.' });
return;
}
const placeService = new google.maps.places.PlacesService(map);
placeService.findPlaceFromQuery(
{
query: inputValue,
fields: ['geometry', 'place_id'],
},
(results, status) => {
if (status !== google.maps.places.PlacesServiceStatus.OK) {
AlertError({ text: `장소 검색 중 오류가 발생했습니다. 다시 시도해주세요.` });
return;
}
const location = results[0]?.geometry?.location;
const placeId = results[0]?.place_id;
if (!location || !placeId) return;
// 검색한 inputValue의 placeId를 기반으로 address_components에서 국가명 찾아냄
placeService.getDetails(
{
placeId: placeId,
fields: ['address_components'],
},
(place, status) => {
if (status !== google.maps.places.PlacesServiceStatus.OK) {
AlertError({ text: `상세 주소 정보 가져오기 중 오류가 발생했습니다. 다시 시도해주세요.` });
return;
}
// 검색한 해당 국가명이 선택된 국가와 일치하는 지 확인
const searchedCountry = processCountryComponent(place.address_components || []);
if (searchedCountry !== selectedCountry) {
AlertError({ text: `선택한 위치는 '${selectedCountry}'의 올바른 위치가 아닙니다. 올바른 위치를 선택해주세요.` });
return;
}
// 일치하면 검색한 위치에 마커 찍는 함수 호출
setMarkerOnMap(location);
},
);
},
);
};
// 지도 검색/클릭 내역 초기화 함수
const handleClearAddress = (e?: React.MouseEvent<HTMLButtonElement>) => {
e?.preventDefault();
setInputValue('');
setLatitude(null);
setLongitude(null);
setAddress('');
};
return (
<St.SearchAddressBox>
<St.SearchAddress>
<input type="text" value={inputValue} onChange={handleInputChange} onKeyPress={handleEnterToSearch} placeholder="장소를 검색하세요!" />
<button type="button" onClick={handleSearch}>
검색
</button>
</St.SearchAddress>
<St.Address>
{address ? address : `검색한 주소가 표시됩니다`}
<button onClick={handleClearAddress}>지도 초기화</button>
</St.Address>
</St.SearchAddressBox>
);
};
3) convert.ts (위도, 경도 -> 주소 변환 함수 / 국가명 뽑아서 한글명 변환시키는 함수)
// components 폴더 하위 spotShare 폴더 하위 map 폴더 하위 convert.ts
import { AlertError } from '../../common/modal/alert';
// 위도, 경도 -> 주소 변환 함수
export const getAddress = async (lat: number, lng: number, setAddress: (address: string | null) => void) => {
const geocoder = new google.maps.Geocoder();
const latlng = { lat: lat, lng: lng };
geocoder.geocode({ location: latlng }, (results, status) => {
if (status === 'OK') {
if (results[0]) {
setAddress(results[0].formatted_address);
} else {
AlertError({ text: '결과가 없습니다.' });
}
} else {
AlertError({ text: `다음의 이유로 주소 변환 과정에서 문제가 발생했습니다. ${status} 다시 한 번 시도해주세요!` });
}
});
};
// results: 배열 형식으로 address_components(주소)가 여러 형식으로 존재함
// address_components 중에서 country 타입 있는 걸 찾아서, 그 국가명(long_name) 뽑고, 한글 국가명으로 변환해서 반환하는 함수
export const processCountryComponent = (addressComponents: google.maps.GeocoderAddressComponent[]) => {
const countryComponent = addressComponents.find((component) => component.types.includes('country'));
let clickedCountry: string | null = null;
if (countryComponent) {
const clickedCountryEnglish = countryComponent.long_name;
clickedCountry = countryMapping[clickedCountryEnglish as keyof typeof countryMapping] || clickedCountryEnglish;
}
return clickedCountry;
};
// 국가 제한 설정 위한 mapping 정보
export const countryMapping = {
Australia: '호주',
'New Zealand': '뉴질랜드',
Egypt: '이집트',
'South Africa': '남아프리카공화국',
Tanzania: '탄자니아',
Ethiopia: '에티오피아',
Kenya: '케냐',
Namibia: '나미비아',
Morocco: '모로코',
USA: '미국',
Canada: '캐나다',
Mexico: '멕시코',
Peru: '페루',
Bolivia: '볼리비아',
Chile: '칠레',
Argentina: '아르헨티나',
Cuba: '쿠바',
Brazil: '브라질',
France: '프랑스',
Italy: '이탈리아',
Türkiye: '터키',
Spain: '스페인',
UK: '영국',
Austria: '오스트리아',
Netherlands: '네덜란드',
Germany: '독일',
Switzerland: '스위스',
Portugal: '포르투갈',
Poland: '폴란드',
Iceland: '아이슬란드',
Finland: '핀란드',
Sweden: '스웨덴',
Norway: '노르웨이',
Denmark: '덴마크',
Greece: '그리스',
Russia: '러시아',
Ireland: '아일랜드',
Hungary: '헝가리',
Belgium: '벨기에',
Czechia: '체코',
Slovenia: '슬로베니아',
'South Korea': '대한민국',
Japan: '일본',
China: '중국',
'Hong Kong': '홍콩',
Taiwan: '대만',
Mongolia: '몽골',
Singapore: '싱가포르',
Vietnam: '베트남',
Thailand: '태국',
Indonesia: '인도네시아',
Malaysia: '말레이시아',
Philippines: '필리핀',
Laos: '라오스',
Cambodia: '캄보디아',
Myanmar: '미얀마',
'United Arab Emirates': '아랍에미리트',
Oman: '오만',
India: '인도',
Nepal: '네팔',
Israel: '이스라엘',
Qatar: '카타르',
} as const;
4) createMarker.ts -> 클릭 시 마커 추가하는 로직 함수, 이전 마커 제거 후 새로운 마커 추가 함수
// components 폴더 하위 spotShare 폴더 하위 map 폴더 하위 createMarker.ts
import { AlertError } from '../../common/modal/alert';
import { getAddress, processCountryComponent } from './convert';
// 클릭 시 마커 추가하는 로직 함수
export const addMarker = (
map: google.maps.Map,
location: google.maps.LatLng,
setLatitude: (lat: number | null) => void,
setLongitude: (lng: number | null) => void,
setAddress: (address: string | null) => void,
countryRef: React.MutableRefObject<string | undefined>,
markerRef: React.MutableRefObject<google.maps.Marker | null>,
) => {
if (!countryRef.current) {
AlertError({ text: '국가 정보가 없습니다. 국가를 먼저 선택해주세요.' });
return;
}
const geocoder = new google.maps.Geocoder();
geocoder.geocode({ location }, (results, status) => {
if (status === 'OK' && results.length > 0) {
// 클릭한 위치의 국가명이 선택된 국가와 일치하는 지 확인
const matchedCountry = results.some((result) => {
const clickedCountry = processCountryComponent(result.address_components);
return clickedCountry === countryRef.current;
});
// 선택한 국가와 map에서 찍은 위치의 국가 정보가 일치하면 마커 찍는 함수 실행
if (matchedCountry) {
createAndSetMarker(map, location, setLatitude, setLongitude, setAddress, markerRef);
} else {
AlertError({ text: `선택한 위치는 '${countryRef.current}'의 올바른 위치가 아닙니다. 올바른 위치를 선택해주세요.` });
}
} else {
AlertError({ text: `다음의 이유로 주소 확인 과정에서 문제가 발생했습니다. ${status} 다시 한 번 시도해주세요!` });
}
});
};
// 이전 마커를 제거하고 새로운 마커를 추가하는 함수
export const createAndSetMarker = (
map: google.maps.Map,
location: google.maps.LatLng,
setLatitude: (lat: number | null) => void,
setLongitude: (lng: number | null) => void,
setAddress: (address: string | null) => void,
markerRef: React.MutableRefObject<google.maps.Marker | null>,
) => {
// 이전 마커 제거
if (markerRef.current) {
markerRef.current.setMap(null);
}
// 새로운 마커 생성
const newMarker = new google.maps.Marker({
map: map,
position: location,
});
markerRef.current = newMarker;
// 마커의 위도와 경도 값 얻기
const lat = location.lat();
const lng = location.lng();
// 위도와 경도, 주소 설정
setLatitude(lat);
setLongitude(lng);
getAddress(lat, lng, setAddress);
};
5) 관련 style.ts
// components 폴더 하위 spotShare 폴더 하위 map 폴더 하위 style.ts
import { styled } from 'styled-components';
export const SearchAddressBox = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
button {
margin-left: 5px;
background-color: #000;
border: 0;
color: #fff;
padding: 3px 10px;
border-radius: 2px;
}
@media screen and (max-width: 650px) {
flex-direction: column;
align-items: flex-start;
gap: 12px;
padding-left: 16px;
}
`;
export const SearchAddress = styled.div`
& .map-search-input {
padding: 3px 6px;
font-size: 16px;
}
button {
font-size: 16px;
padding: 5px 20px;
}
`;
export const Address = styled.div`
font-size: 14px;
color: #777;
button {
background-color: #999;
}
`;