<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Business Calendar on jyukki's Blog</title><link>https://jyukki.com/tags/business-calendar/</link><description>Recent content in Business Calendar on jyukki's Blog</description><generator>Hugo -- 0.147.0</generator><language>ko-KR</language><lastBuildDate>Sat, 12 Sep 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://jyukki.com/tags/business-calendar/index.xml" rel="self" type="application/rss+xml"/><item><title>백엔드 커리큘럼 심화: Business Calendar와 Cutoff Policy, 영업일·마감 시각을 재현 가능하게 설계하는 법</title><link>https://jyukki.com/learning/deep-dive/deep-dive-business-calendar-cutoff-policy-playbook/</link><pubDate>Sat, 12 Sep 2026 00:00:00 +0000</pubDate><guid>https://jyukki.com/learning/deep-dive/deep-dive-business-calendar-cutoff-policy-playbook/</guid><description>영업일, 공휴일, 지역별 마감 시각을 단순 cron 조건이 아닌 버전 있는 도메인 정책으로 모델링하고, 정산·예약·리포트의 재현성과 운영 안전성을 확보하는 기준을 정리합니다.</description><content:encoded><![CDATA[<h2 id="이-글에서-얻는-것">이 글에서 얻는 것</h2>
<ul>
<li>영업일과 마감 시각을 <code>LocalDate.now()</code> 또는 서버 cron에 묻어 두지 않고, 명시적 정책으로 설계하는 방법을 배웁니다.</li>
<li>국가·고객·상품마다 다른 공휴일, 조기 마감, grace window를 어떤 데이터로 저장하고 어떤 시점에 판정할지 정할 수 있습니다.</li>
<li>마감 직전 요청, 지연 이벤트, 정책 변경, 재처리 상황에서 <strong>당시의 결정</strong>을 재현하는 기준을 얻습니다.</li>
<li>정산·예약·배치·리포트에서 정합성을 우선할지 즉시성을 우선할지 숫자와 상태 전이로 결정할 수 있습니다.</li>
</ul>
<p>백엔드에서 시간 문제는 흔히 UTC 저장 여부로 끝난다고 생각합니다. 그러나 &ldquo;한국 영업일 기준 오늘&rdquo;, &ldquo;뉴욕 장 마감 전&rdquo;, &ldquo;매월 셋째 영업일&rdquo;, &ldquo;공휴일이면 다음 영업일&rdquo; 같은 요구가 등장하면 UTC와 타임존만으로는 부족합니다. 이때 필요한 것은 시각 변환 함수가 아니라 <strong>업무 달력과 마감 정책</strong>입니다.</p>
<p><a href="/learning/deep-dive/deep-dive-timezone-i18n-handling/">타임존·국제화 처리</a>가 절대 시각과 지역 시각을 구분하는 기본을 다뤘다면, 이 글은 지역 시각 위에 놓이는 업무 규칙을 다룹니다. <a href="/learning/deep-dive/deep-dive-clock-skew-time-semantics-playbook/">Clock Skew와 시간 의미론</a>의 시간 오차, <a href="/learning/deep-dive/deep-dive-bitemporal-effective-dated-records-playbook/">Bitemporal·Effective-Dated Records</a>의 정책 이력, <a href="/learning/deep-dive/deep-dive-reconciliation-ledger-pipeline/">Reconciliation Ledger Pipeline</a>의 사후 대사가 함께 있어야 마감 결과를 설명할 수 있습니다.</p>
<h2 id="핵심-개념이슈">핵심 개념/이슈</h2>
<h3 id="1-영업일은-날짜-타입이-아니라-계산-결과다">1) 영업일은 날짜 타입이 아니라 계산 결과다</h3>
<p><code>2026-09-12</code>라는 <code>LocalDate</code>가 있다고 해서 모든 업무의 영업일이 같은 것은 아닙니다. 한 서비스 안에서도 한국 고객 지원은 <code>Asia/Seoul</code>, 미국 결제 정산은 <code>America/New_York</code>, 글로벌 리포트는 UTC를 기준으로 삼을 수 있습니다. 여기에 주말, 법정 공휴일, 거래소 휴장, 임시 휴무, 조기 마감이 더해집니다.</p>
<p>따라서 <code>business_date</code>는 원본 입력이 아니라 다음 정책으로 계산한 결과여야 합니다.</p>
<table>
  <thead>
      <tr>
          <th>구성 요소</th>
          <th>예시</th>
          <th>반드시 남길 값</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>calendar</td>
          <td>KR-BUSINESS, US-NYSE</td>
          <td><code>calendar_id</code>, source version</td>
      </tr>
      <tr>
          <td>지역</td>
          <td>Asia/Seoul, America/New_York</td>
          <td>IANA <code>zone_id</code></td>
      </tr>
      <tr>
          <td>영업 규칙</td>
          <td>월~금, 지정 휴일 제외</td>
          <td>weekend/holiday rule</td>
      </tr>
      <tr>
          <td>예외</td>
          <td>12:00 조기 마감, 임시 휴장</td>
          <td>exception id, effective range</td>
      </tr>
      <tr>
          <td>마감 정책</td>
          <td>당일 17:00, 다음 영업일 이월</td>
          <td>policy version, grace window</td>
      </tr>
  </tbody>
