v2.10.0: \uc815\ubd80 \ud2b9\uc77c\uc815\ubcf4 API \uc5f0\ub3d9 + \uc77c \uc790\ub3d9 \ub3d9\uae30\ud654

\uacf5\uacf5\ub370\uc774\ud130\ud3ec\ud138 \ud55c\uad6d\ucc9c\ubb38\uc5f0\uad6c\uc6d0 \ud2b9\uc77c\uc815\ubcf4 API\ub85c \uc784\uc2dc\uacf5\ud734\uc77c\uae4c\uc9c0
\uc815\ubd80 \uacf5\uc778 \ub370\uc774\ud130\ub85c \ubcf4\uac15. holidays \ud328\ud0a4\uc9c0\ub294 fallback.

- utils/holiday_api.py: getRestDeInfo \uc5d4\ub4dc\ud3ec\uc778\ud2b8 + \uc751\ub2f5 \ud30c\uc11c (\ub2e8\uc77c/\ub2e4\uc218 item)
- Database.add_korean_holidays_from_api(year) + add_korean_holidays_auto fallback chain
- migrate_v290_holidays_auto_sync: \uc77c 1\ud68c \ubc31\uadf8\ub77c\uc6b4\ub4dc \ub3d9\uae30\ud654
  (sentinel holidays_synced_date, daemon thread, CLOCKOUT_DISABLE_HOLIDAY_SYNC env var)
- Settings UI \uc548\ub0b4\ubb38 \uc5c5\ub370\uc774\ud2b8

Tests: tests/test_holiday_api.py 14\uac1c + conftest.py + 175\u2192189 pytest \uc804\ubd80 green
\ud1b5\ud569 \uc2dc\ub098\ub9ac\uc624 53/53 green

\uc8fc\uc758: \ud0a4 \ud65c\uc6a9\uae30\uac04 \uc2dc\uc791 \uc9c1\ud6c4 (2026-05-01) propagation \uc73c\ub85c 401 \uac00\ub2a5,
fallback \uacbd\ub85c\uac00 \ud574\ub2f9 \uc0ac\ub840 \ucee4\ubc84 \u2014 \uadfc\ub85c\uc790\uc758 \ub0a0 \ud3ec\ud568 22\uac1c \ud734\uc77c \uc790\ub3d9 \ub4f1\ub85d \ud655\uc778
This commit is contained in:
KINDNICK 2026-05-01 13:51:33 +09:00
parent 47296dd35b
commit 97dd4e39f7
7 changed files with 386 additions and 12 deletions

View File

@ -4,6 +4,34 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [2.10.0] — 2026-05-01
### Added — 정부 공휴일 API 자동 동기화
- **공공데이터포털 특일정보 API 연동** (`utils/holiday_api.py`)
- 한국천문연구원 운영 공식 데이터 — `/getRestDeInfo` 엔드포인트
- 임시공휴일·근로자의 날까지 정부 공인 데이터로 보강
- 일일 한도 10,000회 / 사용자 50명 = 0.5% 사용
- 키는 dev 본인 계정의 특일정보 API 한정 키
- **`Database.add_korean_holidays_from_api(year)`** — 정부 API 1차 시도
- **`add_korean_holidays_auto()` 동작 변경** — 1차 정부 API → 2차 fallback `holidays` 패키지
- **`migrate_v290_holidays_auto_sync`** — 일 1회 자동 동기화 (백그라운드 스레드)
- sentinel: `settings['holidays_synced_date']`
- 매일 호출 → 정부가 임시공휴일 발표하면 다음 날 자동 반영
- 부트스트랩 비차단 (네트워크 호출은 daemon thread)
- 테스트 환경: `CLOCKOUT_DISABLE_HOLIDAY_SYNC=1` 로 비활성화
### Changed
- 설정 → "한국 공휴일 자동 추가" 버튼 안내문 — 1차 정부 API / 2차 holidays 패키지
### Tests
- `tests/test_holiday_api.py` 14개 신규 (응답 파싱 / 단일/다중 item / 401·timeout / 응답 검증)
- `tests/conftest.py` — 모든 테스트에서 백그라운드 동기화 비활성화
- pytest: 175 → **189**
### 주의
- 키 활용기간 시작 직후엔 백엔드 propagation으로 401 가능 (1~2시간 또는 익일 활성화).
401 시 fallback (holidays 패키지 + 근로자의 날 명시 추가) 정상 동작 — 사용자 영향 없음.
## [2.9.0] — 2026-05-01 ## [2.9.0] — 2026-05-01
### Fixed — 휴일 hot-path 버그 (사용자 보고) ### Fixed — 휴일 hot-path 버그 (사용자 보고)

