<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="ko-KR"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://seonhyeokjun.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://seonhyeokjun.github.io/" rel="alternate" type="text/html" hreflang="ko-KR" /><updated>2026-08-25T13:11:20+09:00</updated><id>https://seonhyeokjun.github.io/feed.xml</id><title type="html">Throughput</title><subtitle>Java, Spring Boot, Kafka, Redis와 클라우드 환경에서 배운 백엔드 엔지니어링 기록</subtitle><author><name>SEON HYEOK JUN</name><email></email></author><entry><title type="html">결제 이벤트를 Kafka로 옮기며 지킨 세 가지 불변식</title><link href="https://seonhyeokjun.github.io/posts/kafka-payment-event-consistency/" rel="alternate" type="text/html" title="결제 이벤트를 Kafka로 옮기며 지킨 세 가지 불변식" /><published>2026-08-18T09:00:00+09:00</published><updated>2026-08-18T09:00:00+09:00</updated><id>https://seonhyeokjun.github.io/posts/kafka-payment-event-consistency</id><content type="html" xml:base="https://seonhyeokjun.github.io/posts/kafka-payment-event-consistency/"><![CDATA[<p>결제 시스템에 Kafka를 붙이는 일은 이벤트를 <code class="language-plaintext highlighter-rouge">send()</code> 하는 것으로 끝나지 않는다. 결제 승인은 성공했는데 이벤트가 유실되거나, 재시도로 같은 정산이 두 번 반영되거나, 취소가 승인보다 먼저 소비되면 금액이 틀어진다. 처리량보다 먼저 지켜야 할 것은 <strong>돈의 상태가 한 방향으로만, 설명 가능한 순서로 이동한다는 사실</strong>이다.</p>

<p>이 글은 결제 승인 이벤트를 동기 호출에서 Kafka 기반 비동기 흐름으로 옮길 때 정한 세 가지 불변식과 그 구현을 다룬다.</p>

<h2 id="1-승인-결과와-이벤트는-함께-남는다">1. 승인 결과와 이벤트는 함께 남는다</h2>

<p>처음에는 결제 트랜잭션이 끝난 뒤 프로듀서로 이벤트를 보냈다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Transactional</span>
<span class="kd">public</span> <span class="nc">Payment</span> <span class="nf">approve</span><span class="o">(</span><span class="nc">ApproveCommand</span> <span class="n">command</span><span class="o">)</span> <span class="o">{</span>
    <span class="nc">Payment</span> <span class="n">payment</span> <span class="o">=</span> <span class="n">paymentRepository</span><span class="o">.</span><span class="na">save</span><span class="o">(</span><span class="nc">Payment</span><span class="o">.</span><span class="na">approved</span><span class="o">(</span><span class="n">command</span><span class="o">));</span>
    <span class="n">kafkaTemplate</span><span class="o">.</span><span class="na">send</span><span class="o">(</span><span class="s">"payment-approved"</span><span class="o">,</span> <span class="n">payment</span><span class="o">.</span><span class="na">getId</span><span class="o">(),</span> <span class="n">toEvent</span><span class="o">(</span><span class="n">payment</span><span class="o">));</span>
    <span class="k">return</span> <span class="n">payment</span><span class="o">;</span>
<span class="o">}</span>
</code></pre></div></div>

<p>코드는 짧지만 데이터베이스 커밋과 Kafka 발행은 하나의 원자적 연산이 아니다. DB 커밋 후 프로세스가 종료되면 승인은 남고 이벤트는 사라진다. 반대로 Kafka 발행을 기다리는 동안 DB 트랜잭션을 오래 열어 두면 지연과 장애의 영향 범위가 커진다.</p>