</table>
<p>예를 들어 &ldquo;9월 12일 16:58 KST에 접수한 환불&quot;은 서버가 받은 <code>received_at</code>만으로 당일 처리인지 판단하면 안 됩니다. 고객 계약의 <code>calendar_id</code>, 업무 유형의 cutoff, 해당 날짜의 조기 마감 예외, 그리고 결제 원장이 허용하는 지연을 확인해야 합니다. 날짜를 테이블 컬럼 하나로 저장하는 것은 좋지만, 그 날짜가 <strong>어떤 정책으로 산출됐는지</strong>를 잃으면 장애·감사·재정산에서 근거가 사라집니다.</p>
<h3 id="2-cutoff는-비교-연산이-아니라-상태-전이-규칙이다">2) cutoff는 비교 연산이 아니라 상태 전이 규칙이다</h3>
<p>가장 단순한 구현은 <code>now &lt; 17:00</code>이면 당일, 아니면 다음 날로 보내는 방식입니다. 이 구현은 요청이 몰리는 마감 시점에 특히 취약합니다. API를 16:59:59에 수신했지만 DB commit이 17:00:03에 끝났다면 어느 날짜에 귀속할까요? 외부 결제 승인 시각이 더 중요한가요? 큐 지연으로 10분 늦게 처리됐다면요?</p>
<p>업무마다 먼저 **권위 있는 시각(authoritative timestamp)**을 정해야 합니다.</p>
<table>
  <thead>
      <tr>
          <th>업무</th>
          <th>우선 시각</th>
          <th>권장 규칙</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>고객 접수</td>
          <td>API gateway 수신 시각</td>
          <td>검증 완료 전이라도 접수 영수증을 남김</td>
      </tr>
      <tr>
          <td>결제/정산</td>
          <td>결제사 승인 시각 또는 원장 event time</td>
          <td>수신 시각과 분리 보관, 늦은 이벤트는 보정</td>
      </tr>
      <tr>
          <td>예약 변경</td>
          <td>사용자가 확정한 시각</td>
          <td>지역 calendar와 상품별 변경 마감 적용</td>
      </tr>
      <tr>
          <td>일 마감 배치</td>
          <td>snapshot 기준 시각</td>
          <td>동일 snapshot을 재실행에도 재사용</td>
      </tr>
  </tbody>
