<?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>UTC on jyukki's Blog</title><link>https://jyukki.com/tags/utc/</link><description>Recent content in UTC on jyukki's Blog</description><generator>Hugo -- 0.147.0</generator><language>ko-KR</language><lastBuildDate>Sun, 01 Nov 2026 10:06:00 +0900</lastBuildDate><atom:link href="https://jyukki.com/tags/utc/index.xml" rel="self" type="application/rss+xml"/><item><title>백엔드 커리큘럼 심화: 결정론적 시간 설계 — Clock 주입, UTC 저장, 경계 테스트 플레이북</title><link>https://jyukki.com/learning/deep-dive/2026-11-01-deterministic-time-clock-injection-boundary-test-playbook/</link><pubDate>Sun, 01 Nov 2026 10:06:00 +0900</pubDate><guid>https://jyukki.com/learning/deep-dive/2026-11-01-deterministic-time-clock-injection-boundary-test-playbook/</guid><description>서버의 현재 시간을 전역 함수로 읽지 않고 Clock·UTC·업무 시간대·경계 테스트로 분리해 마감, 만료, 재시도, 정산 로직을 재현 가능하게 만드는 방법을 정리합니다.</description><content:encoded><![CDATA[<p><code>Instant.now()</code>나 <code>new Date()</code>는 보기에는 무해합니다. 그러나 이 호출이 쿠폰 만료, 정산 마감, 토큰 검증, 예약 취소, 재시도 가능 시각을 결정하기 시작하면 서버의 “지금”은 외부 입력이 됩니다. 테스트는 실행한 순간에만 통과하고, 자정·월말·시간대 전환에서는 재현하기 어려운 버그가 생깁니다.</p>
<p>시간 문제를 피하는 핵심은 모든 값을 복잡한 시간 모델로 바꾸는 일이 아닙니다. <strong>현재 시각을 얻는 일, 저장하는 일, 업무 날짜로 해석하는 일, 테스트에서 시간을 움직이는 일을 분리</strong>하는 것입니다. 이 글은 <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-business-calendar-cutoff-policy-playbook/">업무 마감 정책</a>, <a href="/learning/deep-dive/deep-dive-scheduler-misfire-backfill-control-playbook/">scheduler misfire와 backfill</a>을 실무 코드로 이어 주는 플레이북입니다.</p>
<h2 id="이-글에서-얻는-것">이 글에서 얻는 것</h2>
<ul>
<li><code>Instant</code>, <code>LocalDate</code>, <code>ZonedDateTime</code>, DB의 <code>timestamptz</code>를 언제 각각 써야 하는지 구분할 수 있습니다.</li>
<li>Spring/Java 서비스에서 <code>Clock</code>을 주입해 만료·마감·재시도 로직을 같은 입력으로 재현하는 방법을 배웁니다.</li>
<li>자정, 월말, 시간대 전환, 지연 이벤트를 포함한 최소 시간 테스트 행렬을 만들 수 있습니다.</li>
<li>시간 설계를 과하게 일반화하지 않고, 어떤 기능부터 고칠지 위험도 기준으로 우선순위를 정할 수 있습니다.</li>
</ul>
<h2 id="핵심-개념이슈">핵심 개념/이슈</h2>
<h3 id="1-현재-시간은-하나가-아니다">1) “현재 시간”은 하나가 아니다</h3>
<p>주문이 23:59:59에 생성됐고 worker가 00:00:03에 처리했다면 어느 날의 주문일까요? 사용자의 한국 업무일 기준으로는 전날일 수 있지만, DB 서버의 UTC 날짜는 다릅니다. 외부 결제사가 1분 늦게 webhook을 보냈다면 이벤트가 발생한 시각과 시스템이 안 시각도 다릅니다. 이 값을 하나의 <code>created_at</code>으로 뭉치면 나중에 재처리와 감사가 서로 다른 답을 냅니다.</p>
<table>
  <thead>
      <tr>
          <th>시간 의미</th>
          <th>대표 타입/컬럼</th>
          <th>쓰는 곳</th>
          <th>혼동했을 때의 문제</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>사건 발생 시각</td>
          <td><code>event_occurred_at: Instant</code></td>
          <td>webhook, 로그, 원장</td>
          <td>지연 도착을 현재 사건으로 오해</td>
      </tr>
      <tr>
          <td>시스템 처리 시각</td>
          <td><code>processed_at: Instant</code></td>
          <td>worker, 재시도, 관측</td>
          <td>처리 지연을 원인 시각으로 착각</td>
      </tr>
      <tr>
          <td>업무 기준일</td>
          <td><code>business_date: LocalDate</code> + <code>ZoneId</code></td>
          <td>마감, 일 정산, 예약</td>
          <td>UTC 자정에 고객의 날짜가 바뀜</td>
      </tr>
      <tr>
          <td>표시 시각</td>
          <td><code>ZonedDateTime</code></td>
          <td>UI, 이메일, 보고서</td>
          <td>서버 시간대에 따라 화면이 달라짐</td>
      </tr>
  </tbody>