<p>그래서 <strong>Transactional Outbox</strong>를 사용했다. 결제 상태와 발행할 이벤트를 같은 로컬 트랜잭션에 기록하고, 별도 릴레이가 outbox를 읽어 Kafka로 보낸다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Transactional</span>
<span class="kd">public</span> <span class="nc">Payment</span> <span class="nf">approve</span><span class="o">(</span><span class="nc">ApproveCommand</span> <span class="n">command</span><span class="o">)</span> <span class="o">{</span>
    <span class="nc">Payment</span> <span class="n">payment</span> <span class="o">=</span> <span class="n">paymentRepository</span><span class="o">.</span><span class="na">save</span><span class="o">(</span><span class="nc">Payment</span><span class="o">.</span><span class="na">approved</span><span class="o">(</span><span class="n">command</span><span class="o">));</span>
    <span class="n">outboxRepository</span><span class="o">.</span><span class="na">save</span><span class="o">(</span><span class="nc">OutboxEvent</span><span class="o">.</span><span class="na">pending</span><span class="o">(</span>
        <span class="n">payment</span><span class="o">.</span><span class="na">getId</span><span class="o">(),</span>
        <span class="s">"PaymentApproved"</span><span class="o">,</span>
        <span class="n">eventSerializer</span><span class="o">.</span><span class="na">serialize</span><span class="o">(</span><span class="n">toEvent</span><span class="o">(</span><span class="n">payment</span><span class="o">))</span>
    <span class="o">));</span>
    <span class="k">return</span> <span class="n">payment</span><span class="o">;</span>
<span class="o">}</span>
</code></pre></div></div>

<p>이 구조가 보장하는 것은 “정확히 한 번 발행”이 아니다. <strong>승인이 존재하면 결국 발행할 이벤트도 존재한다</strong>는 복구 가능한 상태다. 릴레이는 발행 직후 종료될 수 있으므로 같은 이벤트를 다시 보낼 수 있다. 중복은 다음 불변식에서 처리한다.</p>

<h2 id="2-같은-이벤트는-여러-번-와도-결과가-같다">2. 같은 이벤트는 여러 번 와도 결과가 같다</h2>

<p>Kafka의 재전달, 컨슈머 재시작, outbox 릴레이 재시도 때문에 소비자는 중복을 정상 입력으로 받아들여야 한다. 이벤트 ID를 기준으로 처리 이력을 남기고, 비즈니스 변경과 같은 트랜잭션에서 중복 여부를 확인한다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@KafkaListener</span><span class="o">(</span><span class="n">topics</span> <span class="o">=</span> <span class="s">"payment-approved"</span><span class="o">)</span>
<span class="nd">@Transactional</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">handle</span><span class="o">(</span><span class="nc">PaymentApproved</span> <span class="n">event</span><span class="o">)</span> <span class="o">{</span>
    <span class="k">if</span> <span class="o">(</span><span class="n">processedEventRepository</span><span class="o">.</span><span class="na">existsById</span><span class="o">(</span><span class="n">event</span><span class="o">.</span><span class="na">eventId</span><span class="o">()))</span> <span class="o">{</span>
        <span class="k">return</span><span class="o">;</span>
    <span class="o">}</span>

    <span class="n">settlementRepository</span><span class="o">.</span><span class="na">createPending</span><span class="o">(</span>
        <span class="n">event</span><span class="o">.</span><span class="na">paymentId</span><span class="o">(),</span>
        <span class="n">event</span><span class="o">.</span><span class="na">amount</span><span class="o">()</span>
    <span class="o">);</span>
    <span class="n">processedEventRepository</span><span class="o">.</span><span class="na">save</span><span class="o">(</span><span class="k">new</span> <span class="nc">ProcessedEvent</span><span class="o">(</span><span class="n">event</span><span class="o">.</span><span class="na">eventId</span><span class="o">()));</span>
<span class="o">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">exists</code> 다음 <code class="language-plaintext highlighter-rouge">insert</code> 사이에는 경쟁 조건이 있다. 따라서 <code class="language-plaintext highlighter-rouge">processed_event.event_id</code>에 유니크 제약을 두고, 충돌을 이미 처리된 이벤트로 해석한다. 애플리케이션의 사전 조회는 빠른 종료를 위한 최적화일 뿐, 정확성은 데이터베이스 제약이 지킨다.</p>

<p>이벤트 ID는 재시도마다 새로 만들지 않는다. <strong>비즈니스 사건 하나에 ID 하나</strong>를 부여하고 outbox 레코드와 Kafka 메시지 헤더에서 끝까지 유지한다.</p>

