fix: 지오코딩 count 10→20 — "New York"이 네브래스카 York로 잡히던 문제

고르는 규칙(나라 명시 우선, 아니면 인구 최다)은 멀쩡했다. 문제는 후보
목록에 뉴욕시가 아예 없었던 것이다. count=10으로 받으면 응답이
York(네브래스카, 7,864명) / Florence(인디애나, 80명) / New York(영국) /
New York(자메이카)로 채워지고 인구 880만의 뉴욕시는 10위 밖으로 밀린다.
그래서 "남은 것 중 최다"가 네브래스카 York를 골랐다 — 에러 없이 조용히.

count=20이면 뉴욕시가 후보에 들어온다. 주요 도시 23곳을 10 vs 20으로
대조했고 바뀐 건 New York 하나뿐이다: York는 영국 요크 유지(뉴욕에
끌려가지 않음), Rome/Paris/London/Cairo/Sydney/Springfield 동일,
Los Angeles·Mexico City·Ho Chi Minh City 등 복합어 10곳도 동일.

후보 선택을 pickGeocodeResult()로 빼서 네트워크 없이 회귀 검증되게 했다.
당시 실제 응답을 픽스처로 넣고, 이 함수의 한계도 테스트로 명시했다 —
목록 밖의 정답은 구제할 수 없으므로 방어선은 호출부의 count이고, 둘을
한 몸으로 봐야 한다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-08-16 17:34:21 +09:00
co-authored by Claude Opus 5
parent d0ddce1361
commit c4911520f7
2 changed files with 94 additions and 3 deletions
+24 -3
View File
@@ -352,6 +352,22 @@ export function lookupKoreanPlace(name: string): { lat: number; lon: number } |
return k ? (getStationPlaceIndex().get(k) ?? null) : null;
}
// 지오코딩 후보 중 하나 고르기. 나라가 명시됐으면 그 나라를 먼저 찾고, 아니면 인구 최다를
// 고른다 — Open-Meteo는 "Rome" 같은 맨 질의에 미국의 작은 동명 도시를 국제적으로 알려진
// 도시보다 위에 올리는 일이 잦아서 results[0]을 그냥 쓰면 안 된다.
//
// 다만 이 규칙은 **후보 목록 안에 정답이 들어 있을 때만** 작동한다. count가 작으면 정답이
// 목록 밖으로 밀려나고, 그러면 남은 것 중 최다를 골라 조용히 틀린다(New York → 네브래스카
// York 사건). 그래서 호출부의 count와 이 함수는 한 몸으로 봐야 한다.
export function pickGeocodeResult(results: any[], countryCode?: string): any | null {
if (!results?.length) return null;
if (countryCode) {
const byCountry = results.find((x: any) => String(x.country_code || '').toUpperCase() === countryCode);
if (byCountry) return byCountry;
}
return results.reduce((best: any, cur: any) => (cur.population || 0) > (best.population || 0) ? cur : best, results[0]);
}
export async function geocodeCity(name: string): Promise<{ lat: number; lon: number; displayName: string } | null> {
const key = name.trim().toLowerCase();
if (KR_CITY_COORDS[key]) {
@@ -365,7 +381,12 @@ export async function geocodeCity(name: string): Promise<{ lat: number; lon: num
if (stn) return { lat: stn.lat, lon: stn.lon, displayName: name };
}
const { city, countryCode } = parseExplicitCountry(name);
const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=10&language=ko&format=json`;
// count=10이면 "New York"에 뉴욕시(인구 880만)가 아예 안 딸려온다 — 후보 10개가
// York(네브래스카, 7,864명), Florence(인디애나, 80명), New York(영국/자메이카)로 채워지고,
// 아래 "인구 최다" 규칙이 그중 최다인 네브래스카 York를 고른다. 에러 없이 조용히 틀린다.
// count=20이면 뉴욕시가 후보에 들어온다. 2026-08-16에 주요 도시 23곳을 10 vs 20으로
// 대조했더니 바뀐 건 New York 하나뿐이라, 다른 지명 선택엔 영향이 없다.
const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=20&language=ko&format=json`;
// Open-Meteo geocoding throws a transient network/5xx blip now and then that
// clears on an immediate retry. Observed 2026-07-29: a single Madrid call in
// the middle of a 6-city burst returned "위치를 찾을 수 없습니다" while every
@@ -383,8 +404,8 @@ export async function geocodeCity(name: string): Promise<{ lat: number; lon: num
// Prefer an explicit country match; otherwise fall back to the most populous
// candidate rather than index 0 (Open-Meteo often ranks a small US namesake
// above the internationally-known city for a bare query like "Rome").
let r = countryCode ? results.find((x: any) => String(x.country_code || '').toUpperCase() === countryCode) : undefined;
if (!r) r = results.reduce((best: any, cur: any) => (cur.population || 0) > (best.population || 0) ? cur : best, results[0]);
const r = pickGeocodeResult(results, countryCode);
if (!r) return null;
return { lat: r.latitude, lon: r.longitude, displayName: `${r.name}${r.country ? ', ' + r.country : ''}` };
} catch (err: any) {
if (attempt >= 3) break;
+70
View File
@@ -0,0 +1,70 @@
/**
* geocode-pick.test.ts
*
* Open-Meteo 지오코딩 후보 중 하나를 고르는 규칙.
*
* 2026-08-16: "New York"이 네브래스카의 York(인구 7,864)로 잡히고 있었다. 고르는 규칙은
* 멀쩡했다 — 문제는 **후보 목록에 뉴욕시가 아예 없었다**는 것이다. count=10으로 받으면
* 응답이 York(NE)·Florence(IN)·New York(영국)·New York(자메이카)로 채워지고 인구 880만의
* 뉴욕시는 10위 밖으로 밀린다. 그래서 "남은 것 중 최다"가 네브래스카를 골랐다.
*
* count=20으로 올려 고쳤다. 주요 도시 23곳을 10 vs 20으로 대조했을 때 바뀐 건 New York
* 하나뿐이었다(York→영국 요크, Rome→로마, Springfield→일리노이 등 전부 그대로).
*
* 아래 후보 배열은 그때 실제로 받은 응답에서 추린 것이다. 이 테스트는 네트워크를 안 탄다 —
* count 회귀 자체는 못 잡지만, "정답이 목록에 있으면 반드시 그걸 고른다"를 고정한다.
*/
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { pickGeocodeResult } from '../src/tools/weather';
// count=10일 때 실제로 돌아온 목록 — 뉴욕시가 없다.
const NY_COUNT10 = [
{ name: 'York', country_code: 'US', country: '미국', population: 7864, latitude: 40.87, longitude: -97.59 },
{ name: 'Florence', country_code: 'US', country: '미국', population: 80, latitude: 38.78, longitude: -84.92 },
{ name: 'New York', country_code: 'GB', country: '영국', latitude: 53.08, longitude: -0.14 },
{ name: 'New York', country_code: 'JM', country: '자메이카', latitude: 18.25, longitude: -77.18 },
];
// count=20이면 뉴욕시가 들어온다.
const NY_COUNT20 = [...NY_COUNT10,
{ name: '뉴욕', country_code: 'US', country: '미국', population: 8804190, latitude: 40.71, longitude: -74.01 }];
describe('pickGeocodeResult', () => {
test('정답이 목록에 있으면 인구 최다로 뉴욕시를 고른다', () => {
const r = pickGeocodeResult(NY_COUNT20);
assert.equal(r.name, '뉴욕');
assert.equal(Math.round(r.latitude), 41);
});
test('목록에 없으면 못 고른다 — 이게 count=10일 때의 실제 실패', () => {
// 규칙의 한계를 명시해 둔다: 이 함수는 목록 밖의 정답을 구제할 수 없다.
// 방어선은 호출부의 count이고, 그래서 둘을 한 몸으로 봐야 한다.
const r = pickGeocodeResult(NY_COUNT10);
assert.equal(r.name, 'York');
});
test('나라가 명시되면 인구를 제치고 그 나라를 고른다', () => {
const r = pickGeocodeResult(NY_COUNT20, 'GB');
assert.equal(r.country_code, 'GB');
});
test('명시된 나라가 목록에 없으면 인구 최다로 물러난다', () => {
const r = pickGeocodeResult(NY_COUNT20, 'FR');
assert.equal(r.name, '뉴욕');
});
test('인구 필드가 전부 비어도 첫 후보를 돌려준다', () => {
// Open-Meteo는 population을 통째로 비워 보내는 경우가 있다(구미 사건의 원인이기도 했다).
const none = [
{ name: 'A', latitude: 1, longitude: 1 },
{ name: 'B', latitude: 2, longitude: 2 },
];
assert.equal(pickGeocodeResult(none).name, 'A');
});
test('빈 목록은 null', () => {
assert.equal(pickGeocodeResult([]), null);
assert.equal(pickGeocodeResult(undefined as any), null);
});
});