feat(backend): 기상청 단기예보와 공휴일 표 추가
CI / Frontend (lint · type-check · test) (pull_request) Successful in 11m5s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m31s
CI / Backend (lint · test · e2e · build) (pull_request) Successful in 12m9s
CI / Frontend (lint · type-check · test) (push) Successful in 11m12s
CI / Backend (lint · test · e2e · build) (push) Successful in 11m46s
CI / 에러 코드 SSOT 동기화 검사 (push) Successful in 11m8s

AI 코스 조건 해석·거르기(설계 §3.3)에 그날 날씨와 공휴일이 필요하다. 강수확률 40% 이상이면 실내 위주로 짜고,
공휴일·명절에 쉬는 곳은 후보에서 뺀다. 4단계(API 골격)에서 생성 작업에 연결한다.

- 공휴일 표(2026–2027): 설·추석 연휴·대체공휴일·지방선거일, 2026년부터 공휴일이 된 노동절·제헌절(대체공휴일 포함).
  대체공휴일 규칙을 테스트로 검사해 해마다 표를 갱신할 때 빠뜨린 날을 잡는다
- 공휴일 거르기를 명절 종류에 맞췄다 — 설·추석 당일 휴관인 곳을 어린이날에 빼지 않고, '공휴일' 휴관은 모든 공휴일에 뺀다
- 날씨: 위경도 → 5km 격자, 가장 최근 발표(자료가 아직 없으면 앞 발표), 시간 범위의 최고 강수확률·강수 형태·하늘·기온.
  1시간 캐시, 3초·2번 시도, 하루 1,000번, 실패하면 null(입력 화면은 '예보 없음')
- 공공데이터 공통 클라이언트에 호출 제한 시간·시도 횟수 설정을 더했다(기존 동기화는 그대로 20초·3번)
- 실제 호출 확인 명령 weather:check

