Skip to content

Commit 53c7fa8

Browse files
committed
API 키 발급, 테스트 파일 추가
1 parent f3ab23e commit 53c7fa8

7 files changed

Lines changed: 598 additions & 12 deletions

File tree

CLAUDE.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# CLAUDE.md
2+
3+
에움길(Eumgil) 작업 시 참고할 프로젝트 컨텍스트. 새 세션에서도 이 파일을 먼저 읽고 시작.
4+
5+
## 서비스 개요
6+
7+
- **에움길** — 강원도 특화 테마 관광 추천 서비스 (관광데이터 공모전 출품작).
8+
- 여행 목적을 **자연어로 입력 → 강원도 특화 테마 매칭 → 혼잡도·방문 적합성 기반 코스/대체지 추천**.
9+
- **제출 형태: 전체 실연동 · 실제 운영 서비스** (mock 시연 아님). 7종 공공 API 모두 실제 연동 + DB + 소셜 인증까지 목표.
10+
- 기획 상세: [docs/제안서.md](docs/제안서.md)
11+
12+
## 기술 스택
13+
14+
- pnpm workspaces 모노레포 · Next.js 15 (App Router) · React 19 · TypeScript(strict, `noUncheckedIndexedAccess`) · Tailwind v4
15+
- 앱: `apps/web` (`@eumgil/web`) / 공용 UI: `packages/ui` (`@eumgil/ui`)
16+
17+
## 명령어
18+
19+
```bash
20+
pnpm dev # 개발 서버 (localhost:3000)
21+
pnpm build # 프로덕션 빌드
22+
pnpm typecheck # 타입 검사 (pnpm -r)
23+
pnpm --filter @eumgil/web verify:api # 공공 API 키 연결 검증 (.env 필요)
24+
```
25+
26+
## 핵심 아키텍처 (가장 중요)
27+
28+
전체 화면이 **데이터 출처를 모른 채 `getRepository()` 인터페이스만 호출**한다. mock↔실데이터를
29+
화면 수정 없이 갈아끼우는 것이 이 설계의 목표.
30+
31+
- **도메인 모델**: [apps/web/src/domain/types.ts](apps/web/src/domain/types.ts)`Theme/Spot/Course/Eat/Stay/User/Review/Visit` 등. 모든 다국어 텍스트는 `LocalizedText {ko,en}`.
32+
- **Repository 인터페이스**: [apps/web/src/domain/repository.ts](apps/web/src/domain/repository.ts) — 모든 메서드 `async`.
33+
- **팩토리**: [apps/web/src/data/index.ts](apps/web/src/data/index.ts)`getRepository()`. `DATA_SOURCE` 환경변수로 `mock`(기본)↔`live` 전환. **live 는 아직 미구현(mock 폴백)**.
34+
- **mock 구현**: [apps/web/src/data/mock/repository.ts](apps/web/src/data/mock/repository.ts)`design/data.ts`(@ts-nocheck)를 도메인 타입으로 정규화. **`DATA` 직접 읽기는 여기서만 존재.**
35+
- **자연어 매칭**: [apps/web/src/domain/matching.ts](apps/web/src/domain/matching.ts) — 키워드 규칙 순수 함수. (확정: 키워드 우선, 후반에 LLM 검토)
36+
37+
### 화면 / 라우팅
38+
39+
- App Router 17개 라우트. **서버 `page.tsx``getRepository()`로 데이터 페치 → 도메인 타입 props → 클라이언트 뷰** 렌더.
40+
- 화면 뷰: [apps/web/src/components/screens/](apps/web/src/components/screens/)`home/matching/theme-result/course/spot/alternatives/discover/map/saved/profile/profile-edit/reviews/settings/doc/login/onboarding`.
41+
- 전역 상태/크롬(Sidebar·TabBar·Toast): [apps/web/src/components/app-shell.tsx](apps/web/src/components/app-shell.tsx)`useAppState()`로 auth/saved/toast/lang 제공. **auth·user 는 현재 mock**(클라이언트 상태).
42+
- 네비게이션 어댑터: [apps/web/src/lib/nav.ts](apps/web/src/lib/nav.ts)`urlFor(name, params)` + `useAppNav()`.
43+
- `design/` 에는 이제 **재사용 프리미티브만**: `icons.tsx`, `ui.tsx`(Sidebar/TabBar/ThemeHueBg/Signal/Placeholder 등), `brand-logos.tsx`, `styles.css`, `data.ts`(mock). 화면 컴포넌트(screens-*)는 전부 삭제됨.
44+
45+
### 공공 API 연동 계층 (서버 전용)
46+
47+
- [apps/web/src/server/public-api/http.ts](apps/web/src/server/public-api/http.ts) — data.go.kr 공통 fetch(serviceKey 주입·타임아웃·XML 에러 감지).
48+
- [apps/web/src/server/public-api/tourapi.ts](apps/web/src/server/public-api/tourapi.ts) — 국문관광정보(KorService2, 강원 areaCode=32). **유일하게 구현된 클라이언트.**
49+
- [apps/web/scripts/verify-public-api.mjs](apps/web/scripts/verify-public-api.mjs) — 키 연결 검증.
50+
- 환경변수: 루트 `.env` (next.config.mjs 가 dotenv 로 로드) → [apps/web/src/lib/env.ts](apps/web/src/lib/env.ts)(zod 검증). 템플릿 [.env.example](.env.example).
51+
- **API 신청 가이드(키 발급용)**: [docs/공공API-신청가이드.md](docs/공공API-신청가이드.md)
52+
53+
## 데이터 분류 (연동 전략의 기준)
54+
55+
1. **정적 큐레이션** (테마·코스·키워드 규칙·대체지 매핑) → 코드/시드 유지. API 불필요.
56+
2. **반정적** (관광지·맛집·숙박 기본정보) → TourAPI 수집 → DB. 큐레이션 스팟에 이미지/주소/좌표 보강.
57+
3. **실시간 동적** (혼잡도/날씨/대기질/교통) → 요청 시 호출 + 단기 캐시. **방문 적합성 점수(Phase 3)의 입력.**
58+
4. **사용자** (회원/저장/리뷰/방문/온보딩) → DB (Phase 4).
59+
60+
## 진행 상태
61+
62+
전체 단계·체크리스트는 [docs/구현-계획.md](docs/구현-계획.md) 참고. 요약:
63+
64+
-**Phase 0** 기반 정비 (도메인 타입·Repository·env·i18n)
65+
-**Phase 1a** 실제 App Router 라우팅 전환 (단일 상태머신 → 17 라우트)
66+
-**Phase 1b** 전 화면을 `getRepository()` 도메인 데이터 + Server Component 로 전환 (콘텐츠 + 인증/마이페이지, 모두 mock 데이터)
67+
- 🔄 **Phase 2** 공공 API 연동 — 신청 가이드·http 헬퍼·TourAPI 클라이언트·검증 스크립트 완료. **나머지 클라이언트 + LiveRepository 남음.**
68+
-**Phase 3** 방문 적합성 스코어·혼잡 분산 로직 / **Phase 4** 실 인증(Auth.js)·DB
69+
70+
### 현재 블로커 / 다음 작업
71+
72+
- **공공 API 키 상태**: TourAPI(1번) ✅ 정상(강원 totalCount 확인). **기상청 단기예보(4)·에어코리아(6)는 HTTP 403 Forbidden.**
73+
- 진단 완료: **인코딩 문제 아님**(키 64자, 특수문자 없음 → Encoding=Decoding 동일, 6가지 변형 전부 403). 더미키=401 vs 실키=403 → **키는 유효하나 해당 서비스 권한 없음**.
74+
- 추정 원인: 승인 게이트웨이 반영 지연 **또는** TourAPI와 **다른 계정**으로 신청. → data.go.kr 해당 서비스 "미리보기"로 확인 필요.
75+
- **다음 코딩 작업(키 무관)**: 기상청 격자 좌표 변환 유틸(위경도→nx/ny) + 강원 권역 좌표/측정소 테이블. 그 후 키 풀리면 날씨/대기질 클라이언트 → `LiveRepository`(TourAPI contentId 매핑으로 스팟 보강) → `DATA_SOURCE=live`.
76+
77+
## 컨벤션 / 주의사항
78+
79+
- **새 코드는 타입 안전**하게. `design/*.tsx`(icons/ui/brand-logos/data)는 `@ts-nocheck`라 타입 없음 → 화면에서 쓸 땐 [components/screens/_ui.ts](apps/web/src/components/screens/_ui.ts)에서 `UI`/`Icon``any`로 캐스팅해 사용(도메인 데이터 흐름은 정상 타입검사 유지).
80+
- 화면은 **flat `_ko/_en` 직접 접근 금지**. 도메인 타입 + `localized(text, lang)` 사용.
81+
- **공공 API/서비스키는 서버에서만**. `server/public-api/*`, `lib/env.ts`를 클라이언트에서 import 금지.
82+
- env 키는 `lib/env.ts`에서 대부분 optional. 실제 필요한 기능 경로에선 `requireEnv("KEY")` 사용.
83+
- UI 텍스트·코드 주석은 **한국어**. 강원특별자치도 = TourAPI `areaCode` **32**.
84+
- 확정된 방향: 전체 실연동 제출 / 자연어 매칭은 키워드 우선(후반 LLM 검토). 상세 [docs/구현-계획.md](docs/구현-계획.md).
85+
- **이 문서를 최신으로 유지할 것**: 의미 있는 마일스톤(Phase 완료, 주요 모듈 완성, 블로커 발생·해소) 시 위의 **"진행 상태 / 현재 블로커"** 섹션을 별도 요청 없이 갱신한다. 문서 전체를 다시 쓰지 말고 해당 섹션만 국소 수정(상세 체크리스트는 [docs/구현-계획.md](docs/구현-계획.md)가 정본).

