From 5086f9c1d0df4d9e70edd88d83dc8a1e64445478 Mon Sep 17 00:00:00 2001 From: minsour Date: Wed, 24 Jun 2026 22:33:10 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20geocoder=20provider=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=ED=9D=90=EB=A6=84=20=EB=B0=8F=20=ED=83=80=EC=9E=85?= =?UTF-8?q?=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../geocoder/geocoder.service.spec.ts | 103 +++++++++++++++--- .../geocoder/geocoder.service.ts | 23 +++- src/infrastructures/geocoder/geocoder.type.ts | 8 +- src/modules/place/place.service.spec.ts | 29 ++++- src/modules/place/place.service.ts | 3 +- 5 files changed, 145 insertions(+), 21 deletions(-) diff --git a/src/infrastructures/geocoder/geocoder.service.spec.ts b/src/infrastructures/geocoder/geocoder.service.spec.ts index 93675cbc..fd72f288 100644 --- a/src/infrastructures/geocoder/geocoder.service.spec.ts +++ b/src/infrastructures/geocoder/geocoder.service.spec.ts @@ -1,19 +1,43 @@ import "reflect-metadata"; -import { beforeEach, describe, expect, it } from "bun:test"; +import { beforeEach, describe, expect, it, jest } from "bun:test"; import { ConfigModule } from "@nestjs/config"; import { Test } from "@nestjs/testing"; +import { AppException } from "../../common/exceptions/app.exception"; import { GeocoderModule } from "./geocoder.module"; import { GeocoderService } from "./geocoder.service"; -import type { GeoQuery } from "./geocoder.type"; -import { GoogleProvider } from "./providers/google.provider"; +import type { GeoCandidate, GeocoderProvider, GeoQuery } from "./geocoder.type"; import { KakaoProvider } from "./providers/kakao.provider"; -const query: GeoQuery = { areaName: "서울", placeName: "남산타워" }; +const query: GeoQuery = { + areaName: "서울", + areaType: "landmark", + placeName: "남산타워", +}; describe("Geocoder", () => { let service: GeocoderService; let kakao: KakaoProvider; - let google: GoogleProvider; + + function makeCandidate(overrides: Partial = {}): GeoCandidate { + return { + provider: "kakao", + providerPlaceId: "kakao-1", + placeName: "남산서울타워", + address: "서울 용산구 남산공원길 105", + coordinate: { lat: 37.5512, lng: 126.9882 }, + ...overrides, + }; + } + + function makeProvider( + overrides: Partial = {}, + ): GeocoderProvider { + return { + name: "kakao", + search: jest.fn().mockResolvedValue([makeCandidate()]), + ...overrides, + }; + } beforeEach(async () => { const module = await Test.createTestingModule({ @@ -24,24 +48,75 @@ describe("Geocoder", () => { }).compile(); service = module.get(GeocoderService); kakao = module.get(KakaoProvider); - google = module.get(GoogleProvider); }); it("DI 컨테이너에서 GeocoderService를 해석한다", () => { expect(service).toBeInstanceOf(GeocoderService); }); - it("searchAll은 아직 구현되지 않아 에러를 던진다", async () => { - await expect(service.searchAll(query)).rejects.toThrow("Not implemented"); + it("KakaoProvider는 DI 컨테이너에서 해석된다", () => { + expect(kakao.name).toBe("kakao"); }); - it("KakaoProvider는 name이 'kakao'이고 search는 에러를 던진다", async () => { - expect(kakao.name).toBe("kakao"); - await expect(kakao.search(query)).rejects.toThrow("Not implemented"); + it("searchAll은 provider 결과를 병합한다", async () => { + const kakaoProvider = makeProvider({ + name: "kakao", + search: jest + .fn() + .mockResolvedValue([ + makeCandidate({ provider: "kakao", providerPlaceId: "kakao-1" }), + ]), + }); + const googleProvider = makeProvider({ + name: "google", + search: jest.fn().mockResolvedValue([ + makeCandidate({ + provider: "google", + providerPlaceId: "google-1", + placeName: "N Seoul Tower", + }), + ]), + }); + const geocoder = new GeocoderService([kakaoProvider, googleProvider]); + + const result = await geocoder.searchAll(query); + + expect(kakaoProvider.search).toHaveBeenCalledWith(query); + expect(googleProvider.search).toHaveBeenCalledWith(query); + expect(result).toHaveLength(2); + expect(result.map((candidate) => candidate.provider)).toEqual([ + "kakao", + "google", + ]); }); - it("GoogleProvider는 name이 'google'이고 search는 에러를 던진다", async () => { - expect(google.name).toBe("google"); - await expect(google.search(query)).rejects.toThrow("Not implemented"); + it("일부 provider가 실패해도 성공한 결과를 반환한다", async () => { + const successfulProvider = makeProvider({ + search: jest.fn().mockResolvedValue([makeCandidate()]), + }); + const failedProvider = makeProvider({ + name: "google", + search: jest.fn().mockRejectedValue(new Error("provider down")), + }); + const geocoder = new GeocoderService([successfulProvider, failedProvider]); + + const result = await geocoder.searchAll(query); + + expect(result).toEqual([makeCandidate()]); + }); + + it("모든 provider가 실패하면 GEOCODER_ALL_PROVIDERS_FAILED(502)를 던진다", async () => { + const failedProvider = makeProvider({ + search: jest.fn().mockRejectedValue(new Error("provider down")), + }); + const geocoder = new GeocoderService([failedProvider]); + + const error = await geocoder.searchAll(query).then( + () => undefined, + (error: unknown) => error, + ); + + expect(error).toBeInstanceOf(AppException); + expect(error).toMatchObject({ errorCode: "GEOCODER_ALL_PROVIDERS_FAILED" }); }); }); diff --git a/src/infrastructures/geocoder/geocoder.service.ts b/src/infrastructures/geocoder/geocoder.service.ts index 011de6d6..609dedbd 100644 --- a/src/infrastructures/geocoder/geocoder.service.ts +++ b/src/infrastructures/geocoder/geocoder.service.ts @@ -1,4 +1,5 @@ -import { Inject, Injectable } from "@nestjs/common"; +import { HttpStatus, Inject, Injectable } from "@nestjs/common"; +import { AppException } from "../../common/exceptions/app.exception"; import type { GeoCandidate, GeocoderProvider, GeoQuery } from "./geocoder.type"; export const GEOCODER_PROVIDERS = Symbol("GEOCODER_PROVIDERS"); @@ -10,7 +11,23 @@ export class GeocoderService { private readonly providers: GeocoderProvider[], ) {} - async searchAll(_query: GeoQuery): Promise { - throw new Error("Not implemented"); + async searchAll(query: GeoQuery): Promise { + const settled = await Promise.allSettled( + this.providers.map((provider) => provider.search(query)), + ); + const succeeded = settled.filter( + (result): result is PromiseFulfilledResult => + result.status === "fulfilled", + ); + + if (succeeded.length === 0 && settled.length > 0) { + throw new AppException( + "GEOCODER_ALL_PROVIDERS_FAILED", + "모든 지도 검색 제공자가 실패했습니다.", + HttpStatus.BAD_GATEWAY, + ); + } + + return succeeded.flatMap((result) => result.value); } } diff --git a/src/infrastructures/geocoder/geocoder.type.ts b/src/infrastructures/geocoder/geocoder.type.ts index 11370693..89442633 100644 --- a/src/infrastructures/geocoder/geocoder.type.ts +++ b/src/infrastructures/geocoder/geocoder.type.ts @@ -3,23 +3,27 @@ export interface Coordinate { lng: number; } +export type AreaType = "landmark" | "address" | "region"; + export interface GeoQuery { areaName: string; + areaType?: AreaType; placeName: string; } export interface GeoCandidate { provider: "kakao" | "google"; + providerPlaceId: string; placeName: string; address: string; coordinate: Coordinate; distance?: number; - url?: string; + mapUrl?: string; phone?: string; category?: string; } export interface GeocoderProvider { - name: GeoCandidate["provider"]; + readonly name: GeoCandidate["provider"]; search(query: GeoQuery): Promise; } diff --git a/src/modules/place/place.service.spec.ts b/src/modules/place/place.service.spec.ts index 40c8e318..273581a7 100644 --- a/src/modules/place/place.service.spec.ts +++ b/src/modules/place/place.service.spec.ts @@ -38,6 +38,7 @@ describe("PlaceService", () => { function makeCandidate(overrides: Partial = {}): GeoCandidate { return { provider: "kakao", + providerPlaceId: "kakao-1", placeName: "어니언 성수", address: "서울 성동구 아차산로 8", coordinate: { lat: 37.5445, lng: 127.0559 }, @@ -72,6 +73,7 @@ describe("PlaceService", () => { expect(geocoder.searchAll).toHaveBeenCalledWith({ placeName: QUERY.place_name, areaName: QUERY.area_name, + areaType: QUERY.area_type, }); expect(result).toHaveLength(1); }); @@ -230,7 +232,7 @@ describe("PlaceService", () => { makeCandidate({ placeName: "정보 많은 곳", coordinate: { lat: 37.2, lng: 127.2 }, - url: "https://x", + mapUrl: "https://x", phone: "02-000", category: "카페", }), @@ -243,6 +245,31 @@ describe("PlaceService", () => { expect(result[0].placeName).toBe("정보 많은 곳"); }); + it("정보 완전도가 같으면 더 가까운 후보를 먼저 정렬한다", async () => { + // given + const { service, instagram, ai, geocoder } = createService(); + instagram.fetchPost.mockResolvedValue(makePost()); + ai.extract.mockResolvedValue({ places: [QUERY] }); + geocoder.searchAll.mockResolvedValue([ + makeCandidate({ + placeName: "먼 후보", + coordinate: { lat: 37.1, lng: 127.1 }, + distance: 1200, + }), + makeCandidate({ + placeName: "가까운 후보", + coordinate: { lat: 37.2, lng: 127.2 }, + distance: 300, + }), + ]); + + // when + const result = await service.extractFromUrl(URL); + + // then + expect(result[0].placeName).toBe("가까운 후보"); + }); + it("PlaceModule이 PlaceService와 PlaceController를 해석한다", async () => { // given const module = await Test.createTestingModule({ diff --git a/src/modules/place/place.service.ts b/src/modules/place/place.service.ts index a821fdbd..f606ff6a 100644 --- a/src/modules/place/place.service.ts +++ b/src/modules/place/place.service.ts @@ -40,6 +40,7 @@ Respond in the same language as the source content (use Korean when the content this.geocoderService.searchAll({ placeName: query.place_name, areaName: query.area_name, + areaType: query.area_type, }), ), ); @@ -117,7 +118,7 @@ Respond in the same language as the source content (use Korean when the content /** Counts how many optional fields are present (higher = more complete). */ private completeness(candidate: GeoCandidate): number { let score = 0; - if (candidate.url) score++; + if (candidate.mapUrl) score++; if (candidate.phone) score++; if (candidate.category) score++; if (candidate.distance !== undefined) score++; From c560b7a20bf1baaa3eb8cb7a400393134a9ec0de Mon Sep 17 00:00:00 2001 From: minsour Date: Thu, 25 Jun 2026 05:38:41 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20Kakao=20=ED=82=A4=EC=9B=8C=EB=93=9C?= =?UTF-8?q?=20=EC=9E=A5=EC=86=8C=20=EA=B2=80=EC=83=89=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../geocoder/providers/kakao.provider.spec.ts | 171 ++++++++++++++++++ .../geocoder/providers/kakao.provider.ts | 145 ++++++++++++++- .../geocoder/providers/kakao.type.ts | 24 +++ 3 files changed, 337 insertions(+), 3 deletions(-) create mode 100644 src/infrastructures/geocoder/providers/kakao.provider.spec.ts create mode 100644 src/infrastructures/geocoder/providers/kakao.type.ts diff --git a/src/infrastructures/geocoder/providers/kakao.provider.spec.ts b/src/infrastructures/geocoder/providers/kakao.provider.spec.ts new file mode 100644 index 00000000..7ca95e3c --- /dev/null +++ b/src/infrastructures/geocoder/providers/kakao.provider.spec.ts @@ -0,0 +1,171 @@ +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(apiKey: string | undefined = "test-api-key") { + const configService = { + get: jest.fn((key: keyof Env) => + key === "KAKAO_REST_API_KEY" ? apiKey : undefined, + ), + } as unknown as ConfigService; + + return new KakaoProvider(configService); +} + +function createKakaoResponse(overrides: Record = {}) { + 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, + }, + ], + }; +} + +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, + 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("KAKAO_REST_API_KEY가 없으면 KAKAO_REST_API_KEY_MISSING을 던진다", async () => { + const provider = createProvider(""); + + await expectAppException( + provider.search(query), + "KAKAO_REST_API_KEY_MISSING", + ); + }); + + 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"); + }); +}); diff --git a/src/infrastructures/geocoder/providers/kakao.provider.ts b/src/infrastructures/geocoder/providers/kakao.provider.ts index f58f3d46..44c26f1d 100644 --- a/src/infrastructures/geocoder/providers/kakao.provider.ts +++ b/src/infrastructures/geocoder/providers/kakao.provider.ts @@ -1,11 +1,22 @@ -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 { @@ -13,7 +24,135 @@ export class KakaoProvider implements GeocoderProvider { constructor(private readonly configService: ConfigService) {} - async search(_query: GeoQuery): Promise { - throw new Error("Not implemented"); + async search(query: GeoQuery): Promise { + const apiKey = this.getApiKey(); + const url = this.createKeywordSearchUrl(query); + let response: Response; + + 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, + ); + } + + if (!response.ok) { + throw new AppException( + "KAKAO_REQUEST_FAILED", + "카카오 장소 검색 요청에 실패했습니다.", + HttpStatus.BAD_GATEWAY, + ); + } + + 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 getApiKey(): string { + const apiKey = this.configService.get("KAKAO_REST_API_KEY", { + infer: true, + }); + + if (!apiKey?.trim()) { + throw new AppException( + "KAKAO_REST_API_KEY_MISSING", + "카카오 REST API 키가 설정되지 않았습니다.", + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + return apiKey; + } + + 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 { + 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, + ); } } diff --git a/src/infrastructures/geocoder/providers/kakao.type.ts b/src/infrastructures/geocoder/providers/kakao.type.ts new file mode 100644 index 00000000..c36fe7ac --- /dev/null +++ b/src/infrastructures/geocoder/providers/kakao.type.ts @@ -0,0 +1,24 @@ +import * as v from "valibot"; + +export const kakaoKeywordSearchResponseSchema = v.object({ + documents: v.array( + v.object({ + id: v.string(), + place_name: v.string(), + category_name: v.string(), + phone: v.string(), + address_name: v.string(), + road_address_name: v.string(), + x: v.string(), + y: v.string(), + place_url: v.string(), + distance: v.optional(v.string()), + }), + ), +}); + +type KakaoKeywordSearchResponse = v.InferOutput< + typeof kakaoKeywordSearchResponseSchema +>; +export type KakaoKeywordDocument = + KakaoKeywordSearchResponse["documents"][number]; From 63e49d42b5aa76e91a2706febb3233dd69b09240 Mon Sep 17 00:00:00 2001 From: minsour Date: Thu, 25 Jun 2026 05:39:21 +0900 Subject: [PATCH 3/6] =?UTF-8?q?chore:=20Kakao=20geocoder=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=EC=9A=A9=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/place/place.controller.ts | 29 ++++++++++++++++++++++++--- src/modules/place/place.dto.ts | 10 +++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/modules/place/place.controller.ts b/src/modules/place/place.controller.ts index e8934aad..90c87c0f 100644 --- a/src/modules/place/place.controller.ts +++ b/src/modules/place/place.controller.ts @@ -1,12 +1,23 @@ -import { Body, Controller, Post } from "@nestjs/common"; +import { Body, Controller, Get, Post, Query } from "@nestjs/common"; import { ValibotPipe } from "../../common/pipes/valibot.pipe"; -import { type CreatePlaceRequest, createPlaceRequestSchema } from "./place.dto"; +import { GeocoderService } from "../../infrastructures/geocoder/geocoder.service"; +import type { GeoCandidate } from "../../infrastructures/geocoder/geocoder.type"; +import { + type CreatePlaceRequest, + createPlaceRequestSchema, + type TestGeocodeRequest, + testGeocodeRequestSchema, +} from "./place.dto"; import { PlaceService } from "./place.service"; import type { PlaceCandidate } from "./place.type"; @Controller("api/v1/place") export class PlaceController { - constructor(private readonly placeService: PlaceService) {} + constructor( + private readonly placeService: PlaceService, + // TODO: KakaoProvider 확인용 임시 의존성으로 전체 플로우 연동 후 제거합니다. + private readonly geocoderService: GeocoderService, + ) {} @Post("places") async createPlace( @@ -19,4 +30,16 @@ export class PlaceController { throw new Error(`Unsupported method: ${body.method}`); } } + + // TODO: KakaoProvider 확인용 임시 테스트 엔드포인트로 전체 플로우 연동 후 제거합니다. + @Get("_test/geocode") + async testGeocode( + @Query(new ValibotPipe(testGeocodeRequestSchema)) query: TestGeocodeRequest, + ): Promise { + return this.geocoderService.searchAll({ + placeName: query.placeName, + areaName: query.areaName, + areaType: query.areaType, + }); + } } diff --git a/src/modules/place/place.dto.ts b/src/modules/place/place.dto.ts index 398e14a3..7a3cf63e 100644 --- a/src/modules/place/place.dto.ts +++ b/src/modules/place/place.dto.ts @@ -8,3 +8,13 @@ export const createPlaceRequestSchema = v.object({ }); export type CreatePlaceRequest = v.InferOutput; + +// TODO: KakaoProvider 확인용 임시 의존성으로 전체 플로우 연동 후 제거합니다. +export const testGeocodeRequestSchema = v.object({ + placeName: v.pipe(v.string(), v.minLength(1)), + areaName: v.pipe(v.string(), v.minLength(1)), + areaType: v.optional(v.picklist(["landmark", "address", "region"])), +}); + +// TODO: KakaoProvider 확인용 임시 의존성으로 전체 플로우 연동 후 제거합니다. +export type TestGeocodeRequest = v.InferOutput; From 588a0e692ed11a5529938e58eb08438fa1fc1ce5 Mon Sep 17 00:00:00 2001 From: minsoopark Date: Sun, 5 Jul 2026 18:44:22 +0900 Subject: [PATCH 4/6] =?UTF-8?q?refactor:=20Kakao=20API=20=ED=82=A4?= =?UTF-8?q?=EB=A5=BC=20env=20=EA=B2=80=EC=A6=9D=EC=97=90=EC=84=9C=20fail-f?= =?UTF-8?q?ast=EB=A1=9C=20=EA=B0=95=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/config/env.schema.ts | 2 +- .../geocoder/providers/kakao.provider.spec.ts | 15 ++------------ .../geocoder/providers/kakao.provider.ts | 20 +++---------------- 3 files changed, 6 insertions(+), 31 deletions(-) diff --git a/src/config/env.schema.ts b/src/config/env.schema.ts index ec99c6f5..54dcf995 100644 --- a/src/config/env.schema.ts +++ b/src/config/env.schema.ts @@ -26,7 +26,7 @@ const envSchema = v.object({ GOOGLE_CLOUD_PROJECT: v.optional(v.string()), // Gemini 3.x는 global 전용이므로 기본 "global" 사용 GOOGLE_VERTEX_LOCATION: v.optional(v.string(), "global"), - KAKAO_REST_API_KEY: v.optional(v.string()), + KAKAO_REST_API_KEY: v.pipe(v.string(), v.minLength(1)), }); export type Env = v.InferOutput; diff --git a/src/infrastructures/geocoder/providers/kakao.provider.spec.ts b/src/infrastructures/geocoder/providers/kakao.provider.spec.ts index 7ca95e3c..5ce15cf1 100644 --- a/src/infrastructures/geocoder/providers/kakao.provider.spec.ts +++ b/src/infrastructures/geocoder/providers/kakao.provider.spec.ts @@ -12,11 +12,9 @@ const query: GeoQuery = { placeName: "카카오프렌즈", }; -function createProvider(apiKey: string | undefined = "test-api-key") { +function createProvider() { const configService = { - get: jest.fn((key: keyof Env) => - key === "KAKAO_REST_API_KEY" ? apiKey : undefined, - ), + getOrThrow: jest.fn(() => "test-api-key"), } as unknown as ConfigService; return new KakaoProvider(configService); @@ -139,15 +137,6 @@ describe("KakaoProvider", () => { expect(candidate.distance).toBeUndefined(); }); - it("KAKAO_REST_API_KEY가 없으면 KAKAO_REST_API_KEY_MISSING을 던진다", async () => { - const provider = createProvider(""); - - await expectAppException( - provider.search(query), - "KAKAO_REST_API_KEY_MISSING", - ); - }); - it("429 응답이면 KAKAO_RATE_LIMITED를 던진다", async () => { mockFetchJson({ error: "rate limited" }, { status: 429 }); const provider = createProvider(); diff --git a/src/infrastructures/geocoder/providers/kakao.provider.ts b/src/infrastructures/geocoder/providers/kakao.provider.ts index 44c26f1d..82ac677f 100644 --- a/src/infrastructures/geocoder/providers/kakao.provider.ts +++ b/src/infrastructures/geocoder/providers/kakao.provider.ts @@ -25,7 +25,9 @@ export class KakaoProvider implements GeocoderProvider { constructor(private readonly configService: ConfigService) {} async search(query: GeoQuery): Promise { - const apiKey = this.getApiKey(); + const apiKey = this.configService.getOrThrow("KAKAO_REST_API_KEY", { + infer: true, + }); const url = this.createKeywordSearchUrl(query); let response: Response; @@ -70,22 +72,6 @@ export class KakaoProvider implements GeocoderProvider { ); } - private getApiKey(): string { - const apiKey = this.configService.get("KAKAO_REST_API_KEY", { - infer: true, - }); - - if (!apiKey?.trim()) { - throw new AppException( - "KAKAO_REST_API_KEY_MISSING", - "카카오 REST API 키가 설정되지 않았습니다.", - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - - return apiKey; - } - private createKeywordSearchUrl(query: GeoQuery): string { const url = new URL(KAKAO_KEYWORD_SEARCH_URL); url.searchParams.set("query", this.createKeyword(query)); From 3daa0ad2c7cc90427d07238ea6df950882ac3263 Mon Sep 17 00:00:00 2001 From: minsoopark Date: Sun, 5 Jul 2026 19:39:05 +0900 Subject: [PATCH 5/6] =?UTF-8?q?chore:=20CodeRabbit=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20(=EC=8B=A4=ED=8C=A8=20=EB=A1=9C=EA=B9=85?= =?UTF-8?q?=C2=B7fetch=20=ED=85=8C=EC=8A=A4=ED=8A=B8=C2=B7AreaType=20?= =?UTF-8?q?=EB=8B=A8=EC=9D=BC=ED=99=94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/infrastructures/geocoder/geocoder.service.ts | 11 ++++++++++- src/infrastructures/geocoder/geocoder.type.ts | 3 ++- .../geocoder/providers/kakao.provider.spec.ts | 9 +++++++++ src/modules/place/place.dto.ts | 3 ++- 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/infrastructures/geocoder/geocoder.service.ts b/src/infrastructures/geocoder/geocoder.service.ts index 609dedbd..e6eaee8e 100644 --- a/src/infrastructures/geocoder/geocoder.service.ts +++ b/src/infrastructures/geocoder/geocoder.service.ts @@ -1,4 +1,4 @@ -import { HttpStatus, Inject, Injectable } from "@nestjs/common"; +import { HttpStatus, Inject, Injectable, Logger } from "@nestjs/common"; import { AppException } from "../../common/exceptions/app.exception"; import type { GeoCandidate, GeocoderProvider, GeoQuery } from "./geocoder.type"; @@ -6,6 +6,8 @@ export const GEOCODER_PROVIDERS = Symbol("GEOCODER_PROVIDERS"); @Injectable() export class GeocoderService { + private readonly logger = new Logger(GeocoderService.name); + constructor( @Inject(GEOCODER_PROVIDERS) private readonly providers: GeocoderProvider[], @@ -15,6 +17,13 @@ export class GeocoderService { const settled = await Promise.allSettled( this.providers.map((provider) => provider.search(query)), ); + settled.forEach((result, index) => { + if (result.status === "rejected") { + this.logger.warn( + `Geocoder provider "${this.providers[index].name}" failed: ${result.reason}`, + ); + } + }); const succeeded = settled.filter( (result): result is PromiseFulfilledResult => result.status === "fulfilled", diff --git a/src/infrastructures/geocoder/geocoder.type.ts b/src/infrastructures/geocoder/geocoder.type.ts index 89442633..462ab069 100644 --- a/src/infrastructures/geocoder/geocoder.type.ts +++ b/src/infrastructures/geocoder/geocoder.type.ts @@ -3,7 +3,8 @@ export interface Coordinate { lng: number; } -export type AreaType = "landmark" | "address" | "region"; +export const AREA_TYPES = ["landmark", "address", "region"] as const; +export type AreaType = (typeof AREA_TYPES)[number]; export interface GeoQuery { areaName: string; diff --git a/src/infrastructures/geocoder/providers/kakao.provider.spec.ts b/src/infrastructures/geocoder/providers/kakao.provider.spec.ts index 5ce15cf1..dd154740 100644 --- a/src/infrastructures/geocoder/providers/kakao.provider.spec.ts +++ b/src/infrastructures/geocoder/providers/kakao.provider.spec.ts @@ -137,6 +137,15 @@ describe("KakaoProvider", () => { 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(); diff --git a/src/modules/place/place.dto.ts b/src/modules/place/place.dto.ts index 7a3cf63e..a7286251 100644 --- a/src/modules/place/place.dto.ts +++ b/src/modules/place/place.dto.ts @@ -1,4 +1,5 @@ import * as v from "valibot"; +import { AREA_TYPES } from "../../infrastructures/geocoder/geocoder.type"; export const createPlaceRequestSchema = v.object({ method: v.picklist(["instagram_url"]), @@ -13,7 +14,7 @@ export type CreatePlaceRequest = v.InferOutput; export const testGeocodeRequestSchema = v.object({ placeName: v.pipe(v.string(), v.minLength(1)), areaName: v.pipe(v.string(), v.minLength(1)), - areaType: v.optional(v.picklist(["landmark", "address", "region"])), + areaType: v.optional(v.picklist(AREA_TYPES)), }); // TODO: KakaoProvider 확인용 임시 의존성으로 전체 플로우 연동 후 제거합니다. From d5a42d4ce0f300fb59e27b5d3a71a6df1fdcbe04 Mon Sep 17 00:00:00 2001 From: minsoopark Date: Sun, 5 Jul 2026 20:03:42 +0900 Subject: [PATCH 6/6] =?UTF-8?q?test:=20e2e=20=EC=8B=A4=ED=96=89=EC=97=90?= =?UTF-8?q?=20KAKAO=5FREST=5FAPI=5FKEY=20=EC=A3=BC=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KAKAO_REST_API_KEY가 env 검증에서 required가 되면서 e2e 부팅 시 validateEnv가 실패하던 문제 해결. INSTAGRAM_* 주입과 동일 패턴. Co-Authored-By: Claude Opus 4.8 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c61063e1..725931dc 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "start:local": "APP_ENV=local nest start --watch --exec bun", "start:prod": "./dist/server", "test": "bun run test:unit && bun run test:e2e", - "test:e2e": "DATABASE_URL=${TEST_DATABASE_URL:-postgres://postgres:postgres@localhost:5432/team_mino} INSTAGRAM_GRAPHQL_ENDPOINT=https://www.instagram.com/api/graphql INSTAGRAM_DOC_ID=test INSTAGRAM_LSD=test INSTAGRAM_APP_ID=test INSTAGRAM_USER_AGENT=test bun test e2e/", + "test:e2e": "DATABASE_URL=${TEST_DATABASE_URL:-postgres://postgres:postgres@localhost:5432/team_mino} KAKAO_REST_API_KEY=test INSTAGRAM_GRAPHQL_ENDPOINT=https://www.instagram.com/api/graphql INSTAGRAM_DOC_ID=test INSTAGRAM_LSD=test INSTAGRAM_APP_ID=test INSTAGRAM_USER_AGENT=test bun test e2e/", "test:unit": "bun test src/", "test:watch": "bun test --watch", "typecheck": "tsgo --noEmit",