2026-11-08 실제 호출: 대구 도심 격자 89,90, 14시 발표로 11-08~11-11 예보, 11-12 는 예보 없음(호출 1번).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #12.
This commit is contained in:
2026-11-08 15:39:58 +09:00
parent d2cd5cd96e
commit 187a99ca7f
20 changed files with 1547 additions and 16 deletions
@@ -0,0 +1,23 @@
import { CacheModule } from '@nestjs/cache-manager';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { WeatherModule } from '@/modules/weather/weather.module';
import { validateEnv } from '@/shared/config/env.validation';
/**
* 날씨 확인 CLI 전용 루트 모듈 — HTTP·DB 없이 설정·캐시·날씨만 올린다.
* 설정은 API 서버와 같은 경로로 읽는다(backend/.env.{NODE_ENV} + compose 주입값).
*/
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: `.env.${process.env.NODE_ENV || 'development'}`,
validate: validateEnv,
}),
CacheModule.register({ isGlobal: true }),
WeatherModule,
],
})
export class WeatherCheckCliModule {}
+113
View File
@@ -0,0 +1,113 @@
import 'reflect-metadata';
import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { WinstonModule } from 'nest-winston';
import { WeatherService } from '@/modules/weather/weather.service';
import { toKmaGrid } from '@/modules/weather/kma-grid';
import { winstonConfig } from '@/shared/logger/winston.config';
import { addDaysIso } from '@/shared/utils/iso-date';
import { kstDateString } from '@/shared/utils/kst';
import { WeatherCheckCliModule } from './weather-check.cli.module';
/**
* 기상청 단기예보 실제 호출 확인 (설계: docs/design/ai-course.md §3.3).
* 공공데이터포털 키(PUBLIC_DATA_SERVICE_KEY)를 쓰므로 키가 있는 환경(개발 컨테이너)에서 실행한다. 호출은 1~2번이다.
*
* docker exec wandergo-backend npm run weather:check -- [--lat=35.87021] [--lng=128.59409] [--start=10:00] [--end=20:00]
*/
const USAGE = `사용법: weather:check [옵션]
오늘부터 4일 뒤까지, 날마다 시간 범위의 강수확률·하늘·기온을 보여 준다(예보가 닿지 않는 날은 '예보 없음')
--lat=35.87021 --lng=128.59409 위치 (기본: 대구 도심)
--start=10:00 --end=20:00 시간 범위 (기본 10:00~20:00)`;
/** 기본 위치 — 대구 도심(앱 동네 'downtown' 의 중심) */
const DEFAULT_POINT = { lat: 35.87021, lng: 128.59409 };
const DEFAULT_START = '10:00';
const DEFAULT_END = '20:00';
/** 오늘부터 며칠 뒤까지 보여 줄지 — 단기예보는 길어야 4일 뒤까지다 */
const DAYS_TO_SHOW = 5;
function option(argv: string[], name: string): string | undefined {
return argv
.find((a) => a.startsWith(`--${name}=`))
?.slice(name.length + 3)
.trim();
}
function toMinutes(hhmm: string): number | null {
const m = /^(\d{1,2}):(\d{2})$/.exec(hhmm);
if (!m) return null;
const minutes = Number(m[1]) * 60 + Number(m[2]);
return minutes <= 1440 ? minutes : null;
}
async function main(): Promise<void> {
const argv = process.argv.slice(2);
const lat = Number(option(argv, 'lat') ?? DEFAULT_POINT.lat);
const lng = Number(option(argv, 'lng') ?? DEFAULT_POINT.lng);
const start = toMinutes(option(argv, 'start') ?? DEFAULT_START);
const end = toMinutes(option(argv, 'end') ?? DEFAULT_END);
if (
!Number.isFinite(lat) ||
!Number.isFinite(lng) ||
start === null ||
end === null ||
end <= start
) {
Logger.error(USAGE, 'WeatherCheck');
process.exitCode = 1;
return;
}
const app = await NestFactory.createApplicationContext(
WeatherCheckCliModule,
{
logger: WinstonModule.createLogger(winstonConfig),
},
);
try {
const weather = app.get(WeatherService);
const now = new Date();
const grid = toKmaGrid(lat, lng);
Logger.log(`격자 ${grid.nx},${grid.ny}`, 'WeatherCheck');
let found = 0;
for (let day = 0; day < DAYS_TO_SHOW; day++) {
const date = addDaysIso(kstDateString(now), day) ?? kstDateString(now);
const forecast = await weather.forecastWindow(
{ lat, lng },
date,
start,
end,
now,
);
if (!forecast) {
Logger.log(`${date} 예보 없음`, 'WeatherCheck');
continue;
}
found += 1;
Logger.log(
`${date} 강수확률 ${forecast.precipitationProbability}% (${forecast.precipitationType}) · ` +
`하늘 ${forecast.sky ?? '미확인'} · 기온 ${forecast.temperatureMin ?? '?'}~${forecast.temperatureMax ?? '?'}℃ · ` +
`발표 ${forecast.issuedAt}`,
'WeatherCheck',
);
}
if (found === 0) {
// 키 없음·호출 실패면 위 경고 로그에 이유가 있다
Logger.error('예보를 하나도 받지 못했다', 'WeatherCheck');
process.exitCode = 1;
}
} finally {
await app.close();
}
}
main().catch((error: unknown) => {
// 오류 메시지에는 키가 들어가지 않는다(클라이언트가 가린다)
Logger.error(
error instanceof Error ? error.message : String(error),
'WeatherCheck',
);
process.exitCode = 1;
});
@@ -44,10 +44,18 @@ describe('exclusionReason', () => {
).toBeNull();
});
it('공휴일·명절이고 명절·공휴일 휴무 표시가 있으면 뺀다', () => {
it('그날 공휴일의 휴무 표시가 있으면 뺀다(설·추석은 연휴 내내, 공휴일 휴무는 모든 공휴일)', () => {
const museum = place({ restText: '매주 월요일 / 설날·추석 당일' });
expect(exclusionReason(museum, context({ holiday: true }))).toBe('holiday');
expect(exclusionReason(museum, context({ holiday: 'seollal' }))).toBe(
'holiday',
);
// 어린이날에는 설·추석 휴무인 곳을 빼지 않는다
expect(exclusionReason(museum, context({ holiday: 'public' }))).toBeNull();
expect(exclusionReason(museum, ctx)).toBeNull();
const closedOnHolidays = place({ restText: '매주 월요일 / 공휴일' });
expect(
exclusionReason(closedOnHolidays, context({ holiday: 'public' })),
).toBe('holiday');
});
it('반려동물 조건이면 실내 미확인·동반 불가·크기 불일치를 빼고 야외 미확인은 남긴다', () => {
@@ -3,6 +3,7 @@
* 비는 거르지 않는다(시간표 검증에서 실내 비율을 본다).
*/
import { distanceMeters } from '@/shared/utils/geo';
import { closedOnHoliday } from '@/shared/utils/kr-holidays';
import { scheduleEnd, stayAvailability } from './availability';
import {
@@ -44,7 +45,7 @@ function isRunningOn(c: Candidate, date: string): boolean {
* 후보에서 빼야 하는 이유. 남기면 null. 비싼 영업 확인을 마지막에 한다.
* - 영업: 시간 범위 안에서 분류별 최소 머무는 시간만큼 열려 있어야 한다
* - 영업시간 미확인: 미확인인 시각을 끼워야만 머물 수 있으면, 자연·관광지와 행사만 남긴다
* - 공휴일: 그날이 공휴일·명절이고 휴무 표시(명절·공휴일 휴무)가 있으면 뺀다
* - 공휴일: 그날이 공휴일·명절이고 그 공휴일의 휴무 표시가 있으면 뺀다('공휴일' 휴무는 모든 공휴일, 설·추석은 연휴 내내)
*/
export function exclusionReason(
c: Candidate,
@@ -57,7 +58,11 @@ export function exclusionReason(
if (c.kind === 'event' && !isRunningOn(c, ctx.date)) {
return 'event_not_running';
}
if (ctx.holiday && (c.hours?.holidayClosures.length ?? 0) > 0) {
if (
ctx.holiday &&
c.hours &&
closedOnHoliday(c.hours.holidayClosures, ctx.holiday)
) {
return 'holiday';
}
const pet = petExclusion(c, ctx.pet);
@@ -98,7 +98,7 @@ export function context(overrides: Partial<PlanContext> = {}): PlanContext {
mobility: 'walk',
center: CENTER,
pet: null,
holiday: false,
holiday: null,
preferIndoor: false,
previousIds: [],
...overrides,
@@ -10,6 +10,7 @@ import type {
PlaceSetting,
} from '@/modules/place/place.constants';
import type { LatLng } from '@/shared/utils/geo';
import type { HolidayKind } from '@/shared/utils/kr-holidays';
import type { ParsedOpeningHours } from '@/shared/utils/opening-hours';
import type { Mobility } from './planner.constants';
@@ -56,8 +57,8 @@ export interface PlanContext {
/** 지역 중심(동네 중심 · 현재 위치 · 기준 장소) */
center: LatLng;
pet: PetCondition | null;
/** 그날이 공휴일·명절인가(공휴일 표는 3단계에서 붙인다) */
holiday: boolean;
/** 그날의 공휴일 종류(shared/utils/kr-holidays 의 holidayOn). 공휴일이 아니면 null */
holiday: HolidayKind | null;
/** 비 조건 — 강수확률 40% 이상이고 indoorIfRain(prefersIndoor) */
preferIndoor: boolean;
/** 다시 만들기 — 이전 결과의 장소·행사 id */
@@ -48,6 +48,10 @@ export interface PublicDataClientSettings {
keyParam?: string;
/** 연속 호출 최소 간격(ms) */
intervalMs?: number;
/** 호출 하나의 제한 시간(ms) — 사용자 요청 중에 부르는 API(날씨)는 짧게 둔다 */
timeoutMs?: number;
/** 네트워크 오류·5xx 때 몇 번까지 시도할지(처음 호출 포함) */
maxAttempts?: number;
}
const defaultSleep = (ms: number) =>
@@ -88,6 +92,8 @@ export abstract class PublicDataClient<Page> {
private readonly sleep: (ms: number) => Promise<void>;
private readonly keyParam: string;
private readonly intervalMs: number;
private readonly timeoutMs: number;
private readonly maxAttempts: number;
private lastCallAt = 0;
protected constructor(
@@ -100,6 +106,8 @@ export abstract class PublicDataClient<Page> {
this.sleep = options.sleep ?? defaultSleep;
this.keyParam = settings.keyParam ?? 'serviceKey';
this.intervalMs = settings.intervalMs ?? DEFAULT_CALL_INTERVAL_MS;
this.timeoutMs = settings.timeoutMs ?? REQUEST_TIMEOUT_MS;
this.maxAttempts = settings.maxAttempts ?? MAX_ATTEMPTS;
}
/** 응답 본문 → 페이지. 결과 코드가 오류면 PublicDataApiError / BudgetExceededError 를 던진다 */
@@ -124,12 +132,12 @@ export abstract class PublicDataClient<Page> {
const url = `${this.options.baseUrl ?? this.baseUrl}/${path}?${query.toString()}`;
let lastError: unknown;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
for (let attempt = 1; attempt <= this.maxAttempts; attempt += 1) {
this.options.budget.take(operation);
await this.pace();
try {
const res = await this.fetchImpl(url, {
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
signal: AbortSignal.timeout(this.timeoutMs),
});
const body = await res.text();
if (res.status >= 500) {
@@ -153,13 +161,15 @@ export abstract class PublicDataClient<Page> {
}
lastError = error; // 네트워크 오류·시간 초과 → 다시 시도
}
if (attempt < MAX_ATTEMPTS) await this.sleep(RETRY_DELAY_MS * attempt);
if (attempt < this.maxAttempts) {
await this.sleep(RETRY_DELAY_MS * attempt);
}
}
const reason =
lastError instanceof Error ? lastError.message : String(lastError);
throw this.mask(
new PublicDataApiError(
`${operation}: ${MAX_ATTEMPTS}회 시도 실패 — ${reason}`,
`${operation}: ${this.maxAttempts}회 시도 실패 — ${reason}`,
),
);
}
@@ -0,0 +1,108 @@
import {
BudgetExceededError,
CallBudget,
} from '@/modules/place/sync/call-budget';
import {
type FetchLike,
PublicDataApiError,
} from '@/modules/place/sync/public-data.client';
import { KmaForecastClient, parseKmaPage } from './kma-forecast.client';
const KEY = 'abc+def/ghi==';
const body = (resultCode: string, item?: unknown, totalCount = 1) =>
JSON.stringify({
response: {
header: { resultCode, resultMsg: 'X' },
body: item === undefined ? undefined : { items: { item }, totalCount },
},
});
function client(
responses: Array<{ status?: number; body?: string; throws?: Error }>,
) {
const urls: string[] = [];
const fetchImpl: FetchLike = (url) => {
urls.push(url);
const next = responses.shift();
if (!next) throw new Error('예상보다 많이 호출했다');
if (next.throws) return Promise.reject(next.throws);
const status = next.status ?? 200;
return Promise.resolve({
ok: status < 400,
status,
text: () => Promise.resolve(next.body ?? ''),
});
};
const api = new KmaForecastClient({
serviceKey: KEY,
budget: new CallBudget(100),
fetchImpl,
sleep: () => Promise.resolve(),
});
return { api, urls };
}
describe('parseKmaPage', () => {
it('정상(00)이면 항목을, 03(NODATA)이면 자료 없음을 돌려준다', () => {
const item = {
category: 'POP',
fcstDate: '20261109',
fcstTime: '1500',
fcstValue: '60',
};
expect(parseKmaPage(body('00', [item], 1), 'getVilageFcst')).toEqual({
items: [item],
totalCount: 1,
noData: false,
});
expect(parseKmaPage(body('03'), 'getVilageFcst')).toEqual({
items: [],
totalCount: 0,
noData: true,
});
});
it('22 는 한도 초과, 나머지 코드는 오류다', () => {
expect(() => parseKmaPage(body('22'), 'getVilageFcst')).toThrow(
BudgetExceededError,
);
expect(() => parseKmaPage(body('10'), 'getVilageFcst')).toThrow(
/getVilageFcst: 결과 코드 10/,
);
});
});
describe('KmaForecastClient', () => {
it('발표 시각·격자·JSON 을 실어 부른다', async () => {
const { api, urls } = client([{ body: body('00', [], 0) }]);
await api.forecast({ date: '2026-11-08', hour: 11 }, { nx: 89, ny: 90 });
const url = new URL(urls[0]);
expect(url.pathname).toBe(
'/1360000/VilageFcstInfoService_2.0/getVilageFcst',
);
expect(Object.fromEntries(url.searchParams)).toMatchObject({
serviceKey: KEY,
dataType: 'JSON',
base_date: '20261108',
base_time: '1100',
nx: '89',
ny: '90',
});
});
it('입력 화면을 막지 않게 두 번까지만 시도하고, 오류에 키를 남기지 않는다', async () => {
const { api, urls } = client([
{ throws: new Error(`connect ETIMEDOUT ${KEY}`) },
{ throws: new Error('connect ETIMEDOUT') },
]);
const error = await api
.forecast({ date: '2026-11-08', hour: 11 }, { nx: 89, ny: 90 })
.catch((e: unknown) => e);
expect(error).toBeInstanceOf(PublicDataApiError);
expect((error as Error).message).toContain('2회 시도 실패');
expect((error as Error).message).not.toContain(KEY);
expect(urls).toHaveLength(2);
});
});
@@ -0,0 +1,102 @@
/**
* 기상청 단기예보 조회서비스(공공데이터포털 VilageFcstInfoService_2.0) 호출.
* 공공데이터 공통 클라이언트(키 가리기·한도·재시도)를 쓰되, 사용자 요청 중에 부르므로 제한 시간을 짧게 둔다.
*/
import { BudgetExceededError } from '@/modules/place/sync/call-budget';
import {
asRecord,
parseJsonOrThrow,
PublicDataApiError,
PublicDataClient,
type PublicDataClientOptions,
type PublicDataItem,
toItems,
} from '@/modules/place/sync/public-data.client';
import type { KmaGrid } from './kma-grid';
import { issueParams, type KmaIssue } from './kma-forecast';
export const KMA_FORECAST_BASE_URL =
'https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0';
export const KMA_FORECAST_OPERATION = 'getVilageFcst';
/** 한 번에 받을 행 수 — 발표 하나가 약 850~1,000행이다(시간당 12개 요소 × 최대 4일) */
const ROWS_PER_PAGE = 1500;
/** 입력 화면을 오래 막지 않게 짧게 두고, 한 번만 다시 시도한다 */
const TIMEOUT_MS = 3_000;
const MAX_ATTEMPTS = 2;
const RESULT_OK = '00';
const RESULT_NO_DATA = '03';
const RESULT_QUOTA_EXCEEDED = '22';
export interface KmaPage {
items: PublicDataItem[];
totalCount: number;
/** 결과 코드 03(NODATA_ERROR) — 발표 직후라 아직 자료가 없을 때 온다 */
noData: boolean;
}
/**
* 응답 → 항목. `{ response: { header: { resultCode }, body: { items: { item }, totalCount } } }` 이고 정상 코드는 '00'.
* 03 은 오류가 아니라 '아직 자료 없음'으로 돌려준다(앞 발표로 다시 받는다).
*/
export function parseKmaPage(body: string, operation: string): KmaPage {
const response = asRecord(
asRecord(parseJsonOrThrow(body, operation))?.response,
);
const header = asRecord(response?.header);
const code = typeof header?.resultCode === 'string' ? header.resultCode : '';
if (code === RESULT_NO_DATA)
return { items: [], totalCount: 0, noData: true };
if (code !== RESULT_OK) {
if (code === RESULT_QUOTA_EXCEEDED) {
throw new BudgetExceededError(operation, 'server');
}
const msg = typeof header?.resultMsg === 'string' ? header.resultMsg : '';
throw new PublicDataApiError(
`${operation}: 결과 코드 ${code || '없음'} ${msg}`.trim(),
);
}
const resBody = asRecord(response?.body);
const items = toItems(asRecord(resBody?.items)?.item);
const totalCount = Number(resBody?.totalCount ?? items.length);
return {
items,
totalCount: Number.isFinite(totalCount) ? totalCount : items.length,
noData: false,
};
}
/** 날씨 서비스가 쓰는 창구 — 테스트에서는 가짜로 바꿔 끼운다 */
export interface KmaForecastApi {
forecast(issue: KmaIssue, grid: KmaGrid): Promise<KmaPage>;
}
export class KmaForecastClient
extends PublicDataClient<KmaPage>
implements KmaForecastApi
{
constructor(options: PublicDataClientOptions) {
super(
options,
KMA_FORECAST_BASE_URL,
{ dataType: 'JSON' },
{ timeoutMs: TIMEOUT_MS, maxAttempts: MAX_ATTEMPTS },
);
}
forecast(issue: KmaIssue, grid: KmaGrid): Promise<KmaPage> {
return this.get(KMA_FORECAST_OPERATION, {
pageNo: 1,
numOfRows: ROWS_PER_PAGE,
...issueParams(issue),
nx: grid.nx,
ny: grid.ny,
});
}
protected parse(body: string, operation: string): KmaPage {
return parseKmaPage(body, operation);
}
}
@@ -0,0 +1,242 @@
import {
issuedAtIso,
issueParams,
type KmaIssue,
lastForecastDate,
latestIssue,
parseKmaItems,
previousIssue,
summarizeWindow,
} from './kma-forecast';
const kst = (iso: string) => new Date(`${iso}+09:00`);
const at = (hour: number) => hour * 60;
/** 그날 hours 시각마다 강수확률·기온·하늘·강수 형태 항목을 만든다 */
function items(
date: string,
hours: number[],
value: (hour: number) => {
pop: number;
tmp: number;
sky: number;
pty: number;
},
) {
const out: Record<string, string>[] = [];
for (const hour of hours) {
const v = value(hour);
const base = {
fcstDate: date.replace(/-/g, ''),
fcstTime: `${String(hour).padStart(2, '0')}00`,
};
out.push({ ...base, category: 'POP', fcstValue: String(v.pop) });
out.push({ ...base, category: 'TMP', fcstValue: String(v.tmp) });
out.push({ ...base, category: 'SKY', fcstValue: String(v.sky) });
out.push({ ...base, category: 'PTY', fcstValue: String(v.pty) });
}
return out;
}
const range = (from: number, to: number, step = 1) =>
Array.from(
{ length: Math.floor((to - from) / step) + 1 },
(_, i) => from + i * step,
);
describe('latestIssue · previousIssue', () => {
it('발표 10분 뒤부터 그 발표를 쓴다', () => {
expect(latestIssue(kst('2026-11-08T11:09:00'))).toEqual({
date: '2026-11-08',
hour: 8,
});
expect(latestIssue(kst('2026-11-08T11:10:00'))).toEqual({
date: '2026-11-08',
hour: 11,
});
});
it('02:10 전에는 전날 23시 발표다', () => {
expect(latestIssue(kst('2026-11-08T02:09:00'))).toEqual({
date: '2026-11-07',
hour: 23,
});
expect(latestIssue(kst('2026-11-08T02:10:00'))).toEqual({
date: '2026-11-08',
hour: 2,
});
});
it('앞 발표 — 02시 앞은 전날 23시', () => {
expect(previousIssue({ date: '2026-11-08', hour: 11 })).toEqual({
date: '2026-11-08',
hour: 8,
});
expect(previousIssue({ date: '2026-11-01', hour: 2 })).toEqual({
date: '2026-10-31',
hour: 23,
});
});
});
describe('issueParams · lastForecastDate · issuedAtIso', () => {
it('요청 값은 YYYYMMDD · HH00 이다', () => {
expect(issueParams({ date: '2026-11-08', hour: 2 })).toEqual({
base_date: '20261108',
base_time: '0200',
});
});
it('02~14시 발표는 글피(3일 뒤), 17~23시 발표는 그글피(4일 뒤)까지 나온다', () => {
expect(lastForecastDate({ date: '2026-11-08', hour: 14 })).toBe(
'2026-11-11',
);
expect(lastForecastDate({ date: '2026-11-08', hour: 17 })).toBe(
'2026-11-12',
);
});
it('발표 시각을 UTC 로 낸다', () => {
expect(issuedAtIso({ date: '2026-11-08', hour: 11 })).toBe(
'2026-11-08T02:00:00.000Z',
);
expect(issuedAtIso({ date: '2026-11-08', hour: 2 })).toBe(
'2026-11-07T17:00:00.000Z',
);
});
});
describe('parseKmaItems', () => {
it('쓰는 요소만 시각별로 모으고 결측값·형식이 다른 값은 버린다', () => {
const forecast = parseKmaItems([
{
category: 'TMP',
fcstDate: '20261109',
fcstTime: '1500',
fcstValue: '14',
},
{
category: 'POP',
fcstDate: '20261109',
fcstTime: '1500',
fcstValue: '60',
},
{
category: 'REH',
fcstDate: '20261109',
fcstTime: '1500',
fcstValue: '80',
},
{
category: 'POP',
fcstDate: '20261109',
fcstTime: '1400',
fcstValue: '30',
},
{
category: 'TMP',
fcstDate: '20261109',
fcstTime: '1400',
fcstValue: '-999',
},
{
category: 'SKY',
fcstDate: '2026119',
fcstTime: '1400',
fcstValue: '4',
},
]);
expect(forecast).toEqual({
'2026-11-09': [
{ minute: at(14), pop: 30, tmp: null, sky: null, pty: null },
{ minute: at(15), pop: 60, tmp: 14, sky: null, pty: null },
],
});
});
});
describe('summarizeWindow', () => {
const issue: KmaIssue = { date: '2026-11-08', hour: 11 };
it('시간 범위 안의 가장 높은 강수확률과 그때의 강수 형태, 기온 범위, 가장 잦은 하늘을 낸다', () => {
const forecast = parseKmaItems([
...items('2026-11-09', range(0, 23), (h) => ({
pop: h === 17 ? 70 : h === 21 ? 90 : 20,
tmp: 10 + (h % 6),
sky: h < 16 ? 3 : 4,
pty: h === 17 ? 1 : h === 21 ? 3 : 0,
})),
]);
expect(
summarizeWindow(forecast, issue, '2026-11-09', at(14), at(20)),
).toEqual({
date: '2026-11-09',
// 21시 90% 는 범위 밖이다
precipitationProbability: 70,
precipitationType: 'rain',
// 14·15시 구름많음, 16~19시 흐림
sky: 'cloudy',
temperatureMin: 10,
temperatureMax: 15,
issuedAt: '2026-11-08T02:00:00.000Z',
});
});
it('3시간 간격 예보는 한 시각이 3시간을 맡는다(12시 값이 14시를 덮는다)', () => {
const forecast = parseKmaItems([
...items('2026-11-11', range(0, 21, 3), (h) => ({
pop: h === 12 ? 60 : 10,
tmp: 12,
sky: 1,
pty: 0,
})),
...items('2026-11-12', [0], () => ({ pop: 0, tmp: 8, sky: 1, pty: 0 })),
]);
const summary = summarizeWindow(
forecast,
issue,
'2026-11-11',
at(14),
at(20),
);
expect(summary?.precipitationProbability).toBe(60);
expect(summary?.sky).toBe('clear');
});
it('예보가 시간 범위 끝까지 닿지 않으면 null', () => {
const forecast = parseKmaItems([
// 글피 다음 날은 0시 값만 온다
...items('2026-11-12', [0], () => ({ pop: 0, tmp: 8, sky: 1, pty: 0 })),
...items('2026-11-10', range(0, 18), () => ({
pop: 0,
tmp: 8,
sky: 1,
pty: 0,
})),
]);
expect(
summarizeWindow(forecast, issue, '2026-11-12', at(14), at(20)),
).toBeNull();
expect(
summarizeWindow(forecast, issue, '2026-11-10', at(14), at(20)),
).toBeNull();
expect(
summarizeWindow(forecast, issue, '2026-11-13', at(14), at(20)),
).toBeNull();
});
it('발표 당일은 이미 지난 시각의 예보가 없어도 된다', () => {
const forecast = parseKmaItems([
...items('2026-11-08', range(12, 23), () => ({
pop: 30,
tmp: 15,
sky: 1,
pty: 0,
})),
]);
expect(
summarizeWindow(forecast, issue, '2026-11-08', at(11), at(17))
?.precipitationProbability,
).toBe(30);
});
});
+252
View File
@@ -0,0 +1,252 @@
/**
* 기상청 단기예보(getVilageFcst) 발표 시각 고르기·응답 해석·시간 범위 요약 (순수 함수).
* 매뉴얼 검토: docs/research/data-openapi-review/store_and_weather.md §4·§5. 설계: docs/design/ai-course.md §3.3
*/
import type { PublicDataItem } from '@/modules/place/sync/public-data.client';
import { addDaysIso } from '@/shared/utils/iso-date';
import { kstClock } from '@/shared/utils/kst';
/** 단기예보 발표 시각(시, 한국 시간) — 하루 8번 */
export const KMA_ISSUE_HOURS: readonly number[] = [2, 5, 8, 11, 14, 17, 20, 23];
/** 발표 뒤 이만큼(분) 지나야 받을 수 있다 */
const AVAILABLE_AFTER_MINUTES = 10;
/** 이 시각 이후 발표는 그글피(4일 뒤)까지, 앞의 발표는 글피(3일 뒤)까지 나온다 */
const LONG_RANGE_FROM_HOUR = 17;
const SHORT_RANGE_DAYS = 3;
const LONG_RANGE_DAYS = 4;
/** 기상청 결측값 — 절댓값이 이보다 크면 버린다(매뉴얼: +900 이상, -900 이하) */
const MISSING_VALUE_ABS = 900;
const MINUTES_PER_HOUR = 60;
const MINUTES_PER_DAY = 1440;
const KST_OFFSET_HOURS = 9;
/** 단기예보 발표 하나 — 한국 날짜 'YYYY-MM-DD' 와 시 */
export interface KmaIssue {
date: string;
hour: number;
}
/** 지금 받을 수 있는 가장 최근 발표 */
export function latestIssue(now: Date): KmaIssue {
const { date, minutes } = kstClock(now);
for (let i = KMA_ISSUE_HOURS.length - 1; i >= 0; i--) {
const hour = KMA_ISSUE_HOURS[i];
if (minutes >= hour * MINUTES_PER_HOUR + AVAILABLE_AFTER_MINUTES) {
return { date, hour };
}
}
return previousIssue({ date, hour: KMA_ISSUE_HOURS[0] });
}
/** 바로 앞 발표 — 최근 발표 자료가 아직 없을 때(발표가 늦어질 때) 쓴다 */
export function previousIssue(issue: KmaIssue): KmaIssue {
const index = KMA_ISSUE_HOURS.indexOf(issue.hour);
if (index > 0) return { date: issue.date, hour: KMA_ISSUE_HOURS[index - 1] };
return {
date: addDaysIso(issue.date, -1) ?? issue.date,
hour: KMA_ISSUE_HOURS[KMA_ISSUE_HOURS.length - 1],
};
}
/** API 요청 값 base_date('YYYYMMDD')·base_time('HH00') */
export function issueParams(issue: KmaIssue): {
base_date: string;
base_time: string;
} {
return {
base_date: issue.date.replace(/-/g, ''),
base_time: `${String(issue.hour).padStart(2, '0')}00`,
};
}
/** 그 발표가 예보를 내는 마지막 날 — 02~14시 발표는 글피, 17~23시 발표는 그글피 */
export function lastForecastDate(issue: KmaIssue): string {
const days =
issue.hour >= LONG_RANGE_FROM_HOUR ? LONG_RANGE_DAYS : SHORT_RANGE_DAYS;
return addDaysIso(issue.date, days) ?? issue.date;
}
/** 발표 시각(ISO 8601 UTC) — API 규약에 맞춰 UTC 로 낸다 */
export function issuedAtIso(issue: KmaIssue): string {
const [y, m, d] = issue.date.split('-').map(Number);
return new Date(
Date.UTC(y, m - 1, d, issue.hour - KST_OFFSET_HOURS),
).toISOString();
}
/** 예보 시각 하나의 값. 받지 못한 요소는 null */
export interface ForecastPoint {
/** 그날 00:00 부터의 분 */
minute: number;
/** 강수확률(%) */
pop: number | null;
/** 기온(℃) */
tmp: number | null;
/** 하늘 상태 코드 — 1 맑음 · 3 구름많음 · 4 흐림 */
sky: number | null;
/** 강수 형태 코드 — 0 없음 · 1 비 · 2 비/눈 · 3 눈 · 4 소나기 */
pty: number | null;
}
/** 날짜('YYYY-MM-DD')별 예보 시각(시각 순) */
export type KmaForecast = Record<string, ForecastPoint[]>;
const CATEGORY_FIELDS: Readonly<
Record<string, keyof Omit<ForecastPoint, 'minute'>>
> = {
POP: 'pop',
TMP: 'tmp',
SKY: 'sky',
PTY: 'pty',
};
/** 응답 값(문자열·숫자로 온다)을 글자로. 다른 형식이면 빈 문자열 */
function text(value: unknown): string {
return typeof value === 'string' || typeof value === 'number'
? String(value)
: '';
}
/**
* 응답 항목({category, fcstDate 'YYYYMMDD', fcstTime 'HHMM', fcstValue}) → 날짜별 예보 시각.
* 쓰는 요소(강수확률·기온·하늘·강수 형태)만 남기고, 결측값·형식이 다른 값은 버린다.
*/
export function parseKmaItems(items: PublicDataItem[]): KmaForecast {
const byDate = new Map<string, Map<number, ForecastPoint>>();
for (const item of items) {
const field = CATEGORY_FIELDS[text(item.category)];
const date = /^(\d{4})(\d{2})(\d{2})$/.exec(text(item.fcstDate));
const time = /^(\d{2})(\d{2})$/.exec(text(item.fcstTime));
const value = Number(text(item.fcstValue) || Number.NaN);
if (!field || !date || !time || !Number.isFinite(value)) continue;
if (Math.abs(value) >= MISSING_VALUE_ABS) continue;
const day = `${date[1]}-${date[2]}-${date[3]}`;
const minute = Number(time[1]) * MINUTES_PER_HOUR + Number(time[2]);
const points = byDate.get(day) ?? new Map<number, ForecastPoint>();
const point = points.get(minute) ?? {
minute,
pop: null,
tmp: null,
sky: null,
pty: null,
};
point[field] = value;
points.set(minute, point);
byDate.set(day, points);
}
const out: KmaForecast = {};
for (const [day, points] of byDate) {
out[day] = [...points.values()].sort((a, b) => a.minute - b.minute);
}
return out;
}
export type PrecipitationType =
| 'none'
| 'rain'
| 'rain_snow'
| 'snow'
| 'shower';
export type SkyState = 'clear' | 'partly_cloudy' | 'cloudy';
const PRECIPITATION_TYPES: Readonly<Record<number, PrecipitationType>> = {
0: 'none',
1: 'rain',
2: 'rain_snow',
3: 'snow',
4: 'shower',
};
const SKY_STATES: Readonly<Record<number, SkyState>> = {
1: 'clear',
3: 'partly_cloudy',
4: 'cloudy',
};
/** 코스 시간 범위의 예보 요약 — 입력 화면("토요일 오후 비 60%")과 결과의 날씨에 쓴다(설계 §3.7) */
export interface WindowForecast {
date: string;
/** 시간 범위 중 가장 높은 강수확률(%) — 40% 이상이면 비 조건(prefersIndoor) */
precipitationProbability: number;
/** 강수확률이 가장 높은 때의 강수 형태 */
precipitationType: PrecipitationType;
/** 시간 범위에서 가장 잦은 하늘 상태(같으면 더 흐린 쪽) */
sky: SkyState | null;
temperatureMin: number | null;
temperatureMax: number | null;
/** 발표 시각(ISO 8601 UTC) — 화면에 "11시 발표"로 보인다 */
issuedAt: string;
}
/** 예보 시각마다 그 값이 맡는 구간 [minute, 다음 예보 시각) — 마지막 시각은 다음 날 0시나 앞 간격만큼 */
function intervals(
points: ForecastPoint[],
nextDayFirst: ForecastPoint | undefined,
): { point: ForecastPoint; from: number; to: number }[] {
return points.map((point, i) => {
const next = points[i + 1]?.minute;
let to: number;
if (next !== undefined) {
to = next;
} else if (nextDayFirst?.minute === 0) {
to = MINUTES_PER_DAY;
} else {
const gap =
i > 0 ? point.minute - points[i - 1].minute : MINUTES_PER_HOUR;
to = Math.min(point.minute + gap, MINUTES_PER_DAY);
}
return { point, from: point.minute, to };
});
}
/**
* 그날 [start, end) 의 예보 요약. 예보가 시간 범위 끝까지 닿지 않으면 null(입력 화면에 '예보 없음').
* 글피·그글피는 3시간 간격이라 한 예보 시각이 3시간을 맡는다. 발표 당일은 이미 지난 시각의 예보가 없어도 된다.
*/
export function summarizeWindow(
forecast: KmaForecast,
issue: KmaIssue,
date: string,
start: number,
end: number,
): WindowForecast | null {
const points = forecast[date] ?? [];
if (points.length === 0 || end <= start) return null;
const nextDay = addDaysIso(date, 1);
const spans = intervals(points, nextDay ? forecast[nextDay]?.[0] : undefined);
const covered = spans.filter((s) => s.from < end && start < s.to);
if (covered.length === 0) return null;
if (spans[spans.length - 1].to < end) return null;
if (date !== issue.date && spans[0].from > start) return null;
const withPop = covered.filter((s) => s.point.pop !== null);
if (withPop.length === 0) return null;
const wettest = withPop.reduce((a, b) =>
(b.point.pop ?? 0) > (a.point.pop ?? 0) ? b : a,
);
const temps = covered
.map((s) => s.point.tmp)
.filter((t): t is number => t !== null);
const skyCounts = new Map<number, number>();
for (const s of covered) {
if (s.point.sky !== null) {
skyCounts.set(s.point.sky, (skyCounts.get(s.point.sky) ?? 0) + 1);
}
}
let sky: number | null = null;
for (const [code, count] of skyCounts) {
const best = sky === null ? 0 : (skyCounts.get(sky) ?? 0);
if (count > best || (count === best && sky !== null && code > sky)) {
sky = code;
}
}
return {
date,
precipitationProbability: wettest.point.pop ?? 0,
precipitationType: PRECIPITATION_TYPES[wettest.point.pty ?? 0] ?? 'none',
sky: sky === null ? null : (SKY_STATES[sky] ?? null),
temperatureMin: temps.length > 0 ? Math.min(...temps) : null,
temperatureMax: temps.length > 0 ? Math.max(...temps) : null,
issuedAt: issuedAtIso(issue),
};
}
@@ -0,0 +1,11 @@
import { toKmaGrid } from './kma-grid';
describe('toKmaGrid', () => {
it('서울시청은 기상청 격자 (60, 127) 이다', () => {
expect(toKmaGrid(37.5665, 126.978)).toEqual({ nx: 60, ny: 127 });
});
it('대구 도심은 (89, 90) 이다(2026-10-29 실제 호출과 같다)', () => {
expect(toKmaGrid(35.87021, 128.59409)).toEqual({ nx: 89, ny: 90 });
});
});
+45
View File
@@ -0,0 +1,45 @@
/**
* 위도·경도 → 기상청 단기예보 격자(nx, ny) (순수 함수).
* 기상청이 공개한 람베르트 정각원추도법(LCC) 공식이다. 2026-10-29 격자표 3,801행과 대조해 98.9% 가 정확히 맞고
* 나머지 43행도 1칸 차이였다(docs/research/data-openapi-review/store_and_weather.md §6).
*/
/** 지구 반지름(km)과 격자 간격(km) */
const EARTH_RADIUS_KM = 6371.00877;
const GRID_SPACING_KM = 5.0;
/** 표준 위도 1·2, 기준점 경도·위도 */
const STANDARD_LAT_1 = 30.0;
const STANDARD_LAT_2 = 60.0;
const ORIGIN_LNG = 126.0;
const ORIGIN_LAT = 38.0;
/** 기준점의 격자 좌표 */
const ORIGIN_X = 43;
const ORIGIN_Y = 136;
const DEG = Math.PI / 180;
const QUARTER_PI = Math.PI / 4;
export interface KmaGrid {
nx: number;
ny: number;
}
export function toKmaGrid(lat: number, lng: number): KmaGrid {
const re = EARTH_RADIUS_KM / GRID_SPACING_KM;
const s1 = STANDARD_LAT_1 * DEG;
const s2 = STANDARD_LAT_2 * DEG;
const sn =
Math.log(Math.cos(s1) / Math.cos(s2)) /
Math.log(Math.tan(QUARTER_PI + s2 / 2) / Math.tan(QUARTER_PI + s1 / 2));
const sf = (Math.tan(QUARTER_PI + s1 / 2) ** sn * Math.cos(s1)) / sn;
const ro = (re * sf) / Math.tan(QUARTER_PI + (ORIGIN_LAT * DEG) / 2) ** sn;
const ra = (re * sf) / Math.tan(QUARTER_PI + (lat * DEG) / 2) ** sn;
let theta = lng * DEG - ORIGIN_LNG * DEG;
if (theta > Math.PI) theta -= 2 * Math.PI;
if (theta < -Math.PI) theta += 2 * Math.PI;
theta *= sn;
return {
nx: Math.floor(ra * Math.sin(theta) + ORIGIN_X + 0.5),
ny: Math.floor(ro - ra * Math.cos(theta) + ORIGIN_Y + 0.5),
};
}
@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import {
createKmaApi,
KMA_API_FACTORY,
WeatherService,
} from './weather.service';
/**
* 날씨(기상청 단기예보). AI 코스 조건 해석·생성(설계 §3.3)이 WeatherService 를 쓴다.
* 설정(ConfigModule)과 캐시(CacheModule)는 앱 전역 모듈을 쓴다.
*/
@Module({
providers: [
WeatherService,
{ provide: KMA_API_FACTORY, useValue: createKmaApi },
],
exports: [WeatherService],
})
export class WeatherModule {}
@@ -0,0 +1,166 @@
import type { ConfigService } from '@nestjs/config';
import type { Cache } from 'cache-manager';
import type { CallBudget } from '@/modules/place/sync/call-budget';
import { PublicDataApiError } from '@/modules/place/sync/public-data.client';
import type { KmaForecastApi, KmaPage } from './kma-forecast.client';
import type { KmaIssue } from './kma-forecast';
import type { KmaGrid } from './kma-grid';
import { WeatherService } from './weather.service';
const DOWNTOWN = { lat: 35.87021, lng: 128.59409 };
/** 2026-11-08(일) 11:30 KST — 최근 발표는 11시, 예보는 글피(11-11)까지 */
const NOW = new Date('2026-11-08T11:30:00+09:00');
const at = (hour: number) => hour * 60;
/** 11-08 ~ 11-11 매시, 11-12 0시의 강수확률이 pop 인 응답 */
function page(pop: number): KmaPage {
const items: Record<string, string>[] = [];
const dates = ['20261108', '20261109', '20261110', '20261111', '20261112'];
for (const date of dates) {
for (let h = 0; h < 24; h++) {
if (date === '20261112' && h > 0) break;
const base = {
fcstDate: date,
fcstTime: `${String(h).padStart(2, '0')}00`,
};
items.push({ ...base, category: 'POP', fcstValue: String(pop) });
items.push({ ...base, category: 'TMP', fcstValue: '12' });
items.push({ ...base, category: 'SKY', fcstValue: '4' });
items.push({ ...base, category: 'PTY', fcstValue: pop > 0 ? '1' : '0' });
}
}
return { items, totalCount: items.length, noData: false };
}
const NO_DATA: KmaPage = { items: [], totalCount: 0, noData: true };
function memoryCache(): Cache {
const store = new Map<string, unknown>();
return {
get: (key: string) => Promise.resolve(store.get(key)),
set: (key: string, value: unknown) => {
store.set(key, value);
return Promise.resolve(value);
},
} as unknown as Cache;
}
function setup(
respond: (issue: KmaIssue, grid: KmaGrid) => Promise<KmaPage>,
key = 'test-key',
) {
const calls: { issue: KmaIssue; grid: KmaGrid }[] = [];
const api: KmaForecastApi = {
forecast: (issue, grid) => {
calls.push({ issue, grid });
return respond(issue, grid);
},
};
const budgets: CallBudget[] = [];
const config = { get: () => key } as unknown as ConfigService;
const service = new WeatherService(config, memoryCache(), (_key, budget) => {
budgets.push(budget);
return api;
});
return { service, calls, budgets };
}
describe('WeatherService.forecastWindow', () => {
it('최근 발표를 격자로 불러 시간 범위를 요약하고, 같은 격자·발표는 다시 부르지 않는다', async () => {
const { service, calls } = setup(() => Promise.resolve(page(60)));
const first = await service.forecastWindow(
DOWNTOWN,
'2026-11-09',
at(14),
at(20),
NOW,
);
expect(first).toMatchObject({
date: '2026-11-09',
precipitationProbability: 60,
precipitationType: 'rain',
sky: 'cloudy',
issuedAt: '2026-11-08T02:00:00.000Z',
});
await service.forecastWindow(DOWNTOWN, '2026-11-10', at(10), at(16), NOW);
expect(calls).toEqual([
{ issue: { date: '2026-11-08', hour: 11 }, grid: { nx: 89, ny: 90 } },
]);
});
it('최근 발표 자료가 아직 없으면(03) 앞 발표를 쓴다', async () => {
const { service, calls } = setup((issue) =>
Promise.resolve(issue.hour === 11 ? NO_DATA : page(10)),
);
const summary = await service.forecastWindow(
DOWNTOWN,
'2026-11-09',
at(14),
at(20),
NOW,
);
expect(summary?.issuedAt).toBe('2026-11-07T23:00:00.000Z');
expect(calls.map((c) => c.issue.hour)).toEqual([11, 8]);
});
it('기상청 장애면 null 이고, 잠깐 동안은 다시 부르지 않는다', async () => {
const { service, calls } = setup(() =>
Promise.reject(new PublicDataApiError('getVilageFcst: HTTP 502')),
);
for (let i = 0; i < 2; i++) {
expect(
await service.forecastWindow(
DOWNTOWN,
'2026-11-09',
at(14),
at(20),
NOW,
),
).toBeNull();
}
expect(calls).toHaveLength(1);
});
it('지난 날짜·예보가 닿지 않는 날짜·한국 밖은 부르지 않고 null', async () => {
const { service, calls } = setup(() => Promise.resolve(page(0)));
const window = [at(14), at(20), NOW] as const;
expect(
await service.forecastWindow(DOWNTOWN, '2026-11-07', ...window),
).toBeNull();
// 11시 발표는 글피(11-11)까지다
expect(
await service.forecastWindow(DOWNTOWN, '2026-11-12', ...window),
).toBeNull();
expect(
await service.forecastWindow({ lat: 0, lng: 0 }, '2026-11-09', ...window),
).toBeNull();
expect(calls).toHaveLength(0);
});
it('키가 없으면 부르지 않고 null', async () => {
const { service, calls, budgets } = setup(
() => Promise.resolve(page(0)),
'',
);
expect(
await service.forecastWindow(DOWNTOWN, '2026-11-09', at(14), at(20), NOW),
).toBeNull();
expect(calls).toHaveLength(0);
expect(budgets).toHaveLength(0);
});
it('한국 날짜가 바뀌면 하루 호출 수를 새로 센다', async () => {
const { service, budgets } = setup(() => Promise.resolve(page(0)));
await service.forecastWindow(DOWNTOWN, '2026-11-09', at(14), at(20), NOW);
await service.forecastWindow(
DOWNTOWN,
'2026-11-10',
at(14),
at(20),
new Date('2026-11-09T09:00:00+09:00'),
);
expect(budgets).toHaveLength(2);
});
});
@@ -0,0 +1,167 @@
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { Cache } from 'cache-manager';
import { CallBudget } from '@/modules/place/sync/call-budget';
import { isInKorea, type LatLng } from '@/shared/utils/geo';
import { kstDateString } from '@/shared/utils/kst';
import { KmaForecastClient, type KmaForecastApi } from './kma-forecast.client';
import {
type KmaForecast,
type KmaIssue,
lastForecastDate,
latestIssue,
parseKmaItems,
previousIssue,
summarizeWindow,
type WindowForecast,
} from './kma-forecast';
import { type KmaGrid, toKmaGrid } from './kma-grid';
/** 기상청 클라이언트를 만든다 — 테스트에서는 가짜로 바꿔 끼운다 */
export const KMA_API_FACTORY = Symbol('KMA_API_FACTORY');
export type KmaApiFactory = (
serviceKey: string,
budget: CallBudget,
) => KmaForecastApi;
export const createKmaApi: KmaApiFactory = (serviceKey, budget) =>
new KmaForecastClient({ serviceKey, budget });
const SERVICE_KEY_NAME = 'PUBLIC_DATA_SERVICE_KEY';
/** 격자·발표 시각별 예보를 이만큼(ms) 다시 부르지 않는다(설계 §3.3) */
const FORECAST_TTL_MS = 60 * 60 * 1000;
/** 실패·자료 없음은 짧게 기억한다 — 장애 때 요청마다 제한 시간만큼 기다리지 않게 */
const FAILURE_TTL_MS = 2 * 60 * 1000;
/**
* 하루 호출 상한(한국 시간 자정에 다시 센다) — 캐시가 있어 실제로는 하루 수십 번이다.
* 공공데이터포털 개발 계정 한도보다 낮게 두어 키 오류·버그로 한도를 다 쓰지 않게 한다.
*/
const DAILY_CALL_LIMIT = 1_000;
type CachedForecast =
| { kind: 'ok'; forecast: KmaForecast }
| { kind: 'no_data' }
| { kind: 'failed' };
function cacheKey(grid: KmaGrid, issue: KmaIssue): string {
return `weather:kma:${grid.nx},${grid.ny}:${issue.date}T${issue.hour}`;
}
/**
* 코스 날짜·지역의 단기예보(설계 §3.3). 입력 화면을 막지 않도록 **실패하면 던지지 않고 null** 을 돌려준다
* (키 없음 · 예보가 없는 날짜 · 한국 밖 · 기상청 장애). null 이면 화면에 '예보 없음'을 보이고 비 조건은 쓰지 않는다.
*/
@Injectable()
export class WeatherService {
private readonly logger = new Logger(WeatherService.name);
private daily: { date: string; api: KmaForecastApi } | null = null;
private readonly inflight = new Map<string, Promise<CachedForecast>>();
private warnedMissingKey = false;
constructor(
private readonly config: ConfigService,
@Inject(CACHE_MANAGER) private readonly cache: Cache,
@Inject(KMA_API_FACTORY) private readonly createApi: KmaApiFactory,
) {}
/**
* 그날 [start, end)(00:00 부터 분) 의 예보 요약. 지난 날짜, 예보가 닿지 않는 날짜(4일 뒤 이후)는 null.
* @param now 지금 시각 — 발표 시각을 고른다(테스트에서 고정)
*/
async forecastWindow(
point: LatLng,
date: string,
start: number,
end: number,
now: Date = new Date(),
): Promise<WindowForecast | null> {
if (date < kstDateString(now)) return null;
if (!isInKorea(point.lat, point.lng)) return null;
const latest = latestIssue(now);
if (date > lastForecastDate(latest)) return null;
const grid = toKmaGrid(point.lat, point.lng);
// 최근 발표 자료가 아직 없으면(발표가 늦어질 때) 앞 발표를 쓴다
for (const issue of [latest, previousIssue(latest)]) {
const cached = await this.load(grid, issue, now);
if (cached.kind === 'failed') return null;
if (cached.kind === 'ok') {
return summarizeWindow(cached.forecast, issue, date, start, end);
}
}
return null;
}
private async load(
grid: KmaGrid,
issue: KmaIssue,
now: Date,
): Promise<CachedForecast> {
const key = cacheKey(grid, issue);
const cached = await this.cache.get<CachedForecast>(key);
if (cached) return cached;
// 같은 격자를 동시에 물으면 한 번만 부른다
let pending = this.inflight.get(key);
if (!pending) {
pending = this.fetch(grid, issue, now).finally(() =>
this.inflight.delete(key),
);
this.inflight.set(key, pending);
}
const result = await pending;
await this.cache.set(
key,
result,
result.kind === 'ok' ? FORECAST_TTL_MS : FAILURE_TTL_MS,
);
return result;
}
private async fetch(
grid: KmaGrid,
issue: KmaIssue,
now: Date,
): Promise<CachedForecast> {
const api = this.api(now);
if (!api) return { kind: 'failed' };
try {
const page = await api.forecast(issue, grid);
if (page.noData) return { kind: 'no_data' };
if (page.totalCount > page.items.length) {
this.logger.warn(
`기상청 단기예보가 잘려서 왔다(격자 ${grid.nx},${grid.ny} · ${page.items.length}/${page.totalCount}행)`,
);
}
return { kind: 'ok', forecast: parseKmaItems(page.items) };
} catch (error) {
// 클라이언트가 오류 메시지의 키를 가린다. 사용자 위치 대신 5km 격자만 남긴다
const reason = error instanceof Error ? error.message : String(error);
this.logger.warn(
`기상청 단기예보를 받지 못했다(격자 ${grid.nx},${grid.ny} · ${issue.date} ${issue.hour}시 발표): ${reason}`,
);
return { kind: 'failed' };
}
}
/** 오늘 쓸 클라이언트 — 한국 날짜가 바뀌면 하루 호출 수를 다시 센다. 키가 없으면 null */
private api(now: Date): KmaForecastApi | null {
const today = kstDateString(now);
if (this.daily?.date === today) return this.daily.api;
const key = this.config.get<string>(SERVICE_KEY_NAME)?.trim();
if (!key || /change[-_]?me/i.test(key)) {
if (!this.warnedMissingKey) {
// 키 값은 어떤 경우에도 남기지 않는다
this.logger.warn(
`${SERVICE_KEY_NAME} 가 없어 날씨 예보를 쓰지 않는다(입력 화면은 '예보 없음')`,
);
this.warnedMissingKey = true;
}
return null;
}
const budget = new CallBudget(DAILY_CALL_LIMIT);
this.daily = { date: today, api: this.createApi(key, budget) };
return this.daily.api;
}
}
@@ -0,0 +1,131 @@
import {
closedOnHoliday,
HOLIDAY_TABLE_YEARS,
holidayOn,
holidaysIn,
isHolidayTableYear,
} from './kr-holidays';
/** 'YYYY-MM-DD' 의 요일(0=일요일) */
const weekday = (date: string) => new Date(`${date}T00:00:00Z`).getUTCDay();
const addDays = (date: string, days: number) =>
new Date(Date.parse(`${date}T00:00:00Z`) + days * 86_400_000)
.toISOString()
.slice(0, 10);
/** 토·일요일과 겹치면 대체공휴일이 붙는 공휴일(현충일·신정·선거일은 붙지 않는다) */
const SUBSTITUTED = [
'삼일절',
'노동절',
'어린이날',
'부처님오신날',
'제헌절',
'광복절',
'개천절',
'한글날',
'성탄절',
];
describe('holidayOn', () => {
it('설·추석 연휴와 대체공휴일을 담는다', () => {
expect(holidayOn('2026-02-17')).toEqual({ name: '설날', kind: 'seollal' });
expect(holidayOn('2026-09-26')?.kind).toBe('chuseok');
// 2027 설날(2월 7일)이 일요일이라 2월 9일이 대체공휴일이다 — 설 연휴로 본다
expect(holidayOn('2027-02-09')).toEqual({
name: '대체공휴일(설날)',
kind: 'seollal',
});
expect(holidayOn('2026-10-05')?.name).toBe('대체공휴일(개천절)');
});
it('2026년부터 노동절·제헌절이 공휴일이고, 주말과 겹치면 대체공휴일이 있다', () => {
expect(holidayOn('2026-05-01')?.name).toBe('노동절');
expect(holidayOn('2026-07-17')?.name).toBe('제헌절');
expect(holidayOn('2027-05-03')?.name).toBe('대체공휴일(노동절)');
expect(holidayOn('2027-07-19')?.name).toBe('대체공휴일(제헌절)');
});
it('현충일은 주말과 겹쳐도 대체공휴일이 없고, 선거일은 공휴일이다', () => {
expect(holidayOn('2026-06-06')?.name).toBe('현충일');
expect(holidayOn('2026-06-08')).toBeNull();
expect(holidayOn('2026-06-03')?.name).toBe('전국동시지방선거');
});
it('평일·주말은 null 이다', () => {
expect(holidayOn('2026-11-09')).toBeNull();
expect(holidayOn('2026-11-08')).toBeNull();
});
it('표가 담은 해만 공휴일 여부를 안다', () => {
expect(HOLIDAY_TABLE_YEARS).toEqual([2026, 2027]);
expect(isHolidayTableYear('2027-12-31')).toBe(true);
expect(isHolidayTableYear('2028-01-01')).toBe(false);
// 공식 발표와 같은 개수(2026년 22일 · 2027년 24일, 주말과 겹친 날 포함)
expect(holidaysIn(2026)).toHaveLength(22);
expect(holidaysIn(2027)).toHaveLength(24);
});
it('표의 날짜는 실제 있는 날이다', () => {
for (const year of HOLIDAY_TABLE_YEARS) {
for (const { date } of holidaysIn(year)) {
expect(addDays(date, 0)).toBe(date);
}
}
});
it('주말과 겹친 공휴일은 그다음 첫 평일(다른 공휴일이 아닌 날)이 대체공휴일이다', () => {
for (const year of HOLIDAY_TABLE_YEARS) {
for (const { date, name } of holidaysIn(year)) {
if (!SUBSTITUTED.includes(name)) continue;
if (weekday(date) !== 0 && weekday(date) !== 6) continue;
const substituteName = `대체공휴일(${name})`;
let expected = addDays(date, 1);
while (
weekday(expected) === 0 ||
weekday(expected) === 6 ||
(holidayOn(expected) && holidayOn(expected)?.name !== substituteName)
) {
expected = addDays(expected, 1);
}
expect(holidayOn(expected)?.name).toBe(substituteName);
}
}
});
it('설·추석 연휴가 일요일과 겹치면 연휴 다음 첫 평일이 대체공휴일이다', () => {
for (const year of HOLIDAY_TABLE_YEARS) {
for (const [kind, label] of [
['seollal', '설날'],
['chuseok', '추석'],
] as const) {
const days = holidaysIn(year).filter(
(h) => h.kind === kind && !h.name.startsWith('대체공휴일'),
);
const substitute = holidaysIn(year).find(
(h) => h.name === `대체공휴일(${label})`,
);
const overlapsSunday = days.some((h) => weekday(h.date) === 0);
expect(Boolean(substitute)).toBe(overlapsSunday);
if (substitute) {
expect(substitute.date).toBe(addDays(days[days.length - 1].date, 1));
}
}
}
});
});
describe('closedOnHoliday', () => {
it("'공휴일' 휴무는 모든 공휴일에 쉰다", () => {
expect(closedOnHoliday(['공휴일'], 'public')).toBe(true);
expect(closedOnHoliday(['공휴일'], 'seollal')).toBe(true);
});
it('설·추석·신정 휴무는 그 명절에만 쉰다', () => {
expect(closedOnHoliday(['설', '추석'], 'seollal')).toBe(true);
expect(closedOnHoliday(['설', '추석'], 'chuseok')).toBe(true);
expect(closedOnHoliday(['설', '추석'], 'public')).toBe(false);
expect(closedOnHoliday(['1월 1일'], 'new_year')).toBe(true);
expect(closedOnHoliday(['1월 1일'], 'public')).toBe(false);
expect(closedOnHoliday([], 'seollal')).toBe(false);
});
});
+118
View File
@@ -0,0 +1,118 @@
/**
* 대한민국 관공서 공휴일 표 (순수 데이터·함수) — AI 코스 거르기에서 명절·공휴일 휴무를 본다(설계 §3.3).
*
* **해마다 갱신한다.** 표에 없는 해는 공휴일 여부를 모르므로(isHolidayTableYear) 쓰는 쪽이 경고를 남긴다.
* 근거(2026-11-08 확인): 「관공서의 공휴일에 관한 규정」(2026-04-28 국무회의 의결 — 노동절·제헌절을 공휴일로,
* 두 날도 주말·다른 공휴일과 겹치면 대체공휴일), 우주항공청 월력요항(2027년 설날 2월 7일 · 추석 9월 15일).
* 대체공휴일: 설·추석 연휴는 일요일·다른 공휴일과 겹칠 때, 나머지(현충일·신정 제외)는 토·일요일과 겹칠 때.
*/
/**
* 공휴일 종류 — 휴무 표시와 맞춰 보려고 나눈다.
* 설·추석 연휴의 대체공휴일은 연휴로 본다(쉬는 곳이 연휴 내내 쉬는지 당일만 쉬는지 알 수 없어서).
*/
export type HolidayKind = 'new_year' | 'seollal' | 'chuseok' | 'public';
export interface Holiday {
name: string;
kind: HolidayKind;
}
const HOLIDAYS: Readonly<Record<string, Holiday>> = {
// 2026
'2026-01-01': { name: '신정', kind: 'new_year' },
'2026-02-16': { name: '설날 연휴', kind: 'seollal' },
'2026-02-17': { name: '설날', kind: 'seollal' },
'2026-02-18': { name: '설날 연휴', kind: 'seollal' },
'2026-03-01': { name: '삼일절', kind: 'public' },
'2026-03-02': { name: '대체공휴일(삼일절)', kind: 'public' },
'2026-05-01': { name: '노동절', kind: 'public' },
'2026-05-05': { name: '어린이날', kind: 'public' },
'2026-05-24': { name: '부처님오신날', kind: 'public' },
'2026-05-25': { name: '대체공휴일(부처님오신날)', kind: 'public' },
'2026-06-03': { name: '전국동시지방선거', kind: 'public' },
'2026-06-06': { name: '현충일', kind: 'public' },
'2026-07-17': { name: '제헌절', kind: 'public' },
'2026-08-15': { name: '광복절', kind: 'public' },
'2026-08-17': { name: '대체공휴일(광복절)', kind: 'public' },
'2026-09-24': { name: '추석 연휴', kind: 'chuseok' },
'2026-09-25': { name: '추석', kind: 'chuseok' },
'2026-09-26': { name: '추석 연휴', kind: 'chuseok' },
'2026-10-03': { name: '개천절', kind: 'public' },
'2026-10-05': { name: '대체공휴일(개천절)', kind: 'public' },
'2026-10-09': { name: '한글날', kind: 'public' },
'2026-12-25': { name: '성탄절', kind: 'public' },
// 2027
'2027-01-01': { name: '신정', kind: 'new_year' },
'2027-02-06': { name: '설날 연휴', kind: 'seollal' },
'2027-02-07': { name: '설날', kind: 'seollal' },
'2027-02-08': { name: '설날 연휴', kind: 'seollal' },
'2027-02-09': { name: '대체공휴일(설날)', kind: 'seollal' },
'2027-03-01': { name: '삼일절', kind: 'public' },
'2027-05-01': { name: '노동절', kind: 'public' },
'2027-05-03': { name: '대체공휴일(노동절)', kind: 'public' },
'2027-05-05': { name: '어린이날', kind: 'public' },
'2027-05-13': { name: '부처님오신날', kind: 'public' },
'2027-06-06': { name: '현충일', kind: 'public' },
'2027-07-17': { name: '제헌절', kind: 'public' },
'2027-07-19': { name: '대체공휴일(제헌절)', kind: 'public' },
'2027-08-15': { name: '광복절', kind: 'public' },
'2027-08-16': { name: '대체공휴일(광복절)', kind: 'public' },
'2027-09-14': { name: '추석 연휴', kind: 'chuseok' },
'2027-09-15': { name: '추석', kind: 'chuseok' },
'2027-09-16': { name: '추석 연휴', kind: 'chuseok' },
'2027-10-03': { name: '개천절', kind: 'public' },
'2027-10-04': { name: '대체공휴일(개천절)', kind: 'public' },
'2027-10-09': { name: '한글날', kind: 'public' },
'2027-10-11': { name: '대체공휴일(한글날)', kind: 'public' },
'2027-12-25': { name: '성탄절', kind: 'public' },
'2027-12-27': { name: '대체공휴일(성탄절)', kind: 'public' },
};
/** 표가 담은 해 — 해마다 다음 해를 더한다 */
export const HOLIDAY_TABLE_YEARS: readonly number[] = [2026, 2027];
/**
* 영업시간 파서가 쉬는날 문장에서 뽑는 휴무 표시(ParsedOpeningHours.holidayClosures)와 공휴일 종류의 대응.
* '공휴일' 휴무는 모든 공휴일에 쉰다.
*/
const ANY_HOLIDAY_CLOSURE = '공휴일';
const CLOSURES_BY_KIND: Readonly<Record<HolidayKind, readonly string[]>> = {
new_year: ['1월 1일'],
seollal: ['설'],
chuseok: ['추석'],
public: [],
};
/** 그날의 공휴일('YYYY-MM-DD'). 아니거나 표에 없는 해면 null */
export function holidayOn(date: string): Holiday | null {
return HOLIDAYS[date] ?? null;
}
/** 그해 공휴일 목록(날짜 순) */
export function holidaysIn(year: number): (Holiday & { date: string })[] {
return Object.entries(HOLIDAYS)
.filter(([date]) => date.startsWith(`${year}-`))
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, holiday]) => ({ date, ...holiday }));
}
/** 표가 그날의 해를 담고 있는가 — false 면 공휴일 여부를 모른다 */
export function isHolidayTableYear(date: string): boolean {
return HOLIDAY_TABLE_YEARS.includes(Number(date.slice(0, 4)));
}
/**
* 휴무 표시로 보아 그 공휴일에 쉬는가.
* 설·추석은 당일만 쉬는지 연휴 내내 쉬는지 문장마다 달라 연휴 전체를 쉬는 것으로 본다(닫힌 곳으로 보내지 않게).
*/
export function closedOnHoliday(
holidayClosures: readonly string[],
kind: HolidayKind,
): boolean {
return holidayClosures.some(
(closure) =>
closure === ANY_HOLIDAY_CLOSURE ||
CLOSURES_BY_KIND[kind].includes(closure),
);
}