Repository navigation
feat: Kakao 키워드 장소 검색 구현 #33
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5086f9c
c560b7a
63e49d4
588a0e6
3daa0ad
714f9f0
d5a42d4
53a5768
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,169 @@ | ||
| import { afterEach, describe, expect, it, jest } from "bun:test"; | ||
| import type { ConfigService } from "@nestjs/config"; | ||
| import { AppException } from "../../../common/exceptions/app.exception"; | ||
| import type { Env } from "../../../config/env.schema"; | ||
| import type { GeoQuery } from "../geocoder.type"; | ||
| import { KakaoProvider } from "./kakao.provider"; | ||
|
|
||
| const originalFetch = globalThis.fetch; | ||
| const query: GeoQuery = { | ||
| areaName: "서울 강남구", | ||
| areaType: "landmark", | ||
| placeName: "카카오프렌즈", | ||
| }; | ||
|
|
||
| function createProvider() { | ||
| const configService = { | ||
| getOrThrow: jest.fn(() => "test-api-key"), | ||
| } as unknown as ConfigService<Env>; | ||
|
|
||
| return new KakaoProvider(configService); | ||
| } | ||
|
|
||
| function createKakaoResponse(overrides: Record<string, unknown> = {}) { | ||
| return { | ||
| meta: { | ||
| same_name: { | ||
| region: [], | ||
| keyword: "카카오프렌즈", | ||
| selected_region: "", | ||
| }, | ||
| pageable_count: 1, | ||
| total_count: 1, | ||
| is_end: true, | ||
| }, | ||
| documents: [ | ||
| { | ||
| id: "26338954", | ||
| place_name: "카카오프렌즈 코엑스점", | ||
| category_name: "가정,생활 > 문구,사무용품 > 디자인문구 > 카카오프렌즈", | ||
| category_group_code: "", | ||
| category_group_name: "", | ||
| phone: "02-6002-1880", | ||
| address_name: "서울 강남구 삼성동 159", | ||
| road_address_name: "서울 강남구 영동대로 513", | ||
| x: "127.05902969025047", | ||
| y: "37.51207412593136", | ||
| place_url: "http://place.map.kakao.com/26338954", | ||
| distance: "418", | ||
| ...overrides, | ||
| }, | ||
| ], | ||
| }; | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| function mockFetchJson(body: unknown, init: ResponseInit = {}) { | ||
| const fetchMock = jest.fn().mockResolvedValue( | ||
| new Response(JSON.stringify(body), { | ||
| status: 200, | ||
| headers: { "content-type": "application/json" }, | ||
| ...init, | ||
| }), | ||
| ); | ||
| globalThis.fetch = fetchMock as unknown as typeof fetch; | ||
| return fetchMock; | ||
| } | ||
|
|
||
| async function expectAppException( | ||
| promise: Promise<unknown>, | ||
| errorCode: string, | ||
| ) { | ||
| const error = await promise.then( | ||
| () => undefined, | ||
| (error: unknown) => error, | ||
| ); | ||
|
|
||
| expect(error).toBeInstanceOf(AppException); | ||
| expect(error).toMatchObject({ errorCode }); | ||
| } | ||
|
|
||
| describe("KakaoProvider", () => { | ||
| afterEach(() => { | ||
| globalThis.fetch = originalFetch; | ||
| }); | ||
|
|
||
| it("keyword search API를 올바른 query와 인증 헤더로 호출한다", async () => { | ||
| const fetchMock = mockFetchJson(createKakaoResponse()); | ||
| const provider = createProvider(); | ||
|
|
||
| await provider.search(query); | ||
|
|
||
| const [url, init] = fetchMock.mock.calls[0] as [ | ||
| string, | ||
| RequestInit | undefined, | ||
| ]; | ||
| const requestUrl = new URL(url); | ||
| expect(requestUrl.origin).toBe("https://dapi.kakao.com"); | ||
| expect(requestUrl.pathname).toBe("/v2/local/search/keyword.json"); | ||
| expect(requestUrl.searchParams.get("query")).toBe( | ||
| "서울 강남구 카카오프렌즈", | ||
| ); | ||
| expect(requestUrl.searchParams.get("size")).toBe("15"); | ||
| expect(init?.headers).toEqual({ | ||
| Authorization: "KakaoAK test-api-key", | ||
| }); | ||
| }); | ||
|
|
||
| it("Kakao 응답을 GeoCandidate로 정규화한다", async () => { | ||
| mockFetchJson(createKakaoResponse()); | ||
| const provider = createProvider(); | ||
|
|
||
| const result = await provider.search(query); | ||
|
|
||
| expect(result).toEqual([ | ||
| { | ||
| provider: "kakao", | ||
| providerPlaceId: "26338954", | ||
| placeName: "카카오프렌즈 코엑스점", | ||
| address: "서울 강남구 삼성동 159", | ||
| coordinate: { | ||
| lat: 37.51207412593136, | ||
| lng: 127.05902969025047, | ||
| }, | ||
| distance: 418, | ||
| mapUrl: "http://place.map.kakao.com/26338954", | ||
| phone: "02-6002-1880", | ||
| category: "가정,생활 > 문구,사무용품 > 디자인문구 > 카카오프렌즈", | ||
| }, | ||
| ]); | ||
| }); | ||
|
|
||
| it("distance가 없거나 빈 문자열이면 distance를 생략한다", async () => { | ||
| mockFetchJson(createKakaoResponse({ distance: "" })); | ||
| const provider = createProvider(); | ||
|
|
||
| const [candidate] = await provider.search(query); | ||
|
|
||
| expect(candidate.distance).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("fetch가 실패하면 KAKAO_REQUEST_FAILED를 던진다", async () => { | ||
| globalThis.fetch = jest | ||
| .fn() | ||
| .mockRejectedValue(new Error("network error")) as unknown as typeof fetch; | ||
| const provider = createProvider(); | ||
|
|
||
| await expectAppException(provider.search(query), "KAKAO_REQUEST_FAILED"); | ||
| }); | ||
|
|
||
| it("429 응답이면 KAKAO_RATE_LIMITED를 던진다", async () => { | ||
| mockFetchJson({ error: "rate limited" }, { status: 429 }); | ||
| const provider = createProvider(); | ||
|
|
||
| await expectAppException(provider.search(query), "KAKAO_RATE_LIMITED"); | ||
| }); | ||
|
|
||
| it("2xx 응답이 아니면 KAKAO_REQUEST_FAILED를 던진다", async () => { | ||
| mockFetchJson({ error: "bad gateway" }, { status: 502 }); | ||
| const provider = createProvider(); | ||
|
|
||
| await expectAppException(provider.search(query), "KAKAO_REQUEST_FAILED"); | ||
| }); | ||
|
|
||
| it("응답 형식이 다르면 KAKAO_RESPONSE_INVALID를 던진다", async () => { | ||
| mockFetchJson(createKakaoResponse({ x: "not-a-number" })); | ||
| const provider = createProvider(); | ||
|
|
||
| await expectAppException(provider.search(query), "KAKAO_RESPONSE_INVALID"); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,19 +1,144 @@ | ||
| import { Injectable } from "@nestjs/common"; | ||
| import { HttpStatus, Injectable } from "@nestjs/common"; | ||
| import { ConfigService } from "@nestjs/config"; | ||
| import * as v from "valibot"; | ||
| import { AppException } from "../../../common/exceptions/app.exception"; | ||
| import type { Env } from "../../../config/env.schema"; | ||
| import type { | ||
| GeoCandidate, | ||
| GeocoderProvider, | ||
| GeoQuery, | ||
| } from "../geocoder.type"; | ||
| import { | ||
| type KakaoKeywordDocument, | ||
| kakaoKeywordSearchResponseSchema, | ||
| } from "./kakao.type"; | ||
|
|
||
| const KAKAO_KEYWORD_SEARCH_URL = | ||
| "https://dapi.kakao.com/v2/local/search/keyword.json"; | ||
| const KAKAO_SEARCH_SIZE = "15"; | ||
| const KAKAO_REQUEST_TIMEOUT_MS = 5000; | ||
|
|
||
| @Injectable() | ||
| export class KakaoProvider implements GeocoderProvider { | ||
| readonly name = "kakao" as const; | ||
|
|
||
| constructor(private readonly configService: ConfigService<Env>) {} | ||
|
|
||
| async search(_query: GeoQuery): Promise<GeoCandidate[]> { | ||
| throw new Error("Not implemented"); | ||
| async search(query: GeoQuery): Promise<GeoCandidate[]> { | ||
| const apiKey = this.configService.getOrThrow("KAKAO_REST_API_KEY", { | ||
| infer: true, | ||
| }); | ||
| const url = this.createKeywordSearchUrl(query); | ||
| let response: Response; | ||
|
Comment on lines
+31
to
+32
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💬
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
제 생각은..! 예전에는 저도 싫어서 많이 지양하려고 했던 것 같은데요
|
||
|
|
||
| try { | ||
| response = await fetch(url, { | ||
| headers: { Authorization: `KakaoAK ${apiKey}` }, | ||
| signal: AbortSignal.timeout(KAKAO_REQUEST_TIMEOUT_MS), | ||
| }); | ||
| } catch { | ||
| throw new AppException( | ||
| "KAKAO_REQUEST_FAILED", | ||
| "카카오 장소 검색 요청에 실패했습니다.", | ||
| HttpStatus.BAD_GATEWAY, | ||
| ); | ||
| } | ||
|
|
||
| if (response.status === HttpStatus.TOO_MANY_REQUESTS) { | ||
| throw new AppException( | ||
| "KAKAO_RATE_LIMITED", | ||
| "카카오 장소 검색 요청 한도를 초과했습니다.", | ||
| HttpStatus.TOO_MANY_REQUESTS, | ||
| ); | ||
| } | ||
|
Comment on lines
+47
to
+53
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💬
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
찾아보니 응답에도 없고 사용량 조회 API도 없네요!
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 제안주신것들 좋습니다! 요거는 회의때 한번 논의해보면 좋을것 같은데요 그 후에 작업 분리해서 가시죠ㅎㅎ |
||
|
|
||
| if (!response.ok) { | ||
| throw new AppException( | ||
| "KAKAO_REQUEST_FAILED", | ||
| "카카오 장소 검색 요청에 실패했습니다.", | ||
| HttpStatus.BAD_GATEWAY, | ||
| ); | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| const body = await this.parseJson(response); | ||
| const parsed = v.safeParse(kakaoKeywordSearchResponseSchema, body); | ||
|
|
||
| if (!parsed.success) { | ||
| throw this.createInvalidResponseException(); | ||
| } | ||
|
|
||
| return parsed.output.documents.map((document) => | ||
| this.toGeoCandidate(document), | ||
| ); | ||
| } | ||
|
|
||
| private createKeywordSearchUrl(query: GeoQuery): string { | ||
| const url = new URL(KAKAO_KEYWORD_SEARCH_URL); | ||
| url.searchParams.set("query", this.createKeyword(query)); | ||
| url.searchParams.set("size", KAKAO_SEARCH_SIZE); | ||
|
|
||
| return url.toString(); | ||
| } | ||
|
|
||
| private createKeyword(query: GeoQuery): string { | ||
| return [query.areaName, query.placeName] | ||
| .map((value) => value.trim()) | ||
| .filter(Boolean) | ||
| .join(" "); | ||
| } | ||
|
|
||
| private async parseJson(response: Response): Promise<unknown> { | ||
| try { | ||
| return await response.json(); | ||
| } catch { | ||
| throw this.createInvalidResponseException(); | ||
| } | ||
| } | ||
|
|
||
| private toGeoCandidate(document: KakaoKeywordDocument): GeoCandidate { | ||
| return { | ||
| provider: this.name, | ||
| providerPlaceId: document.id, | ||
| placeName: document.place_name, | ||
| address: document.address_name, | ||
| coordinate: { | ||
| lat: this.parseCoordinate(document.y), | ||
| lng: this.parseCoordinate(document.x), | ||
| }, | ||
| distance: this.parseDistance(document.distance), | ||
| mapUrl: document.place_url, | ||
| phone: document.phone || undefined, | ||
| category: document.category_name || undefined, | ||
| }; | ||
| } | ||
|
|
||
| private parseCoordinate(value: string): number { | ||
| const coordinate = Number(value); | ||
|
|
||
| if (!Number.isFinite(coordinate)) { | ||
| throw this.createInvalidResponseException(); | ||
| } | ||
|
|
||
| return coordinate; | ||
| } | ||
|
|
||
| private parseDistance(value: string | undefined): number | undefined { | ||
| if (!value) return undefined; | ||
|
|
||
| const distance = Number(value); | ||
|
|
||
| if (!Number.isFinite(distance)) { | ||
| throw this.createInvalidResponseException(); | ||
| } | ||
|
|
||
| return distance; | ||
| } | ||
|
|
||
| private createInvalidResponseException(): AppException { | ||
| return new AppException( | ||
| "KAKAO_RESPONSE_INVALID", | ||
| "카카오 장소 검색 응답 형식이 올바르지 않습니다.", | ||
| HttpStatus.BAD_GATEWAY, | ||
| ); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value
과거 리뷰 요청사항 정확히 반영됨.
Provider별 실패 사유를 warn 레벨로 로깅하는 로직이 이전 리뷰 코멘트에서 요청한 대로 정확히 구현되었습니다.
다만
result.reason이Error인스턴스인 경우 템플릿 문자열 삽입 시toString()결과(예:Error: message)만 남고 stack trace는 사라집니다. 운영 중 디버깅 편의를 위해Error인스턴스 여부를 확인해 stack을 함께 로깅하는 것을 고려해보세요.♻️ 제안
settled.forEach((result, index) => { if (result.status === "rejected") { + const reason = + result.reason instanceof Error + ? result.reason.stack + : result.reason; this.logger.warn( - `Geocoder provider "${this.providers[index].name}" failed: ${result.reason}`, + `Geocoder provider "${this.providers[index].name}" failed: ${reason}`, ); } });📝 Committable suggestion
🤖 Prompt for AI Agents