</table>
<p>저장 시각은 가능한 한 <code>Instant</code> 또는 PostgreSQL의 <code>timestamptz</code>로 둡니다. 반면 “11월 1일 마감”처럼 날짜 자체가 계약인 값은 <code>LocalDate</code>만으로도 충분할 수 있습니다. 단, 그 날짜를 계산한 <code>Asia/Seoul</code> 같은 IANA 시간대와 cut-off 시각을 정책으로 함께 정해야 합니다. <code>LocalDateTime</code>은 시간대가 빠진 벽시계 값이라 외부 API 경계나 영구 저장의 기본값으로 쓰기에는 위험합니다.</p>
<h3 id="2-도메인-코드에서-벽시계를-직접-읽지-않는다">2) 도메인 코드에서 벽시계를 직접 읽지 않는다</h3>
<p>아래 두 구현은 같은 일을 하는 듯 보이지만 테스트 가능성이 다릅니다.</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-java" data-lang="java"><span style="display:flex;"><span><span style="color:#6272a4">// 피해야 할 형태: 실행한 순간이 곧 입력값이다.</span>
</span></span><span style="display:flex;"><span><span style="color:#8be9fd">boolean</span> <span style="color:#50fa7b">isExpired</span>(Coupon coupon) {
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">return</span> coupon.<span style="color:#50fa7b">expiresAt</span>().<span style="color:#50fa7b">isBefore</span>(Instant.<span style="color:#50fa7b">now</span>());
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">// 권장: 시간도 의존성으로 받는다.</span>
</span></span><span style="display:flex;"><span>@Service
</span></span><span style="display:flex;"><span><span style="color:#8be9fd;font-style:italic">class</span> <span style="color:#50fa7b">CouponPolicy</span> {
</span></span><span style="display:flex;"><span>    <span style="color:#8be9fd;font-style:italic">private</span> <span style="color:#8be9fd;font-style:italic">final</span> Clock clock;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    CouponPolicy(Clock clock) {
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">this</span>.<span style="color:#50fa7b">clock</span> <span style="color:#ff79c6">=</span> clock;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#8be9fd">boolean</span> <span style="color:#50fa7b">isExpired</span>(Coupon coupon) {
</span></span><span style="display:flex;"><span>        <span style="color:#ff79c6">return</span> <span style="color:#ff79c6">!</span>coupon.<span style="color:#50fa7b">expiresAt</span>().<span style="color:#50fa7b">isAfter</span>(Instant.<span style="color:#50fa7b">now</span>(clock));
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>운영 프로필에는 <code>Clock.systemUTC()</code> 하나를 bean으로 등록하면 됩니다. 테스트에서는 <code>Clock.fixed(Instant.parse(&quot;2026-11-01T14:59:59Z&quot;), ZoneOffset.UTC)</code>를 넣어 결과를 고정합니다. 여기서 중요한 결정은 만료 조건입니다. <code>isBefore(now)</code>는 정확히 만료 시각과 같은 요청을 허용하고, <code>!isAfter(now)</code>는 거부합니다. 정책을 코드와 테스트에 한 번만 쓰고, API 문서의 “만료 시각 포함 여부”와 맞춰야 합니다.</p>
<p>시간을 주입한다고 타이머까지 자동으로 가상화되는 것은 아닙니다. <code>@Scheduled</code>, queue visibility timeout, DB <code>now()</code>, 브라우저 시간은 별도 실행 주체입니다. 도메인 판단은 fixed clock 단위 테스트로, 실제 지연·재시도·락은 짧은 통합 테스트와 운영 지표로 나눠 확인하는 편이 현실적입니다.</p>
<h3 id="3-시간-경계는-정상-흐름보다-먼저-명세한다">3) 시간 경계는 정상 흐름보다 먼저 명세한다</h3>
<p>시간 버그는 대개 평일 오후 3시 테스트에서 나오지 않습니다. 마감 1초 전후, 2월 말, 서버 재시작 뒤 지연된 이벤트처럼 경계에서만 드러납니다. 다음 기준을 PR의 최소 테스트 행렬로 둘 수 있습니다.</p>
<table>
  <thead>
      <tr>
          <th>조건</th>
          <th>꼭 확인할 질문</th>
          <th>권장 판정</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>마감 직전·정확한 마감·직후</td>
          <td><code>&lt;=</code>인지 <code>&lt;</code>인지</td>
          <td>세 시점 모두 명시적 assertion</td>
      </tr>
      <tr>
          <td>UTC 날짜와 업무 날짜가 다른 시간</td>
          <td>어느 기준일로 집계하는가</td>
          <td><code>ZoneId</code> 고정 테스트</td>
      </tr>
      <tr>
          <td>월말·윤년</td>
          <td>다음 billing period가 존재하는가</td>
          <td>달력 계산 API 사용, 일 수 하드코딩 금지</td>
      </tr>
      <tr>
          <td>DST gap/overlap 대상 지역</td>
          <td>존재하지 않거나 두 번인 로컬 시각을 어떻게 처리하는가</td>
          <td>입력 거부 또는 명시적 offset 선택</td>
      </tr>
      <tr>
          <td>늦은 webhook·재시도</td>
          <td>사건 시각과 처리 시각 중 어느 것을 기준으로 삼는가</td>
          <td>두 컬럼 보존, 멱등 처리</td>
      </tr>
  </tbody>
</table>
<p>한국 서비스라도 공급자, 파트너, 해외 사용자, 분석 파이프라인이 다른 시간대를 쓸 수 있습니다. “우리 서버는 KST니까 괜찮다”는 해결책이 아닙니다. 서버 환경 변수와 도메인 기준 시간대는 별개의 계약으로 관리해야 합니다.</p>
<h2 id="실무-적용">실무 적용</h2>
<h3 id="1-시간-의존성을-네-경계로-inventory한다">1) 시간 의존성을 네 경계로 inventory한다</h3>
<p>먼저 repository 전체를 바꾸지 말고 <code>now()</code>, <code>new Date()</code>, <code>System.currentTimeMillis()</code>, DB의 시간 함수 호출을 찾습니다. 다음 중 하나에 해당하면 우선 전환 대상입니다.</p>
<ol>
<li>결과가 금전·권한·삭제·공개 여부를 바꾼다.</li>
<li>24시간 이내 만료, 예약, 마감처럼 한 시간의 차이가 사용자 결과를 바꾼다.</li>
<li>장애 뒤 재시도·backfill에서 과거 시점을 다시 계산해야 한다.</li>
<li>최근 30일에 시간대·자정 관련 CS나 재현 불가 테스트가 한 번이라도 있었다.</li>
</ol>
<p>그다음 API controller가 아니라 도메인 service 경계에 <code>Clock</code>을 넣습니다. controller에서 받은 <code>requestedAt</code>을 무조건 신뢰하지 말고, 감사가 필요한 경우 서버 처리 시각과 요청이 주장한 사건 시각을 분리해 저장합니다. 외부 입력 시각은 미래 허용 범위(예: 5분)와 과거 허용 범위(예: 7일)를 도메인별로 검증해야 합니다.</p>
<h3 id="2-utc-저장과-업무-날짜-projection을-한-흐름으로-만든다">2) UTC 저장과 업무 날짜 projection을 한 흐름으로 만든다</h3>
<p>예를 들어 서울 기준 매일 15시에 마감하는 주문이 있다면, 주문 원장에는 <code>accepted_at timestamptz</code>를 저장하고 집계용 <code>business_date</code>는 명시적으로 계산합니다.</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-java" data-lang="java"><span style="display:flex;"><span>Instant acceptedAt <span style="color:#ff79c6">=</span> Instant.<span style="color:#50fa7b">now</span>(clock);
</span></span><span style="display:flex;"><span>ZoneId businessZone <span style="color:#ff79c6">=</span> ZoneId.<span style="color:#50fa7b">of</span>(<span style="color:#f1fa8c">&#34;Asia/Seoul&#34;</span>);
</span></span><span style="display:flex;"><span>LocalDate businessDate <span style="color:#ff79c6">=</span> acceptedAt.<span style="color:#50fa7b">atZone</span>(businessZone)
</span></span><span style="display:flex;"><span>    .<span style="color:#50fa7b">minusHours</span>(15)
</span></span><span style="display:flex;"><span>    .<span style="color:#50fa7b">toLocalDate</span>();
</span></span></code></pre></div><p>이 예시는 “15시 이전은 같은 날짜, 15시 이후는 다음 업무일”이라는 정책을 코드로 드러냅니다. 더 복잡한 휴일·영업일 규칙은 산술을 늘리지 말고 <a href="/learning/deep-dive/deep-dive-business-calendar-cutoff-policy-playbook/">business calendar</a> 서비스로 승격하세요. 집계 query에서 매번 서버의 <code>timezone</code> 설정에 기대어 날짜를 만들면 replica, BI, backfill job마다 결과가 달라질 수 있습니다.</p>
<h3 id="3-관측성에는-시각의-역할을-함께-남긴다">3) 관측성에는 시각의 역할을 함께 남긴다</h3>
<p>장애 조사에서 <code>timestamp</code> 하나만 있으면 “언제 발생했는가”와 “언제 처리했는가”를 분리할 수 없습니다. 이벤트 기반 흐름에는 <code>event_occurred_at</code>, <code>received_at</code>, <code>processed_at</code>, <code>business_date</code>, <code>source_timezone</code>을 필요한 범위에서 기록합니다. 민감 데이터에 원문 시간을 과도하게 쌓을 필요는 없지만, 최소한 지연을 계산할 수 있는 두 시각은 남겨야 합니다.</p>
<p>운영 경보는 평균 지연보다 p95 또는 p99를 우선 봅니다. 예를 들어 webhook의 <code>received_at - event_occurred_at</code> p95가 5분을 넘거나, 마감 직후 10분에 전날 기준으로 들어온 주문 비율이 1%를 넘으면 재처리 정책을 점검하는 식입니다. 수치는 서비스 특성에 맞게 조정하되, “새벽에만 어긋난다”는 감각 대신 측정 가능한 기준을 둡니다.</p>
<h2 id="트레이드오프주의점">트레이드오프/주의점</h2>
<p>모든 함수에 <code>Clock</code>을 넘기면 코드가 장황해질 수 있습니다. 따라서 화면용 현재 시각, 단순 access log, 일회성 CLI 출력까지 일괄 변환할 필요는 없습니다. 판단 결과를 바꾸거나 재현해야 하는 도메인 경계부터 적용하는 것이 비용 대비 효과가 큽니다.</p>
<p>반대로 DB에서 <code>now()</code>를 완전히 금지하는 것도 과합니다. <code>created_at</code> 기본값, 원자적 lease 만료, DB 감사 트리거처럼 DB가 권위 있는 시각을 가져야 하는 경우가 있습니다. 이때는 애플리케이션 clock과 DB clock을 섞어 비교하지 말고, 어느 시계가 권위인지 정합니다. 둘의 차이가 1초를 넘으면 경보처럼, 허용 가능한 skew와 처리 규칙을 문서화하세요.</p>
<p>테스트의 fixed clock은 실제 scheduler 장애를 감추기도 합니다. 단위 테스트가 통과해도 queue 지연, NTP 문제, DST 정책, DB 시간대 설정은 통합·운영 단계에서 별도로 검증해야 합니다. 가상 시간은 현실을 대체하는 장치가 아니라, 도메인 규칙을 반복 가능하게 만드는 장치입니다.</p>
<h2 id="체크리스트-또는-연습">체크리스트 또는 연습</h2>
<ul>
<li><input disabled="" type="checkbox"> 만료·마감·권한·재시도 판단에서 전역 현재 시간 호출을 <code>Clock</code> 또는 명시적 입력으로 바꿨다.</li>
<li><input disabled="" type="checkbox"> 영구 시각은 UTC <code>Instant</code>/<code>timestamptz</code>로 저장하고, 업무 날짜에는 IANA 시간대를 명시했다.</li>
<li><input disabled="" type="checkbox"> 마감 직전·정확한 마감·직후 세 사례가 테스트에 있다.</li>
<li><input disabled="" type="checkbox"> 월말, 윤년, 대상 지역의 DST gap/overlap 필요 여부를 도메인별로 판정했다.</li>
</ul>
]]></content:encoded></item></channel></rss>