apps/web/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
"build": "next build",
99
"start": "next start",
1010
"lint": "next lint",
11-
"typecheck": "tsc --noEmit"
11+
"typecheck": "tsc --noEmit",
12+
"verify:api": "node --env-file=../../.env scripts/verify-public-api.mjs"
1213
},
1314
"dependencies": {
1415
"@eumgil/ui": "workspace:*",
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
#!/usr/bin/env node
2+
// ─────────────────────────────────────────────
3+
// 공공 API 키 연결 검증 스크립트
4+
//
5+
// 사용법 (루트에서):
6+
// node --env-file=.env apps/web/scripts/verify-public-api.mjs
7+
//
8+
// .env 의 각 서비스키로 실제 1회 호출해 연결 상태를 출력한다.
9+
// ✓ 연결됨 ✗ 오류(메시지) – 키없음(건너뜀) ? 수동확인 권장
10+
//
11+
// 진단 정보: HTTP 상태코드 + 응답 형태(JSON/XML/HTML) + 키 지문(길이/끝 4자/중복 여부).
12+
// data.go.kr 는 계정당 인증키 1개로 승인된 모든 서비스를 호출한다 →
13+
// TOUR/KMA/AIRKOREA 키는 보통 같은 값이어야 한다(아래 지문으로 확인).
14+
// 개발계정 자동승인이라도 발급 직후 1~2시간 반영 지연이 있을 수 있다.
15+
// ─────────────────────────────────────────────
16+
17+
const KST = () => new Date(Date.now() + 9 * 3600 * 1000).toISOString().slice(0, 10).replace(/-/g, "");
18+
19+
async function getRes(url, timeoutMs = 10000) {
20+
const ctrl = new AbortController();
21+
const t = setTimeout(() => ctrl.abort(), timeoutMs);
22+
try {
23+
const res = await fetch(url, { signal: ctrl.signal });
24+
return { status: res.status, ctype: res.headers.get("content-type") ?? "", text: await res.text() };
25+
} finally {
26+
clearTimeout(t);
27+
}
28+
}
29+
30+
function shape(text) {
31+
const s = text.trimStart();
32+
if (s.startsWith("{") || s.startsWith("[")) return "JSON";
33+
if (/^<\?xml|^<response|^<OpenAPI/i.test(s)) return "XML";
34+
if (s.startsWith("<")) return "HTML";
35+
return "TEXT";
36+
}
37+
38+
function parseMaybeJson(text) {
39+
const s = text.trimStart();
40+
if (s.startsWith("<")) {
41+
const msg = /<returnAuthMsg>(.*?)<\/returnAuthMsg>/.exec(s)?.[1];
42+
const code = /<returnReasonCode>(.*?)<\/returnReasonCode>/.exec(s)?.[1];
43+
return { xmlError: msg ? `${msg}${code ? ` (${code})` : ""}` : null };
44+
}
45+
try {
46+
return { json: JSON.parse(text) };
47+
} catch {
48+
return {};
49+
}
50+
}
51+
52+
const build = (base, params, key) => {
53+
const u = new URL(base);
54+
u.searchParams.set("serviceKey", key);
55+
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, String(v));
56+
return u.toString();
57+
};
58+
59+
const statusHint = (s) =>
60+
s === 401
61+
? " → 키 미인식(키 값 오류/누락)"
62+
: s === 403
63+
? " → 키는 유효하나 이 서비스 권한 없음: data.go.kr 활용신청 '승인' 여부·반영(최대 1~2h) 확인"
64+
: s === 400
65+
? " → 요청 파라미터 오류"
66+
: "";
67+
const diag = (r) => `[HTTP ${r.status} · ${shape(r.text)}] ${r.text.replace(/\s+/g, " ").trim().slice(0, 100)}${statusHint(r.status)}`;
68+
69+
// ── 자동 프로브 (https 사용) ──────────────────
70+
async function probeTourApi(key) {
71+
const r = await getRes(
72+
build("https://apis.data.go.kr/B551011/KorService2/areaBasedList2", { MobileOS: "ETC", MobileApp: "eumgil", _type: "json", areaCode: 32, numOfRows: 1, pageNo: 1 }, key),
73+
);
74+
const p = parseMaybeJson(r.text);
75+
if (p.json?.response?.header?.resultCode === "0000")
76+
return { ok: true, msg: `강원 totalCount=${p.json.response.body?.totalCount ?? "?"}` };
77+
return { ok: false, msg: p.xmlError ?? diag(r) };
78+
}
79+
80+
async function probeKma(key) {
81+
const r = await getRes(
82+
build("https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0/getVilageFcst", { dataType: "JSON", base_date: KST(), base_time: "0500", nx: 92, ny: 131, numOfRows: 1, pageNo: 1 }, key),
83+
);
84+
const p = parseMaybeJson(r.text);
85+
const code = p.json?.response?.header?.resultCode;
86+
if (code === "00" || code === "03") return { ok: true, msg: `resultCode=${code}` };
87+
return { ok: false, msg: p.xmlError ?? diag(r) };
88+
}
89+
90+
async function probeAirKorea(key) {
91+
const r = await getRes(
92+
build("https://apis.data.go.kr/B552584/ArpltnInforInqireSvc/getCtprvnRltmMesureDnsty", { returnType: "json", sidoName: "강원", numOfRows: 1, pageNo: 1, ver: "1.0" }, key),
93+
);
94+
const p = parseMaybeJson(r.text);
95+
if (p.json?.response?.header?.resultCode === "00")
96+
return { ok: true, msg: `강원 측정소 ${p.json.response.body?.totalCount ?? "?"}곳` };
97+
return { ok: false, msg: p.xmlError ?? diag(r) };
98+
}
99+
100+
const CHECKS = [
101+
{ name: "1. 국문 관광정보(TourAPI)", env: "TOUR_API_SERVICE_KEY", probe: probeTourApi },
102+
{ name: "4. 기상청 단기예보", env: "KMA_SERVICE_KEY", probe: probeKma },
103+
{ name: "5. 기상청 생활기상지수", env: "KMA_SERVICE_KEY", manual: "단기예보와 동일 KMA 키(4번으로 검증)" },
104+
{ name: "6. 에어코리아 대기오염", env: "AIRKOREA_SERVICE_KEY", probe: probeAirKorea },
105+
{ name: "2. 관광 빅데이터 방문자수", env: "TOUR_BIGDATA_SERVICE_KEY", manual: "파라미터 확정 후 자동화 예정" },
106+
{ name: "3. 관광지 집중률 예측", env: "TOUR_BIGDATA_SERVICE_KEY", manual: "데이터랩 형태 확인 후 자동화 예정" },
107+
{ name: "7. 한국도로공사 실시간 소통", env: "EX_ROAD_SERVICE_KEY", manual: "data.ex.co.kr 확정 후 자동화 예정" },
108+
];
109+
110+
const C = { green: "\x1b[32m", red: "\x1b[31m", gray: "\x1b[90m", yellow: "\x1b[33m", cyan: "\x1b[36m", reset: "\x1b[0m" };
111+
const fp = (k) => (k ? `len=${k.length}${k.slice(-4)}` : "(없음)");
112+
113+
// ── 키 지문 (같은 값인지 확인) ───────────────
114+
console.log("\n공공 API 연결 검증\n──────────────────────────────");
115+
const tour = process.env.TOUR_API_SERVICE_KEY;
116+
const kma = process.env.KMA_SERVICE_KEY;
117+
const air = process.env.AIRKOREA_SERVICE_KEY;
118+
console.log(`${C.cyan}키 지문${C.reset}`);
119+
console.log(` TOUR ${fp(tour)}`);
120+
console.log(` KMA ${fp(kma)}${kma && tour ? (kma === tour ? ` ${C.gray}(TOUR와 동일)${C.reset}` : ` ${C.yellow}(TOUR와 다름!)${C.reset}`) : ""}`);
121+
console.log(` AIRKOREA ${fp(air)}${air && tour ? (air === tour ? ` ${C.gray}(TOUR와 동일)${C.reset}` : ` ${C.yellow}(TOUR와 다름!)${C.reset}`) : ""}`);
122+
console.log(` ${C.gray}※ data.go.kr 는 계정당 인증키 1개 — 세 값이 같아야 정상인 경우가 많음${C.reset}`);
123+
console.log("──────────────────────────────");
124+
125+
for (const check of CHECKS) {
126+
const key = process.env[check.env];
127+
if (!key) {
128+
console.log(`${C.gray}${check.name} — 키없음 (${check.env})${C.reset}`);
129+
continue;
130+
}
131+
if (check.manual) {
132+
console.log(`${C.yellow}? ${check.name} — 키 설정됨, ${check.manual}${C.reset}`);
133+
continue;
134+
}
135+
try {
136+
const r = await check.probe(key);
137+
console.log(`${r.ok ? C.green + "✓" : C.red + "✗"} ${check.name}${r.msg}${C.reset}`);
138+
} catch (e) {
139+
console.log(`${C.red}${check.name}${e instanceof Error ? e.message : String(e)}${C.reset}`);
140+
}
141+
}
142+
console.log("──────────────────────────────\n");
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// ─────────────────────────────────────────────
2+
// 공공데이터포털(data.go.kr) 공통 fetch 헬퍼 — 서버 전용.
3+
//
4+
// 클라이언트에서 import 금지 (서비스키 노출 방지). Route Handler / Server Component /
5+
// 서버 액션에서만 호출한다.
6+
//
7+
// 키 주의: 포털 발급 키는 Encoding/Decoding 2종. .env 에는 Decoding(원본) 키를 넣고,
8+
// 여기서 URLSearchParams 로 한 번만 인코딩한다. (Encoding 키를 넣으면 이중 인코딩되어 실패)
9+
// ─────────────────────────────────────────────
10+
11+
export class PublicApiError extends Error {
12+
constructor(
13+
message: string,
14+
readonly detail?: string,
15+
) {
16+
super(message);
17+
this.name = "PublicApiError";
18+
}
19+
}
20+
21+
export interface CallOptions {
22+
serviceKey: string;
23+
/** 타임아웃(ms). 기본 8초 */
24+
timeoutMs?: number;
25+
}
26+
27+
/**
28+
* data.go.kr 계열 OpenAPI 를 호출하고 JSON 으로 파싱한다.
29+
*
30+
* @param endpoint 오퍼레이션까지 포함한 전체 URL (예: `${BASE}/areaBasedList2`)
31+
* @param params serviceKey 를 제외한 쿼리 파라미터 (포맷 파라미터는 각 클라이언트가 지정)
32+
*/
33+
export async function callDataGoKr<T = unknown>(
34+
endpoint: string,
35+
params: Record<string, string | number>,
36+
opts: CallOptions,
37+
): Promise<T> {
38+
const url = new URL(endpoint);
39+
// serviceKey 는 Decoding(원본) 기준 — URLSearchParams 가 한 번 인코딩.
40+
url.searchParams.set("serviceKey", opts.serviceKey);
41+
for (const [k, v] of Object.entries(params)) {
42+
url.searchParams.set(k, String(v));
43+
}
44+
45+
const controller = new AbortController();
46+
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 8000);
47+
48+
let res: Response;
49+
try {
50+
res = await fetch(url, { signal: controller.signal, headers: { Accept: "application/json" } });
51+
} catch (e) {
52+
const reason = e instanceof Error && e.name === "AbortError" ? "타임아웃" : "네트워크 오류";
53+
throw new PublicApiError(`공공API 요청 실패 (${reason})`, endpoint);
54+
} finally {
55+
clearTimeout(timer);
56+
}
57+
58+
const text = await res.text();
59+
if (!res.ok) {
60+
throw new PublicApiError(`공공API HTTP ${res.status}`, text.slice(0, 300));
61+
}
62+
63+
// 키 오류 등은 JSON 을 요청해도 XML 에러 봉투로 오는 경우가 있다.
64+
const trimmed = text.trimStart();
65+
if (trimmed.startsWith("<")) {
66+
const msg = /<returnAuthMsg>(.*?)<\/returnAuthMsg>/.exec(trimmed)?.[1];
67+
const code = /<returnReasonCode>(.*?)<\/returnReasonCode>/.exec(trimmed)?.[1];
68+
throw new PublicApiError(
69+
`공공API 오류 응답${msg ? ` (${msg}${code ? ` / ${code}` : ""})` : ""}`,
70+
trimmed.slice(0, 300),
71+
);
72+
}
73+
74+
try {
75+
return JSON.parse(text) as T;
76+
} catch {
77+
throw new PublicApiError("공공API 응답 파싱 실패(JSON 아님)", text.slice(0, 300));
78+
}
79+
}

0 commit comments

Comments
 (0)