View File

@ -89,10 +89,54 @@ class Database:
self.migrate_v271_work_records_indexes() self.migrate_v271_work_records_indexes()
self.migrate_v280_achievements_columns() self.migrate_v280_achievements_columns()
self.migrate_v280_hire_date() self.migrate_v280_hire_date()
self.migrate_v290_holidays_auto_sync()
# 기본 설정 초기화 # 기본 설정 초기화
self.init_default_settings() self.init_default_settings()
def migrate_v290_holidays_auto_sync(self) -> None:
"""일 1회 한국 공휴일 자동 동기화 (백그라운드).
Sentinel: settings['holidays_synced_date'] = 'YYYY-MM-DD' (오늘 날짜).
값이 오늘과 같으면 스킵 같은 여러 켜도 호출 1.
매일 호출하므로 정부가 임시공휴일 발표하면 다음 자동 반영.
일일 한도 10000, 사용자 50 × 1 = 0.5% 소비.
실제 동기화는 백그라운드 스레드에서 부트스트랩이 네트워크에 묶이지 않음.
실패는 silent, 다음 실행 재시도.
테스트 환경에서는 CLOCKOUT_DISABLE_HOLIDAY_SYNC=1 비활성화.
"""
import os
if os.environ.get('CLOCKOUT_DISABLE_HOLIDAY_SYNC'):
return
from datetime import datetime as _dt
import threading
try:
today = _dt.now().date().isoformat()
sentinel = self.get_setting('holidays_synced_date', '')
if sentinel == today:
return
except Exception:
return
cur_year = _dt.now().year
def _worker():
try:
# 새 연결로 작업 (sqlite3 connection은 thread-affine)
from core.database import Database
db = Database(self.db_path)
added = db.add_korean_holidays_auto(cur_year, include_next_year=True)
if added >= 0:
db.set_setting('holidays_synced_date', today)
except Exception:
pass
t = threading.Thread(target=_worker, daemon=True, name='holiday-sync')
t.start()
def _create_tables(self, conn) -> None: def _create_tables(self, conn) -> None:
"""init_database()에서 호출되는 CREATE TABLE 묶음. conn은 호출자가 관리.""" """init_database()에서 호출되는 CREATE TABLE 묶음. conn은 호출자가 관리."""
cursor = conn.cursor() cursor = conn.cursor()
@ -1713,6 +1757,32 @@ class Database:
for date, name in fixed_holidays: for date, name in fixed_holidays:
self.add_holiday(date, name, is_recurring=True) self.add_holiday(date, name, is_recurring=True)
def add_korean_holidays_from_api(self, year: int) -> int:
"""공공데이터포털 특일정보 API로 한국 공휴일 등록 (정부 공인).
임시공휴일 + 근로자의 holidays 패키지가 놓치는 항목까지 포함.
네트워크 실패 -1 반환 호출자 fallback.
Returns:
추가된 공휴일 개수 (기존 등록과 중복은 제외). 실패 -1.
"""
try:
from utils.holiday_api import fetch_korean_holidays
except ImportError:
return -1
items = fetch_korean_holidays(year)
if items is None:
return -1
added = 0
for it in items:
if not it.get('is_holiday'):
continue
date_str = it['date']
if not self.is_holiday(date_str):
self.add_holiday(date_str, it['name'], is_recurring=False)
added += 1
return added
def add_korean_holidays_auto(self, year: int, include_next_year: bool = False) -> int: def add_korean_holidays_auto(self, year: int, include_next_year: bool = False) -> int:
"""`holidays` 패키지로 음력 명절 포함 한국 공휴일 자동 등록. """`holidays` 패키지로 음력 명절 포함 한국 공휴일 자동 등록.
@ -1738,30 +1808,32 @@ class Database:
Returns: Returns:
추가된 공휴일 개수. 패키지 미설치 -1. 추가된 공휴일 개수. 패키지 미설치 -1.
""" """
try:
import holidays as _holidays
except ImportError:
return -1
years_to_add = [year] years_to_add = [year]
if include_next_year: if include_next_year:
years_to_add.append(year + 1) years_to_add.append(year + 1)
added = 0 added = 0
for y in years_to_add: for y in years_to_add:
# 1차: 정부 API (임시공휴일 포함, 가장 정확)
api_count = self.add_korean_holidays_from_api(y)
if api_count >= 0:
added += api_count
# API가 응답했으면 근로자의 날도 포함되어 있음. 끝.
continue
# 2차 fallback: holidays 패키지
try: try:
import holidays as _holidays
kr = _holidays.country_holidays('KR', years=y) kr = _holidays.country_holidays('KR', years=y)
except Exception: except Exception:
continue # 패키지 내부 오류는 해당 연도만 스킵 continue # 둘 다 실패면 해당 연도만 스킵
for d, name in kr.items(): for d, name in kr.items():
date_str = d.isoformat() date_str = d.isoformat()
if not self.is_holiday(date_str): if not self.is_holiday(date_str):
self.add_holiday(date_str, name, is_recurring=False) self.add_holiday(date_str, name, is_recurring=False)
added += 1 added += 1
# holidays.KR이 누락하는 한국 노동자 휴일 보강. # holidays.KR이 누락하는 근로자의 날 명시적 보강
# 근로자의 날(5/1)은 공식 '공휴일'은 아니지만 대부분 회사가 휴무.
# 패키지 버전마다 포함 여부가 달라서 명시적 추가.
extra = [(f"{y}-05-01", "근로자의 날")] extra = [(f"{y}-05-01", "근로자의 날")]
for date_str, name in extra: for date_str, name in extra:
if not self.is_holiday(date_str): if not self.is_holiday(date_str):