<h2 id="3-순서가-필요한-사건은-같은-파티션으로-간다">3. 순서가 필요한 사건은 같은 파티션으로 간다</h2>

<p>결제에는 <code class="language-plaintext highlighter-rouge">Approved → Cancelled</code>처럼 인과 순서가 있다. 토픽 전체의 순서를 보장할 필요는 없지만, 같은 결제 건의 이벤트는 순서대로 처리되어야 한다. 프로듀서 키를 <code class="language-plaintext highlighter-rouge">paymentId</code>로 고정해 한 결제의 이벤트가 같은 파티션에 들어가게 했다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">kafkaTemplate</span><span class="o">.</span><span class="na">send</span><span class="o">(</span>
    <span class="s">"payment-events"</span><span class="o">,</span>
    <span class="n">event</span><span class="o">.</span><span class="na">paymentId</span><span class="o">().</span><span class="na">toString</span><span class="o">(),</span>
    <span class="n">event</span>
<span class="o">);</span>
</code></pre></div></div>

<p>파티션 키를 정할 때는 순서와 부하 분산을 함께 본다. 가맹점 ID를 키로 사용하면 특정 대형 가맹점이 한 파티션을 과열시킬 수 있다. 결제 ID는 개별 결제의 인과 관계를 유지하면서 비교적 고르게 분산된다.</p>

<p>컨슈머에서는 예상하지 못한 상태 전이를 거부한다. 취소 이벤트가 도착했는데 승인 상태가 없다면 무작정 재시도하지 않고 보류 큐로 보내 원인을 분리한다. 순서 보장은 정상 조건의 약속이지, 손상된 데이터까지 자동으로 고치는 장치가 아니다.</p>

<h2 id="운영-지표는-불변식의-위반을-보여줘야-한다">운영 지표는 불변식의 위반을 보여줘야 한다</h2>

<p>기술 지표만으로는 돈의 흐름이 맞는지 알 수 없다. 다음 지표를 함께 운영했다.</p>

<table>
  <thead>
    <tr>
      <th>지표</th>
      <th>의미</th>
      <th>대응 기준</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>outbox 대기 시간</td>
      <td>DB 기록 후 아직 발행되지 않은 시간</td>
      <td>p99가 1분을 넘으면 릴레이 확인</td>
    </tr>
    <tr>
      <td>중복 이벤트 비율</td>
      <td>재전달과 재시도의 빈도</td>
      <td>급증 시 프로듀서·컨슈머 재시작 확인</td>
    </tr>
    <tr>
      <td>유효하지 않은 상태 전이</td>
      <td>순서 또는 데이터 계약 위반</td>
      <td>1건도 조사 대상</td>
    </tr>
    <tr>
      <td>컨슈머 지연</td>
      <td>비동기 처리의 최신성</td>
      <td>정산 마감 SLO와 연결</td>
    </tr>
  </tbody>
</table>

<p>알람에는 토픽과 파티션뿐 아니라 <code class="language-plaintext highlighter-rouge">eventType</code>, 최초 발생 시각, 영향 결제 수를 넣었다. 그래야 “Kafka lag이 높다”가 아니라 “승인 312건의 정산 생성이 4분 늦다”로 상황을 설명할 수 있다.</p>

<h2 id="마치며">마치며</h2>

<p>Kafka를 도입해 얻은 가장 큰 이점은 처리량이 아니라 경계를 명확히 한 것이었다. 결제 서비스는 승인과 발행 의도를 원자적으로 남기고, 릴레이는 적어도 한 번 전달하며, 소비자는 중복을 제거하고 상태 전이를 검증한다.</p>

<p>정리하면 세 가지 불변식은 다음과 같다.</p>

<ol>
  <li>승인이 남으면 발행할 이벤트도 반드시 남는다.</li>
  <li>같은 이벤트가 여러 번 도착해도 결과는 한 번 처리한 것과 같다.</li>
  <li>같은 결제의 상태 전이는 관찰 가능한 순서를 지킨다.</li>
</ol>

