GeoLocation API랑 카카오맵 API를 활용해서
기본 지역 정보 추출 기능 (ㅇㅇ시 ㅇㅇ동)까지 나오게끔 구현을 완료한 림졍.
벗뜨… 카카오맵 API를 이거 하나로만 끝내긴 아쉬워
단순 주소 출력 외에도 동 검색 기능을 추가하여
보다 세밀한 주소 검색 기능을 제공하도록 확장시키고자 하는데...
CRUD - Kakaomap API를 활용한 주소 검색기능 추가구현
자, 작업을 시작해볼까..
1. 프로젝트 작업, 이어서 진행
GitHub - reizvoll/testtt
Contribute to reizvoll/testtt development by creating an account on GitHub.
github.com
스케쥴링 테스트를 진행했던 GitHub 레포지토리에 이어서 테스트를 진행
2. 작업 진행
해당 로직의 가능여부 판단 및 기술적 의사결정을 위해
기존 실시간 주소 기능을 반영했던 코드에 해당 기능을 담은 모달을 제작하여 작업을 진행하였다.
디자인은 와이어프레임에 나온 구조와 최대한 비슷하게 가도록 제작하였다.
3. 로직 설명
카카오 개발자 공식 페이지를 참고하여 REST API를 활용하여 주소를 가져오도록 제작된 로직이다.
간단하게 코드 로직에 대해 설명하자면 아래와 같다.
- 너무 길어서 넣어버린 코드로직
import React, { useState } from 'react';
type AddressModalProps = {
isOpen: boolean;
onClose: () => void;
onSelectAddress: (selectedAddress: string) => void;
};
const AddressModal: React.FC<AddressModalProps> = ({ isOpen, onClose, onSelectAddress }) => {
const [searchTerm, setSearchTerm] = useState('');
const [searchResults, setSearchResults] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const handleSearch = async () => {
if (!searchTerm.trim()) return;
const url = `https://dapi.kakao.com/v2/local/search/address.json?query=${searchTerm}`;
const apiKey = process.env.NEXT_PUBLIC_KAKAO_MAP_API_KEY;
try {
setLoading(true);
const response = await fetch(url, {
headers: {
Authorization: `KakaoAK ${apiKey}`,
},
});
const data = await response.json();
if (data.documents.length > 0) {
const results = data.documents.map((doc: any) => {
const { address_name } = doc;
return address_name;
});
setSearchResults(results);
} else {
setSearchResults(['검색 결과가 없습니다.']);
}
} catch (error) {
console.error('주소 검색 실패:', error);
setSearchResults(['검색 중 오류가 발생했습니다.']);
} finally {
setLoading(false);
}
};
return (
isOpen && (
<div className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 z-50">
<div className="bg-white p-6 rounded-lg shadow-lg max-w-md w-full">
<h2 className="text-xl font-bold mb-4">주소 검색</h2>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="주소 입력 (예: 동대문구, 휘경동)"
className="w-full p-3 border rounded-md focus:ring-2 focus:ring-blue-400"
/>
<button
onClick={handleSearch}
className="w-full p-3 mt-2 bg-blue-500 hover:bg-blue-600 text-white rounded-md"
disabled={loading}
>
{loading ? '검색 중...' : '검색'}
</button>
<ul className="mt-4">
{searchResults.map((result, index) => (
<li
key={index}
className="p-2 border-b cursor-pointer hover:bg-gray-100"
onClick={() => {
if (result !== '검색 결과가 없습니다.' && result !== '검색 중 오류가 발생했습니다.') {
onSelectAddress(result);
onClose();
}
}}
>
{result}
</li>
))}
</ul>
<button
onClick={onClose}
className="w-full p-3 mt-4 bg-gray-500 hover:bg-gray-600 text-white rounded-md"
>
닫기
</button>
</div>
</div>
)
);
};
export default AddressModal;
1. API 엔드포인트 구성
입력받은 검색어(searchTerm)를 활용하여 템플릿 리터럴로
https://dapi.kakao.com/v2/local/search/address.json?query=${searchTerm} URL 을 동적으로 생성한다.
환경변수에 저장된 카카오 API 키를 사용하여 인증 헤더(Authorization: KakaoAK ${apiKey}) 설정을 통해 엔드포인트를 구성한다.
2. 비동기 API 호출 및 응답 처리
fetch 함수를 사용하여 위에서 생성한 URL로 GET 요청을, 응답은 JSON 형식으로 파싱하여
반환된 객체의 documents 배열에 포함된 주소 데이터를 확인한다.
If, 만약 검색 결과가 있다면 각 문서의 address_name을 추출, 없다면 사용자에게 안내 메시지를 표시한다.
3. 로딩 상태 및 오류 처리
API 요청 전에 로딩 상태를 true로 설정하고 요청 후 finally 블록에서 로딩 상태를 false로 되돌린다.
요청 중 오류 발생 시 catch 블록에서 콘솔에 오류를 출력하고 사용자에게 오류 안내 메시지를 제공한다.
4. 결과 활용 및 UI 반영
추출된 주소 데이터 or 안내 메시지, 컴포넌트 상태 searchResults에 저장한다.
검색 결과 목록 렌더링 이후 항목 클릭 시 주소 선택 함수 onSelectAddress를 통해 상위 컴포넌트로 전달하여,
모달을 닫는 onClose 동작과 연동시켜 바로 결과 창에 결과를 볼 수 있게끔 제작하였다.
4. 결과 이미지
- 주소 검색 클릭 시, 검색 모달이 뜨게 되는데, 검색창에 찾고자 하는 주소의 '동'을 입력한다. (예: 서초동)
((왜 동으로만 검색하냐... 이건 다음 과정에서 알아보도록 하자.))
- 검색 결과 확인 후, 원하는 주소 항목을 누르면 바로 결과값을 저장하며 모달은 닫히게 된다.
5. 갑자기 문득 떠올랐던(?) 추가구현의 욕심…
해당 기능을 활용하면 ‘구’까지 검색하면 ㅇㅇ구까지, ‘동’까지 검색하면 ㅇㅇ동까지 나온다.
근데… 다들 알다시피 ㅇㅇ구를 입력하면 해당 구의 모든 동이 나오지 않는가!!?
그래서 도전해봤다.‘구’ 검색 시 동 정보 누락 문제 해결해보기.
그렇게 코드를 대대적으로 업데이트하는 공사(?) 작업을 진행하게 되었는데…
- 코드가 너무 길다...
import React, { useState } from 'react';
type AddressModalProps = {
isOpen: boolean;
onClose: () => void;
onSelectAddress: (selectedAddress: string) => void;
};
const AddressModal: React.FC<AddressModalProps> = ({ isOpen, onClose, onSelectAddress }) => {
const [searchTerm, setSearchTerm] = useState('');
const [searchResults, setSearchResults] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1); // 현재 페이지
const resultsPerPage = 10; // 한 페이지에 표시할 결과 수
const handleSearch = async () => {
if (!searchTerm.trim()) return;
const url = `https://dapi.kakao.com/v2/local/search/keyword.json?query=${searchTerm} 동`;
const apiKey = process.env.NEXT_PUBLIC_KAKAO_MAP_API_KEY;
try {
setLoading(true);
const response = await fetch(url, {
headers: {
Authorization: `KakaoAK ${apiKey}`,
},
});
const data = await response.json();
console.log('API 응답 데이터:', data); // 디버깅용 출력
if (data.documents.length > 0) {
// "ㅇㅇ동" 정보만 필터링 및 중복 제거
const filteredResults = data.documents
.filter((doc: any) => doc.address_name.includes('동')) // 동 포함 여부 확인
.map((doc: any) => {
const addressParts = doc.address_name.split(' ');
return addressParts.slice(0, 3).join(' '); // 시, 구, 동까지만 포함
});
const uniqueResults = Array.from(new Set(filteredResults)) as string[]; // 중복 제거
setSearchResults(uniqueResults);
setCurrentPage(1); // 검색 시 페이지 초기화
} else {
setSearchResults([]); // 검색 결과 없을 때 빈 배열 설정
}
} catch (error) {
console.error('주소 검색 실패:', error);
setSearchResults([]);
} finally {
setLoading(false);
}
};
// 페이지네이션: 현재 페이지의 결과 가져오기
const paginatedResults = searchResults.slice(
(currentPage - 1) * resultsPerPage,
currentPage * resultsPerPage
);
return (
isOpen && (
<div className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 z-50">
<div className="bg-white p-6 rounded-lg shadow-lg max-w-md w-full">
<h2 className="text-xl font-bold mb-4">주소 검색</h2>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="주소 입력 (예: 동대문구, 휘경동)"
className="w-full p-3 border rounded-md focus:ring-2 focus:ring-blue-400"
/>
<button
onClick={handleSearch}
className="w-full p-3 mt-2 bg-blue-500 hover:bg-blue-600 text-white rounded-md"
disabled={loading}
>
{loading ? '검색 중...' : '검색'}
</button>
{searchResults.length > 0 ? (
<>
<ul className="mt-4">
{paginatedResults.map((result, index) => (
<li
key={index}
className="p-2 border-b cursor-pointer hover:bg-gray-100"
onClick={() => {
onSelectAddress(result);
onClose();
}}
>
{result}
</li>
))}
</ul>
<div className="flex justify-between items-center mt-4">
<button
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
disabled={currentPage === 1}
className={`px-4 py-2 rounded-md ${
currentPage === 1 ? 'bg-gray-300' : 'bg-blue-500 hover:bg-blue-600 text-white'
}`}
>
이전
</button>
<span>
{currentPage} / {Math.ceil(searchResults.length / resultsPerPage)}
</span>
<button
onClick={() =>
setCurrentPage((prev) =>
Math.min(prev + 1, Math.ceil(searchResults.length / resultsPerPage))
)
}
disabled={currentPage === Math.ceil(searchResults.length / resultsPerPage)}
className={`px-4 py-2 rounded-md ${
currentPage === Math.ceil(searchResults.length / resultsPerPage)
? 'bg-gray-300'
: 'bg-blue-500 hover:bg-blue-600 text-white'
}`}
>
다음
</button>
</div>
</>
) : (
<p className="mt-4 text-center text-gray-500">검색 결과가 없습니다.</p>
)}
<button
onClick={onClose}
className="w-full p-3 mt-4 bg-gray-500 hover:bg-gray-600 text-white rounded-md"
>
닫기
</button>
</div>
</div>
)
);
};
export default AddressModal;
[업데이트된 코드 내용 정리]
1. 검색어 구체화
기존 ‘구’만 입력하면 결과에 동 정보가 누락이 되는 상황을 방지하고자 보다 구체적인 동 정보를 포함하도록 API 엔드포인트를 keyword 방식으로 호출하게 변경한 다음, 검색어에 “동” 키워드를 추가하여 검색 되게끔 설정했다.
const url = `https://dapi.kakao.com/v2/local/search/keyword.json?query=${searchTerm} 동`;
2. 후처리 및 중복 제거
API 응답에서 반환된 address_name 값 중 ‘동’이 포함된 데이터만 필터링하고,
주소를 시, 구, 동까지만 분리하여 결과를 출력하도록 후처리 로직을 추가했다.
또한 Set 객체를 활용하여 중복되는 결과를 제거하여 동 목록을 생성하도록 제작했다.
3. 페이지네이션 기능 도입
검색 결과가 많을 경우 사용자 인터페이스가 복잡해지는 문제를 해결하기 위해,
한 페이지에 일정 개수의 결과만 표시하고 추가적인 데이터는 뒷 페이지로 이동하도록 페이지네이션을 추가해주었다.
6. 과연 결과는…????
검색 결과가 잘 나오는 것 처럼 볼 수 있겠지만....
그렇다. 노올랍게도 동대문구에 있는 모든 동의 정보가 나오지 않는 것을 볼 수 있다.
이유가 뭔지 알아보도록 하자...
7. 원인 분석 - 키워드 검색 API 의 한계점
이유는 주소 검색 API에서 변경한 키워드 검색 API 때문이었는데…
키워드 검색 API 의 경우 주소에서 가져오는 것이 아닌
정말 키워드를 기반으로 주소를 가져오는 것이기 때문에 문제가 발생한 것이었다.
( 이것을 어떻게 알았냐.. 콘솔을 찍었죠. )
예를 들어 ‘동대문구’ 를 입력하게 되면, 해당 구의 주소 정보를 활용하여 결과를 일부만 가져오게 된다.
8. 뭐? 공공데이터 API는 그게 가능하다고?
그렇다. 정부에서 제공하는 공공데이터 API를 활용하면
구 단위 검색 시에도 해당 구에 속하는 모든 동(법정동)의 결과가 나오게끔 구상할 수 있다!
API 신청하기
API신청하기
business.juso.go.kr
매우 좋은 방안이라고 생각하지만, 아쉽게도 이번 프로젝트에서는 해당 API 사용 대신
카카오맵 API를 활용한 ‘동’ 검색만 가능하도록 할 예정이다.
사유는 아래와 같다.
1. 통합 및 관리 이슈
이미 카카오 소셜로그인과 카카오맵 API를 사용 중인 상황에서 추가로 공공데이터 API를 도입하면 복잡성이 증가하게 된다.
또한 두 API의 응답 형식 및 인증 방식 등이 상이할 수 있으므로, 데이터 가공 및 통합 처리에 추가적인 시간과 노력이 필요하게 될 것이다.
2. 서비스 일관성 및 사용자 경험
카카오맵 API만 사용했을 때와 공공데이터 API를 추가로 도입했을 때의 데이터 일관성이 달라질 수 있다.
또한 우리의 도전 기능으로 들어가 있는 지도가 생길 수도 있는 점을 생각하며 전반적인 UX적인 면을 고려할 때,
두 API를 혼용하는 것이 오히려 혼란을 야기할 수 있어 활용하지 않는 것이 좋다고 판단했다.
3. 까다로운(?) 관련 법령 및 규정을 준수하기 위한 복잡한 행정 절차
법적인 절치를 하나하나 다 따져가며 작업을 진행하는 것도 좋지만, 아무래도 직접 회사에서 제작되는 것이 아니기도 하고..
모든 법적 요구사항을 하나하나 검토하고 이행하는 과정에서 상당한 시간이 발생하게 된다.
기회비용을 따졌을 때, 이는 다른 기능 개발시간 확보 불가로 이어지게 되어
전체 개발 일정과 효율성에 부정적인 영향이 갈 것을 고려하여 사용하지 않게 되었다.
9. 결론 및 마무리
- 최종적으로 사용된 주소검색 코드
'use client';
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import { createClient } from '@supabase/supabase-js';
import AddressModal from './_components/AddressModal';
// Supabase 클라이언트 생성
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
const WritePage = () => {
const router = useRouter();
const userId = process.env.NEXT_PUBLIC_AUTH_2;
const [address, setAddress] = useState('');
const [position, setPosition] = useState({ lat: '', lng: '' });
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [loading, setLoading] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
// geolocation api를 활용한 사용자의 위치 가져오는 로직 (위도/경도)
const handleLocationCheck = () => {
if (navigator.geolocation) {
setLoading(true);
navigator.geolocation.getCurrentPosition(
(pos) => {
const { latitude, longitude } = pos.coords;
setPosition({ lat: latitude.toString(), lng: longitude.toString() });
getAddress(latitude, longitude);
},
(err) => {
console.error('위치 권한이 거부되었습니다.', err);
setLoading(false);
}
);
} else {
console.error('Geolocation을 지원하지 않는 브라우저입니다.');
}
};
// 가져온 위도 경도를 맵 api를 활용해서 지역으로 변환시키는 과정
const getAddress = async (lat: number, lng: number) => {
const url = `https://dapi.kakao.com/v2/local/geo/coord2address.json?x=${lng}&y=${lat}`;
const apiKey = process.env.NEXT_PUBLIC_KAKAO_MAP_API_KEY;
try {
const response = await fetch(url, {
headers: {
Authorization: `KakaoAK ${apiKey}`,
},
});
const data = await response.json();
if (data.documents.length > 0) {
const { region_1depth_name, region_2depth_name, region_3depth_name } =
data.documents[0].address;
setAddress(`${region_1depth_name} ${region_2depth_name} ${region_3depth_name}`);
} else {
setAddress('주소 정보 없음');
}
} catch (error) {
console.error('주소 조회 실패:', error);
} finally {
setLoading(false);
}
};
// 해당 주소를 supabase posts 테이블에 업로드시키는 로직
const handleSubmit = async () => {
if (!title || !content || !address) {
alert('모든 항목을 입력해주세요.');
return;
}
if (!userId) {
alert('로그인이 필요합니다.');
console.error('USER_ID is missing in environment variables.');
return;
}
const { error } = await supabase.from('posts').insert([
{
title,
content,
upload_place: address,
latitude: position.lat,
longitude: position.lng,
created_at: new Date().toISOString(),
user_id: userId,
},
]);
if (error) {
console.error('Supabase 저장 실패:', error);
alert('저장 실패');
} else {
alert('저장 성공!');
router.push('/posts');
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-white text-black">
<div className="w-full max-w-2xl p-8 border border-gray-300 shadow-lg rounded-md">
<h1 className="text-3xl font-bold mb-6">게시글 작성</h1>
<label className="block mb-4">
제목
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="제목을 입력하세요"
className="mt-2 p-3 w-full border rounded-md focus:ring-2 focus:ring-blue-400"
/>
</label>
<label className="block mb-4">
내용
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="내용을 입력하세요"
className="mt-2 p-3 w-full border rounded-md focus:ring-2 focus:ring-blue-400"
rows={5}
/>
</label>
<button
onClick={handleLocationCheck}
disabled={loading}
className={`w-full p-3 mt-4 text-white rounded-md ${
loading ? 'bg-gray-400' : 'bg-blue-500 hover:bg-blue-600'
}`}
>
{loading ? '주소 확인 중...' : '현재 위치 주소 확인'}
</button>
<p className="mt-4 text-center text-lg">
현재 위치: {address || '주소 정보 없음'}
</p>
<button
onClick={() => setIsModalOpen(true)}
className="w-full p-3 mt-4 bg-blue-500 hover:bg-blue-600 text-white rounded-md"
>
주소 검색
</button>
<button
onClick={handleSubmit}
className="w-full p-3 mt-6 bg-green-500 hover:bg-green-600 text-white rounded-md"
>
게시글 업로드
</button>
</div>
<AddressModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSelectAddress={(selectedAddress: string) => setAddress(selectedAddress)}
/>
</div>
);
};
export default WritePage;
이번 기능구현을 통해 기존 카카오맵 API를 활용한 주소 검색 기능에서 ‘구’ 단위 검색 시
하위의 모든 동 정보를 완벽하게 제공하지 못하는 한계를 확인하는 다소 씁쓸한 시간을 보내게 되었다..
이를 보완하기 위해 키워드 검색 API 도입, 검색어에 “동” 키워드 추가, 후처리 및 중복 제거 로직,페이지네이션 기능 등을 적용했지만..
여전히 ‘구’ 검색 시 원하는 모든 동 정보를 포괄하지 못하는 결과를 얻었다.
결국 원하는 기능을 완벽하게 구현하지 못하게 되었지만, 이 과정에서 API 간의 응답 형식 차이와 데이터 통합의 복잡성,
그리고 관련 법령 준수를 위한 복잡한 행정 절차 등이 서비스 개발에 미치는 영향을 직접 체감하게 되었다.
통합 및 관리의 복잡성, 서비스 일관성 및 사용자 경험, 그리고 관련 법령 준수를 위한 복잡한 행정 절차 등의 요인을 고려하여,
이번 프로젝트에서는 공공데이터 API를 별도로 도입하지 않고 카카오맵 API를 활용한 ‘동’ 검색 기능만 구현하기로 결정하였다.
이러한 전략적 판단은 전체 개발 일정과 효율성을 최우선으로 고려한 결과로서,
향후 추가 기능 개발 시 해당 부분에 대한 재검토가 필요하다는 결론으로 마무리.
[추가 자료]
Kakao Developers
카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.
developers.kakao.com
마무리 - 아무도 림졍을 막을 수 없으셈!
일정이 있어 오늘 기차 안에서 1차 코드리뷰를 진행했었는데, 생각보다 많은 피드백이 있었다...
내 파트의 경우, 기술적 의사결정을 한답시고 별도의 테스트 환경에서 진행해서
빠르게 적용시켜서 다음 코드 리뷰때 받는 것으로 결정. (언능 후다닥 해야겠지?)
내일도 모여서 작업한다고했으니, 오늘도 일찍 호도독 가봐야겠다!
그럼 굳바4.
오늘의 KPT 회고
Keep: 내려가는 무궁화호에서도 노트북키면서 열심히 작업하면서 리뷰듣던 림졍 칭차내!
Problem: 덜컹덜컹 너무 멀미나요..
Try: 안정된 환경에서 작업하기
'React TIL' 카테고리의 다른 글
[React] Day_81 최종 프로젝트 기능 구현 내용정리 (0) | 2025.01.14 |
---|---|
[React] Day_80 최종 프로젝트 기능 구현 내용정리 (1) | 2025.01.13 |
[React] Day_78 최종 프로젝트 관련 내용정리 (1) | 2025.01.09 |
[React] Day_77 최종 프로젝트 관련 내용정리 (0) | 2025.01.08 |
[React] Day_76 최종 프로젝트 관련 내용정리 (0) | 2025.01.07 |