View File

@ -4,4 +4,4 @@
릴리스 값을 올린 git tag push. 릴리스 값을 올린 git tag push.
CHANGELOG.md의 최상단 항목과 일치시킬 . CHANGELOG.md의 최상단 항목과 일치시킬 .
""" """
__version__ = '2.9.0' __version__ = '2.10.0'

9
tests/conftest.py Normal file
View File

@ -0,0 +1,9 @@
"""
pytest 공통 설정.
모든 테스트는 백그라운드 휴일 동기화를 Database 생성 spawn되는
holiday-sync 스레드가 DB 파일을 lock해서 다음 테스트의 fixture cleanup이 깨짐.
"""
import os
os.environ['CLOCKOUT_DISABLE_HOLIDAY_SYNC'] = '1'

166
tests/test_holiday_api.py Normal file
View File

@ -0,0 +1,166 @@
"""
utils.holiday_api 단위 테스트.
실제 정부 API는 호출하지 않음 모두 urlopen mock.
"""
import json
import os
import sys
from unittest.mock import patch, MagicMock
import pytest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from utils.holiday_api import (
fetch_korean_holidays, _parse_response, is_configured,
)
def _ok_response(items):
"""API 정상 응답 형식 빌드."""
return {
'response': {
'header': {'resultCode': '00', 'resultMsg': 'NORMAL SERVICE.'},
'body': {
'items': {'item': items} if items else {'item': []},
'numOfRows': 100,
'pageNo': 1,
'totalCount': len(items) if isinstance(items, list) else 1,
},
}
}
class TestParseResponse:
def test_multiple_items(self):
items = [
{'dateKind': '01', 'dateName': '근로자의 날', 'isHoliday': 'Y',
'locdate': 20260501, 'seq': 1},
{'dateKind': '01', 'dateName': '어린이날', 'isHoliday': 'Y',
'locdate': 20260505, 'seq': 1},
]
out = _parse_response(_ok_response(items))
assert len(out) == 2
assert out[0]['date'] == '2026-05-01'
assert out[0]['name'] == '근로자의 날'
assert out[0]['is_holiday'] is True
assert out[1]['date'] == '2026-05-05'
def test_single_item_as_dict(self):
# API가 결과 1개일 때 list가 아닌 dict로 반환하는 케이스
item = {'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': 20260501}
data = {
'response': {
'header': {'resultCode': '00'},
'body': {'items': {'item': item}, 'totalCount': 1},
}
}
out = _parse_response(data)
assert len(out) == 1
assert out[0]['name'] == '근로자의 날'
def test_empty_year(self):
# totalCount=0 같은 정상 빈 응답
data = {
'response': {
'header': {'resultCode': '00'},
'body': {'items': '', 'totalCount': 0},
}
}
assert _parse_response(data) == []
def test_error_result_code(self):
data = {
'response': {
'header': {'resultCode': '30', 'resultMsg': 'SERVICE_KEY_IS_NOT_REGISTERED'},
'body': {},
}
}
assert _parse_response(data) is None
def test_isholiday_n_filtered_at_caller_level(self):
# 응답 자체엔 is_holiday=False도 포함됨 (예: 24절기). _parse는 그대로 반환,
# 실제 휴일 등록은 호출자가 is_holiday=True만 필터.
items = [{'dateName': '동지', 'isHoliday': 'N', 'locdate': 20261221}]
out = _parse_response(_ok_response(items))
assert len(out) == 1
assert out[0]['is_holiday'] is False
def test_locdate_str_form(self):
items = [{'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': '20260501'}]
out = _parse_response(_ok_response(items))
assert out[0]['date'] == '2026-05-01'
def test_invalid_locdate_skipped(self):
items = [
{'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': 20260501},
{'dateName': '잘못된 날짜', 'isHoliday': 'Y', 'locdate': 'abc'},
{'dateName': '짧은 날짜', 'isHoliday': 'Y', 'locdate': '202605'},
]
out = _parse_response(_ok_response(items))
assert len(out) == 1 # 정상 1개만
assert out[0]['name'] == '근로자의 날'
def test_missing_required_fields_skipped(self):
items = [
{'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': 20260501},
{'isHoliday': 'Y', 'locdate': 20260505}, # name 없음
{'dateName': '신정', 'isHoliday': 'Y'}, # locdate 없음
]
out = _parse_response(_ok_response(items))
assert len(out) == 1
def test_malformed_response_returns_none(self):
# response 구조 자체가 깨진 경우
assert _parse_response({'random': 'data'}) is None or _parse_response({'random': 'data'}) == []
# 위는 implementation-dependent — 둘 다 합리적
# 정확히는: response 키 없음 → response={}, header={}, resultCode != '00' → None
assert _parse_response({}) is None
class TestFetchNetwork:
@patch('utils.holiday_api.urllib.request.urlopen')
def test_success(self, mock_urlopen):
items = [{'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': 20260501}]
resp = MagicMock()
resp.read.return_value = json.dumps(_ok_response(items)).encode('utf-8')
resp.__enter__.return_value = resp
resp.__exit__.return_value = False
mock_urlopen.return_value = resp
out = fetch_korean_holidays(2026)
assert out is not None
assert len(out) == 1
assert out[0]['date'] == '2026-05-01'
# 요청 URL에 serviceKey + solYear=2026 + _type=json 포함되었는지
req = mock_urlopen.call_args[0][0]
assert 'serviceKey=' in req.full_url
assert 'solYear=2026' in req.full_url
assert '_type=json' in req.full_url
@patch('utils.holiday_api.urllib.request.urlopen')
def test_network_error_returns_none(self, mock_urlopen):
import urllib.error
mock_urlopen.side_effect = urllib.error.URLError('boom')
assert fetch_korean_holidays(2026) is None
@patch('utils.holiday_api.urllib.request.urlopen')
def test_timeout_returns_none(self, mock_urlopen):
mock_urlopen.side_effect = TimeoutError('slow')
assert fetch_korean_holidays(2026) is None
@patch('utils.holiday_api.urllib.request.urlopen')
def test_invalid_json_returns_none(self, mock_urlopen):
resp = MagicMock()
resp.read.return_value = b'<html>error</html>'
resp.__enter__.return_value = resp
resp.__exit__.return_value = False
mock_urlopen.return_value = resp
assert fetch_korean_holidays(2026) is None
class TestConfigured:
def test_key_set(self):
assert is_configured() is True