<p>“Exactly once”라는 한 문장보다 각 경계가 무엇을 보장하고 어디서 복구하는지 적어 두는 편이 운영에서 훨씬 강했다.</p>]]></content><author><name>Backend Engineer</name></author><category term="분산시스템" /><category term="Kafka" /><category term="결제" /><category term="Transactional Outbox" /><category term="멱등성" /><summary type="html"><![CDATA[결제 승인과 이벤트 발행 사이의 원자성, 중복 소비, 파티션 순서를 운영 가능한 규칙으로 바꾼 과정을 정리합니다.]]></summary></entry><entry><title type="html">Redis 분산 락이 결제 중복을 막아주지 못한 이유</title><link href="https://seonhyeokjun.github.io/posts/redis-distributed-lock/" rel="alternate" type="text/html" title="Redis 분산 락이 결제 중복을 막아주지 못한 이유" /><published>2026-08-11T09:00:00+09:00</published><updated>2026-08-11T09:00:00+09:00</updated><id>https://seonhyeokjun.github.io/posts/redis-distributed-lock</id><content type="html" xml:base="https://seonhyeokjun.github.io/posts/redis-distributed-lock/"><![CDATA[<p>같은 주문에 결제 요청이 동시에 들어오는 문제를 막기 위해 Redis 분산 락을 사용했다. 키가 존재하면 한 요청만 실행되므로 중복 결제가 사라질 것으로 기대했다. 평소에는 잘 동작했지만, 한 번의 긴 GC 정지 이후 같은 주문의 결제 레코드가 두 개 생겼다.</p>

<p>문제는 Redis가 락을 잘못 준 것이 아니었다. <strong>락을 획득한 프로세스가 여전히 유효한 소유자인지 실제 데이터를 변경하는 시스템은 알지 못했다.</strong></p>

<h2 id="재현한-실패-경로">재현한 실패 경로</h2>

<p>락의 TTL은 3초였고 일반적인 결제 준비 단계는 300ms 안에 끝났다. 장애 당시 흐름은 다음과 같았다.</p>

<ol>
  <li>인스턴스 A가 <code class="language-plaintext highlighter-rouge">order:42</code> 락을 얻는다.</li>
  <li>A가 4초 동안 GC로 멈춘다.</li>
  <li>락이 만료되고 인스턴스 B가 같은 락을 얻는다.</li>
  <li>B가 결제 레코드를 생성한다.</li>
  <li>A가 깨어나 자신이 여전히 소유자라고 생각하고 레코드를 생성한다.</li>
</ol>

<p>락을 얻을 때 임의 토큰을 값으로 저장하고 Lua 스크립트로 소유자만 해제하도록 구현해도 이 실패는 남는다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>SET order:42 9b71... NX PX 3000
</code></pre></div></div>

<p>안전한 해제는 다른 소유자의 락을 지우지 않게 할 뿐, <strong>만료된 이전 소유자의 쓰기</strong>를 막지는 않는다.</p>

<h2 id="ttl을-늘리는-것은-확률을-낮출-뿐이다">TTL을 늘리는 것은 확률을 낮출 뿐이다</h2>

<p>TTL을 30초로 늘리자는 제안이 있었다. 평균 처리 시간보다 충분히 길어 보이지만 상한을 증명할 수 없다면 같은 문제가 더 드물게 일어날 뿐이다. 네트워크 지연, stop-the-world GC, 외부 결제사 타임아웃이 겹치면 어떤 TTL도 만료될 수 있다.</p>

<p>락 연장 watchdog도 도움이 되지만 완전한 답은 아니다. 프로세스가 멈추면 watchdog도 멈추고, 네트워크가 분리되면 연장 성공 여부를 확정하기 어렵다. 가용성을 위해 만료가 필요하고, 만료를 허용하면 과거 소유자가 돌아올 가능성을 처리해야 한다.</p>

<h2 id="쓰기-시스템이-오래된-소유자를-거절하게-한다">쓰기 시스템이 오래된 소유자를 거절하게 한다</h2>

