카테고리 없음

카카오 API 키워드 주소 검색하기&우편번호 찾기 페이지 만들기

minnote29 2025. 2. 2. 22:41

  • 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을 활용하여, 리팩토링을 해야겠다고 생각했다. 
  • 일단, 작업하는 시간이 오래 걸렸을 뿐만 아니라 복잡한 커스텀훅으로 인해서 나조차도 이해 못하는 결과가 초래됐다.
  • 추후, 변경 예정이다.