View File

@ -688,10 +688,11 @@ class SettingsView(QDialog):
"한국 공휴일 자동 추가", "한국 공휴일 자동 추가",
f"{target_label} 한국 공휴일을 자동으로 등록하시겠습니까?\n\n" f"{target_label} 한국 공휴일을 자동으로 등록하시겠습니까?\n\n"
"포함:\n" "포함:\n"
"• 양력 공휴일 (신정/삼일절/어린이날 등)\n" "• 양력 공휴일 (신정/삼일절/어린이날/근로자의 날 등)\n"
"• 음력 명절 (설날 연휴/추석 연휴/석가탄신일)\n" "• 음력 명절 (설날 연휴/추석 연휴/석가탄신일)\n"
"• 정부 지정 대체·임시공휴일\n\n" "• 정부 지정 대체·임시공휴일\n\n"
"※ 외부 'holidays' 패키지 사용 (requirements.txt 참조)", "※ 1차: 공공데이터포털 특일정보 API (정부 공인, 임시공휴일 포함)\n"
"※ 2차 fallback: 'holidays' 패키지 (오프라인)",
QMessageBox.Yes | QMessageBox.No QMessageBox.Yes | QMessageBox.No
) )
if reply != QMessageBox.Yes: if reply != QMessageBox.Yes:

98
utils/holiday_api.py Normal file
View File

@ -0,0 +1,98 @@
"""
공공데이터포털 한국천문연구원 특일정보 OpenAPI 클라이언트.
엔드포인트: getRestDeInfo (국경일/공휴일 임시공휴일 포함)
공식 문서: https://www.data.go.kr/data/15012690/openapi.do
`holidays` 패키지가 누락하는 임시공휴일·근로자의 등을
정부 공인 데이터로 보강하기 위해 사용.
설계:
- 네트워크 실패는 silent (None 반환) 호출자가 fallback 처리
- API 키는 코드 박혀있으나 dev 본인 계정의 특일정보 API 한정
(50 이내 사용 환경에서 일일 한도 1000 충분)
"""
from __future__ import annotations
import json
import urllib.parse
import urllib.request
import urllib.error
from typing import List, Dict, Optional
# 공공데이터포털 dev 키 (특일정보 API 한정).
# 노출 시 data.go.kr 마이페이지에서 즉시 폐기/재발급 가능.
_SERVICE_KEY = 'fa419259319e31d2fcd4f959e65da817fe2f19894bff340a63889db7a8ffac93'
_BASE = 'https://apis.data.go.kr/B090041/openapi/service/SpcdeInfoService'
_USER_AGENT = 'ClockOutCalculator/2.10 (KASI special-day client)'
def fetch_korean_holidays(year: int, timeout: int = 10) -> Optional[List[Dict]]:
"""해당 연도의 한국 공휴일 전체를 정부 API에서 받아 반환.
Returns:
성공: [{'date': '2026-05-01', 'name': '근로자의 날', 'is_holiday': True}, ...]
실패: None (네트워크 오류, 인증 실패, 응답 파싱 실패 )
"""
params = {
'serviceKey': _SERVICE_KEY,
'solYear': str(year),
'_type': 'json',
'numOfRows': '100',
'pageNo': '1',
}
url = f"{_BASE}/getRestDeInfo?" + urllib.parse.urlencode(params)
req = urllib.request.Request(url, headers={'User-Agent': _USER_AGENT})
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = json.loads(resp.read())
except (urllib.error.URLError, urllib.error.HTTPError,
json.JSONDecodeError, OSError, TimeoutError):
return None
return _parse_response(data)
def _parse_response(data: Dict) -> Optional[List[Dict]]:
"""API 응답 JSON을 표준 형식으로 정규화.
API 응답 패턴:
- resultCode == '00' 정상
- items.item: 단일 결과면 dict, 여러 개면 list
- items가 문자열일 (totalCount=0) 정상으로 간주
"""
try:
response = data.get('response') or {}
header = response.get('header') or {}
if header.get('resultCode') != '00':
return None
body = response.get('body') or {}
items_root = body.get('items')
if not items_root:
return [] # 그 해 공휴일 없음 (드물지만 정상 응답)
item = items_root.get('item') if isinstance(items_root, dict) else None
if item is None:
return []
if isinstance(item, dict):
item = [item]
out = []
for entry in item:
locdate = entry.get('locdate')
name = entry.get('dateName')
is_holiday = (entry.get('isHoliday') == 'Y')
if not locdate or not name:
continue
# locdate: 20260501 (int 또는 str)
ds = str(locdate)
if len(ds) != 8 or not ds.isdigit():
continue
iso = f"{ds[0:4]}-{ds[4:6]}-{ds[6:8]}"
out.append({'date': iso, 'name': str(name), 'is_holiday': is_holiday})
return out
except (AttributeError, TypeError, KeyError):
return None
def is_configured() -> bool:
"""키가 설정되어 있는지 (테스트/빈 키 환경 가드)."""
return bool(_SERVICE_KEY) and len(_SERVICE_KEY) > 10