<p>해결의 핵심은 단조 증가하는 <strong>fencing token</strong>이었다. 락을 얻을 때마다 더 큰 번호를 받고, 데이터를 변경할 때 토큰을 함께 전달한다. 저장소는 마지막으로 본 토큰보다 작은 쓰기를 거절한다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">UPDATE</span> <span class="n">payment_attempt</span>
<span class="k">SET</span> <span class="n">status</span> <span class="o">=</span> <span class="s1">'READY'</span><span class="p">,</span>
    <span class="n">fencing_token</span> <span class="o">=</span> <span class="p">:</span><span class="n">token</span>
<span class="k">WHERE</span> <span class="n">order_id</span> <span class="o">=</span> <span class="p">:</span><span class="n">orderId</span>
  <span class="k">AND</span> <span class="n">fencing_token</span> <span class="o">&lt;</span> <span class="p">:</span><span class="n">token</span><span class="p">;</span>
</code></pre></div></div>

<p>A가 토큰 101을 받은 뒤 멈추고 B가 102를 받아 먼저 쓰면, 늦게 돌아온 A의 101은 <code class="language-plaintext highlighter-rouge">updated rows = 0</code>으로 거절된다. 시간이나 프로세스 상태를 추측하지 않고 순서 자체를 데이터에 남긴다.</p>

<p>다만 모든 외부 시스템이 fencing token을 이해하는 것은 아니다. 결제사 API에 임의 버전 조건을 전달할 수 없다면 토큰만으로 외부 부작용을 막을 수 없다. 이 경우 결제사에서 지원하는 멱등 키를 주문의 결제 시도 ID에 연결해야 한다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">PaymentResult</span> <span class="n">result</span> <span class="o">=</span> <span class="n">paymentGateway</span><span class="o">.</span><span class="na">approve</span><span class="o">(</span>
    <span class="n">request</span><span class="o">.</span><span class="na">withIdempotencyKey</span><span class="o">(</span><span class="n">paymentAttempt</span><span class="o">.</span><span class="na">getId</span><span class="o">().</span><span class="na">toString</span><span class="o">())</span>
<span class="o">);</span>
</code></pre></div></div>

<h2 id="데이터베이스-제약을-마지막-방어선으로-둔다">데이터베이스 제약을 마지막 방어선으로 둔다</h2>

<p>우리의 실제 불변식은 “한 번에 한 프로세스만 실행”이 아니라 “주문 하나에 유효한 결제 시도는 하나”였다. 따라서 정확성을 락에만 맡기지 않고 데이터 모델에 표현했다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">UNIQUE</span> <span class="k">INDEX</span> <span class="n">ux_payment_attempt_order_active</span>
<span class="k">ON</span> <span class="n">payment_attempt</span> <span class="p">(</span><span class="n">order_id</span><span class="p">,</span> <span class="n">active_key</span><span class="p">);</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">active_key</code>는 활성 시도일 때 고정값, 종료된 시도일 때 고유값을 사용했다. 데이터베이스 종류에 따라 partial unique index나 별도 상태 테이블을 선택할 수 있다. 중요한 점은 경쟁 요청이 락 경계를 통과하더라도 저장소가 불변식을 거절한다는 것이다.</p>

<p>각 장치의 역할은 달랐다.</p>

<table>
  <thead>
    <tr>
      <th>장치</th>
      <th>담당한 역할</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Redis 락</td>
      <td>동시에 수행되는 비싼 작업을 줄임</td>
    </tr>
    <tr>
      <td>Fencing token</td>
      <td>만료된 소유자의 늦은 쓰기를 거절</td>
    </tr>
    <tr>
      <td>DB 유니크 제약</td>
      <td>비즈니스 불변식의 최종 보장</td>
    </tr>
    <tr>
      <td>결제사 멱등 키</td>
      <td>외부 부작용의 중복 방지</td>
    </tr>
  </tbody>
</table>

<h2 id="락을-적용하기-전에-묻는-질문">락을 적용하기 전에 묻는 질문</h2>

<p>이후 분산 락을 검토할 때 네 가지를 먼저 확인한다.</p>

