diff --git a/package.json b/package.json index c643a097..180d4db9 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} GOOGLE_CLOUD_PROJECT=team-mino-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:e2e": "DATABASE_URL=${TEST_DATABASE_URL:-postgres://postgres:postgres@localhost:5432/team_mino} KAKAO_REST_API_KEY=test GOOGLE_CLOUD_PROJECT=team-mino-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", diff --git a/src/config/env.schema.spec.ts b/src/config/env.schema.spec.ts index f64626b5..764b944b 100644 --- a/src/config/env.schema.spec.ts +++ b/src/config/env.schema.spec.ts @@ -9,6 +9,7 @@ const requiredEnvironment = { INSTAGRAM_GRAPHQL_ENDPOINT: "https://www.instagram.com/api/graphql", INSTAGRAM_LSD: "test", INSTAGRAM_USER_AGENT: "test", + KAKAO_REST_API_KEY: "test", }; describe("Sentry environment", () => { diff --git a/src/config/env.schema.ts b/src/config/env.schema.ts index 688c2c7f..3b9fd9ac 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.pipe(v.string(), v.minLength(1)), // 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)), SENTRY_DSN: v.optional(v.pipe(v.string(), v.url(), v.regex(/^https:\/\//))), SENTRY_RELEASE: v.optional(v.pipe(v.string(), v.minLength(1))), // Instagram 비공개 GraphQL 호출용 값들. 인스타가 토큰/구조를 바꾸면 env만 갱신하면 됨. 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 new file mode 100644 index 00000000..dd154740 --- /dev/null +++ b/src/infrastructures/geocoder/providers/kakao.provider.spec.ts @@ -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; + + 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("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"); + }); +}); diff --git a/src/infrastructures/geocoder/providers/kakao.provider.ts b/src/infrastructures/geocoder/providers/kakao.provider.ts index f58f3d46..82ac677f 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,121 @@ 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.configService.getOrThrow("KAKAO_REST_API_KEY", { + infer: true, + }); + 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 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]; 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..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"]), @@ -8,3 +9,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(AREA_TYPES)), +}); + +// TODO: KakaoProvider 확인용 임시 의존성으로 전체 플로우 연동 후 제거합니다. +export type TestGeocodeRequest = v.InferOutput;