generated from ttipo/project-base
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
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:
@@ -27,7 +27,8 @@
|
||||
"migration:revert": "npm run typeorm -- migration:revert",
|
||||
"migration:show": "npm run typeorm -- migration:show",
|
||||
"place:sync": "ts-node -r tsconfig-paths/register src/commands/place-sync.ts",
|
||||
"place:sync:prod": "node dist/commands/place-sync.js"
|
||||
"place:sync:prod": "node dist/commands/place-sync.js",
|
||||
"weather:check": "ts-node -r tsconfig-paths/register src/commands/weather-check.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/cache-manager": "^3.0.1",
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user