<ol>
  <li>보호하려는 것은 실행 구간인가, 데이터 불변식인가?</li>
  <li>락이 만료된 뒤 이전 소유자가 돌아오면 누가 쓰기를 거절하는가?</li>
  <li>외부 부작용은 멱등 키나 상태 조회로 복구할 수 있는가?</li>
  <li>Redis를 사용할 수 없을 때 실패를 닫을 것인가, 제한적으로 열 것인가?</li>
</ol>

<p>락이 없어도 데이터가 틀리지 않고, 락이 있을 때 불필요한 경쟁만 줄어드는 구조가 가장 운영하기 쉬웠다.</p>

<h2 id="마치며">마치며</h2>

<p>분산 락은 상호 배제를 위한 유용한 조정 도구지만 정확성의 증명은 아니다. TTL이 있는 락에는 항상 과거 소유자가 돌아올 수 있다. 그 가능성을 fencing token, 데이터베이스 제약, 외부 API의 멱등성으로 각 경계에서 차단해야 한다.</p>

<p>장애 이후 우리가 바꾼 가장 중요한 문장은 “Redis 락으로 중복 결제를 막는다”에서 “저장소와 결제사가 중복을 거절하고, Redis 락은 경쟁 비용을 줄인다”였다.</p>]]></content><author><name>Backend Engineer</name></author><category term="Redis" /><category term="Redis" /><category term="분산 락" /><category term="Fencing Token" /><category term="동시성" /><summary type="html"><![CDATA[락 만료와 긴 GC 정지 뒤에 발생한 중복 처리를 추적하고, fencing token과 데이터베이스 제약으로 정확성의 경계를 다시 세운 기록입니다.]]></summary></entry><entry><title type="html">Spring Boot 관측 가능성: 로그를 더 쌓기 전에 연결할 것들</title><link href="https://seonhyeokjun.github.io/posts/spring-boot-observability/" rel="alternate" type="text/html" title="Spring Boot 관측 가능성: 로그를 더 쌓기 전에 연결할 것들" /><published>2026-08-04T09:00:00+09:00</published><updated>2026-08-04T09:00:00+09:00</updated><id>https://seonhyeokjun.github.io/posts/spring-boot-observability</id><content type="html" xml:base="https://seonhyeokjun.github.io/posts/spring-boot-observability/"><![CDATA[<p>장애 대응이 느린 팀에는 로그가 부족한 경우보다 로그가 서로 연결되지 않은 경우가 많다. API 오류율 알람은 울리지만 어떤 요청이 실패했는지, 그 요청이 어떤 Kafka 이벤트와 외부 결제 호출로 이어졌는지 찾는 데 시간이 걸린다.</p>

<p>관측 가능성을 “로그를 많이 남기는 일”이 아니라 <strong>사용자의 실패에서 원인 후보까지 이동하는 경로를 짧게 만드는 일</strong>로 정의하고 구조를 바꿨다.</p>

<h2 id="도구보다-먼저-slo를-정한다">도구보다 먼저 SLO를 정한다</h2>

<p>첫 대시보드는 JVM 메모리와 CPU로 가득했지만 “사용자가 결제를 완료할 수 있는가?”에는 바로 답하지 못했다. 결제 승인 API에 다음 SLI를 정했다.</p>

<ul>
  <li>성공률: 시스템 오류 없이 최종 결과를 반환한 요청 비율</li>
  <li>지연 시간: 승인 API의 p50, p95, p99</li>
  <li>결과 최신성: 승인 후 정산 준비 이벤트가 생성되기까지 걸린 시간</li>
  <li>불명확 결과: 타임아웃 뒤 성공·실패를 즉시 확정하지 못한 요청 수</li>
</ul>

<p>시스템 메트릭은 원인 분석에 필요하지만 알람의 출발점은 사용자 경험이어야 한다. 예를 들어 CPU 90% 자체보다 “승인 p99이 2초를 넘고 오류 예산 소진 속도가 6배”라는 신호가 대응 우선순위를 더 명확히 만든다.</p>

<h2 id="한-요청의-식별자를-경계-끝까지-전달한다">한 요청의 식별자를 경계 끝까지 전달한다</h2>

