영업일 계산을 위한 비영업일 테이블 설계 (Oracle + Spring Batch)
영업일 계산이 생각보다 까다로운 이유
"주문일로부터 영업일 기준 4일이 지났는데 발송이 안 되면 알림 메일을 보내라."
간단해 보이는 요구사항이지만, 막상 구현하려면 금방 복잡해진다. 토요일과 일요일은 기본이고, 법정 공휴일, 대체공휴일, 그리고 급작스럽게 지정되는 임시공휴일까지 고려해야 한다. 매번 API를 호출해서 확인하는 건 낭비고, 그렇다고 공휴일 목록을 하드코딩하면 임시공휴일에 대응할 수 없다.
이 글에서는 공공데이터포털의 특일정보 API를 활용해 공휴일을 자동으로 수집하고, 영업일 계산을 SQL 한 방으로 처리하는 전체 설계를 정리한다.
전제 조건
- Oracle Database (CONNECT BY, MERGE 사용)
- Java / Spring / MyBatis
- Spring Batch (배치 스케줄링)
- 공공데이터포털 API 인증키 (특일정보 API)
첫 번째 설계 결정: 공휴일 테이블 vs 비영업일 테이블
직관적으로는 "공휴일만 저장하는 테이블"을 떠올린다. 설날, 추석, 광복절 같은 날짜를 넣어두고, 영업일 계산 시 "공휴일도 아니고 주말도 아닌 날"을 세면 될 것 같다.
하지만 이 방식에는 구조적인 문제가 있다.
공휴일만 저장하면 영업일 판별 로직이 두 곳에 흩어진다. "주말인가?"는 애플리케이션이 판단하고, "공휴일인가?"는 DB에서 확인한다. 배치에서 주문 테이블과 조인해서 발송 지연 대상을 일괄로 뽑으려면 쿼리 안에 TO_CHAR(dt, 'DY') 같은 요일 체크를 넣어야 하고, 여기에 공휴일 조건까지 붙으면 쿼리가 지저분해진다.
비영업일 테이블은 이 문제를 데이터 레벨에서 해결한다. 주말, 법정 공휴일, 임시공휴일, 사내 휴무일을 전부 한 테이블에 넣는다. 영업일 판별은 NOT IN (비영업일 테이블) 한 줄이면 끝난다.
핵심 컬럼 구성:
BASE_DT VARCHAR2(8)— 날짜 (YYYYMMDD), UNIQUE 제약DAY_TP_CD VARCHAR2(10)— 유형 코드. 주말(WKEND), 공휴일(PBLHD), 임시공휴일(TMPHD), 사내휴무(CMPNY)DATA_SRC_CD VARCHAR2(10)— 출처. 시스템(SYS), API(API), 수기(MNL)
유형과 출처를 분리하는 이유는 "왜 쉬는 날인지"와 "어디서 들어온 데이터인지"가 독립적인 관심사이기 때문이다. 임시공휴일이 API에서 왔는지 관리자가 수기로 넣었는지 추적할 수 있어야 한다.
공공데이터포털 특일정보 API
한국천문연구원이 제공하는 SpcdeInfoService/getRestDeInfo API를 사용한다. solYear 파라미터만 보내면 해당 연도의 전체 공휴일 정보가 한 번에 내려온다. 월별로 12번 호출할 필요가 없다.
응답의 isHoliday 필드가 핵심이다. 현충일 같은 날은 국가기념일이지만 공공기관 휴일이 아닌 경우가 있으므로, isHoliday=Y인 항목만 필터링해야 한다.
주의할 점은 이 데이터가 천문연구원의 수기 입력에 의존한다는 것이다. 정기 공휴일은 미리 등록되어 있지만, 임시공휴일은 국무회의 의결 후 반영되며 그 시점이 보장되지 않는다.
배치 전략: 6개월 + 월 1회 + 수기 대응
배치를 어떤 주기로 돌릴지가 실질적인 설계 포인트다.
6개월 배치 (1월, 7월): 현재 연도와 다음 연도의 주말을 MERGE로 생성하고, API를 호출해서 공휴일을 insert한다. 이미 존재하는 날짜는 skip. 6개월 주기인 이유는 연 1회(1월)로만 하면 12월 말 주문의 영업일+4가 다음 해로 넘어갈 때 데이터가 없기 때문이다.
월 1회 배치 (매월 1일): API를 재호출해서 신규 공휴일이 추가됐는지 확인한다. 임시공휴일이 API에 반영됐을 경우 자동으로 잡아낸다. 이것이 월 배치의 실질적인 존재 이유다.
수기 대응: 관리자가 두 가지 행위를 할 수 있어야 한다. 첫째, API 재호출 트리거(버튼을 누르면 즉시 API를 호출해서 DB 반영). 둘째, 직접 입력(API에 아직 반영되지 않은 임시공휴일이나 사내 휴무일을 날짜+명칭으로 등록).
주말 생성은 Oracle의 MERGE INTO를 사용한다. CONNECT BY로 해당 연도의 모든 날짜를 생성하고, 토/일만 필터링해서 MERGE. 이미 있으면 skip이므로 몇 번 실행해도 중복이 생기지 않는다.
영업일+N 계산 쿼리
비영업일 테이블이 갖춰지면, 영업일 계산 쿼리는 세 단계로 동작한다.
1단계: CONNECT BY LEVEL로 기준일 이후의 후보 날짜를 생성한다. 2단계: NOT IN (비영업일 테이블)로 비영업일을 제외한다. 3단계: ROW_NUMBER()로 N번째 영업일을 뽑는다.
SELECT dt FROM (
SELECT dt, ROW_NUMBER() OVER (ORDER BY dt) AS biz_seq
FROM (
SELECT TO_CHAR(TO_DATE(:baseDt, 'YYYYMMDD') + LEVEL, 'YYYYMMDD') AS dt
FROM DUAL
CONNECT BY LEVEL <= (:addDays * 3)
)
WHERE dt NOT IN (
SELECT BASE_DT FROM TB_NON_BSNS_DAY WHERE USE_YN = 'Y'
)
) WHERE biz_seq = :addDays;
buffer를 addDays * 3으로 잡는 이유는 설/추석 연휴 + 전후 주말 + 대체공휴일이 겹치면 최대 9~10일 연속 비영업일이 될 수 있기 때문이다. 영업일 4일 기준으로 12일의 후보가 생기므로 충분하다.
이 쿼리를 주문 테이블과 조인하면 발송 지연 대상을 배치에서 한 번에 추출할 수 있다.
임시공휴일이라는 기술로 못 막는 문제
임시공휴일 대응의 핵심은 기술이 아니라 운영이다. API에 언제 반영되는지 보장이 없으므로, 아무리 배치 주기를 촘촘하게 가져가도 반영 전에는 틀린 계산이 나올 수 있다.
실질적인 해결은 "임시공휴일 지정 뉴스 → 담당자가 관리 화면에서 수기 등록"이라는 운영 프로세스를 갖추는 것이다. 기술적으로는 수기 등록 화면과 API 트리거 버튼을 만들어두고, 운영 매뉴얼에 "임시공휴일 지정 시 즉시 등록" 절차를 포함시킨다.
영업일 계산의 목적이 메일 발송 트리거라면, 임시공휴일이 반영 안 된 상태에서 "아직 기한 안 지났는데 독촉 메일이 가는" 상황이 발생할 수 있다. 이건 고객 경험에 직접 영향을 주는 문제이므로 운영 프로세스를 반드시 병행해야 한다.
정리
영업일 계산 시스템의 설계 포인트를 요약하면 다음과 같다.
공휴일만 저장하지 말고 비영업일 전체를 하나의 테이블에 모은다. 판별 로직의 복잡도를 애플리케이션이 아닌 데이터가 흡수하게 만든다. API 연동은 solYear 단위로 1회 호출이면 충분하고, MERGE와 존재 체크로 멱등성을 보장한다. 임시공휴일은 기술적 자동화의 한계가 있으므로 운영 프로세스로 보완한다.