</table>
<p>이후 상태는 <code>RECEIVED → VALIDATED → ELIGIBLE_FOR_TODAY → CLOSED</code>처럼 나눕니다. 마감 뒤 늦게 들어온 값은 실패로 뭉개지 말고 <code>LATE_REVIEW</code> 또는 <code>NEXT_BUSINESS_DATE</code>로 분기합니다. 이 구조가 있어야 운영자가 &ldquo;당일 반영인지&rdquo;, &ldquo;다음 영업일 이월인지&rdquo;, &ldquo;사람 승인이 필요한 예외인지&quot;를 데이터로 확인할 수 있습니다.</p>
<h3 id="3-grace-window는-편의-기능이-아니라-위험-예산이다">3) grace window는 편의 기능이 아니라 위험 예산이다</h3>
<p>마감 직후 1~2분 동안 네트워크 재시도나 큐 지연을 허용하고 싶을 수 있습니다. 이를 무제한으로 허용하면 마감의 의미가 사라지고, 아예 허용하지 않으면 정상 사용자가 불공정하게 탈락합니다. grace window는 고객 경험과 정합성 사이의 <strong>명시적 위험 예산</strong>으로 다뤄야 합니다.</p>
<p>출발점은 다음처럼 작게 두는 편이 안전합니다.</p>
<ul>
<li>고객 접수: gateway 수신 시각 기준, <strong>최대 120초</strong> grace window</li>
<li>금액·권리 변동: 원장 event time 기준, 자동 허용은 <strong>0~30초</strong>, 그 뒤는 검토 큐</li>
<li>대량 파일: upload 완료 시각이 아닌 검증 완료 전의 접수 receipt 기준, 파일 hash 필수</li>
<li>외부 파트너 이벤트: watermark를 <strong>5~15분</strong> 두고, 이후 도착분은 adjustment로 분리</li>
</ul>
<p>숫자는 고정값이 아닙니다. 최근 30일의 p99 큐 지연이 8초인데 10분 grace를 둔다면 편의를 위해 통제를 잃는 것입니다. 반대로 p99 지연이 70초인데 30초만 허용하면 정상 요청을 예외 처리로 밀어 넣습니다. grace window는 <code>p99 end-to-end delay + 2배 안전 여유</code>에서 시작하고, 월 1회 실제 late ratio와 고객 이의 제기 비율로 조정합니다.</p>
<h2 id="실무-적용">실무 적용</h2>
<h3 id="1-정책을-versioned-data로-모델링한다">1) 정책을 versioned data로 모델링한다</h3>
<p>공휴일 목록과 cutoff를 코드 상수 또는 cron 표현식에 숨기지 않습니다. 최소한 아래와 같은 정책 레코드를 둡니다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>calendar_id: KR_SETTLEMENT
</span></span><span style="display:flex;"><span>zone_id: Asia/Seoul
</span></span><span style="display:flex;"><span>policy_version: 2026.09.1
</span></span><span style="display:flex;"><span>effective_from: 2026-09-01T00:00:00+09:00
</span></span><span style="display:flex;"><span>regular_cutoff: 17:00
</span></span><span style="display:flex;"><span>grace_seconds: 120
</span></span><span style="display:flex;"><span>holiday_source: operations-calendar-v4
</span></span><span style="display:flex;"><span>exception: 2026-09-29, cutoff=12:00, reason=half-day
</span></span></code></pre></div><p>마감 판정 결과에도 <code>policy_version</code>, <code>calendar_id</code>, <code>evaluated_at</code>, <code>source_event_time</code>, <code>result_business_date</code>를 함께 적습니다. 정책이 9월 20일에 바뀌어도 9월 12일 결정을 다시 계산할 때 당시 버전을 쓸 수 있어야 합니다. 단순히 현재 달력으로 과거를 재계산하면 이미 확정한 정산과 다른 결과가 나올 수 있습니다.</p>
<h3 id="2-마감-job은-idempotent-snapshot으로-닫는다">2) 마감 job은 idempotent snapshot으로 닫는다</h3>
<p>일 마감은 &ldquo;자정에 한 번 실행&quot;하는 작업이 아니라 특정 범위를 확정하는 작업입니다. 따라서 작업 키를 <code>calendar_id + business_date + cutoff_policy_version</code>으로 잡고, 선택한 입력 범위의 snapshot hash를 저장합니다. 재실행하더라도 같은 key와 같은 snapshot이면 결과를 덮어쓰지 말고 이전 실행의 결과를 재사용하거나 명시적 correction run으로 분리합니다.</p>
<p>권장 운영 기준은 다음과 같습니다.</p>
<ol>
<li>cutoff 15분 전부터 queue lag, DB write p99, clock offset을 1분 간격으로 관측합니다.</li>
<li>queue lag가 grace window의 50%를 넘거나 NTP offset이 100ms를 넘으면 자동 확정을 멈추고 <code>PENDING_REVIEW</code>로 전환합니다.</li>
<li>마감 job은 대상 레코드 수, 금액 합계, rejected count, policy version을 receipt로 남깁니다.</li>
<li>확정 뒤에는 원장 합계와 파생 집계의 차이가 **0.01%**를 넘는지 대사하고, 초과하면 다음 영업일 처리 전에 원인을 분류합니다.</li>
</ol>
<p>이 순서는 성능보다 정합성을 보호합니다. 마감 시간을 맞추기 위해 불완전한 데이터를 확정하는 것보다, 보류 상태와 근거를 남겨 재처리하는 편이 더 싸게 끝납니다.</p>
<h3 id="3-테스트는-날짜-예제가-아니라-정책-경계표로-만든다">3) 테스트는 날짜 예제가 아니라 정책 경계표로 만든다</h3>
<p>단위 테스트에 평일 한 건만 넣어서는 부족합니다. calendar마다 아래 경계를 fixture로 고정합니다.</p>
<table>
  <thead>
      <tr>
          <th>케이스</th>
          <th>기대 결과</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>평일 cutoff 1초 전</td>
          <td>당일 business date</td>
      </tr>
      <tr>
          <td>평일 cutoff 정확히</td>
          <td>정책의 inclusive/exclusive 정의대로 고정</td>
      </tr>
      <tr>
          <td>grace 마지막 1초</td>
          <td>자동 허용 또는 review를 명확히 검증</td>
      </tr>
      <tr>
          <td>조기 마감일</td>
          <td>일반 cutoff가 아닌 exception 적용</td>
      </tr>
      <tr>
          <td>휴일 전 영업일 마감 뒤</td>
          <td>다음 영업일로 이동</td>
      </tr>
      <tr>
          <td>DST gap/overlap 지역</td>
          <td>존재하지 않는 시각 거부, 중복 시각의 offset 기록</td>
      </tr>
      <tr>
          <td>과거 정책 재실행</td>
          <td>당시 policy version으로 동일 결과</td>
      </tr>
  </tbody>