<p>Spring Boot 3의 Micrometer Tracing을 사용해 HTTP 요청에 trace ID와 span ID를 만들고 로그 패턴에 포함했다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">management</span><span class="pi">:</span>
  <span class="na">tracing</span><span class="pi">:</span>
    <span class="na">sampling</span><span class="pi">:</span>
      <span class="na">probability</span><span class="pi">:</span> <span class="m">0.1</span>

<span class="na">logging</span><span class="pi">:</span>
  <span class="na">pattern</span><span class="pi">:</span>
    <span class="na">level</span><span class="pi">:</span> <span class="s2">"</span><span class="s">%5p</span><span class="nv"> </span><span class="s">[traceId=%X{traceId:-},spanId=%X{spanId:-}]"</span>
</code></pre></div></div>

<p>샘플링 비율이 10%여도 오류 요청은 별도 정책으로 보존했다. 개인정보와 카드 정보는 span attribute에 넣지 않았다. 대신 조사에 필요한 안전한 식별자만 제한적으로 기록했다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">Observation</span> <span class="n">observation</span> <span class="o">=</span> <span class="nc">Observation</span><span class="o">.</span><span class="na">createNotStarted</span><span class="o">(</span>
    <span class="s">"payment.approve"</span><span class="o">,</span>
    <span class="n">observationRegistry</span>
<span class="o">).</span><span class="na">lowCardinalityKeyValue</span><span class="o">(</span><span class="s">"provider"</span><span class="o">,</span> <span class="n">providerCode</span><span class="o">)</span>
 <span class="o">.</span><span class="na">lowCardinalityKeyValue</span><span class="o">(</span><span class="s">"result"</span><span class="o">,</span> <span class="n">resultCode</span><span class="o">);</span>
</code></pre></div></div>

<p>여기서 <code class="language-plaintext highlighter-rouge">paymentId</code>를 low-cardinality tag로 넣지 않는다. 고유값이 많은 태그는 시계열 수를 폭발시킨다. 결제 ID처럼 개별 요청을 찾기 위한 값은 로그나 trace attribute에 두고, 메트릭 태그는 결제사·결과 코드·API처럼 제한된 집합만 사용한다.</p>

<h2 id="비동기-경계에서도-맥락을-끊지-않는다">비동기 경계에서도 맥락을 끊지 않는다</h2>

<p>HTTP 요청에서 만든 추적 맥락은 Kafka로 넘어갈 때 자동으로 이어지는지 확인해야 한다. 프로듀서가 trace context를 메시지 헤더에 주입하고 컨슈머가 추출하도록 계측 라이브러리를 맞췄다. 비즈니스 사건을 추적하는 <code class="language-plaintext highlighter-rouge">eventId</code>와 기술적인 <code class="language-plaintext highlighter-rouge">traceId</code>는 역할이 다르므로 둘 다 유지했다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">traceId</code>: 한 번의 실행 경로를 추적한다. 재시도하면 새 trace가 생길 수 있다.</li>
  <li><code class="language-plaintext highlighter-rouge">eventId</code>: 같은 비즈니스 사건을 식별한다. 재전달돼도 유지된다.</li>
</ul>

<p>컨슈머 로그에는 두 값을 함께 남겼다.</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"message"</span><span class="p">:</span><span class="w"> </span><span class="s2">"settlement event consumed"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"traceId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"8d7e4b2a..."</span><span class="p">,</span><span class="w">
  </span><span class="nl">"eventId"</span><span class="p">:</span><span class="w"> </span><span class="s2">"evt_01J..."</span><span class="p">,</span><span class="w">
  </span><span class="nl">"topic"</span><span class="p">:</span><span class="w"> </span><span class="s2">"payment-approved"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"partition"</span><span class="p">:</span><span class="w"> </span><span class="mi">7</span><span class="p">,</span><span class="w">
  </span><span class="nl">"offset"</span><span class="p">:</span><span class="w"> </span><span class="mi">184201</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>이 구조 덕분에 한 실행의 지연은 trace에서, 같은 이벤트의 반복 처리는 event ID 검색에서 확인할 수 있었다.</p>

