카카오 API 키워드 주소 검색하기&우편번호 찾기 페이지 만들기
- SearchPage라는 폴더를 만들어 장소를 찾는 페이지를 구현했다. 일반적인 모바일 앱에서 주소입력할 때 쓰는 주소찾기 화면을 찾고 했고, 이를 확장하여, 카카오 키워드 기반 주소 검색하기&우편번호 API를 불러오는 작업을 했다.
- 처음에는 우편번호와 주소찾는 것을 별개로 불러와서 작업할 생각을 했는데 중복되는 코드가 있고, 화면에 제대로 나타나지 않았었다. 그렇기에 좀더 생각을 해봤고 서치를 하면서 해결책을 찾아나갔다.
- 일단, 중복되는 일이 없게 searchZip, SearchPlace 폴더를 만들어 주고, index.ts로 파일로 api를 불러와서 처리할 수 있게 했다.
- searchPlace
- 사용 라이브러리: @tanstack/react-query, axios -> 환경변수에서 KAKAO_API_KEY 가져와서 사용.
const fetchSearchPlace = async (params: KakaoPlaceSearchParams): Promise<KakaoPlaceSearchResponse> => {
const response = await axios.get<KakaoPlaceSearchResponse>(BASE_URL, {
headers: {
Authorization: `KakaoAK ${KAKAO_API_KEY}`,
},
params,
});
return response.data;
};
fetchSearchPlace 함수는 카카오 장소 검색 API에 HTTP GET 요청을 보내는 역할을 하고, params를 인자로 받아 axios.get()을 통해 API를 호출한다. 요청을 보낼 때 Authorization 헤더에 KakaoAK {API_KEY} 형식으로 카카오 API 키를 포함해야 한다. params 객체를 axios의 params 속성으로 전달해 API 요청에 필요한 쿼리 파라미터를 추가한다. 응답 데이터는 KakaoPlaceSearchResponse 타입으로 지정하고 response.data를 반환한다.
export const useSearchPlace = (params: KakaoPlaceSearchParams) => {
return useQuery({
queryKey: ['searchPlace', params],
queryFn: () => fetchSearchPlace(params),
enabled: !!params.query,
});
};
useSearchPlace는 React Query의 useQuery를 사용하여 장소 검색을 수행하는 커스텀 Hook이다. params를 인자로 받아 fetchSearchPlace 함수를 실행하도록 설정한다. -> react-query를 이용했고, 공식문서를 참고해서 만들었다.
- searchZip
import { useMutation } from '@tanstack/react-query';
import axios from 'axios';
import { KakaoCoordToAddressParams, KakaoCoordToAddressResponse, KakaoPlaceDocument } from '../types';
const BASE_COORD_TO_ADDRESS_URL = '~~~~~~~~';
const KAKAO_API_KEY = import.meta.env.VITE_KAKAO_REST_API_KEY;
/**
* 좌표로 우편번호를 가져오는 함수
* @param params KakaoCoordToAddressParams - 좌표 파라미터 (x, y)
* @returns Promise<Partial<KakaoPlaceDocument>> - 우편번호가 추가된 장소 데이터
*/
export const fetchZipForPlace = async (
params: KakaoCoordToAddressParams
): Promise<Partial<KakaoPlaceDocument>> => {
try {
const response = await axios.get<KakaoCoordToAddressResponse>(BASE_COORD_TO_ADDRESS_URL, {
headers: {
Authorization: `KakaoAK ${KAKAO_API_KEY}`,
},
params,
});
const road_address = response.data.documents[0]?.road_address;
const zone_no = road_address?.zone_no;
return {
x: params.x,
y: params.y,
road_address_name: road_address?.road_address_name || '',
zone_no,
};
} catch (error) {
console.error('API 호출 실패:', error);
throw error;
}
};
/**
* 좌표 기반 우편번호 검색용 React Query Mutation
*/
export const useFetchZipForPlace = () => {
return useMutation<Partial<KakaoPlaceDocument>, Error, KakaoCoordToAddressParams>({
mutationFn: fetchZipForPlace,
onError: (error: Error, variables: KakaoCoordToAddressParams) => {
console.error(
`좌표 (${variables.x}, ${variables.y})에 대한 우편번호 가져오기 실패:`,
error.message
);
},
});
};
- React Query의 useMutation을 활용하여 좌표를 기반으로 우편번호를 조회하는 기능을 제공하는 코드 -> Kakao API(coord2address.json)에 좌표 정보를 전달해, 해당 위치의 도로명 주소 및 우편번호를 조회한다. API 응답 데이터에서 road_address.zone_no 값을 추출하여 반환한다. 요청 실패 시 에러를 콘솔에 출력하고 예외를 던진다.
- useFetchZipForPlace 커스텀은 HookuseMutation을 사용하여 fetchZipForPlace를 실행할 수 있도록 한다. mutationFn: fetchZipForPlace → fetchZipForPlace를 호출해 API 요청을 수행한다. onError 핸들러를 통해 API 요청 실패 시 좌표 정보와 에러 메시지를 로그로 남긴다.
-> 불러올 때는 url이 카카오 API 공식문서에 있으니 참고.
import { useEffect } from "react";
import * as S from "./SearchMap.style";
import Card from "../../../../components/Card";
import { MapProps } from "../../types/types";
export default function MapSearch({ center, results, mapContainerId }: MapProps) {
useEffect(() => {
const { kakao } = window;
if (!kakao || !kakao.maps) {
return;
}
const mapContainer = document.getElementById(mapContainerId);
if (!mapContainer) {
return;
}
const mapOption = {
center: new kakao.maps.LatLng(Number(center.y), Number(center.x)),
level: 3,
};
const map = new kakao.maps.Map(mapContainer, mapOption);
const markers: kakao.maps.Marker[] = [];
results.forEach((result, index) => {
const markerPosition = new kakao.maps.LatLng(Number(result.y), Number(result.x));
const markerImageSrc =
"data:image/svg+xml," +
encodeURIComponent(`
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="80" viewBox="0 0 30 35">
<path fill="#3b46f1" d="M15 0c8.3 0 15 6.7 15 15 0 12.5-15 20-15 20S0 27.5 0 15C0 6.7 6.7 0 15 0z"/>
<circle cx="15" cy="15" r="8" fill="white"/>
<text x="15" y="19" text-anchor="middle" fill="#3b46f1" font-size="12px" font-weight="bold">${index + 1}</text>
</svg>
`);
const markerImageSize = new kakao.maps.Size(35, 50);
const markerImage = new kakao.maps.MarkerImage(markerImageSrc, markerImageSize);
const marker = new kakao.maps.Marker({
position: markerPosition,
map,
image: markerImage,
});
markers.push(marker);
const infowindow = new kakao.maps.InfoWindow({
content: `<div style="padding:5px; font-size:12px;">${result.place_name || "위치"}</div>`,
});
kakao.maps.event.addListener(marker, "mouseover", () => {
infowindow.open(map, marker);
});
kakao.maps.event.addListener(marker, "mouseout", () => {
infowindow.close();
});
});
return () => {
markers.forEach((marker) => marker.setMap(null)); // 마커 제거
};
}, [center, results, mapContainerId]);
return (
<Card>
<S.Map id={mapContainerId} />
</Card>
);
}
- 카카오 지도 객체를 생성하고, center 좌표를 기준으로 초기 위치를 설정한다.
- 검색된 장소 목록(results)을 기반으로 마커를 추가한다.
- 마커를 SVG 기반으로 커스텀 생성하여, 각 장소의 번호를 마커 위에 표시한다.
- 마커에 마우스를 올리면 정보창(InfoWindow)이 나타나고, 마우스를 떼면 사라지는 기능을 추가한다.
- 컴포넌트가 언마운트될 때(cleanup) 기존 마커를 모두 제거하여 메모리 누수를 방지한다.
useEffect를 사용하여 지도 생성-> 검색된 장소마다 마커 추가-> SVG를 활용한 커스텀 마커 생성-> 마커를 지도에 추가->마커에 마우스를 올리면 정보창 표시-> useEffect의 Cleanup 함수 (컴포넌트 언마운트 시 마커 제거) ->
useEffect의 Cleanup 함수에서, 컴포넌트가 언마운트되거나 results가 변경될 때 기존 마커들을 지도에서 제거하여 메모리 누수를 방지.
- S.Map 스타일이 적용된 div 요소에 카카오 지도를 렌더링.
- Card 컴포넌트를 감싸 UI를 정리된 형태로 제공.
import * as S from "./Pagination.styles";
import { PaginationProps } from "../types/types";
export default function Pagination({
currentPage,
totalItems,
itemsPerPage,
onPageChange,
}: PaginationProps) {
const totalPages = Math.ceil(totalItems / itemsPerPage);
const handlePrevious = () => {
if (currentPage > 1) onPageChange(currentPage - 1);
};
const handleNext = () => {
if (currentPage < totalPages) onPageChange(currentPage + 1);
};
if (totalPages === 0) return null;
return (
<S.PaginationWrapper>
<S.ArrowButton onClick={handlePrevious} $disabled={currentPage === 1}>
{"<"}
</S.ArrowButton>
<S.PageIndicator $isActive={true}>{currentPage}</S.PageIndicator>
<S.Separator>/</S.Separator>
<S.PageIndicator $isActive={false}>{totalPages}</S.PageIndicator>
<S.ArrowButton onClick={handleNext} $disabled={currentPage === totalPages}>
{">"}
</S.ArrowButton>
</S.PaginationWrapper>
);
}
- 현재 페이지(currentPage)를 기준으로 총 페이지 수(totalPages)를 계산하고,
이전(handlePrevious)과 다음(handleNext) 버튼을 클릭하면 페이지를 변경하는 로직을 제공한다. - 현재 페이지 / 총 페이지 수 형태로 페이지 정보를 표시한다.
- 첫 페이지에서는 이전 버튼을 비활성화, 마지막 페이지에서는 다음 버튼을 비활성화하여,
잘못된 페이지 이동을 방지한다.
import React from "react";
import * as S from "./ResultList.styles";
import MapComponent from "./SearchMap/SearchMap";
import ToggleIcon from "../../../assets/icons/ToggleIcon.svg?react";
import { ResultListProps } from "../types/types";
export default function ResultList({
results,
selectedResult,
handleSelectItem,
}: ResultListProps) {
return (
<S.Results $isVisible={results.length > 0}>
{results.map((result, index) => {
const isSelected = selectedResult?.id === result.id;
return (
<React.Fragment key={result.id}>
<S.ResultWrapper>
<S.ResultItem
$isFirst={index === 0}
$isLast={index === results.length - 1}
onClick={() => handleSelectItem(result)}
$isSelected={isSelected}
>
<S.ZipCode>{result.zone_no || "우편번호 없음"}</S.ZipCode>
<S.PlaceName>{result.place_name}</S.PlaceName>
<S.InfoContainer>
<S.AddressName>
{result.road_address_name || result.address_name}
</S.AddressName>
<S.MapButton>
지도
<S.RotateIcon $isRotated={isSelected}>
<ToggleIcon />
</S.RotateIcon>
</S.MapButton>
</S.InfoContainer>
</S.ResultItem>
</S.ResultWrapper>
{isSelected && (
<MapComponent
center={{
x: result.x.toString(),
y: result.y.toString(),
}}
results={[{ ...result, x: result.x.toString(), y: result.y.toString() }]}
mapContainerId={`map-${result.id}`}
/>
)}
{index < results.length - 1 && <S.Divider />}
</React.Fragment>
);
})}
</S.Results>
);
}
이 코드는 장소 검색 결과를 리스트로 표시하고, 특정 항목을 선택하면 해당 위치의 지도도 함께 표시하는 컴포넌트이다.
- results 배열을 순회하며 각 장소를 리스트 형태로 렌더링.
- 항목 클릭 시 handleSelectItem 호출 → 선택된 장소(selectedResult)가 변경됨.
- 선택된 항목(isSelected)일 경우 해당 위치의 MapComponent(카카오 지도) 표시.
- 장소 정보에는 **우편번호, 장소명, 도로명 주소 및 버튼(“지도”)**이 포함됨.
- 마지막 항목이 아닐 경우 S.Divider를 추가하여 항목 구분선 렌더링.
이 방식으로 검색 결과를 직관적으로 탐색하고, 지도에서 위치를 확인할 수 있도록 설계됨.
import Input from "../../../components/Input";
import { SearchInputProps } from "../types/types";
export default function SearchInput({
query,
setQuery,
isError,
handleSearch,
isButtonDisabled,
}: SearchInputProps) {
return (
<Input
variant="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
onSearch={handleSearch}
isSearchButtonDisabled={isButtonDisabled}
isError={isError}
placeholder="도로명 또는 지번을 입력하세요"
/>
);
}
이 코드는 검색 입력(Search Input) 컴포넌트로, 사용자가 검색어를 입력하고 검색 버튼을 클릭할 수 있도록 한다.
- query(입력값)를 setQuery를 통해 업데이트하여 상태를 관리.
- handleSearch 함수가 검색 버튼 클릭 시 실행됨.
- isError가 true이면 에러 스타일 적용.
- isButtonDisabled가 true이면 검색 버튼 비활성화.
- placeholder로 "도로명 또는 지번을 입력하세요"를 표시하여 사용자 가이드 제공. -> 이 방식으로 검색 UX를 단순화하고, 입력 및 버튼 상태를 컨트롤하여 직관적인 검색 기능을 구현.
- useEffect, useQuery, useMutation을 활용하여, 리팩토링을 해야겠다고 생각했다.
- 일단, 작업하는 시간이 오래 걸렸을 뿐만 아니라 복잡한 커스텀훅으로 인해서 나조차도 이해 못하는 결과가 초래됐다.
- 추후, 변경 예정이다.