Skip to main content
Glama
kakao-api.ts6.38 kB
// 카카오맵 로컬 API 클라이언트 // API 문서: https://developers.kakao.com/docs/latest/ko/local/dev-guide import { fetchWithTimeout } from "../utils/fetch-with-timeout.js"; import type { KakaoPlaceResponse, KakaoPlace, KakaoAddressResponse, Coordinates, Competitor, } from "../types.js"; const KAKAO_API_KEY = process.env.KAKAO_API_KEY || ""; const KAKAO_API_BASE = "https://dapi.kakao.com/v2/local"; // 검색어 정규화 function normalizeQuery(input: string): string { const normalized = input.trim().replace(/\s+/g, " "); if (!normalized) throw new Error("검색어를 입력해주세요."); if (normalized.length < 2) throw new Error("검색어는 2글자 이상 입력해주세요."); if (normalized.length > 100) throw new Error("검색어가 너무 깁니다. (최대 100자)"); return normalized; } class KakaoLocalApi { private apiKey: string; constructor() { this.apiKey = KAKAO_API_KEY; } private checkApiKey(): void { if (!this.apiKey) { throw new Error("KAKAO_API_KEY가 설정되지 않았습니다."); } } // 주소/장소명으로 좌표 검색 async getCoordinates(query: string): Promise<Coordinates | null> { this.checkApiKey(); const normalizedQuery = normalizeQuery(query); // 먼저 주소 검색 시도 const addressUrl = `${KAKAO_API_BASE}/search/address.json?query=${encodeURIComponent(normalizedQuery)}`; try { const response = await fetchWithTimeout(addressUrl, { headers: { Authorization: `KakaoAK ${this.apiKey}` }, }); if (response.ok) { const data = (await response.json()) as KakaoAddressResponse; if (data.documents.length > 0) { const doc = data.documents[0]; return { lat: parseFloat(doc.y), lng: parseFloat(doc.x), }; } } } catch { // 주소 검색 실패 시 키워드 검색으로 폴백 } // 키워드 검색으로 폴백 const keywordUrl = `${KAKAO_API_BASE}/search/keyword.json?query=${encodeURIComponent(normalizedQuery)}&size=1`; const response = await fetchWithTimeout(keywordUrl, { headers: { Authorization: `KakaoAK ${this.apiKey}` }, }); if (!response.ok) { throw new Error(`카카오 API 요청 실패: ${response.status}`); } const data = (await response.json()) as KakaoPlaceResponse; if (data.documents.length === 0) { return null; } const doc = data.documents[0]; return { lat: parseFloat(doc.y), lng: parseFloat(doc.x), }; } // 키워드로 장소 검색 async searchByKeyword( query: string, options?: { x?: string; y?: string; radius?: number; size?: number; sort?: "distance" | "accuracy"; } ): Promise<KakaoPlace[]> { this.checkApiKey(); const normalizedQuery = normalizeQuery(query); const params = new URLSearchParams({ query: normalizedQuery, size: String(options?.size || 15), }); if (options?.x) params.append("x", options.x); if (options?.y) params.append("y", options.y); if (options?.radius) params.append("radius", String(options.radius)); if (options?.sort) params.append("sort", options.sort); const url = `${KAKAO_API_BASE}/search/keyword.json?${params}`; const response = await fetchWithTimeout(url, { headers: { Authorization: `KakaoAK ${this.apiKey}` }, }); if (!response.ok) { throw new Error(`카카오 API 요청 실패: ${response.status}`); } const data = (await response.json()) as KakaoPlaceResponse; return data.documents; } // 카테고리로 장소 검색 async searchByCategory( categoryCode: string, x: string, y: string, options?: { radius?: number; size?: number; } ): Promise<KakaoPlace[]> { this.checkApiKey(); const params = new URLSearchParams({ category_group_code: categoryCode, x, y, radius: String(options?.radius || 500), size: String(options?.size || 15), sort: "distance", }); const url = `${KAKAO_API_BASE}/search/category.json?${params}`; const response = await fetchWithTimeout(url, { headers: { Authorization: `KakaoAK ${this.apiKey}` }, }); if (!response.ok) { throw new Error(`카카오 API 요청 실패: ${response.status}`); } const data = (await response.json()) as KakaoPlaceResponse; return data.documents; } // 특정 업종 경쟁업체 검색 async findCompetitors( businessType: string, location: string, radius: number = 300, limit: number = 15 ): Promise<Competitor[]> { // 먼저 위치 좌표 얻기 const coords = await this.getCoordinates(location); if (!coords) { throw new Error(`위치를 찾을 수 없습니다: ${location}`); } // 업종으로 검색 const places = await this.searchByKeyword(`${location} ${businessType}`, { x: String(coords.lng), y: String(coords.lat), radius, size: limit, sort: "distance", }); return places.map((place) => ({ id: place.id, name: place.place_name, category: place.category_name, address: place.road_address_name || place.address_name, distance: place.distance ? parseInt(place.distance, 10) : 0, phone: place.phone || undefined, placeUrl: place.place_url, })); } // 상권 내 업종별 업체 수 조회 async countByCategories( x: string, y: string, radius: number = 500 ): Promise<Record<string, number>> { const categories = [ { code: "FD6", name: "음식점" }, { code: "CE7", name: "카페" }, { code: "CS2", name: "편의점" }, { code: "MT1", name: "대형마트" }, ]; const counts: Record<string, number> = {}; // 병렬로 조회 const results = await Promise.all( categories.map(async (cat) => { try { const places = await this.searchByCategory(cat.code, x, y, { radius, size: 15 }); return { name: cat.name, count: places.length }; } catch { return { name: cat.name, count: 0 }; } }) ); for (const result of results) { counts[result.name] = result.count; } return counts; } } export const kakaoApi = new KakaoLocalApi();

Implementation Reference

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/re171113-byte/startup-helper-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server