Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ POSTGRES_DB=team_mino
GOOGLE_CLOUD_PROJECT=team-mino-prod
GOOGLE_VERTEX_LOCATION=global
KAKAO_REST_API_KEY=changeme
GOOGLE_MAPS_API_KEY=changeme

# Optional error monitoring (APP_CONFIG_SOURCE=env 전용; prod는 Secret Manager에 저장)
# SENTRY_DSN=https://public@example.ingest.sentry.io/project-id
Expand Down
1 change: 1 addition & 0 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ NODE_ENV=test

GOOGLE_CLOUD_PROJECT=team-mino-test
KAKAO_REST_API_KEY=test
GOOGLE_MAPS_API_KEY=test

INSTAGRAM_GRAPHQL_ENDPOINT=https://www.instagram.com/api/graphql
INSTAGRAM_DOC_ID=test
Expand Down
8 changes: 5 additions & 3 deletions docs/prd.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ Figma **새 보드**(node `1512:70762`)의 섹션 번호 [000]~[007]을 기준
**백엔드 관점**

1. 링크 접수: `original_url` 정규화 → `sources` 조회·등록(살아있는 행 기준 유니크이므로 재유입 시 기존 행 재사용) → 분석 잡 생성 후 **즉시 202 응답**(`jobId` 반환).
2. 백그라운드 처리(Cloud Tasks 워커): 스크래핑(`infrastructures/scraper`) → AI 장소 추출(`infrastructures/ai`) → 지오코딩(`infrastructures/geocoder`, Kakao/Google).
2. 백그라운드 처리(Cloud Tasks 워커): 스크래핑(`infrastructures/scraper`) → AI 장소 추출(`infrastructures/ai`) → 지오코딩(`infrastructures/geocoder`). 지오코더는 AI가 장소마다 뽑은 국가 코드로 provider를 고른다 — **국내는 Kakao, 해외는 Google Places**.
3. 저장: `places`에 `(provider, provider_place_id)` 기준 dedup upsert → `place_sources` 연결(`(place_id, source_id)` 살아있는 행 유니크라 재유입 시 기존 연결 재사용) → 선택한 방에 `pins` 생성.
4. 중복 판정: 같은 방에 같은 장소가 이미 있으면(`pins (room_id, place_id)` 살아있는 행 유니크) 새 핀을 만들지 않고 **중복 결과**로 처리한다.
5. 결과 알림: 잡 종료 시 결과 유형별 알림 레코드를 남기고, **중복·실패인 경우 푸시를 발송한다.** 중복인 경우 **기존 핀으로 이동할 수 있는 참조(방·핀 식별자, 최초 저장 시각)** 를 함께 담는다. (→ [[006] 알림함](#006-알림함))
Expand Down Expand Up @@ -219,7 +219,7 @@ Figma **새 보드**(node `1512:70762`)의 섹션 번호 [000]~[007]을 기준
**백엔드 관점**

- 지도뷰는 [004] 방 상세의 핀 리스트를 좌표와 함께 재사용한다. 별도 엔드포인트를 두지 않는다.
- 카테고리 필터는 `places`의 카테고리 값 기준이다. 지오코더(Kakao/Google) 응답의 카테고리 체계가 provider마다 달라 **표시용 카테고리 정규화 규칙**이 필요하다.
- 카테고리 필터는 `places`의 카테고리 값 기준이다. 지오코더(Kakao/Google) 응답의 카테고리 체계가 provider마다 달라 **표시용 카테고리 정규화 규칙**이 필요하다. Google provider가 붙으면서 두 체계가 실제로 공존하기 시작했으므로(Kakao `"음식점 > 카페 > 커피전문점"` / Google `"카페"`), **정규화 대상 카테고리 목록 확정이 이 화면의 선행 조건**이다.
- 장소 상세 = 장소 정보 + 출처 링크(`place_sources` → `sources.original_url`) + 코멘트 목록/수.
- 코멘트 작성·조회·삭제 모두 방 멤버십을 검증한다. **삭제는 작성자 본인만 가능하다.** (PR 리뷰 확정) 삭제는 soft delete(`pin_comments.deleted_at`)로 처리한다. **수정은 MVP 미지원**(삭제 후 재작성)이며 수정 API를 두지 않는다.
- 코멘트 목록 정렬은 **`createdAt` 오름차순(ASC)** — 최신 코멘트가 목록 맨 아래에 온다.
Expand Down Expand Up @@ -312,7 +312,7 @@ PR #44에서 8개 테이블이 정의·머지됐다. **모든 테이블에 soft
|---|---|
| **스크래퍼** (`infrastructures/scraper`) | 인스타그램 게시물 콘텐츠 추출 — 구현됨 |
| **AI** (`infrastructures/ai`) | Vertex AI(Gemini) 장소 추출 — 구현됨 |
| **지오코더** (`infrastructures/geocoder`) | **Kakao 키워드/주소 검색 구현 완료. Google provider는 스켈레톤만 존재하며 `search()`는 미구현**(`Not implemented` throw) |
| **지오코더** (`infrastructures/geocoder`) | **국가 기준 라우팅 구현됨** — AI가 뽑은 `country_code`(ISO 3166-1 alpha-2)로 provider를 고른다. 국내는 Kakao(키워드/주소 검색), 그 외는 Google Places(Text Search). **운영 전 GCP에 Places API (New) 활성화와 `GOOGLE_MAPS_API_KEY` 주입이 필요하다** |
| **장소 검색** (`modules/place`) | 장소 검색 API — 구현됨 |
| **DB** | Drizzle + PostgreSQL(Supabase), search_path 기반 develop/production 분리 — 구현됨 |
| **모니터링** | Sentry 오류 수집 + Discord 알림 — 구현됨 |
Expand All @@ -333,6 +333,8 @@ PR #44에서 8개 테이블이 정의·머지됐다. **모든 테이블에 soft
7. **닉네임 중복 허용 범위**: 스키마상 닉네임 유니크 제약이 없어 중복 가능하다. 전체 유저 간 허용인지, 방 안에서만 제한할지 프디팀 확인 필요. (PR 리뷰 질문 — 미답)
8. **방 커버 도입 여부**: 새 보드에 "방 커버 변경 예정" 주석이 있다. 도입되면 `rooms`에 커버 필드가 필요할 수 있다.
9. **방 변경 시 라벨 vs 툴팁**: 디자인 리뷰에서는 고정 라벨로 확정했으나 새 보드에 "툴팁 3초 유지" 화면이 남아 있다. 재확인 필요.
10. **해외 장소의 외부 지도 앱 이동**: 지오코더가 해외 장소를 지원하게 되면서 [[005] 장소 상세](#005-장소-상세--지도뷰)의 "카카오맵만 가능" 전제가 깨졌다. 해외 장소는 카카오맵에 없을 수 있어 provider에 따라 딥링크를 분기해야 한다. 서버는 `mapUrl`을 provider별로 이미 내려주므로 계약 변경은 없고, **클라이언트 분기와 라이팅을 프디팀과 확정해야 한다.**
11. **해외 장소의 표시 이름 언어**: 지오코딩에 성공하면 Google이 한국어 이름을 주지만(`languageCode=ko`), 후보가 0건이면 AI가 뽑은 현지어·영어 이름만 남는다. 검색 정확도를 위해 한국어 음차를 금지한 결과라 정책 확인이 필요하다.

### 해소된 항목

Expand Down
5 changes: 4 additions & 1 deletion e2e/scenarios/place/place.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const PLACE_QUERY = {
place_name: "어니언 성수",
area_name: "성수동",
area_type: "landmark" as const,
country_code: "KR",
relation: "카페",
};
const CANDIDATE: GeoCandidate = {
Expand All @@ -44,7 +45,7 @@ const CANDIDATE: GeoCandidate = {

const instagram = { fetchPost: jest.fn() };
const ai = { extract: jest.fn() };
const geocoder = { name: "kakao", search: jest.fn() };
const geocoder = { name: "kakao", supports: () => true, search: jest.fn() };
const placeImage = { storePostImages: jest.fn().mockResolvedValue([]) };
let app: INestApplication;
let baseUrl: string;
Expand Down Expand Up @@ -101,6 +102,7 @@ describe("POST /api/v1/place/places", () => {
placeName: "어니언 성수",
areaName: "성수동",
areaType: "landmark",
countryCode: "KR",
relation: "카페",
},
matches: [CANDIDATE],
Expand All @@ -112,6 +114,7 @@ describe("POST /api/v1/place/places", () => {
placeName: "어니언 성수",
areaName: "성수동",
areaType: "landmark",
countryCode: "KR",
});
});

Expand Down
15 changes: 15 additions & 0 deletions src/config/env.schema.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,23 @@ const requiredEnvironment = {
INSTAGRAM_GRAPHQL_ENDPOINT: "https://www.instagram.com/graphql/query/",
INSTAGRAM_USER_AGENT: "test",
KAKAO_REST_API_KEY: "test",
GOOGLE_MAPS_API_KEY: "test",
};

// 키가 없으면 해당 국가의 장소가 조용히 0건이 되므로 부팅에서 막는다.
describe("지오코더 키 환경변수", () => {
it.each([
"KAKAO_REST_API_KEY",
"GOOGLE_MAPS_API_KEY",
])("%s가 없으면 부팅을 막는다", (key) => {
const { [key]: _omitted, ...withoutKey } = requiredEnvironment;

expect(() => validateEnv(withoutKey)).toThrow(
"Invalid environment variables",
);
});
});

describe("GCS 버킷 환경변수", () => {
it("미지정이면 undefined로 두어 APP_ENV가 버킷을 유도하게 한다", () => {
expect(
Expand Down
2 changes: 2 additions & 0 deletions src/config/env.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ const envSchema = v.object({
* 로컬 실행이 운영 버킷에 쌓이지 않도록 한다.
*/
GCS_PLACE_IMAGES_BUCKET: v.optional(v.pipe(v.string(), v.minLength(1))),
// 지오코더 키. 국내는 Kakao, 해외는 Google Places를 쓰므로 둘 다 필요하다.
KAKAO_REST_API_KEY: v.pipe(v.string(), v.minLength(1)),
GOOGLE_MAPS_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만 갱신하면 됨.
Expand Down
1 change: 1 addition & 0 deletions src/infrastructures/geocoder/geocoder.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { KakaoProvider } from "./providers/kakao.provider";
KakaoProvider,
GoogleProvider,
{
// 배열 순서가 곧 우선순위다. Kakao가 국내만 지원하므로 나머지는 Google로 간다.
provide: GEOCODER_PROVIDERS,
useFactory: (kakao: KakaoProvider, google: GoogleProvider) => [
kakao,
Expand Down
112 changes: 64 additions & 48 deletions src/infrastructures/geocoder/geocoder.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,28 @@ import "reflect-metadata";
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 { GEOCODER_PROVIDERS, GeocoderService } from "./geocoder.service";
import type { GeoCandidate, GeocoderProvider, GeoQuery } from "./geocoder.type";
import { KakaoProvider } from "./providers/kakao.provider";

const query: GeoQuery = {
const domesticQuery: GeoQuery = {
areaName: "서울",
areaType: "landmark",
placeName: "남산타워",
countryCode: "KR",
};
const overseasQuery: GeoQuery = {
...domesticQuery,
areaName: "Paris",
placeName: "Eiffel Tower",
countryCode: "FR",
};

describe("Geocoder", () => {
let service: GeocoderService;
let kakao: KakaoProvider;
let providers: GeocoderProvider[];

function makeCandidate(overrides: Partial<GeoCandidate> = {}): GeoCandidate {
return {
Expand All @@ -34,6 +41,7 @@ describe("Geocoder", () => {
): GeocoderProvider {
return {
name: "kakao",
supports: () => true,
search: jest.fn().mockResolvedValue([makeCandidate()]),
...overrides,
};
Expand All @@ -48,6 +56,7 @@ describe("Geocoder", () => {
}).compile();
service = module.get(GeocoderService);
kakao = module.get(KakaoProvider);
providers = module.get(GEOCODER_PROVIDERS);
});

it("DI 컨테이너에서 GeocoderService를 해석한다", () => {
Expand All @@ -58,65 +67,72 @@ describe("Geocoder", () => {
expect(kakao.name).toBe("kakao");
});

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([
it("provider 주입 순서가 라우팅 정책을 표현한다", () => {
expect(providers.map((provider) => provider.name)).toEqual([
"kakao",
"google",
]);
expect(providers[0].supports(domesticQuery)).toBe(true);
expect(providers[0].supports(overseasQuery)).toBe(false);
expect(providers[1].supports(overseasQuery)).toBe(true);
});

it("일부 provider가 실패해도 성공한 결과를 반환한다", async () => {
const successfulProvider = makeProvider({
search: jest.fn().mockResolvedValue([makeCandidate()]),
it("질의를 지원하는 provider로 검색한다", async () => {
const provider = makeProvider();
const geocoder = new GeocoderService([provider]);

const result = await geocoder.search(domesticQuery);

expect(provider.search).toHaveBeenCalledWith(domesticQuery);
expect(result).toEqual([makeCandidate()]);
});

it("여러 provider가 지원하면 주입 순서가 앞선 하나만 호출한다", async () => {
// 국가마다 정확한 provider가 정해져 있어 병합할 이유가 없고, 유료 provider 헛호출을 막는다.
const first = makeProvider({ name: "kakao" });
const second = makeProvider({ name: "google" });
const geocoder = new GeocoderService([first, second]);

await geocoder.search(domesticQuery);

expect(first.search).toHaveBeenCalledTimes(1);
expect(second.search).not.toHaveBeenCalled();
});

it("앞선 provider가 지원하지 않으면 다음 provider로 넘어간다", async () => {
const domesticOnly = makeProvider({
name: "kakao",
supports: (query) => query.countryCode === "KR",
});
const failedProvider = makeProvider({
name: "google",
search: jest.fn().mockRejectedValue(new Error("provider down")),
const worldwide = makeProvider({ name: "google" });
const geocoder = new GeocoderService([domesticOnly, worldwide]);

await geocoder.search(overseasQuery);

expect(domesticOnly.search).not.toHaveBeenCalled();
expect(worldwide.search).toHaveBeenCalledWith(overseasQuery);
});

it("지원하는 provider가 없으면 에러가 아니라 빈 결과를 반환한다", async () => {
const domesticOnly = makeProvider({
supports: (query) => query.countryCode === "KR",
});
const geocoder = new GeocoderService([successfulProvider, failedProvider]);
const geocoder = new GeocoderService([domesticOnly]);

const result = await geocoder.searchAll(query);
const result = await geocoder.search(overseasQuery);

expect(result).toEqual([makeCandidate()]);
expect(result).toEqual([]);
expect(domesticOnly.search).not.toHaveBeenCalled();
});

it("모든 provider가 실패하면 GEOCODER_ALL_PROVIDERS_FAILED(502)를 던진다", async () => {
const failedProvider = makeProvider({
it("provider 검색 실패는 감추지 않고 그대로 전파한다", async () => {
const failing = makeProvider({
search: jest.fn().mockRejectedValue(new Error("provider down")),
});
const geocoder = new GeocoderService([failedProvider]);
const geocoder = new GeocoderService([failing]);

const error = await geocoder.searchAll(query).then(
() => undefined,
(error: unknown) => error,
await expect(geocoder.search(domesticQuery)).rejects.toThrow(
"provider down",
);

expect(error).toBeInstanceOf(AppException);
expect(error).toMatchObject({ errorCode: "GEOCODER_ALL_PROVIDERS_FAILED" });
});
});
38 changes: 16 additions & 22 deletions src/infrastructures/geocoder/geocoder.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { HttpStatus, Inject, Injectable, Logger } from "@nestjs/common";
import { AppException } from "../../common/exceptions/app.exception";
import { Inject, Injectable, Logger } from "@nestjs/common";
import type { GeoCandidate, GeocoderProvider, GeoQuery } from "./geocoder.type";

export const GEOCODER_PROVIDERS = Symbol("GEOCODER_PROVIDERS");
Expand All @@ -13,30 +12,25 @@ export class GeocoderService {
private readonly providers: GeocoderProvider[],
) {}

async searchAll(query: GeoQuery): Promise<GeoCandidate[]> {
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<GeoCandidate[]> =>
result.status === "fulfilled",
/**
* 질의를 다룰 수 있는 provider 중 우선순위가 가장 높은 하나로 검색한다.
*
* 우선순위는 GEOCODER_PROVIDERS 주입 순서다. 여러 provider에 같은 질의를 보내지 않는 이유는
* 국가마다 정확한 provider가 정해져 있어 병합할 이유가 없고, 유료 provider 호출 수를 줄이기 위함이다.
*/
async search(query: GeoQuery): Promise<GeoCandidate[]> {
const [provider] = this.providers.filter((candidate) =>
candidate.supports(query),
);

if (succeeded.length === 0 && settled.length > 0) {
throw new AppException(
"GEOCODER_ALL_PROVIDERS_FAILED",
"모든 지도 검색 제공자가 실패했습니다.",
HttpStatus.BAD_GATEWAY,
if (!provider) {
this.logger.warn(
{ countryCode: query.countryCode, placeName: query.placeName },
"질의를 지원하는 지오코더 provider 없음 — 빈 결과",
);
return [];
}

return succeeded.flatMap((result) => result.value);
return provider.search(query);
}
}
3 changes: 2 additions & 1 deletion src/infrastructures/geocoder/geocoder.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface GeoQuery {
areaName: string;
areaType?: AreaType;
placeName: string;
countryCode: string;
}

export interface GeoCandidate {
Expand All @@ -20,11 +21,11 @@ export interface GeoCandidate {
coordinate: Coordinate;
distance?: number;
mapUrl?: string;
phone?: string;
category?: string;
}

export interface GeocoderProvider {
readonly name: GeoCandidate["provider"];
supports(query: GeoQuery): boolean;
search(query: GeoQuery): Promise<GeoCandidate[]>;
}
Loading
Loading