<h2 id="로그에는-사실과-다음-행동을-남긴다">로그에는 사실과 다음 행동을 남긴다</h2>

<p>다음과 같은 로그는 실제 대응에 거의 도움이 되지 않았다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ERROR payment failed
</code></pre></div></div>

<p>오류 로그를 설계할 때 최소한 네 가지를 포함했다.</p>

<ol>
  <li>어떤 작업이 실패했는가</li>
  <li>재시도 가능한가</li>
  <li>외부 부작용이 발생했을 가능성이 있는가</li>
  <li>안전하게 식별할 수 있는 비즈니스 키는 무엇인가</li>
</ol>

<p>예외 스택은 원인을 보여 주지만 현재 상태와 복구 방법까지 알려주지는 않는다. 그래서 <code class="language-plaintext highlighter-rouge">outcome=UNKNOWN</code>, <code class="language-plaintext highlighter-rouge">retryable=false</code>, <code class="language-plaintext highlighter-rouge">reconciliationRequired=true</code>처럼 다음 행동을 결정하는 필드를 구조화해 남겼다.</p>

<h2 id="알람에서-trace까지-한-번에-이동한다">알람에서 trace까지 한 번에 이동한다</h2>

<p>최종 대시보드는 다음 순서로 읽게 만들었다.</p>

<ol>
  <li>SLO와 오류 예산 소진 속도로 영향도를 확인한다.</li>
  <li>실패한 API·결제사·결과 코드로 범위를 좁힌다.</li>
  <li>exemplar가 연결한 trace에서 가장 긴 span과 오류를 본다.</li>
  <li>trace ID로 구조화 로그를 검색해 비즈니스 상태를 확인한다.</li>
</ol>

<p>알람 메시지에도 같은 경로를 넣었다. 담당자는 채널에서 대시보드, 대표 trace, 대응 문서로 바로 이동할 수 있었다. 도구를 추가한 것보다 <strong>관찰 화면 사이의 이동 비용을 줄인 것</strong>이 평균 확인 시간을 더 크게 줄였다.</p>

<h2 id="비용과-개인정보도-설계-대상이다">비용과 개인정보도 설계 대상이다</h2>

<p>모든 요청을 영구 보관할 수는 없다. 정상 trace는 낮은 비율로 샘플링하고 오류와 고지연 trace는 tail-based sampling으로 더 많이 보존했다. 로그 보존 기간은 용도별로 나누고, 운영 검색에 필요 없는 body와 헤더는 수집 단계에서 제거했다.</p>

<p>관측 데이터는 디버깅 편의를 위한 복사본이 아니라 또 하나의 데이터 시스템이다. 접근 권한, 마스킹, 보존 기간, 비용 상한이 애플리케이션 설계와 함께 정해져야 한다.</p>

<h2 id="마치며">마치며</h2>

<p>관측 가능성의 목표는 데이터의 양이 아니다. 질문에 답하는 데 필요한 연결이 핵심이다.</p>

<ul>
  <li>SLO는 사용자 실패에서 조사를 시작하게 한다.</li>
  <li>trace ID는 동기·비동기 실행 경로를 연결한다.</li>
  <li>event ID는 재시도와 재전달 사이에서도 사건을 연결한다.</li>
  <li>구조화 로그는 현재 상태와 다음 행동을 설명한다.</li>
  <li>메트릭 태그는 집계에 필요한 낮은 카디널리티만 가진다.</li>
</ul>

<p>로그 한 줄을 더 남기기 전에, 그 로그가 어떤 알람과 trace에서 도달 가능한지 먼저 확인하는 습관이 장애 대응 시간을 바꿨다.</p>]]></content><author><name>Backend Engineer</name></author><category term="Spring Boot" /><category term="Spring Boot" /><category term="Observability" /><category term="Micrometer" /><category term="OpenTelemetry" /><summary type="html"><![CDATA[메트릭·로그·트레이스를 하나의 요청 맥락으로 연결하고, 결제 API의 SLO에서 출발해 실제 장애 대응 시간을 줄인 방법을 소개합니다.]]></summary></entry></feed>