</table>
<p><code>cutoff &lt;= now</code>처럼 애매한 비교식을 팀마다 해석하지 않도록, inclusive 여부를 정책 명세에 적고 fixture 이름에도 드러냅니다. CI에서는 앞으로 90일의 holiday source를 파싱해 중복 날짜, 누락 zone, 과거 effective range 충돌을 막는 검증을 추가하는 것이 좋습니다.</p>
<h2 id="트레이드오프주의점">트레이드오프/주의점</h2>
<ol>
<li><strong>외부 공휴일 API를 요청 경로에서 직접 호출하지 않습니다.</strong> 최신성은 좋아 보여도 장애가 마감 판정 장애로 번집니다. 검증된 정책 snapshot을 내부에 복제하고, source 갱신 실패는 사전 경보로 처리합니다.</li>
<li><strong>현재 날짜로 과거 결과를 재계산하지 않습니다.</strong> 예외 휴일이 뒤늦게 추가되거나 policy가 바뀌면 결과가 달라집니다. 원결정과 correction을 구분해야 합니다.</li>
<li><strong>서버 타임존에 의존하지 않습니다.</strong> 컨테이너·개발 PC·배치 worker의 기본 zone은 정책이 아닙니다. IANA zone을 데이터와 코드에 명시합니다.</li>
<li><strong>grace를 무한 예외 처리로 만들지 않습니다.</strong> 자동 허용 범위를 넘긴 건은 사람이 설명 가능한 review queue로 보내고, 승인자·사유·재귀속 결과를 남깁니다.</li>
</ol>
<h2 id="체크리스트와-연습">체크리스트와 연습</h2>
<h3 id="출시-전-체크리스트">출시 전 체크리스트</h3>
<ul>
<li><input disabled="" type="checkbox"> 업무별로 calendar, zone, cutoff, authoritative timestamp, grace를 문서화했다.</li>
<li><input disabled="" type="checkbox"> 마감 결과에 policy version과 입력 snapshot hash를 저장한다.</li>
<li><input disabled="" type="checkbox"> 휴일·조기 마감 변경에 owner, 검토자, effective date가 있다.</li>
<li><input disabled="" type="checkbox"> cutoff 직전 처리 지연·clock offset·큐 적체의 보류 기준이 숫자로 있다.</li>
<li><input disabled="" type="checkbox"> 재실행이 기존 확정 결과를 바꾸지 않고 correction 경로로 분리된다.</li>
<li><input disabled="" type="checkbox"> 다음 90일 정책 fixture와 DST 경계 테스트가 CI에서 통과한다.</li>
</ul>
<h3 id="연습-월말-정산-마감-표-만들기">연습: 월말 정산 마감 표 만들기</h3>
<p>가상의 KR 정산 서비스를 정하고, 월~금 17:00 마감, 공휴일 이월, 120초 grace, 조기 마감일 12:00을 정책 데이터로 표현해 보세요. 이어서 <code>16:59:59</code>, <code>17:00:00</code>, <code>17:01:59</code>, <code>17:02:01</code>, 휴일 전날, 조기 마감일의 여섯 요청을 표로 만들고 각 요청의 authoritative timestamp, policy version, 결과 business date, 상태를 기록합니다. 마지막으로 5분 늦게 도착한 결제사 이벤트를 자동 반영할지 adjustment로 보낼지 이유를 적어 보세요.</p>
<h2 id="마무리">마무리</h2>
<p>Business Calendar는 달력 라이브러리 선택 문제가 아닙니다. 조직이 &ldquo;언제 접수됐고, 어느 영업일에 속하며, 어떤 정책으로 확정됐는가&quot;를 나중에도 답할 수 있게 만드는 도메인 계약입니다. 시간대 변환을 정확히 하는 것에서 멈추지 말고, 정책 버전·상태 전이·snapshot·대사까지 연결해야 마감이 장애가 아닌 재현 가능한 업무 절차가 됩니다.</p>
]]></content:encoded></item></channel></rss>