<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://jongjunn.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://jongjunn.github.io/" rel="alternate" type="text/html" /><updated>2026-08-01T13:46:49+00:00</updated><id>https://jongjunn.github.io/feed.xml</id><title type="html">박종준 기술 블로그</title><subtitle>결제와 학습 데이터의 정합성을 코드와 테스트로 지키는 백엔드 기록</subtitle><author><name>박종준</name></author><entry><title type="html">과금 이후 권한까지: LMS 결제 정합성을 지키는 백엔드 설계</title><link href="https://jongjunn.github.io/%EB%B0%B1%EC%97%94%EB%93%9C/%EA%B2%B0%EC%A0%9C/2026/08/01/payment-consistency.html" rel="alternate" type="text/html" title="과금 이후 권한까지: LMS 결제 정합성을 지키는 백엔드 설계" /><published>2026-08-01T14:00:00+00:00</published><updated>2026-08-01T14:00:00+00:00</updated><id>https://jongjunn.github.io/%EB%B0%B1%EC%97%94%EB%93%9C/%EA%B2%B0%EC%A0%9C/2026/08/01/payment-consistency</id><content type="html" xml:base="https://jongjunn.github.io/%EB%B0%B1%EC%97%94%EB%93%9C/%EA%B2%B0%EC%A0%9C/2026/08/01/payment-consistency.html"><![CDATA[<p>Flown LMS에서 결제·주문·환불 도메인을 맡으며 가장 중요하게 본 것은 “결제가 성공했는가”가 아니라 <strong>과금 이후 서비스 권한까지 일관되게 맞았는가</strong>였다. PG 승인이 성공했는데 구독권이나 수강권이 생기지 않으면, 서버 입장에서는 일부 로직 실패일 수 있어도 사용자에게는 바로 신뢰 문제로 보인다.</p>

<p>그래서 결제 도메인의 불변식을 먼저 정했다.</p>

<blockquote>
  <p>과금이 확정됐다면, 사용자는 반드시 그에 해당하는 권한을 받아야 한다. 같은 결제는 여러 번 시도돼도 PG confirm은 한 번만 호출돼야 한다.</p>
</blockquote>

<h2 id="결제-흐름을-먼저-좁히기">결제 흐름을 먼저 좁히기</h2>

<p>이 글에서 다루는 결제 문제는 “결제 API가 동작한다”가 아니다. 실제로 위험했던 지점은 결제 버튼을 누른 뒤 여러 시스템의 상태가 순서대로 맞아야 한다는 점이었다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Client
-&gt; PaymentConfirmController
-&gt; Idempotency-Key 검증
-&gt; 주문 상태 재조회
-&gt; Redis SETNX 락
-&gt; 주문 상태 재조회
-&gt; Toss confirm
-&gt; 주문 PAID 처리
-&gt; 수강권/구독권 지급
-&gt; 결제 결과와 지급 실패 지표 기록
</code></pre></div></div>

<p>이 흐름에서 하나라도 조용히 실패하면 사용자는 돈은 냈는데 권한은 없는 상태를 만난다. 그래서 구현 기준도 세 가지로 나눴다. 외부 PG 호출은 중복되지 않아야 하고, 내부 주문 상태는 서버가 가진 값으로 판단해야 하며, 권한 지급 실패는 운영자가 볼 수 있는 형태로 남아야 한다.</p>

<h2 id="서버가-진실로-삼아야-하는-값">서버가 진실로 삼아야 하는 값</h2>

<p>결제 승인 요청에서 클라이언트가 보내는 값은 편의를 위한 입력일 뿐, 최종 판단 근거가 될 수 없다. 사용자는 요청 payload를 바꿀 수 있고, 화면에 보이는 금액과 실제 서버 주문 금액이 달라질 수도 있다.</p>

<p>그래서 승인 흐름에서는 요청의 <code class="language-plaintext highlighter-rouge">orderId</code>, <code class="language-plaintext highlighter-rouge">amount</code>, <code class="language-plaintext highlighter-rouge">paymentKey</code>를 그대로 믿지 않고 서버가 가진 주문 상태와 다시 대조했다. Toss 응답의 <code class="language-plaintext highlighter-rouge">paymentKey</code>, <code class="language-plaintext highlighter-rouge">orderId</code>, <code class="language-plaintext highlighter-rouge">totalAmount</code>도 요청값과 비교했다. 결제에서 검증은 부가 기능이 아니라, 잘못된 금액으로 주문이 확정되는 것을 막는 핵심 경계다.</p>

<h2 id="중복-결제-방어">중복 결제 방어</h2>

<p>사용자는 결제 버튼을 여러 번 누를 수 있고, 네트워크 timeout 때문에 같은 요청을 다시 보낼 수 있다. 이때 목표는 “같은 응답을 예쁘게 돌려주는 것”보다 먼저 <strong>PG confirm이 두 번 호출되지 않게 하는 것</strong>이다.</p>

<p>현재 결제 확정 경로는 다음 순서로 중복을 막는다.</p>

<ol>
  <li><code class="language-plaintext highlighter-rouge">Idempotency-Key</code> 헤더의 null, blank, UUID 형식을 검증한다.</li>
  <li>주문 상태를 먼저 조회한다.</li>
  <li>이미 <code class="language-plaintext highlighter-rouge">READY</code>가 아니면 PG를 다시 호출하지 않고 중복 응답으로 반환한다.</li>
  <li><code class="language-plaintext highlighter-rouge">order:pay:lock:{Idempotency-Key}</code> 형태의 Redis <code class="language-plaintext highlighter-rouge">SETNX</code> 락을 잡는다.</li>
  <li>락 획득 후 주문 상태를 다시 조회한다.</li>
  <li>여전히 <code class="language-plaintext highlighter-rouge">READY</code>일 때만 PG confirm을 호출한다.</li>
  <li>결제 성공 후 주문을 <code class="language-plaintext highlighter-rouge">PAID</code>로 바꾸고 권한 지급을 시도한다.</li>
  <li>락 해제는 Lua 스크립트로 토큰을 비교한 뒤 수행한다.</li>
</ol>

<p>Redis 락만으로 결제를 완전히 설명하지 않은 이유는 분명하다. 락은 동시 진입을 줄이는 장치이고, 최종 진실은 DB의 주문 상태다. 락을 잡기 전과 잡은 후에 주문 상태를 다시 확인해야, 거의 동시에 들어온 요청도 이미 처리된 주문으로 안전하게 돌릴 수 있다.</p>

<p>실제 결제 확정 서비스의 핵심 흐름은 아래와 같다. <code class="language-plaintext highlighter-rouge">READY</code>가 아닌 주문은 바로 멱등 응답으로 돌리고, <code class="language-plaintext highlighter-rouge">SETNX</code> 락을 잡은 뒤에도 주문을 다시 읽는다. PG confirm은 이 두 방어선을 지난 뒤에만 호출된다.</p>

<p>원본 코드: <a href="https://github.com/Hard-Click/Hard-Click-BackEnd/blob/ea50993a49340ee2bce8b53b439211c541c4da81/src/main/java/com/wanted/backend/domain/order/application/service/ConfirmOrderPaymentService.java#L75-L121">ConfirmOrderPaymentService.java:75-121</a></p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// ConfirmOrderPaymentService.java</span>
<span class="kd">private</span> <span class="nc">Result</span> <span class="nf">doConfirm</span><span class="o">(</span><span class="nc">Long</span> <span class="n">memberId</span><span class="o">,</span> <span class="nc">String</span> <span class="n">orderNo</span><span class="o">,</span> <span class="nc">String</span> <span class="n">paymentKey</span><span class="o">,</span> <span class="nc">Integer</span> <span class="n">amount</span><span class="o">,</span> <span class="nc">String</span> <span class="n">idempotencyKey</span><span class="o">)</span> <span class="o">{</span>
    <span class="nc">Order</span> <span class="n">order</span> <span class="o">=</span> <span class="n">findOwnedOrder</span><span class="o">(</span><span class="n">memberId</span><span class="o">,</span> <span class="n">orderNo</span><span class="o">);</span>

    <span class="k">if</span> <span class="o">(</span><span class="n">order</span><span class="o">.</span><span class="na">getStatus</span><span class="o">()</span> <span class="o">!=</span> <span class="nc">OrderStatus</span><span class="o">.</span><span class="na">READY</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">return</span> <span class="k">new</span> <span class="nf">Result</span><span class="o">(</span><span class="n">orderNo</span><span class="o">,</span> <span class="n">order</span><span class="o">.</span><span class="na">getStatus</span><span class="o">(),</span> <span class="kc">null</span><span class="o">,</span> <span class="kc">true</span><span class="o">);</span>
    <span class="o">}</span>
    <span class="k">if</span> <span class="o">(</span><span class="n">order</span><span class="o">.</span><span class="na">getFinalAmount</span><span class="o">()</span> <span class="o">!=</span> <span class="n">amount</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">throw</span> <span class="k">new</span> <span class="nf">BusinessException</span><span class="o">(</span><span class="nc">ErrorCode</span><span class="o">.</span><span class="na">ORDER_AMOUNT_MISMATCH</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="nc">String</span> <span class="n">lockKey</span> <span class="o">=</span> <span class="no">LOCK_KEY_PREFIX</span> <span class="o">+</span> <span class="n">idempotencyKey</span><span class="o">;</span>
    <span class="nc">String</span> <span class="n">lockValue</span> <span class="o">=</span> <span class="no">UUID</span><span class="o">.</span><span class="na">randomUUID</span><span class="o">().</span><span class="na">toString</span><span class="o">();</span>
    <span class="nc">Boolean</span> <span class="n">acquired</span> <span class="o">=</span> <span class="n">redisTemplate</span><span class="o">.</span><span class="na">opsForValue</span><span class="o">().</span><span class="na">setIfAbsent</span><span class="o">(</span><span class="n">lockKey</span><span class="o">,</span> <span class="n">lockValue</span><span class="o">,</span> <span class="no">LOCK_TTL</span><span class="o">);</span>
    <span class="k">if</span> <span class="o">(</span><span class="n">acquired</span> <span class="o">==</span> <span class="kc">null</span> <span class="o">||</span> <span class="o">!</span><span class="n">acquired</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">throw</span> <span class="k">new</span> <span class="nf">BusinessException</span><span class="o">(</span><span class="nc">ErrorCode</span><span class="o">.</span><span class="na">DUPLICATE_PAYMENT_REQUEST</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="k">try</span> <span class="o">{</span>
        <span class="nc">Order</span> <span class="n">raced</span> <span class="o">=</span> <span class="n">findOwnedOrder</span><span class="o">(</span><span class="n">memberId</span><span class="o">,</span> <span class="n">orderNo</span><span class="o">);</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">raced</span><span class="o">.</span><span class="na">getStatus</span><span class="o">()</span> <span class="o">!=</span> <span class="nc">OrderStatus</span><span class="o">.</span><span class="na">READY</span><span class="o">)</span> <span class="o">{</span>
            <span class="k">return</span> <span class="k">new</span> <span class="nf">Result</span><span class="o">(</span><span class="n">orderNo</span><span class="o">,</span> <span class="n">raced</span><span class="o">.</span><span class="na">getStatus</span><span class="o">(),</span> <span class="kc">null</span><span class="o">,</span> <span class="kc">true</span><span class="o">);</span>
        <span class="o">}</span>

        <span class="nc">String</span> <span class="n">pgTransactionId</span> <span class="o">=</span> <span class="n">pgClient</span><span class="o">.</span><span class="na">confirm</span><span class="o">(</span><span class="n">paymentKey</span><span class="o">,</span> <span class="n">orderNo</span><span class="o">,</span> <span class="n">amount</span><span class="o">);</span>
        <span class="n">orderRepository</span><span class="o">.</span><span class="na">markPaid</span><span class="o">(</span><span class="n">orderNo</span><span class="o">,</span> <span class="nc">LocalDateTime</span><span class="o">.</span><span class="na">now</span><span class="o">(</span><span class="n">clock</span><span class="o">),</span> <span class="n">pgTransactionId</span><span class="o">);</span>
        <span class="n">dispatchAccessGrant</span><span class="o">(</span><span class="n">raced</span><span class="o">);</span>
        <span class="k">return</span> <span class="k">new</span> <span class="nf">Result</span><span class="o">(</span><span class="n">orderNo</span><span class="o">,</span> <span class="nc">OrderStatus</span><span class="o">.</span><span class="na">PAID</span><span class="o">,</span> <span class="n">pgTransactionId</span><span class="o">,</span> <span class="kc">false</span><span class="o">);</span>
    <span class="o">}</span> <span class="k">finally</span> <span class="o">{</span>
        <span class="n">releaseLockSafely</span><span class="o">(</span><span class="n">lockKey</span><span class="o">,</span> <span class="n">lockValue</span><span class="o">);</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>락 해제도 단순 <code class="language-plaintext highlighter-rouge">DEL</code>이 아니라 토큰을 비교한 뒤 지운다. 락 TTL이 만료된 뒤 다른 요청이 같은 key를 잡았는데, 늦게 끝난 요청이 남의 락을 지우는 상황을 막기 위해서다.</p>

<p>원본 코드: <a href="https://github.com/Hard-Click/Hard-Click-BackEnd/blob/ea50993a49340ee2bce8b53b439211c541c4da81/src/main/java/com/wanted/backend/domain/order/application/service/ConfirmOrderPaymentService.java#L45-L47">UNLOCK_SCRIPT</a>, <a href="https://github.com/Hard-Click/Hard-Click-BackEnd/blob/ea50993a49340ee2bce8b53b439211c541c4da81/src/main/java/com/wanted/backend/domain/order/application/service/ConfirmOrderPaymentService.java#L171-L173">releaseLockSafely</a></p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">private</span> <span class="kd">static</span> <span class="kd">final</span> <span class="nc">DefaultRedisScript</span><span class="o">&lt;</span><span class="nc">Long</span><span class="o">&gt;</span> <span class="no">UNLOCK_SCRIPT</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">DefaultRedisScript</span><span class="o">&lt;&gt;(</span>
        <span class="s">"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"</span><span class="o">,</span>
        <span class="nc">Long</span><span class="o">.</span><span class="na">class</span><span class="o">);</span>
</code></pre></div></div>

<p>이 멱등키는 백엔드 내부 구현으로만 두지 않았다. 프론트엔드 연동 문서와 Swagger에 <code class="language-plaintext highlighter-rouge">POST /api/payments/confirm</code> 호출 시 <code class="language-plaintext highlighter-rouge">Idempotency-Key: {UUID v4}</code> 헤더가 필요하다고 명시했다. 중복 결제 방어는 서버 코드만의 문제가 아니라, 클라이언트가 어떤 키를 생성하고 재시도 때 같은 키를 보내야 하는지까지 포함한 API 계약이었다.</p>

<p>원본 코드: <a href="https://github.com/Hard-Click/Hard-Click-BackEnd/blob/ea50993a49340ee2bce8b53b439211c541c4da81/src/main/java/com/wanted/backend/domain/payment/presentation/PaymentConfirmController.java#L43-L71">PaymentConfirmController.java:43-71</a></p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// PaymentConfirmController.java</span>
<span class="kd">public</span> <span class="nc">ResponseEntity</span><span class="o">&lt;</span><span class="nc">ApiResponse</span><span class="o">&lt;</span><span class="nc">PaymentConfirmResponse</span><span class="o">&gt;&gt;</span> <span class="nf">confirm</span><span class="o">(</span>
        <span class="nd">@Valid</span> <span class="nd">@RequestBody</span> <span class="nc">PaymentConfirmRequest</span> <span class="n">request</span><span class="o">,</span>
        <span class="nd">@RequestHeader</span><span class="o">(</span><span class="n">value</span> <span class="o">=</span> <span class="s">"Idempotency-Key"</span><span class="o">,</span> <span class="n">required</span> <span class="o">=</span> <span class="kc">false</span><span class="o">)</span> <span class="nc">String</span> <span class="n">idempotencyKey</span><span class="o">,</span>
        <span class="nd">@AuthenticationPrincipal</span> <span class="nc">CustomUserDetails</span> <span class="n">userDetails</span>
<span class="o">)</span> <span class="o">{</span>
    <span class="k">if</span> <span class="o">(</span><span class="n">idempotencyKey</span> <span class="o">==</span> <span class="kc">null</span> <span class="o">||</span> <span class="n">idempotencyKey</span><span class="o">.</span><span class="na">isBlank</span><span class="o">())</span> <span class="o">{</span>
        <span class="k">throw</span> <span class="k">new</span> <span class="nf">BusinessException</span><span class="o">(</span><span class="nc">ErrorCode</span><span class="o">.</span><span class="na">INVALID_INPUT_VALUE</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="nc">String</span> <span class="n">normalizedIdempotencyKey</span> <span class="o">=</span> <span class="n">idempotencyKey</span><span class="o">.</span><span class="na">trim</span><span class="o">();</span>
    <span class="k">try</span> <span class="o">{</span>
        <span class="no">UUID</span><span class="o">.</span><span class="na">fromString</span><span class="o">(</span><span class="n">normalizedIdempotencyKey</span><span class="o">);</span>
    <span class="o">}</span> <span class="k">catch</span> <span class="o">(</span><span class="nc">IllegalArgumentException</span> <span class="n">e</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">throw</span> <span class="k">new</span> <span class="nf">BusinessException</span><span class="o">(</span><span class="nc">ErrorCode</span><span class="o">.</span><span class="na">INVALID_INPUT_VALUE</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="nc">ConfirmOrderPaymentUseCase</span><span class="o">.</span><span class="na">Result</span> <span class="n">result</span> <span class="o">=</span> <span class="n">confirmOrderPaymentUseCase</span><span class="o">.</span><span class="na">confirm</span><span class="o">(</span>
            <span class="n">userDetails</span><span class="o">.</span><span class="na">getMemberId</span><span class="o">(),</span>
            <span class="n">request</span><span class="o">.</span><span class="na">orderId</span><span class="o">(),</span>
            <span class="n">request</span><span class="o">.</span><span class="na">paymentKey</span><span class="o">(),</span>
            <span class="n">request</span><span class="o">.</span><span class="na">amount</span><span class="o">(),</span>
            <span class="n">normalizedIdempotencyKey</span>
    <span class="o">);</span>

    <span class="nc">PaymentConfirmResponse</span> <span class="n">response</span> <span class="o">=</span> <span class="nc">PaymentConfirmResponse</span><span class="o">.</span><span class="na">from</span><span class="o">(</span><span class="n">result</span><span class="o">);</span>
    <span class="k">return</span> <span class="n">result</span><span class="o">.</span><span class="na">duplicate</span><span class="o">()</span>
            <span class="o">?</span> <span class="nc">ApiResponse</span><span class="o">.</span><span class="na">success</span><span class="o">(</span><span class="s">"이미 처리된 결제 요청입니다."</span><span class="o">,</span> <span class="n">response</span><span class="o">)</span>
            <span class="o">:</span> <span class="nc">ApiResponse</span><span class="o">.</span><span class="na">created</span><span class="o">(</span><span class="s">"결제가 확정되었습니다."</span><span class="o">,</span> <span class="n">response</span><span class="o">);</span>
<span class="o">}</span>
</code></pre></div></div>

<p>동시성 테스트에서는 동일 멱등키로 50개 스레드가 동시에 결제 확정을 호출했을 때 PG confirm과 DB <code class="language-plaintext highlighter-rouge">markPaid</code>가 각각 1회만 실행되는지 확인했다. 이 테스트가 증명하는 범위는 “같은 멱등키의 동시 요청에서 중복 PG 호출을 막는다”는 것이다. k6 p95, 처리량, 실서비스 장애율은 측정하지 않았으므로 성과 수치로 말하지 않았다.</p>

<p>또한 결제 결과를 로그 한 줄에만 남기지 않고 Micrometer 지표로 분리했다. 성공, 중복, 실패를 같은 counter의 <code class="language-plaintext highlighter-rouge">status</code> 태그로 남기고, 결제 처리 시간은 timer로 기록했다. 그래야 “중복 결제 0건”, “결제 성공률”, “P95 처리시간”을 각각 다른 SLO로 볼 수 있다.</p>

<p>원본 코드: <a href="https://github.com/Hard-Click/Hard-Click-BackEnd/blob/ea50993a49340ee2bce8b53b439211c541c4da81/src/main/java/com/wanted/backend/domain/order/application/service/ConfirmOrderPaymentService.java#L58-L72">ConfirmOrderPaymentService.java:58-72</a></p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// ConfirmOrderPaymentService.java</span>
<span class="nc">Counter</span><span class="o">.</span><span class="na">builder</span><span class="o">(</span><span class="s">"order.payment.result"</span><span class="o">)</span>
        <span class="o">.</span><span class="na">tag</span><span class="o">(</span><span class="s">"status"</span><span class="o">,</span> <span class="n">outcome</span><span class="o">)</span>
        <span class="o">.</span><span class="na">register</span><span class="o">(</span><span class="n">meterRegistry</span><span class="o">)</span>
        <span class="o">.</span><span class="na">increment</span><span class="o">();</span>

<span class="n">sample</span><span class="o">.</span><span class="na">stop</span><span class="o">(</span><span class="nc">Timer</span><span class="o">.</span><span class="na">builder</span><span class="o">(</span><span class="s">"order.payment.processing.duration"</span><span class="o">)</span>
        <span class="o">.</span><span class="na">register</span><span class="o">(</span><span class="n">meterRegistry</span><span class="o">));</span>
</code></pre></div></div>

<h2 id="pg-호출과-db-트랜잭션의-경계">PG 호출과 DB 트랜잭션의 경계</h2>

<p>외부 PG 호출을 DB 트랜잭션 안에 넣으면 구현은 단순해 보인다. 하지만 PG가 느려지는 동안 DB 커넥션을 계속 잡게 되고, 결제와 무관한 API까지 영향을 받을 수 있다.</p>

<p>그래서 PG 호출은 DB 트랜잭션 밖에 두고, 내부 상태 변경은 짧게 가져가는 쪽을 택했다. 이 선택은 하나의 큰 원자성을 포기하는 대신, 긴 네트워크 대기가 DB 트랜잭션을 붙잡지 않게 한다. 대신 PG 성공 후 DB 반영 실패 같은 보정 리스크가 남는다. 이 리스크는 로그, 메트릭, 재시도 가능 경로로 드러내야 한다.</p>

<p>Toss 연동부에서도 응답값을 그대로 신뢰하지 않고 요청값과 다시 대조했다. 클라이언트가 보낸 금액을 믿는 것이 아니라, PG 응답이 우리가 승인하려던 결제와 같은지 확인하는 경계다.</p>

<p>원본 코드: <a href="https://github.com/Hard-Click/Hard-Click-BackEnd/blob/ea50993a49340ee2bce8b53b439211c541c4da81/src/main/java/com/wanted/backend/domain/payment/infrastructure/pg/TossPaymentClient.java#L47-L78">TossPaymentClient.java:47-78</a></p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// TossPaymentClient.java</span>
<span class="nc">Map</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">,</span> <span class="nc">Object</span><span class="o">&gt;</span> <span class="n">response</span> <span class="o">=</span> <span class="n">restClient</span><span class="o">.</span><span class="na">post</span><span class="o">()</span>
        <span class="o">.</span><span class="na">uri</span><span class="o">(</span><span class="s">"/v1/payments/confirm"</span><span class="o">)</span>
        <span class="o">.</span><span class="na">body</span><span class="o">(</span><span class="nc">Map</span><span class="o">.</span><span class="na">of</span><span class="o">(</span>
                <span class="s">"paymentKey"</span><span class="o">,</span> <span class="n">paymentKey</span><span class="o">,</span>
                <span class="s">"orderId"</span><span class="o">,</span> <span class="n">orderId</span><span class="o">,</span>
                <span class="s">"amount"</span><span class="o">,</span> <span class="n">amount</span>
        <span class="o">))</span>
        <span class="o">.</span><span class="na">retrieve</span><span class="o">()</span>
        <span class="o">.</span><span class="na">body</span><span class="o">(</span><span class="nc">Map</span><span class="o">.</span><span class="na">class</span><span class="o">);</span>

<span class="nc">String</span> <span class="n">confirmedPaymentKey</span> <span class="o">=</span> <span class="nc">Objects</span><span class="o">.</span><span class="na">toString</span><span class="o">(</span><span class="n">response</span> <span class="o">!=</span> <span class="kc">null</span> <span class="o">?</span> <span class="n">response</span><span class="o">.</span><span class="na">get</span><span class="o">(</span><span class="s">"paymentKey"</span><span class="o">)</span> <span class="o">:</span> <span class="kc">null</span><span class="o">,</span> <span class="kc">null</span><span class="o">);</span>
<span class="nc">String</span> <span class="n">confirmedOrderId</span> <span class="o">=</span> <span class="nc">Objects</span><span class="o">.</span><span class="na">toString</span><span class="o">(</span><span class="n">response</span> <span class="o">!=</span> <span class="kc">null</span> <span class="o">?</span> <span class="n">response</span><span class="o">.</span><span class="na">get</span><span class="o">(</span><span class="s">"orderId"</span><span class="o">)</span> <span class="o">:</span> <span class="kc">null</span><span class="o">,</span> <span class="kc">null</span><span class="o">);</span>
<span class="nc">Object</span> <span class="n">totalAmount</span> <span class="o">=</span> <span class="n">response</span> <span class="o">!=</span> <span class="kc">null</span> <span class="o">?</span> <span class="n">response</span><span class="o">.</span><span class="na">get</span><span class="o">(</span><span class="s">"totalAmount"</span><span class="o">)</span> <span class="o">:</span> <span class="kc">null</span><span class="o">;</span>

<span class="k">if</span> <span class="o">(!</span><span class="n">confirmedPaymentKey</span><span class="o">.</span><span class="na">equals</span><span class="o">(</span><span class="n">paymentKey</span><span class="o">))</span> <span class="o">{</span>
    <span class="k">throw</span> <span class="k">new</span> <span class="nf">TossPaymentException</span><span class="o">(</span><span class="s">"응답 paymentKey가 요청값과 다릅니다. orderId="</span> <span class="o">+</span> <span class="n">orderId</span><span class="o">);</span>
<span class="o">}</span>
<span class="k">if</span> <span class="o">(!</span><span class="nc">Objects</span><span class="o">.</span><span class="na">equals</span><span class="o">(</span><span class="n">confirmedOrderId</span><span class="o">,</span> <span class="n">orderId</span><span class="o">))</span> <span class="o">{</span>
    <span class="k">throw</span> <span class="k">new</span> <span class="nf">TossPaymentException</span><span class="o">(</span><span class="s">"응답 orderId가 요청값과 다릅니다. orderId="</span> <span class="o">+</span> <span class="n">orderId</span><span class="o">);</span>
<span class="o">}</span>
<span class="k">if</span> <span class="o">(</span><span class="n">totalAmount</span> <span class="o">==</span> <span class="kc">null</span> <span class="o">||</span> <span class="o">!</span><span class="nc">String</span><span class="o">.</span><span class="na">valueOf</span><span class="o">(</span><span class="n">totalAmount</span><span class="o">).</span><span class="na">equals</span><span class="o">(</span><span class="nc">String</span><span class="o">.</span><span class="na">valueOf</span><span class="o">(</span><span class="n">amount</span><span class="o">)))</span> <span class="o">{</span>
    <span class="k">throw</span> <span class="k">new</span> <span class="nf">TossPaymentException</span><span class="o">(</span><span class="s">"응답 금액이 요청 금액과 다릅니다. orderId="</span> <span class="o">+</span> <span class="n">orderId</span><span class="o">);</span>
<span class="o">}</span>
</code></pre></div></div>

<h2 id="지급-실패를-조용히-넘기지-않기">지급 실패를 조용히 넘기지 않기</h2>

<p>실제로 구독 결제 후 구독권이 생성되지 않은 문제가 있었다. 표면 원인은 <code class="language-plaintext highlighter-rouge">subscriptions.subscription_id</code>에 <code class="language-plaintext highlighter-rouge">AUTO_INCREMENT</code>가 빠져 INSERT가 실패한 것이었다. 더 중요한 원인은 결제 확정 서비스가 권한 지급 실패를 삼키고 결제 성공 응답을 반환했다는 점이다.</p>

<p>이 문제는 세 단계로 고쳤다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">subscriptions</code>, <code class="language-plaintext highlighter-rouge">video_progress</code>처럼 같은 계열의 스키마 결함을 전수 점검하고 마이그레이션으로 수정했다.</li>
  <li>권한 지급 실패를 <code class="language-plaintext highlighter-rouge">order.payment.access_grant.failed</code> 메트릭과 ERROR 로그로 표면화했다.</li>
  <li>멱등 재호출 경로에서도 <code class="language-plaintext highlighter-rouge">PAID</code> 주문에 한해 권한 지급을 다시 시도하도록 했다.</li>
</ul>

<p>권한 지급 실패는 주문을 되돌리지 않고 운영자가 볼 수 있는 실패로 남겼다. 결제는 이미 PG에서 확정된 외부 사실이기 때문에, 내부 지급 실패를 조용히 삼키는 대신 메트릭과 로그로 드러냈다.</p>

<p>원본 코드: <a href="https://github.com/Hard-Click/Hard-Click-BackEnd/blob/ea50993a49340ee2bce8b53b439211c541c4da81/src/main/java/com/wanted/backend/domain/order/application/service/ConfirmOrderPaymentService.java#L132-L158">ConfirmOrderPaymentService.java:132-158</a></p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// ConfirmOrderPaymentService.java</span>
<span class="kd">private</span> <span class="kt">void</span> <span class="nf">dispatchAccessGrant</span><span class="o">(</span><span class="nc">Order</span> <span class="n">order</span><span class="o">)</span> <span class="o">{</span>
    <span class="k">try</span> <span class="o">{</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">order</span><span class="o">.</span><span class="na">getType</span><span class="o">()</span> <span class="o">==</span> <span class="nc">OrderType</span><span class="o">.</span><span class="na">SUBSCRIPTION</span><span class="o">)</span> <span class="o">{</span>
            <span class="n">subscribeUseCase</span><span class="o">.</span><span class="na">handle</span><span class="o">(</span><span class="n">order</span><span class="o">.</span><span class="na">getMemberId</span><span class="o">(),</span> <span class="n">order</span><span class="o">.</span><span class="na">getId</span><span class="o">(),</span> <span class="n">order</span><span class="o">.</span><span class="na">getFinalAmount</span><span class="o">());</span>
            <span class="n">orderCartDeletePort</span><span class="o">.</span><span class="na">deleteAllByMemberId</span><span class="o">(</span><span class="n">order</span><span class="o">.</span><span class="na">getMemberId</span><span class="o">());</span>
        <span class="o">}</span> <span class="k">else</span> <span class="o">{</span>
            <span class="k">for</span> <span class="o">(</span><span class="nc">OrderItem</span> <span class="n">item</span> <span class="o">:</span> <span class="n">order</span><span class="o">.</span><span class="na">getItems</span><span class="o">())</span> <span class="o">{</span>
                <span class="k">if</span> <span class="o">(</span><span class="n">item</span><span class="o">.</span><span class="na">getCourseId</span><span class="o">()</span> <span class="o">!=</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span>
                    <span class="n">grantEnrollment</span><span class="o">(</span><span class="n">order</span><span class="o">.</span><span class="na">getMemberId</span><span class="o">(),</span> <span class="n">item</span><span class="o">.</span><span class="na">getCourseId</span><span class="o">());</span>
                <span class="o">}</span>
            <span class="o">}</span>
        <span class="o">}</span>
    <span class="o">}</span> <span class="k">catch</span> <span class="o">(</span><span class="nc">RuntimeException</span> <span class="n">e</span><span class="o">)</span> <span class="o">{</span>
        <span class="n">meterRegistry</span><span class="o">.</span><span class="na">counter</span><span class="o">(</span><span class="s">"order.payment.access_grant.failed"</span><span class="o">,</span>
                <span class="s">"type"</span><span class="o">,</span> <span class="n">order</span><span class="o">.</span><span class="na">getType</span><span class="o">().</span><span class="na">name</span><span class="o">()).</span><span class="na">increment</span><span class="o">();</span>
        <span class="n">log</span><span class="o">.</span><span class="na">error</span><span class="o">(</span><span class="s">"[ACCESS_GRANT_FAILED] 결제는 완료됐지만 수강권/구독권 지급 실패 — orderNo: {}, type: {}"</span><span class="o">,</span>
                <span class="n">order</span><span class="o">.</span><span class="na">getOrderNo</span><span class="o">(),</span> <span class="n">order</span><span class="o">.</span><span class="na">getType</span><span class="o">(),</span> <span class="n">e</span><span class="o">);</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>지급 실패 시 결제를 바로 롤백하지 않은 이유도 있다. PG 승인은 외부 시스템에서 이미 확정된 사실이고, 이를 되돌리려면 결제 취소를 다시 호출해야 한다. 그 취소 역시 실패할 수 있다. 그래서 “과금은 유지하고, 지급은 반드시 맞춘다”는 방향으로 정리했다.</p>

<h2 id="환불도-서버-정책이다">환불도 서버 정책이다</h2>

<p>환불 가능 여부를 프론트에서 <code class="language-plaintext highlighter-rouge">refundable=true</code>로 보여주는 것은 UX일 뿐이다. 사용자는 화면을 오래 열어둘 수 있고, API를 직접 호출할 수도 있다. 따라서 환불 실행 시점에 서버가 다시 판단해야 한다.</p>

<p>강의 환불 조건은 결제 후 7일 이내, 진도율 10% 미만이다. 여기서 10% 이하는 아니고 10% 미만이다. 이 기준은 조회 화면과 실행 API가 같은 <code class="language-plaintext highlighter-rouge">OrderRefundPolicy</code>를 바라보게 해 판단이 갈라지지 않도록 했다. 실행 경로에서는 주문 항목 단위 Redis 락으로 동시 환불을 직렬화하고, Toss cancel 호출 이후 주문 상태를 <code class="language-plaintext highlighter-rouge">PARTIAL_REFUNDED</code> 또는 <code class="language-plaintext highlighter-rouge">REFUNDED</code>로 전이한다.</p>

<p>정책 숫자는 서비스나 컨트롤러에 흩어두지 않고 별도 정책 객체로 뺐다. 조회 화면과 환불 실행 경로가 같은 메서드를 보게 하려는 선택이다.</p>

<p>원본 코드: <a href="https://github.com/Hard-Click/Hard-Click-BackEnd/blob/ea50993a49340ee2bce8b53b439211c541c4da81/src/main/java/com/wanted/backend/domain/order/domain/policy/OrderRefundPolicy.java#L14-L34">OrderRefundPolicy.java:14-34</a></p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// OrderRefundPolicy.java</span>
<span class="kd">public</span> <span class="kd">static</span> <span class="kd">final</span> <span class="kt">int</span> <span class="no">REFUND_WINDOW_DAYS</span> <span class="o">=</span> <span class="mi">7</span><span class="o">;</span>
<span class="kd">public</span> <span class="kd">static</span> <span class="kd">final</span> <span class="kt">int</span> <span class="no">MAX_REFUNDABLE_PROGRESS_PERCENT</span> <span class="o">=</span> <span class="mi">10</span><span class="o">;</span>

<span class="kd">public</span> <span class="kd">static</span> <span class="kt">boolean</span> <span class="nf">withinRefundWindow</span><span class="o">(</span><span class="nc">LocalDateTime</span> <span class="n">paidAt</span><span class="o">,</span> <span class="nc">LocalDateTime</span> <span class="n">now</span><span class="o">)</span> <span class="o">{</span>
    <span class="k">if</span> <span class="o">(</span><span class="n">paidAt</span> <span class="o">==</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">return</span> <span class="kc">false</span><span class="o">;</span>
    <span class="o">}</span>
    <span class="k">return</span> <span class="o">!</span><span class="n">now</span><span class="o">.</span><span class="na">isAfter</span><span class="o">(</span><span class="n">paidAt</span><span class="o">.</span><span class="na">plusDays</span><span class="o">(</span><span class="no">REFUND_WINDOW_DAYS</span><span class="o">));</span>
<span class="o">}</span>

<span class="kd">public</span> <span class="kd">static</span> <span class="kt">boolean</span> <span class="nf">progressWithinLimit</span><span class="o">(</span><span class="kt">int</span> <span class="n">progressPercent</span><span class="o">)</span> <span class="o">{</span>
    <span class="k">return</span> <span class="n">progressPercent</span> <span class="o">&lt;</span> <span class="no">MAX_REFUNDABLE_PROGRESS_PERCENT</span><span class="o">;</span>
<span class="o">}</span>

<span class="kd">public</span> <span class="kd">static</span> <span class="kt">boolean</span> <span class="nf">isCourseItemRefundable</span><span class="o">(</span><span class="nc">LocalDateTime</span> <span class="n">paidAt</span><span class="o">,</span> <span class="nc">LocalDateTime</span> <span class="n">now</span><span class="o">,</span> <span class="kt">int</span> <span class="n">progressPercent</span><span class="o">)</span> <span class="o">{</span>
    <span class="k">return</span> <span class="nf">withinRefundWindow</span><span class="o">(</span><span class="n">paidAt</span><span class="o">,</span> <span class="n">now</span><span class="o">)</span> <span class="o">&amp;&amp;</span> <span class="n">progressWithinLimit</span><span class="o">(</span><span class="n">progressPercent</span><span class="o">);</span>
<span class="o">}</span>
</code></pre></div></div>

<p>실행 서비스에서도 같은 원칙을 유지했다. 주문 항목 단위로 <code class="language-plaintext highlighter-rouge">order:refund:lock:{orderId}:{courseId}</code> 락을 잡고, 주문 소유자와 주문 상태, 이미 환불된 항목인지, 7일 이내인지, 진도율이 10% 미만인지 다시 확인한다. 그 뒤에야 Toss cancel을 호출하고 주문 상태를 갱신한다.</p>

<p>원본 코드: <a href="https://github.com/Hard-Click/Hard-Click-BackEnd/blob/ea50993a49340ee2bce8b53b439211c541c4da81/src/main/java/com/wanted/backend/domain/order/application/service/RefundOrderItemService.java#L49-L105">RefundOrderItemService.java:49-105</a></p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// RefundOrderItemService.java</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">refund</span><span class="o">(</span><span class="nc">Long</span> <span class="n">memberId</span><span class="o">,</span> <span class="nc">Long</span> <span class="n">orderId</span><span class="o">,</span> <span class="nc">Long</span> <span class="n">courseId</span><span class="o">,</span> <span class="nc">String</span> <span class="n">idempotencyKey</span><span class="o">)</span> <span class="o">{</span>
    <span class="n">distributedLock</span><span class="o">.</span><span class="na">runWithLock</span><span class="o">(</span><span class="no">LOCK_KEY_PREFIX</span> <span class="o">+</span> <span class="n">orderId</span> <span class="o">+</span> <span class="s">":"</span> <span class="o">+</span> <span class="n">courseId</span><span class="o">,</span> <span class="no">LOCK_TTL</span><span class="o">,</span> <span class="o">()</span> <span class="o">-&gt;</span> <span class="o">{</span>
        <span class="nc">Order</span> <span class="n">order</span> <span class="o">=</span> <span class="n">orderRepository</span><span class="o">.</span><span class="na">findById</span><span class="o">(</span><span class="n">orderId</span><span class="o">)</span>
                <span class="o">.</span><span class="na">orElseThrow</span><span class="o">(()</span> <span class="o">-&gt;</span> <span class="k">new</span> <span class="nc">BusinessException</span><span class="o">(</span><span class="nc">ErrorCode</span><span class="o">.</span><span class="na">ORDER_NOT_FOUND</span><span class="o">));</span>

        <span class="k">if</span> <span class="o">(!</span><span class="n">order</span><span class="o">.</span><span class="na">getMemberId</span><span class="o">().</span><span class="na">equals</span><span class="o">(</span><span class="n">memberId</span><span class="o">))</span> <span class="o">{</span>
            <span class="k">throw</span> <span class="k">new</span> <span class="nf">BusinessException</span><span class="o">(</span><span class="nc">ErrorCode</span><span class="o">.</span><span class="na">ORDER_ACCESS_DENIED</span><span class="o">);</span>
        <span class="o">}</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">order</span><span class="o">.</span><span class="na">getStatus</span><span class="o">()</span> <span class="o">!=</span> <span class="nc">OrderStatus</span><span class="o">.</span><span class="na">PAID</span>
                <span class="o">&amp;&amp;</span> <span class="n">order</span><span class="o">.</span><span class="na">getStatus</span><span class="o">()</span> <span class="o">!=</span> <span class="nc">OrderStatus</span><span class="o">.</span><span class="na">PARTIAL_REFUNDED</span><span class="o">)</span> <span class="o">{</span>
            <span class="k">throw</span> <span class="k">new</span> <span class="nf">BusinessException</span><span class="o">(</span><span class="nc">ErrorCode</span><span class="o">.</span><span class="na">ORDER_NOT_REFUNDABLE</span><span class="o">);</span>
        <span class="o">}</span>

        <span class="nc">OrderItem</span> <span class="n">item</span> <span class="o">=</span> <span class="n">order</span><span class="o">.</span><span class="na">getItems</span><span class="o">().</span><span class="na">stream</span><span class="o">()</span>
                <span class="o">.</span><span class="na">filter</span><span class="o">(</span><span class="n">i</span> <span class="o">-&gt;</span> <span class="n">courseId</span><span class="o">.</span><span class="na">equals</span><span class="o">(</span><span class="n">i</span><span class="o">.</span><span class="na">getCourseId</span><span class="o">()))</span>
                <span class="o">.</span><span class="na">findFirst</span><span class="o">()</span>
                <span class="o">.</span><span class="na">orElseThrow</span><span class="o">(()</span> <span class="o">-&gt;</span> <span class="k">new</span> <span class="nc">BusinessException</span><span class="o">(</span><span class="nc">ErrorCode</span><span class="o">.</span><span class="na">ORDER_ITEM_NOT_FOUND</span><span class="o">));</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">item</span><span class="o">.</span><span class="na">isRefunded</span><span class="o">())</span> <span class="o">{</span>
            <span class="k">return</span><span class="o">;</span>
        <span class="o">}</span>

        <span class="nc">LocalDateTime</span> <span class="n">now</span> <span class="o">=</span> <span class="nc">LocalDateTime</span><span class="o">.</span><span class="na">now</span><span class="o">(</span><span class="n">clock</span><span class="o">);</span>
        <span class="k">if</span> <span class="o">(!</span><span class="nc">OrderRefundPolicy</span><span class="o">.</span><span class="na">withinRefundWindow</span><span class="o">(</span><span class="n">order</span><span class="o">.</span><span class="na">getPaidAt</span><span class="o">(),</span> <span class="n">now</span><span class="o">))</span> <span class="o">{</span>
            <span class="k">throw</span> <span class="k">new</span> <span class="nf">BusinessException</span><span class="o">(</span><span class="nc">ErrorCode</span><span class="o">.</span><span class="na">REFUND_WINDOW_EXPIRED</span><span class="o">);</span>
        <span class="o">}</span>
        <span class="kt">int</span> <span class="n">progressPercent</span> <span class="o">=</span> <span class="n">orderCourseProgressPort</span>
                <span class="o">.</span><span class="na">findProgressPercents</span><span class="o">(</span><span class="n">memberId</span><span class="o">,</span> <span class="nc">List</span><span class="o">.</span><span class="na">of</span><span class="o">(</span><span class="n">courseId</span><span class="o">))</span>
                <span class="o">.</span><span class="na">getOrDefault</span><span class="o">(</span><span class="n">courseId</span><span class="o">,</span> <span class="mi">0</span><span class="o">);</span>
        <span class="k">if</span> <span class="o">(!</span><span class="nc">OrderRefundPolicy</span><span class="o">.</span><span class="na">progressWithinLimit</span><span class="o">(</span><span class="n">progressPercent</span><span class="o">))</span> <span class="o">{</span>
            <span class="k">throw</span> <span class="k">new</span> <span class="nf">BusinessException</span><span class="o">(</span><span class="nc">ErrorCode</span><span class="o">.</span><span class="na">REFUND_PROGRESS_EXCEEDED</span><span class="o">);</span>
        <span class="o">}</span>

        <span class="kt">boolean</span> <span class="n">allOthersAlreadyRefunded</span> <span class="o">=</span> <span class="n">order</span><span class="o">.</span><span class="na">getItems</span><span class="o">().</span><span class="na">stream</span><span class="o">()</span>
                <span class="o">.</span><span class="na">filter</span><span class="o">(</span><span class="n">i</span> <span class="o">-&gt;</span> <span class="o">!</span><span class="n">courseId</span><span class="o">.</span><span class="na">equals</span><span class="o">(</span><span class="n">i</span><span class="o">.</span><span class="na">getCourseId</span><span class="o">()))</span>
                <span class="o">.</span><span class="na">allMatch</span><span class="o">(</span><span class="nl">OrderItem:</span><span class="o">:</span><span class="n">isRefunded</span><span class="o">);</span>
        <span class="nc">OrderStatus</span> <span class="n">newStatus</span> <span class="o">=</span> <span class="n">allOthersAlreadyRefunded</span>
                <span class="o">?</span> <span class="nc">OrderStatus</span><span class="o">.</span><span class="na">REFUNDED</span>
                <span class="o">:</span> <span class="nc">OrderStatus</span><span class="o">.</span><span class="na">PARTIAL_REFUNDED</span><span class="o">;</span>

        <span class="n">pgClient</span><span class="o">.</span><span class="na">cancel</span><span class="o">(</span><span class="n">order</span><span class="o">.</span><span class="na">getPaymentKey</span><span class="o">(),</span> <span class="n">item</span><span class="o">.</span><span class="na">getPrice</span><span class="o">(),</span> <span class="no">CANCEL_REASON</span><span class="o">);</span>
        <span class="n">orderRepository</span><span class="o">.</span><span class="na">refundItem</span><span class="o">(</span><span class="n">orderId</span><span class="o">,</span> <span class="n">courseId</span><span class="o">,</span> <span class="n">newStatus</span><span class="o">);</span>
    <span class="o">});</span>
<span class="o">}</span>
</code></pre></div></div>

<p>구독 환불은 강의 진도율이 없으므로 별도 경로로 뒀다. 대신 <code class="language-plaintext highlighter-rouge">order:refund:lock:subscription:{orderId}</code> 락을 사용해 같은 구독 주문의 중복 환불을 직렬화했다. 강의 환불과 구독 환불의 조건은 다르지만, “외부 PG 취소를 두 번 보내지 않는다”는 기준은 같았다.</p>

<h2 id="남은-한계">남은 한계</h2>

<p>현재 결제 확정 경로에는 구형 구조에 있던 Redis 결과 캐시가 없다. 같은 멱등키의 응답 본문을 완전히 재현하는 것보다는 중복 과금 방어를 우선한 형태다. 또한 PG 성공 후 DB 반영 실패나 권한 지급 실패를 완전 자동 복구하려면 outbox, 보정 테이블, 재처리 worker, 관리자 보정 화면이 더 필요하다.</p>

<p>환불 쪽도 아직 주문 상태 전이 중심이다. 설계 문서에는 환불 자체를 <code class="language-plaintext highlighter-rouge">PENDING</code>, <code class="language-plaintext highlighter-rouge">SUCCEEDED</code>, <code class="language-plaintext highlighter-rouge">FAILED</code> 상태 머신으로 남기고 PG 장애 재시도나 Circuit Breaker를 붙이는 방향까지 적어두었지만, 현재 코드에 완성된 것처럼 쓰지는 않으려 한다. 지금 구현에서 확실히 말할 수 있는 것은 동일 환불 요청을 락으로 직렬화하고, 화면의 <code class="language-plaintext highlighter-rouge">refundable</code> 값을 믿지 않고 서버 정책으로 다시 검증했다는 점이다.</p>

<p>이 작업에서 남은 것은 “결제 API를 만들었다”가 아니라, 외부 PG와 내부 주문·권한 상태가 어긋날 수 있는 지점을 인정하고 어떤 상태를 진실로 삼을지, 실패를 어떻게 드러내고 보정할지 판단한 경험이었다.</p>]]></content><author><name>박종준</name></author><category term="백엔드" /><category term="결제" /><category term="정합성" /><category term="멱등성" /><category term="PG" /><category term="환불" /><category term="SLO" /><summary type="html"><![CDATA[Flown LMS에서 결제·주문·환불 도메인을 맡으며 가장 중요하게 본 것은 “결제가 성공했는가”가 아니라 과금 이후 서비스 권한까지 일관되게 맞았는가였다. PG 승인이 성공했는데 구독권이나 수강권이 생기지 않으면, 서버 입장에서는 일부 로직 실패일 수 있어도 사용자에게는 바로 신뢰 문제로 보인다.]]></summary></entry><entry><title type="html">조회 후 없으면 INSERT는 왜 깨지는가: 유니크 제약과 멱등 처리</title><link href="https://jongjunn.github.io/%EB%B0%B1%EC%97%94%EB%93%9C/db/2026/08/01/concurrency-db.html" rel="alternate" type="text/html" title="조회 후 없으면 INSERT는 왜 깨지는가: 유니크 제약과 멱등 처리" /><published>2026-08-01T13:00:00+00:00</published><updated>2026-08-01T13:00:00+00:00</updated><id>https://jongjunn.github.io/%EB%B0%B1%EC%97%94%EB%93%9C/db/2026/08/01/concurrency-db</id><content type="html" xml:base="https://jongjunn.github.io/%EB%B0%B1%EC%97%94%EB%93%9C/db/2026/08/01/concurrency-db.html"><![CDATA[<p>단일 요청에서 멀쩡한 코드는 동시에 들어오는 요청에서 쉽게 깨진다. Flown LMS에서 영상 진도 저장과 수강 최초 등록을 고치며 이 문제를 직접 만났다. 두 문제의 뿌리는 같았다.</p>

<blockquote>
  <p>조회해서 없으면 INSERT한다.</p>
</blockquote>

<p>이 흐름은 한 요청만 보면 자연스럽다. 하지만 두 요청이 거의 동시에 들어오면 둘 다 “없다”고 보고 둘 다 INSERT를 시도할 수 있다. 그래서 데이터 불변식은 애플리케이션 코드만이 아니라 DB 제약으로도 지켜야 한다.</p>

<h2 id="영상-진도-중복행">영상 진도 중복행</h2>

<p>영상 진도 저장의 불변식은 단순했다.</p>

<blockquote>
  <p>한 회원의 한 영상 진도는 DB에 정확히 1행이어야 한다.</p>
</blockquote>

<p>즉 <code class="language-plaintext highlighter-rouge">(member_id, video_id)</code>는 유일해야 한다. 하지만 기존 테이블에는 이 유니크 제약이 없었다. 동시 저장 요청이 들어오면 같은 회원과 영상에 대해 중복행이 생길 수 있었고, 이후 조회 코드가 <code class="language-plaintext highlighter-rouge">Optional&lt;VideoProgress&gt;</code>를 기대하는 순간 <code class="language-plaintext highlighter-rouge">NonUniqueResultException</code>으로 터졌다.</p>

<p>문제가 더 나쁜 이유는 일시 오류가 아니라는 점이었다. 중복행이 이미 생긴 회원과 영상 조합은 이후 재생 조회와 진도 저장이 계속 실패했다. 데이터가 깨지면 같은 API를 다시 호출해도 회복되지 않는다.</p>

<p>조치는 세 단계였다.</p>

<ol>
  <li>기존 중복행을 정리한다.</li>
  <li><code class="language-plaintext highlighter-rouge">(member_id, video_id)</code> 유니크 제약을 추가한다.</li>
  <li>동시 INSERT 경합에서 유니크 위반이 발생하면, 이미 만들어진 행을 다시 읽어 갱신한다.</li>
</ol>

<p>여기서 유니크 제약은 단순한 방어선이 아니라 도메인 불변식의 선언이다. 애플리케이션이 실수하거나 여러 요청이 동시에 들어와도 DB가 마지막 경계가 된다.</p>

<p>엔티티에는 이 불변식을 JPA 레벨에서도 드러냈다. 핵심은 <code class="language-plaintext highlighter-rouge">member_id</code>, <code class="language-plaintext highlighter-rouge">video_id</code> 조합이 하나만 존재해야 한다는 점이다.</p>

<p>원본 코드: <a href="https://github.com/Hard-Click/Hard-Click-BackEnd/blob/ea50993a49340ee2bce8b53b439211c541c4da81/src/main/java/com/wanted/backend/domain/learning_activity/infrastructure/persistence/VideoProgressJpaEntity.java#L16-L40">VideoProgressJpaEntity.java:16-40</a></p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// VideoProgressJpaEntity.java</span>
<span class="nd">@Entity</span>
<span class="nd">@Table</span><span class="o">(</span>
        <span class="n">name</span> <span class="o">=</span> <span class="s">"video_progress"</span><span class="o">,</span>
        <span class="n">uniqueConstraints</span> <span class="o">=</span> <span class="nd">@UniqueConstraint</span><span class="o">(</span>
                <span class="n">name</span> <span class="o">=</span> <span class="s">"uk_video_progress_member_video"</span><span class="o">,</span>
                <span class="n">columnNames</span> <span class="o">=</span> <span class="o">{</span><span class="s">"member_id"</span><span class="o">,</span> <span class="s">"video_id"</span><span class="o">}</span>
        <span class="o">)</span>
<span class="o">)</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">VideoProgressJpaEntity</span> <span class="o">{</span>
    <span class="nd">@Id</span>
    <span class="nd">@GeneratedValue</span><span class="o">(</span><span class="n">strategy</span> <span class="o">=</span> <span class="nc">GenerationType</span><span class="o">.</span><span class="na">IDENTITY</span><span class="o">)</span>
    <span class="nd">@Column</span><span class="o">(</span><span class="n">name</span> <span class="o">=</span> <span class="s">"progress_id"</span><span class="o">)</span>
    <span class="kd">private</span> <span class="nc">Long</span> <span class="n">id</span><span class="o">;</span>

    <span class="nd">@Column</span><span class="o">(</span><span class="n">name</span> <span class="o">=</span> <span class="s">"member_id"</span><span class="o">,</span> <span class="n">nullable</span> <span class="o">=</span> <span class="kc">false</span><span class="o">)</span>
    <span class="kd">private</span> <span class="nc">Long</span> <span class="n">memberId</span><span class="o">;</span>

    <span class="nd">@Column</span><span class="o">(</span><span class="n">name</span> <span class="o">=</span> <span class="s">"video_id"</span><span class="o">,</span> <span class="n">nullable</span> <span class="o">=</span> <span class="kc">false</span><span class="o">)</span>
    <span class="kd">private</span> <span class="nc">Long</span> <span class="n">videoId</span><span class="o">;</span>
<span class="o">}</span>
</code></pre></div></div>

<p>이미 깨진 데이터가 있었기 때문에 마이그레이션은 제약 추가만으로 끝나지 않았다. 먼저 중복행을 정리하고, 조합당 가장 많이 진행된 행을 남긴 뒤 제약을 추가했다.</p>

<p>원본 코드: <a href="https://github.com/Hard-Click/Hard-Click-BackEnd/blob/ea50993a49340ee2bce8b53b439211c541c4da81/src/main/resources/db/migration/V3.5.5__dedupe_and_unique_video_progress.sql#L1-L30">V3.5.5__dedupe_and_unique_video_progress.sql:1-30</a></p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- V3.5.5__dedupe_and_unique_video_progress.sql</span>
<span class="k">DELETE</span> <span class="k">FROM</span> <span class="n">video_progress</span>
<span class="k">WHERE</span> <span class="n">progress_id</span> <span class="k">IN</span> <span class="p">(</span>
    <span class="k">SELECT</span> <span class="n">progress_id</span>
    <span class="k">FROM</span> <span class="p">(</span>
        <span class="k">SELECT</span> <span class="n">progress_id</span><span class="p">,</span>
               <span class="n">ROW_NUMBER</span><span class="p">()</span> <span class="n">OVER</span> <span class="p">(</span>
                   <span class="k">PARTITION</span> <span class="k">BY</span> <span class="n">member_id</span><span class="p">,</span> <span class="n">video_id</span>
                   <span class="k">ORDER</span> <span class="k">BY</span> <span class="n">is_completed</span> <span class="k">DESC</span><span class="p">,</span>
                            <span class="n">watch_time_sec</span> <span class="k">DESC</span><span class="p">,</span>
                            <span class="n">last_position_sec</span> <span class="k">DESC</span><span class="p">,</span>
                            <span class="n">progress_id</span> <span class="k">DESC</span>
               <span class="p">)</span> <span class="k">AS</span> <span class="n">rn</span>
        <span class="k">FROM</span> <span class="n">video_progress</span>
    <span class="p">)</span> <span class="n">ranked</span>
    <span class="k">WHERE</span> <span class="n">ranked</span><span class="p">.</span><span class="n">rn</span> <span class="o">&gt;</span> <span class="mi">1</span>
<span class="p">);</span>

<span class="k">ALTER</span> <span class="k">TABLE</span> <span class="n">video_progress</span>
    <span class="k">ADD</span> <span class="k">CONSTRAINT</span> <span class="n">uk_video_progress_member_video</span> <span class="k">UNIQUE</span> <span class="p">(</span><span class="n">member_id</span><span class="p">,</span> <span class="n">video_id</span><span class="p">),</span>
    <span class="n">ALGORITHM</span> <span class="o">=</span> <span class="n">INPLACE</span><span class="p">,</span> <span class="k">LOCK</span> <span class="o">=</span> <span class="k">NONE</span><span class="p">;</span>
</code></pre></div></div>

<h2 id="repeatable-read와-requires_new">REPEATABLE READ와 REQUIRES_NEW</h2>

<p>유니크 위반을 잡은 뒤 “먼저 커밋된 행을 읽어 갱신하면 되겠다”고 생각했지만, 여기서 트랜잭션 격리수준을 만났다.</p>

<p>MySQL 기본 격리수준인 <code class="language-plaintext highlighter-rouge">REPEATABLE READ</code>에서는 트랜잭션의 스냅샷이 첫 조회 시점에 고정된다. 내가 처음 조회했을 때 행이 없었다면, 다른 트랜잭션이 그 뒤에 INSERT 후 커밋해도 같은 트랜잭션 안에서는 그 행이 보이지 않을 수 있다.</p>

<p>그래서 복구 읽기는 <code class="language-plaintext highlighter-rouge">REQUIRES_NEW</code>로 분리했다. 새 트랜잭션에서 새 스냅샷을 얻어, 방금 커밋된 행을 다시 읽고 업데이트할 수 있게 했다. 격리수준은 시험용 정의가 아니라, 복구 코드에서 실제로 결과를 바꾸는 조건이었다.</p>

<p>코드에서는 INSERT를 별도 트랜잭션으로 분리하고, 유니크 경합에서 밀린 요청이 새 트랜잭션으로 기존 행을 읽어 갱신하게 했다.</p>

<p>원본 코드: <a href="https://github.com/Hard-Click/Hard-Click-BackEnd/blob/ea50993a49340ee2bce8b53b439211c541c4da81/src/main/java/com/wanted/backend/domain/learning_activity/infrastructure/persistence/VideoProgressInserter.java#L27-L58">VideoProgressInserter.java:27-58</a></p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// VideoProgressInserter.java</span>
<span class="nd">@Transactional</span><span class="o">(</span><span class="n">propagation</span> <span class="o">=</span> <span class="nc">Propagation</span><span class="o">.</span><span class="na">REQUIRES_NEW</span><span class="o">)</span>
<span class="kd">public</span> <span class="nc">VideoProgressJpaEntity</span> <span class="nf">insert</span><span class="o">(</span><span class="nc">VideoProgressJpaEntity</span> <span class="n">entity</span><span class="o">)</span> <span class="o">{</span>
    <span class="k">return</span> <span class="n">repository</span><span class="o">.</span><span class="na">saveAndFlush</span><span class="o">(</span><span class="n">entity</span><span class="o">);</span>
<span class="o">}</span>

<span class="nd">@Transactional</span><span class="o">(</span><span class="n">propagation</span> <span class="o">=</span> <span class="nc">Propagation</span><span class="o">.</span><span class="na">REQUIRES_NEW</span><span class="o">)</span>
<span class="kd">public</span> <span class="nc">Optional</span><span class="o">&lt;</span><span class="nc">VideoProgressJpaEntity</span><span class="o">&gt;</span> <span class="nf">updateExisting</span><span class="o">(</span>
        <span class="nc">Long</span> <span class="n">memberId</span><span class="o">,</span>
        <span class="nc">Long</span> <span class="n">videoId</span><span class="o">,</span>
        <span class="nc">Integer</span> <span class="n">lastPositionSec</span><span class="o">,</span>
        <span class="nc">Integer</span> <span class="n">watchTimeSec</span><span class="o">,</span>
        <span class="nc">Boolean</span> <span class="n">completed</span><span class="o">,</span>
        <span class="nc">LocalDateTime</span> <span class="n">completedAt</span><span class="o">,</span>
        <span class="nc">LocalDateTime</span> <span class="n">updatedAt</span>
<span class="o">)</span> <span class="o">{</span>
    <span class="k">return</span> <span class="n">repository</span><span class="o">.</span><span class="na">findByMemberIdAndVideoId</span><span class="o">(</span><span class="n">memberId</span><span class="o">,</span> <span class="n">videoId</span><span class="o">)</span>
            <span class="o">.</span><span class="na">map</span><span class="o">(</span><span class="n">entity</span> <span class="o">-&gt;</span> <span class="o">{</span>
                <span class="n">entity</span><span class="o">.</span><span class="na">updateProgress</span><span class="o">(</span><span class="n">lastPositionSec</span><span class="o">,</span> <span class="n">watchTimeSec</span><span class="o">,</span> <span class="n">completed</span><span class="o">,</span> <span class="n">completedAt</span><span class="o">,</span> <span class="n">updatedAt</span><span class="o">);</span>
                <span class="k">return</span> <span class="n">repository</span><span class="o">.</span><span class="na">saveAndFlush</span><span class="o">(</span><span class="n">entity</span><span class="o">);</span>
            <span class="o">});</span>
<span class="o">}</span>
</code></pre></div></div>

<p>호출부는 아무 <code class="language-plaintext highlighter-rouge">DataIntegrityViolationException</code>이나 복구하지 않는다. 제약 이름을 확인해서 <code class="language-plaintext highlighter-rouge">(member_id, video_id)</code> 유니크 경합일 때만 멱등 복구로 해석한다. NOT NULL, FK 같은 다른 위반까지 덮어쓰면 진짜 오류를 숨길 수 있기 때문이다.</p>

<p>원본 코드: <a href="https://github.com/Hard-Click/Hard-Click-BackEnd/blob/ea50993a49340ee2bce8b53b439211c541c4da81/src/main/java/com/wanted/backend/domain/learning_activity/infrastructure/persistence/VideoProgressRepositoryAdapter.java#L21-L115">VideoProgressRepositoryAdapter.java:21-115</a></p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// VideoProgressRepositoryAdapter.java</span>
<span class="kd">private</span> <span class="kd">static</span> <span class="kd">final</span> <span class="nc">String</span> <span class="no">MEMBER_VIDEO_UNIQUE_CONSTRAINT</span> <span class="o">=</span> <span class="s">"uk_video_progress_member_video"</span><span class="o">;</span>

<span class="kd">private</span> <span class="nc">VideoProgress</span> <span class="nf">insert</span><span class="o">(</span><span class="nc">VideoProgress</span> <span class="n">progress</span><span class="o">,</span> <span class="nc">LocalDateTime</span> <span class="n">now</span><span class="o">)</span> <span class="o">{</span>
    <span class="k">try</span> <span class="o">{</span>
        <span class="k">return</span> <span class="nf">toDomain</span><span class="o">(</span><span class="n">inserter</span><span class="o">.</span><span class="na">insert</span><span class="o">(</span><span class="k">new</span> <span class="nc">VideoProgressJpaEntity</span><span class="o">(</span>
                <span class="n">progress</span><span class="o">.</span><span class="na">memberId</span><span class="o">(),</span>
                <span class="n">progress</span><span class="o">.</span><span class="na">courseId</span><span class="o">(),</span>
                <span class="n">progress</span><span class="o">.</span><span class="na">videoId</span><span class="o">(),</span>
                <span class="n">progress</span><span class="o">.</span><span class="na">lastPositionSec</span><span class="o">(),</span>
                <span class="n">progress</span><span class="o">.</span><span class="na">watchTimeSec</span><span class="o">(),</span>
                <span class="n">progress</span><span class="o">.</span><span class="na">completed</span><span class="o">(),</span>
                <span class="n">progress</span><span class="o">.</span><span class="na">completedAt</span><span class="o">(),</span>
                <span class="n">now</span>
        <span class="o">)));</span>
    <span class="o">}</span> <span class="k">catch</span> <span class="o">(</span><span class="nc">DataIntegrityViolationException</span> <span class="n">violation</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">if</span> <span class="o">(!</span><span class="n">isMemberVideoUniqueViolation</span><span class="o">(</span><span class="n">violation</span><span class="o">))</span> <span class="o">{</span>
            <span class="k">throw</span> <span class="n">violation</span><span class="o">;</span>
        <span class="o">}</span>

        <span class="k">return</span> <span class="n">inserter</span><span class="o">.</span><span class="na">updateExisting</span><span class="o">(</span>
                        <span class="n">progress</span><span class="o">.</span><span class="na">memberId</span><span class="o">(),</span>
                        <span class="n">progress</span><span class="o">.</span><span class="na">videoId</span><span class="o">(),</span>
                        <span class="n">progress</span><span class="o">.</span><span class="na">lastPositionSec</span><span class="o">(),</span>
                        <span class="n">progress</span><span class="o">.</span><span class="na">watchTimeSec</span><span class="o">(),</span>
                        <span class="n">progress</span><span class="o">.</span><span class="na">completed</span><span class="o">(),</span>
                        <span class="n">progress</span><span class="o">.</span><span class="na">completedAt</span><span class="o">(),</span>
                        <span class="n">now</span>
                <span class="o">)</span>
                <span class="o">.</span><span class="na">map</span><span class="o">(</span><span class="k">this</span><span class="o">::</span><span class="n">toDomain</span><span class="o">)</span>
                <span class="o">.</span><span class="na">orElseThrow</span><span class="o">(()</span> <span class="o">-&gt;</span> <span class="n">violation</span><span class="o">);</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<h2 id="수강-최초-등록의-멱등-성공">수강 최초 등록의 멱등 성공</h2>

<p>수강 등록에서도 비슷한 경합이 있었다. 한 사용자가 같은 강의를 최초 등록하는 요청을 거의 동시에 두 번 보낼 수 있다. 불변식은 이렇다.</p>

<blockquote>
  <p><code class="language-plaintext highlighter-rouge">(member_id, course_id)</code> 수강 등록은 1건이어야 한다.</p>
</blockquote>

<p>여기서 유니크 제약 위반을 항상 500으로 반환하는 것은 사용자 경험과 도메인 의미에 모두 맞지 않았다. 두 요청의 목표 상태가 “이미 수강 등록됨”으로 같다면, 두 번째 요청은 실패가 아니라 멱등 성공으로 볼 수 있다.</p>

<p>다만 모든 유니크 위반을 성공으로 바꾸면 안 된다. 멱등 성공으로 해석할 수 있는 기준이 필요하다.</p>

<ul>
  <li>최종 상태가 사용자의 의도와 같다.</li>
  <li>중복 요청으로 추가 부작용이 생기지 않는다.</li>
  <li>이미 존재하는 행이 같은 사용자와 같은 강의에 대한 것이다.</li>
  <li>결제나 포인트 차감처럼 외부 부작용이 추가로 발생하지 않는다.</li>
</ul>

<p>이 기준을 만족할 때만 “이미 처리됨”으로 바꿀 수 있다.</p>

<h2 id="락과-제약은-역할이-다르다">락과 제약은 역할이 다르다</h2>

<p>동시성 방어에서 락과 유니크 제약은 서로 대체재가 아니다. 락은 동시에 같은 구간에 들어오는 요청을 줄여준다. 유니크 제약은 어떤 경로로 들어오더라도 DB에 깨진 상태가 저장되지 않게 한다.</p>

<p>결제나 환불처럼 외부 PG 호출이 섞인 경로는 Redis 락으로 중복 외부 호출을 줄이고, 주문 상태 재조회로 최종 판단을 했다. 반면 영상 진도나 수강 등록처럼 DB 행의 유일성이 핵심인 경로는 유니크 제약이 반드시 필요했다.</p>

<h2 id="이-사례에서-남긴-것">이 사례에서 남긴 것</h2>

<p>이 작업을 통해 “조회 후 없으면 INSERT”가 왜 위험한지 코드로 이해했다. 동시 요청에서는 조회 결과가 곧 미래의 안전을 보장하지 않는다. 데이터 불변식은 DB 제약으로 선언하고, 제약 위반이 났을 때 에러인지 멱등 성공인지 도메인 기준으로 해석해야 한다.</p>

<p>동시성은 “막았다”고 말하기보다 “어떤 불변식을 어디에서 보장했고, 어떤 테스트로 확인했는가”로 설명해야 한다. 이 관점이 영상 진도, 수강 등록, 결제/환불을 같은 축에서 다시 보게 만들었다.</p>]]></content><author><name>박종준</name></author><category term="백엔드" /><category term="DB" /><category term="동시성" /><category term="유니크제약" /><category term="격리수준" /><summary type="html"><![CDATA[단일 요청에서 멀쩡한 코드는 동시에 들어오는 요청에서 쉽게 깨진다. Flown LMS에서 영상 진도 저장과 수강 최초 등록을 고치며 이 문제를 직접 만났다. 두 문제의 뿌리는 같았다.]]></summary></entry><entry><title type="html">AI라고 부르기 전에: CP-SAT, FSRS, rule-based 기능을 서비스에 붙인 기록</title><link href="https://jongjunn.github.io/%EB%B0%B1%EC%97%94%EB%93%9C/%EB%8D%B0%EC%9D%B4%ED%84%B0/2026/08/01/ai-data-integration.html" rel="alternate" type="text/html" title="AI라고 부르기 전에: CP-SAT, FSRS, rule-based 기능을 서비스에 붙인 기록" /><published>2026-08-01T12:00:00+00:00</published><updated>2026-08-01T12:00:00+00:00</updated><id>https://jongjunn.github.io/%EB%B0%B1%EC%97%94%EB%93%9C/%EB%8D%B0%EC%9D%B4%ED%84%B0/2026/08/01/ai-data-integration</id><content type="html" xml:base="https://jongjunn.github.io/%EB%B0%B1%EC%97%94%EB%93%9C/%EB%8D%B0%EC%9D%B4%ED%84%B0/2026/08/01/ai-data-integration.html"><![CDATA[<p>Flown LMS에는 개인화 학습 스케줄, 복습일 계산, 이탈위험 점수 같은 기능이 있었다. 겉으로는 AI 기능처럼 보일 수 있지만, 이 글에서는 먼저 선을 긋는다.</p>

<blockquote>
  <p>직접 학습한 예측 모델을 운영한 것이 아니라, CP-SAT 최적화, FSRS 라이브러리, rule-based scoring을 도메인에 맞게 배선한 작업이다.</p>
</blockquote>

<p>이 구분은 중요하다. AI라는 말을 붙이면 검증 책임도 같이 커진다. 직접 만들지 않은 알고리즘은 라이브러리 사용이라고 쓰고, 학습된 모델이 아닌 것은 규칙 기반이라고 쓰는 편이 장기적으로 더 신뢰를 만든다.</p>

<h2 id="cp-sat-스케줄러">CP-SAT 스케줄러</h2>

<p>학습 스케줄러의 목표는 단순히 강의를 주차별로 나눠 담는 것이 아니었다. 수능까지 남은 기간 안에 여러 과목의 강의를 배치하되, 특정 주차에 과목이 몰리지 않고, 주차별 학습량이 너무 들쭉날쭉하지 않아야 했다.</p>

<p>단순 CRUD나 균등 분배로 접근하면 조건이 늘어날수록 코드가 예외 처리 덩어리가 된다. 그래서 OR-Tools의 CP-SAT을 사용해 제약을 선언하는 방식으로 풀었다.</p>

<p>스케줄러가 다룬 문제는 대략 이런 형태다.</p>

<ul>
  <li>남은 주차 안에 모든 학습 단위를 배치한다.</li>
  <li>주차별 학습량의 상한과 하한을 둔다.</li>
  <li>과목이 한쪽으로 몰리지 않게 한다.</li>
  <li>복습이나 시험 일정처럼 도메인상 중요한 날짜를 고려한다.</li>
</ul>

<p>이 접근의 장점은 “왜 이 배치가 가능한가”를 제약으로 설명할 수 있다는 점이다. 반대로 제약이 많아질수록 모델링 난도가 올라가고, 결과가 항상 사용자가 기대하는 감각적 균형과 일치하지는 않는다.</p>

<p>스케줄러는 DB나 프레임워크를 직접 import하지 않는 순수 도메인 함수로 뒀다. 외부 데이터는 파라미터로 받고, 결과는 <code class="language-plaintext highlighter-rouge">lesson_id -&gt; week_index</code> 형태의 값으로 돌려준다.</p>

<p>원본 코드: <a href="https://github.com/Hard-Click/Python-Server/blob/e24420f4bcf6950ce1aa430e4ce8c18e7b266fc3/domain/scheduler.py#L17-L70">Python-Server domain/scheduler.py:17-70</a></p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># domain/scheduler.py
</span><span class="kn">from</span> <span class="nn">ortools.sat.python</span> <span class="kn">import</span> <span class="n">cp_model</span>

<span class="k">def</span> <span class="nf">generate_weekly_schedule</span><span class="p">(</span><span class="n">lessons</span><span class="p">,</span> <span class="n">weekly_caps</span><span class="p">,</span> <span class="n">prerequisites</span><span class="o">=</span><span class="bp">None</span><span class="p">):</span>
    <span class="n">model</span> <span class="o">=</span> <span class="n">cp_model</span><span class="p">.</span><span class="n">CpModel</span><span class="p">()</span>
    <span class="n">num_weeks</span> <span class="o">=</span> <span class="nb">len</span><span class="p">(</span><span class="n">weekly_caps</span><span class="p">)</span>

    <span class="n">x</span> <span class="o">=</span> <span class="p">{}</span>
    <span class="k">for</span> <span class="n">lesson</span> <span class="ow">in</span> <span class="n">lessons</span><span class="p">:</span>
        <span class="n">deadline</span> <span class="o">=</span> <span class="n">lesson</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"deadline_week"</span><span class="p">)</span>
        <span class="n">max_week</span> <span class="o">=</span> <span class="n">deadline</span> <span class="k">if</span> <span class="n">deadline</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span> <span class="k">else</span> <span class="n">num_weeks</span> <span class="o">-</span> <span class="mi">1</span>
        <span class="k">for</span> <span class="n">w</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">num_weeks</span><span class="p">):</span>
            <span class="k">if</span> <span class="n">w</span> <span class="o">&lt;=</span> <span class="n">max_week</span><span class="p">:</span>
                <span class="n">x</span><span class="p">[(</span><span class="n">lesson</span><span class="p">[</span><span class="s">"id"</span><span class="p">],</span> <span class="n">w</span><span class="p">)]</span> <span class="o">=</span> <span class="n">model</span><span class="p">.</span><span class="n">NewBoolVar</span><span class="p">(</span><span class="sa">f</span><span class="s">"x_</span><span class="si">{</span><span class="n">lesson</span><span class="p">[</span><span class="s">'id'</span><span class="p">]</span><span class="si">}</span><span class="s">_</span><span class="si">{</span><span class="n">w</span><span class="si">}</span><span class="s">"</span><span class="p">)</span>
        <span class="n">model</span><span class="p">.</span><span class="n">AddExactlyOne</span><span class="p">(</span><span class="n">x</span><span class="p">[(</span><span class="n">lesson</span><span class="p">[</span><span class="s">"id"</span><span class="p">],</span> <span class="n">w</span><span class="p">)]</span> <span class="k">for</span> <span class="n">w</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">num_weeks</span><span class="p">)</span> <span class="k">if</span> <span class="p">(</span><span class="n">lesson</span><span class="p">[</span><span class="s">"id"</span><span class="p">],</span> <span class="n">w</span><span class="p">)</span> <span class="ow">in</span> <span class="n">x</span><span class="p">)</span>

    <span class="k">for</span> <span class="n">w</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">num_weeks</span><span class="p">):</span>
        <span class="n">terms</span> <span class="o">=</span> <span class="p">[</span>
            <span class="n">lesson</span><span class="p">[</span><span class="s">"duration_min"</span><span class="p">]</span> <span class="o">*</span> <span class="n">x</span><span class="p">[(</span><span class="n">lesson</span><span class="p">[</span><span class="s">"id"</span><span class="p">],</span> <span class="n">w</span><span class="p">)]</span>
            <span class="k">for</span> <span class="n">lesson</span> <span class="ow">in</span> <span class="n">lessons</span>
            <span class="k">if</span> <span class="p">(</span><span class="n">lesson</span><span class="p">[</span><span class="s">"id"</span><span class="p">],</span> <span class="n">w</span><span class="p">)</span> <span class="ow">in</span> <span class="n">x</span>
        <span class="p">]</span>
        <span class="k">if</span> <span class="n">terms</span><span class="p">:</span>
            <span class="n">model</span><span class="p">.</span><span class="n">Add</span><span class="p">(</span><span class="nb">sum</span><span class="p">(</span><span class="n">terms</span><span class="p">)</span> <span class="o">&lt;=</span> <span class="n">weekly_caps</span><span class="p">[</span><span class="n">w</span><span class="p">])</span>

    <span class="n">solver</span> <span class="o">=</span> <span class="n">cp_model</span><span class="p">.</span><span class="n">CpSolver</span><span class="p">()</span>
    <span class="n">status</span> <span class="o">=</span> <span class="n">solver</span><span class="p">.</span><span class="n">Solve</span><span class="p">(</span><span class="n">model</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">status</span> <span class="ow">not</span> <span class="ow">in</span> <span class="p">(</span><span class="n">cp_model</span><span class="p">.</span><span class="n">OPTIMAL</span><span class="p">,</span> <span class="n">cp_model</span><span class="p">.</span><span class="n">FEASIBLE</span><span class="p">):</span>
        <span class="k">return</span> <span class="bp">None</span>

    <span class="k">return</span> <span class="p">{</span>
        <span class="n">lesson_id</span><span class="p">:</span> <span class="n">w</span>
        <span class="k">for</span> <span class="n">lesson_id</span> <span class="ow">in</span> <span class="p">[</span><span class="n">l</span><span class="p">[</span><span class="s">"id"</span><span class="p">]</span> <span class="k">for</span> <span class="n">l</span> <span class="ow">in</span> <span class="n">lessons</span><span class="p">]</span>
        <span class="k">for</span> <span class="n">w</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">num_weeks</span><span class="p">)</span>
        <span class="k">if</span> <span class="p">(</span><span class="n">lesson_id</span><span class="p">,</span> <span class="n">w</span><span class="p">)</span> <span class="ow">in</span> <span class="n">x</span> <span class="ow">and</span> <span class="n">solver</span><span class="p">.</span><span class="n">Value</span><span class="p">(</span><span class="n">x</span><span class="p">[(</span><span class="n">lesson_id</span><span class="p">,</span> <span class="n">w</span><span class="p">)])</span> <span class="o">==</span> <span class="mi">1</span>
    <span class="p">}</span>
</code></pre></div></div>

<h2 id="fsrs-복습일">FSRS 복습일</h2>

<p>복습일 계산은 직접 알고리즘을 만든 것이 아니라 FSRS 라이브러리를 사용했다. 내가 한 일은 퀴즈 점수를 FSRS rating으로 매핑하고, 도메인 흐름에 연결한 것이다.</p>

<p>예를 들어 퀴즈 점수가 높으면 <code class="language-plaintext highlighter-rouge">Easy</code>나 <code class="language-plaintext highlighter-rouge">Good</code>, 낮으면 <code class="language-plaintext highlighter-rouge">Hard</code>나 <code class="language-plaintext highlighter-rouge">Again</code>으로 매핑해 다음 복습일을 계산한다. 여기서 신경 쓴 부분은 콜드스타트와 상한이다.</p>

<p>개인 리뷰 데이터가 없어도 py-fsrs의 기본 가중치로 바로 동작할 수 있어야 했다. 반대로 고득점을 계속 받은 학생의 복습일이 너무 멀리 밀리면 서비스 맥락에 맞지 않는다. 그래서 복습 간격이 구독 상한이나 수능일을 넘어가지 않도록 캡을 두었다.</p>

<p>FSRS 쪽도 직접 알고리즘을 구현한 것이 아니라, 퀴즈 점수를 <code class="language-plaintext highlighter-rouge">Rating</code>으로 매핑하고 라이브러리의 <code class="language-plaintext highlighter-rouge">review_card</code>에 연결했다. 이 부분을 글에 명확히 남기는 이유는 과장하지 않기 위해서다.</p>

<p>원본 코드: <a href="https://github.com/Hard-Click/Python-Server/blob/e24420f4bcf6950ce1aa430e4ce8c18e7b266fc3/domain/review.py#L10-L43">Python-Server domain/review.py:10-43</a></p>

<p>연동 코드: <a href="https://github.com/Hard-Click/Hard-Click-BackEnd/blob/ea50993a49340ee2bce8b53b439211c541c4da81/src/main/resources/db/migration/V3.1.4__add_fsrs_review_tables.sql#L1-L53">FSRS 테이블 마이그레이션</a>, <a href="https://github.com/Hard-Click/Hard-Click-BackEnd/blob/ea50993a49340ee2bce8b53b439211c541c4da81/src/main/java/com/wanted/backend/domain/quiz/infrastructure/review/ReviewCompletionAdapter.java#L14-L90">ReviewCompletionAdapter.java:14-90</a></p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># domain/review.py
</span><span class="kn">from</span> <span class="nn">fsrs</span> <span class="kn">import</span> <span class="n">Scheduler</span><span class="p">,</span> <span class="n">Card</span><span class="p">,</span> <span class="n">Rating</span>

<span class="k">def</span> <span class="nf">quiz_score_to_grade</span><span class="p">(</span><span class="n">score_percent</span><span class="p">:</span> <span class="nb">float</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">Rating</span><span class="p">:</span>
    <span class="k">if</span> <span class="n">score_percent</span> <span class="o">&gt;=</span> <span class="mi">90</span><span class="p">:</span>
        <span class="k">return</span> <span class="n">Rating</span><span class="p">.</span><span class="n">Easy</span>
    <span class="k">if</span> <span class="n">score_percent</span> <span class="o">&gt;=</span> <span class="mi">70</span><span class="p">:</span>
        <span class="k">return</span> <span class="n">Rating</span><span class="p">.</span><span class="n">Good</span>
    <span class="k">if</span> <span class="n">score_percent</span> <span class="o">&gt;=</span> <span class="mi">50</span><span class="p">:</span>
        <span class="k">return</span> <span class="n">Rating</span><span class="p">.</span><span class="n">Hard</span>
    <span class="k">return</span> <span class="n">Rating</span><span class="p">.</span><span class="n">Again</span>

<span class="k">def</span> <span class="nf">review_lesson</span><span class="p">(</span>
    <span class="n">card</span><span class="p">:</span> <span class="n">Card</span> <span class="o">|</span> <span class="bp">None</span><span class="p">,</span>
    <span class="n">quiz_score_percent</span><span class="p">:</span> <span class="nb">float</span><span class="p">,</span>
    <span class="n">scheduler</span><span class="p">:</span> <span class="n">Scheduler</span> <span class="o">|</span> <span class="bp">None</span> <span class="o">=</span> <span class="bp">None</span><span class="p">,</span>
    <span class="n">review_datetime</span><span class="o">=</span><span class="bp">None</span><span class="p">,</span>
    <span class="n">max_interval_days</span><span class="p">:</span> <span class="nb">int</span> <span class="o">|</span> <span class="bp">None</span> <span class="o">=</span> <span class="bp">None</span><span class="p">,</span>
<span class="p">):</span>
    <span class="k">if</span> <span class="n">scheduler</span> <span class="ow">is</span> <span class="bp">None</span><span class="p">:</span>
        <span class="n">scheduler</span> <span class="o">=</span> <span class="n">Scheduler</span><span class="p">(</span><span class="n">maximum_interval</span><span class="o">=</span><span class="n">max_interval_days</span><span class="p">)</span> <span class="k">if</span> <span class="n">max_interval_days</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span> <span class="k">else</span> <span class="n">Scheduler</span><span class="p">()</span>
    <span class="n">card</span> <span class="o">=</span> <span class="n">card</span> <span class="ow">or</span> <span class="n">Card</span><span class="p">()</span>
    <span class="n">rating</span> <span class="o">=</span> <span class="n">quiz_score_to_grade</span><span class="p">(</span><span class="n">quiz_score_percent</span><span class="p">)</span>
    <span class="n">card</span><span class="p">,</span> <span class="n">_review_log</span> <span class="o">=</span> <span class="n">scheduler</span><span class="p">.</span><span class="n">review_card</span><span class="p">(</span><span class="n">card</span><span class="p">,</span> <span class="n">rating</span><span class="p">,</span> <span class="n">review_datetime</span><span class="o">=</span><span class="n">review_datetime</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">card</span><span class="p">,</span> <span class="n">card</span><span class="p">.</span><span class="n">due</span>
</code></pre></div></div>

<h2 id="rule-based-이탈위험">rule-based 이탈위험</h2>

<p>이탈위험 기능은 운영 예측 모델이 아니다. CoxPH 같은 생존분석 모델을 운영 추론에 붙인 것도 아니다. 현재 구현은 rule-based scoring이다.</p>

<p>점수는 세 축을 정규화해 계산했다.</p>

<ul>
  <li>최근 미접속일</li>
  <li>미학습 연속일</li>
  <li>평균 퀴즈 점수</li>
</ul>

<p>퀴즈 미응시 학생은 퀴즈 축을 제외하고, 접속과 학습 연속성 중심으로 계산한다. 응시 데이터가 있으면 퀴즈 점수 축을 함께 반영한다. 중요한 것은 총점만 반환하지 않고, 축별 기여도와 <code class="language-plaintext highlighter-rouge">top_reason</code>을 함께 반환한 점이다.</p>

<p>“위험도 0.72”만 있으면 설명이 어렵다. 하지만 “위험도 0.72이고, 가장 큰 이유는 최근 미접속일”이라고 말할 수 있으면 운영자나 사용자에게 기능을 설명할 수 있다. 이 단계에서는 예측 성능보다 설명 가능성이 더 중요했다.</p>

<p>이탈위험 점수도 총점만 만들지 않고 축별 기여도와 <code class="language-plaintext highlighter-rouge">top_reason</code>을 함께 반환했다. Java 백엔드는 이 <code class="language-plaintext highlighter-rouge">top_reason</code> 코드(<code class="language-plaintext highlighter-rouge">recency</code>, <code class="language-plaintext highlighter-rouge">streak</code>, <code class="language-plaintext highlighter-rouge">quiz</code>)를 화면 라벨로 매핑한다.</p>

<p>원본 코드: <a href="https://github.com/Hard-Click/Python-Server/blob/e24420f4bcf6950ce1aa430e4ce8c18e7b266fc3/domain/risk.py#L8-L77">Python-Server domain/risk.py:8-77</a></p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># domain/risk.py
</span><span class="n">W_RECENCY_3AXIS</span> <span class="o">=</span> <span class="mf">0.45</span>
<span class="n">W_STREAK_3AXIS</span> <span class="o">=</span> <span class="mf">0.30</span>
<span class="n">W_QUIZ_3AXIS</span> <span class="o">=</span> <span class="mf">0.25</span>
<span class="n">W_RECENCY_2AXIS</span> <span class="o">=</span> <span class="mf">0.5</span>
<span class="n">W_STREAK_2AXIS</span> <span class="o">=</span> <span class="mf">0.5</span>

<span class="k">def</span> <span class="nf">compute_risk_breakdown</span><span class="p">(</span><span class="n">recency_days</span><span class="p">,</span> <span class="n">miss_streak_days</span><span class="p">,</span> <span class="n">quiz_avg_score_percent</span><span class="o">=</span><span class="bp">None</span><span class="p">):</span>
    <span class="n">recency_score</span> <span class="o">=</span> <span class="nb">min</span><span class="p">(</span><span class="n">recency_days</span> <span class="o">/</span> <span class="mi">14</span><span class="p">,</span> <span class="mf">1.0</span><span class="p">)</span>
    <span class="n">streak_score</span> <span class="o">=</span> <span class="nb">min</span><span class="p">(</span><span class="n">miss_streak_days</span> <span class="o">/</span> <span class="mi">7</span><span class="p">,</span> <span class="mf">1.0</span><span class="p">)</span>

    <span class="k">if</span> <span class="n">quiz_avg_score_percent</span> <span class="ow">is</span> <span class="bp">None</span><span class="p">:</span>
        <span class="n">contributions</span> <span class="o">=</span> <span class="p">{</span>
            <span class="s">"recency"</span><span class="p">:</span> <span class="nb">round</span><span class="p">(</span><span class="n">W_RECENCY_2AXIS</span> <span class="o">*</span> <span class="n">recency_score</span><span class="p">,</span> <span class="mi">3</span><span class="p">),</span>
            <span class="s">"streak"</span><span class="p">:</span> <span class="nb">round</span><span class="p">(</span><span class="n">W_STREAK_2AXIS</span> <span class="o">*</span> <span class="n">streak_score</span><span class="p">,</span> <span class="mi">3</span><span class="p">),</span>
        <span class="p">}</span>
        <span class="n">raw_score</span> <span class="o">=</span> <span class="n">W_RECENCY_2AXIS</span> <span class="o">*</span> <span class="n">recency_score</span> <span class="o">+</span> <span class="n">W_STREAK_2AXIS</span> <span class="o">*</span> <span class="n">streak_score</span>
    <span class="k">else</span><span class="p">:</span>
        <span class="n">quiz_risk_score</span> <span class="o">=</span> <span class="nb">min</span><span class="p">(</span><span class="nb">max</span><span class="p">(</span><span class="mi">1</span> <span class="o">-</span> <span class="n">quiz_avg_score_percent</span> <span class="o">/</span> <span class="mi">100</span><span class="p">,</span> <span class="mf">0.0</span><span class="p">),</span> <span class="mf">1.0</span><span class="p">)</span>
        <span class="n">contributions</span> <span class="o">=</span> <span class="p">{</span>
            <span class="s">"recency"</span><span class="p">:</span> <span class="nb">round</span><span class="p">(</span><span class="n">W_RECENCY_3AXIS</span> <span class="o">*</span> <span class="n">recency_score</span><span class="p">,</span> <span class="mi">3</span><span class="p">),</span>
            <span class="s">"streak"</span><span class="p">:</span> <span class="nb">round</span><span class="p">(</span><span class="n">W_STREAK_3AXIS</span> <span class="o">*</span> <span class="n">streak_score</span><span class="p">,</span> <span class="mi">3</span><span class="p">),</span>
            <span class="s">"quiz"</span><span class="p">:</span> <span class="nb">round</span><span class="p">(</span><span class="n">W_QUIZ_3AXIS</span> <span class="o">*</span> <span class="n">quiz_risk_score</span><span class="p">,</span> <span class="mi">3</span><span class="p">),</span>
        <span class="p">}</span>
        <span class="n">raw_score</span> <span class="o">=</span> <span class="nb">sum</span><span class="p">(</span><span class="n">contributions</span><span class="p">.</span><span class="n">values</span><span class="p">())</span>

    <span class="n">score</span> <span class="o">=</span> <span class="nb">round</span><span class="p">(</span><span class="n">raw_score</span><span class="p">,</span> <span class="mi">3</span><span class="p">)</span>
    <span class="n">top_reason</span> <span class="o">=</span> <span class="nb">max</span><span class="p">(</span><span class="n">contributions</span><span class="p">,</span> <span class="n">key</span><span class="o">=</span><span class="n">contributions</span><span class="p">.</span><span class="n">get</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">RiskBreakdown</span><span class="p">(</span><span class="n">score</span><span class="o">=</span><span class="n">score</span><span class="p">,</span> <span class="n">label</span><span class="o">=</span><span class="n">risk_label</span><span class="p">(</span><span class="n">score</span><span class="p">),</span> <span class="n">contributions</span><span class="o">=</span><span class="n">contributions</span><span class="p">,</span> <span class="n">top_reason</span><span class="o">=</span><span class="n">top_reason</span><span class="p">)</span>
</code></pre></div></div>

<h2 id="느린-기능을-사용자-요청에-붙이는-법">느린 기능을 사용자 요청에 붙이는 법</h2>

<p>AI 스케줄 생성은 느리고 실패할 수 있다. CP-SAT 최적화는 수 초가 걸릴 수 있고, Python 서버 호출은 언제든 지연되거나 실패할 수 있다. 이 기능을 수강 신청 요청 안에서 동기로 처리하면, 편의 기능 때문에 핵심 흐름이 느려진다.</p>

<p>그래서 수강 시작 후 <code class="language-plaintext highlighter-rouge">EnrollmentStartedEvent</code>를 발행하고, 리스너에서 스케줄 생성을 요청했다. 리스너는 <code class="language-plaintext highlighter-rouge">AFTER_COMMIT</code>에 실행되도록 했다. 이유는 두 가지다.</p>

<ol>
  <li>Python 스케줄러가 방금 생성된 수강 등록 데이터를 읽으려면 커밋 이후여야 한다.</li>
  <li>스케줄 생성이 실패해도 수강 신청 자체가 롤백되면 안 된다.</li>
</ol>

<p>또한 전용 executor를 두어 느린 AI 호출이 다른 비동기 작업을 막지 않게 했다. 포화 정책도 기본 <code class="language-plaintext highlighter-rouge">CallerRunsPolicy</code> 대신 “폐기 + 경고 로그 + 누적 카운트”로 바꿨다. 즉시 생성이 폐기돼도 주간 배치가 미생성 스케줄을 보정하는 구조라면, 사용자 요청 스레드를 붙잡는 것보다 즉시성을 포기하는 편이 낫다고 판단했다.</p>

<p>Java 백엔드에서는 수강 시작 이벤트를 커밋 이후에만 받아 Python 서버를 호출한다. 실패해도 수강 신청 결과를 되돌리지 않고 로그만 남긴다.</p>

<p>원본 코드: <a href="https://github.com/Hard-Click/Hard-Click-BackEnd/blob/ea50993a49340ee2bce8b53b439211c541c4da81/src/main/java/com/wanted/backend/domain/enrollment_management/application/listener/ScheduleGenerationListener.java#L20-L34">ScheduleGenerationListener.java:20-34</a></p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// ScheduleGenerationListener.java</span>
<span class="nd">@Async</span><span class="o">(</span><span class="s">"schedulerAiExecutor"</span><span class="o">)</span>
<span class="nd">@TransactionalEventListener</span><span class="o">(</span><span class="n">phase</span> <span class="o">=</span> <span class="nc">TransactionPhase</span><span class="o">.</span><span class="na">AFTER_COMMIT</span><span class="o">)</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">onEnrollmentStarted</span><span class="o">(</span><span class="nc">EnrollmentStartedEvent</span> <span class="n">event</span><span class="o">)</span> <span class="o">{</span>
    <span class="k">try</span> <span class="o">{</span>
        <span class="n">scheduleGenerationPort</span><span class="o">.</span><span class="na">requestGeneration</span><span class="o">(</span><span class="n">event</span><span class="o">.</span><span class="na">memberId</span><span class="o">());</span>
    <span class="o">}</span> <span class="k">catch</span> <span class="o">(</span><span class="nc">Exception</span> <span class="n">e</span><span class="o">)</span> <span class="o">{</span>
        <span class="n">log</span><span class="o">.</span><span class="na">error</span><span class="o">(</span><span class="s">"스케줄 즉시 생성 요청 실패 (memberId={}, courseId={}): {}"</span><span class="o">,</span>
                <span class="n">event</span><span class="o">.</span><span class="na">memberId</span><span class="o">(),</span> <span class="n">event</span><span class="o">.</span><span class="na">courseId</span><span class="o">(),</span> <span class="n">e</span><span class="o">.</span><span class="na">getMessage</span><span class="o">(),</span> <span class="n">e</span><span class="o">);</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>Python 서버 호출 어댑터는 timeout을 명시하고, <code class="language-plaintext highlighter-rouge">schedule.ai.enabled=false</code>일 때는 즉시 생성을 건너뛴다. 운영에서 기능을 끄더라도 주간 배치가 백업 경로가 된다.</p>

<p>원본 코드: <a href="https://github.com/Hard-Click/Hard-Click-BackEnd/blob/ea50993a49340ee2bce8b53b439211c541c4da81/src/main/java/com/wanted/backend/domain/enrollment_management/infrastructure/ai/ScheduleGenerationAiAdapter.java#L22-L60">ScheduleGenerationAiAdapter.java:22-60</a>
호출 대상: <a href="https://github.com/Hard-Click/Python-Server/blob/e24420f4bcf6950ce1aa430e4ce8c18e7b266fc3/presentation/api.py#L83-L105">Python-Server presentation/api.py:83-105</a>, <a href="https://github.com/Hard-Click/Python-Server/blob/e24420f4bcf6950ce1aa430e4ce8c18e7b266fc3/application/use_cases.py#L99-L164">GenerateWeeklyScheduleUseCase.execute:99-164</a></p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// ScheduleGenerationAiAdapter.java</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">requestGeneration</span><span class="o">(</span><span class="nc">Long</span> <span class="n">memberId</span><span class="o">)</span> <span class="o">{</span>
    <span class="k">if</span> <span class="o">(!</span><span class="n">enabled</span><span class="o">)</span> <span class="o">{</span>
        <span class="n">log</span><span class="o">.</span><span class="na">info</span><span class="o">(</span><span class="s">"스케줄 즉시 생성 비활성화(schedule.ai.enabled=false) — memberId={} 스킵"</span><span class="o">,</span> <span class="n">memberId</span><span class="o">);</span>
        <span class="k">return</span><span class="o">;</span>
    <span class="o">}</span>
    <span class="nc">Map</span><span class="o">&lt;?,</span> <span class="o">?&gt;</span> <span class="n">body</span> <span class="o">=</span> <span class="n">restClient</span><span class="o">.</span><span class="na">post</span><span class="o">()</span>
            <span class="o">.</span><span class="na">uri</span><span class="o">(</span><span class="n">generatePath</span><span class="o">)</span>
            <span class="o">.</span><span class="na">body</span><span class="o">(</span><span class="nc">Map</span><span class="o">.</span><span class="na">of</span><span class="o">(</span><span class="s">"member_id"</span><span class="o">,</span> <span class="n">memberId</span><span class="o">))</span>
            <span class="o">.</span><span class="na">retrieve</span><span class="o">()</span>
            <span class="o">.</span><span class="na">body</span><span class="o">(</span><span class="nc">Map</span><span class="o">.</span><span class="na">class</span><span class="o">);</span>
    <span class="n">log</span><span class="o">.</span><span class="na">info</span><span class="o">(</span><span class="s">"스케줄 즉시 생성 완료 — memberId={}, result={}"</span><span class="o">,</span> <span class="n">memberId</span><span class="o">,</span> <span class="n">body</span><span class="o">);</span>
<span class="o">}</span>
</code></pre></div></div>

<p>전용 executor의 포화 정책은 이 글에서 가장 중요한 결정 중 하나였다. <code class="language-plaintext highlighter-rouge">CallerRunsPolicy</code>를 쓰면 수강 요청 스레드가 긴 Python 호출을 직접 떠안을 수 있기 때문이다.</p>

<p>원본 코드: <a href="https://github.com/Hard-Click/Hard-Click-BackEnd/blob/ea50993a49340ee2bce8b53b439211c541c4da81/src/main/java/com/wanted/backend/global/config/AsyncConfig.java#L61-L80">AsyncConfig.java:61-80</a></p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// AsyncConfig.java</span>
<span class="nd">@Bean</span><span class="o">(</span><span class="n">name</span> <span class="o">=</span> <span class="s">"schedulerAiExecutor"</span><span class="o">)</span>
<span class="kd">public</span> <span class="nc">Executor</span> <span class="nf">schedulerAiExecutor</span><span class="o">()</span> <span class="o">{</span>
    <span class="nc">ThreadPoolTaskExecutor</span> <span class="n">executor</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">ThreadPoolTaskExecutor</span><span class="o">();</span>
    <span class="n">executor</span><span class="o">.</span><span class="na">setCorePoolSize</span><span class="o">(</span><span class="mi">2</span><span class="o">);</span>
    <span class="n">executor</span><span class="o">.</span><span class="na">setMaxPoolSize</span><span class="o">(</span><span class="mi">4</span><span class="o">);</span>
    <span class="n">executor</span><span class="o">.</span><span class="na">setQueueCapacity</span><span class="o">(</span><span class="mi">200</span><span class="o">);</span>
    <span class="n">executor</span><span class="o">.</span><span class="na">setThreadNamePrefix</span><span class="o">(</span><span class="s">"SchedulerAi-"</span><span class="o">);</span>

    <span class="nc">AtomicLong</span> <span class="n">schedulerAiDiscarded</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">AtomicLong</span><span class="o">();</span>
    <span class="n">executor</span><span class="o">.</span><span class="na">setRejectedExecutionHandler</span><span class="o">((</span><span class="n">r</span><span class="o">,</span> <span class="n">e</span><span class="o">)</span> <span class="o">-&gt;</span>
            <span class="n">log</span><span class="o">.</span><span class="na">warn</span><span class="o">(</span><span class="s">"schedulerAiExecutor 포화 — 즉시 스케줄 생성 작업 폐기(주간 배치가 백업). 누적 폐기 건수={}"</span><span class="o">,</span>
                    <span class="n">schedulerAiDiscarded</span><span class="o">.</span><span class="na">incrementAndGet</span><span class="o">()));</span>
    <span class="n">executor</span><span class="o">.</span><span class="na">initialize</span><span class="o">();</span>
    <span class="k">return</span> <span class="n">executor</span><span class="o">;</span>
<span class="o">}</span>
</code></pre></div></div>

<h2 id="남은-한계">남은 한계</h2>

<p>이 기능들이 실제 학습 성과나 이탈률을 개선했다는 A/B 테스트 결과는 없다. FSRS 개인화 optimizer, rule-based 가중치의 실데이터 보정, 즉시 스케줄 생성 실패에 대한 재시도 큐도 다음 단계로 남아 있다.</p>

<p>이 글에서 남기고 싶은 것은 “AI를 만들었다”가 아니다. 느리거나 실패할 수 있는 계산 기능을 핵심 사용자 흐름에 붙일 때, 어디서 실패를 격리하고 무엇을 과장하지 않을지 판단한 과정이다.</p>]]></content><author><name>박종준</name></author><category term="백엔드" /><category term="데이터" /><category term="CP-SAT" /><category term="FSRS" /><category term="rule-based" /><category term="비동기" /><summary type="html"><![CDATA[Flown LMS에는 개인화 학습 스케줄, 복습일 계산, 이탈위험 점수 같은 기능이 있었다. 겉으로는 AI 기능처럼 보일 수 있지만, 이 글에서는 먼저 선을 긋는다.]]></summary></entry><entry><title type="html">협업을 도구가 아니라 계약으로 만들기: API, DB, 오류 공유 기준</title><link href="https://jongjunn.github.io/%ED%98%91%EC%97%85/%EC%9A%B4%EC%98%81/2026/08/01/ops-collaboration-quality.html" rel="alternate" type="text/html" title="협업을 도구가 아니라 계약으로 만들기: API, DB, 오류 공유 기준" /><published>2026-08-01T11:00:00+00:00</published><updated>2026-08-01T11:00:00+00:00</updated><id>https://jongjunn.github.io/%ED%98%91%EC%97%85/%EC%9A%B4%EC%98%81/2026/08/01/ops-collaboration-quality</id><content type="html" xml:base="https://jongjunn.github.io/%ED%98%91%EC%97%85/%EC%9A%B4%EC%98%81/2026/08/01/ops-collaboration-quality.html"><![CDATA[<p>협업은 회의를 많이 하거나 도구를 많이 쓰는 것으로 좋아지지 않았다. Flown LMS를 만들며 실제로 막혔던 지점은 더 단순했다. 프론트엔드는 어떤 요청과 응답을 믿고 개발해야 하는지, 백엔드는 어떤 DB 변경이 배포를 막을 수 있는지, 장애가 났을 때 누가 어떤 정보를 보고 움직여야 하는지가 불명확했다.</p>

<p>그래서 이 글은 “협업 도구를 정리했다”는 이야기가 아니다. 팀원이 같은 상태를 보게 만들기 위해 API 계약, 스키마 변경, 오류 알림을 어디에 남기고 어떻게 검증했는지에 대한 기록이다.</p>

<h2 id="내가-실제로-맡은-기준">내가 실제로 맡은 기준</h2>

<p>이 프로젝트에서 협업을 정리한다는 말은 회의록을 예쁘게 만드는 일이 아니었다. 실제로는 프론트엔드와 백엔드가 서로 다른 상태를 믿고 개발하거나, DB 변경 하나로 배포가 막히거나, 500 오류가 났는데 담당 도메인을 바로 좁히지 못하는 문제를 줄이는 일이었다.</p>

<p>그래서 내가 잡은 기준은 아래 네 가지였다.</p>

<ul>
  <li>API 변경은 PR에서 요청, 응답, 예외, 프론트 주의사항이 먼저 보여야 한다.</li>
  <li>DB 변경은 Flyway 마이그레이션과 Hibernate validate 부팅으로 검증되어야 한다.</li>
  <li>운영 오류는 Sentry 태그와 Slack 알림만 보고도 도메인과 URL을 좁힐 수 있어야 한다.</li>
  <li>결제처럼 사용자 신뢰가 바로 깨지는 흐름은 평균 응답시간보다 중복 결제, 성공률, P95처럼 실패 조건을 분리해 봐야 한다.</li>
</ul>

<p>이 기준이 있어야 Notion, Swagger, Postman, Slack이 도구 나열로 끝나지 않는다. 각각의 도구가 어느 변경 지점에서 어떤 사고를 막는지 설명할 수 있어야 협업 기록으로 남길 수 있었다.</p>

<h2 id="협업-문제를-다시-정의하기">협업 문제를 다시 정의하기</h2>

<p>초기에는 Notion, Swagger, Postman, Slack, PR 설명이 모두 따로 움직였다. 각각은 필요했지만, 변경 사항이 여러 곳에 흩어지면 다음 문제가 생긴다.</p>

<ul>
  <li>프론트엔드는 문서를 보고 호출했는데 실제 엔드포인트가 다르다.</li>
  <li>응답 필드나 enum이 바뀌었는데 화면 작업자가 늦게 알게 된다.</li>
  <li>DB 마이그레이션 파일은 머지됐지만 운영 서버가 부팅 단계에서 멈춘다.</li>
  <li>500 오류가 발생해도 어느 도메인 담당자가 봐야 하는지 알림만 보고 판단하기 어렵다.</li>
</ul>

<p>이 문제를 “소통이 부족했다”로만 정리하면 해결이 흐려진다. 내가 잡은 기준은 이것이었다.</p>

<blockquote>
  <p>협업 정보는 말로 전달하는 것이 아니라, 변경이 발생하는 지점에 남아야 한다.</p>
</blockquote>

<p>이 기준을 세우고 나서 팀 문서를 다시 보면, 쓸 만한 기록과 그렇지 않은 기록이 갈렸다. 단순 일정표나 역할표보다 도움이 된 것은 실제 연동이 깨졌던 지점, API 표의 운영 메모, DB 변경 규칙, GitHub Issue/PR 처리 흐름이었다.</p>

<h2 id="slo는-사용자-기대에서-시작했다">SLO는 사용자 기대에서 시작했다</h2>

<p>운영 기준을 잡을 때도 응답시간부터 정하지 않았다. 먼저 사용자가 어떤 순간에 신뢰를 잃는지 정의했다. 그 다음 실패 조건, 측정 지표, 목표 수치, 알림 조건을 붙였다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>사용자 기대
-&gt; 실패 조건
-&gt; SLI
-&gt; SLO
-&gt; 에러 버짓
-&gt; 알림 조건
</code></pre></div></div>

<p>내가 맡은 결제 도메인은 이 방식이 특히 잘 맞았다. 결제는 평균 응답시간보다 “돈이 두 번 빠져나가지 않는다”가 먼저다. 그래서 중복 결제는 7일 기준 0건으로 두고, 결제 성공률과 P95 응답시간은 별도 지표로 분리했다.</p>

<table>
  <thead>
    <tr>
      <th>사용자 기대</th>
      <th>실패 조건</th>
      <th>SLO</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>결제 버튼을 여러 번 눌러도 돈은 한 번만 빠진다</td>
      <td>동일 멱등키로 2건 이상 결제 저장</td>
      <td>중복 결제 0건 / 7일</td>
    </tr>
    <tr>
      <td>결제를 시도하면 정상 처리된다</td>
      <td>결제 요청 중 실패 비율 초과</td>
      <td>성공률 99.5% 이상</td>
    </tr>
    <tr>
      <td>결제 결과가 너무 늦게 나오지 않는다</td>
      <td>P95 응답시간 2초 초과</td>
      <td>P95 2초 이하</td>
    </tr>
  </tbody>
</table>

<p>이 표가 있어야 부하 테스트와 알림도 방향을 잃지 않는다. k6는 “많이 때려보기”가 아니라 SLO를 깨뜨리는 조건을 재현하는 도구가 되고, Grafana나 Alertmanager는 예쁜 대시보드가 아니라 사용자의 기대가 깨지는 순간을 보는 장치가 된다.</p>

<h2 id="api-변경은-pr에서-먼저-드러나야-한다">API 변경은 PR에서 먼저 드러나야 한다</h2>

<p>프론트엔드와 맞닿는 변경은 코드가 아니라 계약 변경이다. 엔드포인트, 요청 파라미터, 응답 JSON, 예외 코드, 프론트 주의사항이 빠지면 구현은 끝났어도 연동은 시작하기 어렵다.</p>

<p>그래서 PR 템플릿에 프론트엔드 연동 정보를 직접 쓰도록 만들었다. 핵심은 “무엇을 만들었다”보다 “프론트가 무엇을 믿고 호출하면 되는가”를 남기는 것이다.</p>

<p>원본 문서: <a href="https://github.com/Hard-Click/Hard-Click-BackEnd/blob/ea50993a49340ee2bce8b53b439211c541c4da81/.github/pull_request_template.md#L14-L45">.github/pull_request_template.md:14-45</a></p>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gu">## 프론트엔드 연동 가이드 (API 명세)</span>

<span class="gs">**1. 주요 엔드포인트**</span>
<span class="p">-</span> <span class="sb">`[GET/POST/PUT/DELETE]`</span> <span class="sb">`/api/...`</span> : (API 설명)

<span class="gs">**2. 요청 파라미터 (Request)**</span>
| 파라미터명 | 위치 (Query/Body/Path) | 필수 여부 | 설명 |
|---|---|---|---|

<span class="gs">**3. 정상 응답 예시 (200 OK)**</span>
<span class="p">```</span><span class="nl">json
</span><span class="p">{</span><span class="w">
  </span><span class="err">//</span><span class="w"> </span><span class="err">정상</span><span class="w"> </span><span class="err">응답</span><span class="w"> </span><span class="err">JSON</span><span class="w"> </span><span class="err">복사</span><span class="w"> </span><span class="err">붙여넣기</span><span class="w">
</span><span class="p">}</span>
<span class="p">```</span>

<span class="gs">**4. 프론트엔드 참고 및 주의사항**</span>
<span class="p">-</span> 예: <span class="sb">`type=COURSE`</span>일 때 <span class="sb">`courseId`</span> 누락 시 HTTP 400 발생

<span class="gu">## 주요 에러 코드 및 예외</span>
<span class="p">-</span> <span class="sb">`에러 코드`</span> : 발생 조건 및 설명
</code></pre></div></div>

<p>이 방식의 장점은 API 변경을 리뷰 흐름 안으로 끌어온다는 점이다. 프론트와 백엔드가 따로 문서를 찾기 전에, PR에서 “이 변경이 화면에 어떤 영향을 주는가”를 먼저 보게 된다.</p>

<p>하지만 이것만으로는 충분하지 않다. 템플릿은 작성 누락을 줄일 뿐, 실제 API와 문서가 일치하는지 자동으로 보장하지는 않는다. 그래서 팀 API 표에는 단순 URL만 두지 않고, 아래 항목을 같이 관리했다.</p>

<p>관련 문서: <a href="https://github.com/Hard-Click/Hard-Click-BackEnd/blob/ea50993a49340ee2bce8b53b439211c541c4da81/docs/SCHEDULE_API.md#L1-L21">SCHEDULE_API.md:1-21</a>, <a href="https://github.com/Hard-Click/Hard-Click-BackEnd/blob/ea50993a49340ee2bce8b53b439211c541c4da81/docs/ONBOARDING_API.md">ONBOARDING_API.md</a></p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>API명칭 / METHOD / URL / 권한 / 개발 상황 / 작성완료
DB 변경 / 캐싱 / 이벤트/SSE / 기술 메모 / 운영 메모
</code></pre></div></div>

<p>이 표에서 중요한 칸은 <code class="language-plaintext highlighter-rouge">기술 메모</code>와 <code class="language-plaintext highlighter-rouge">운영 메모</code>였다. 예를 들어 영상 진도 API는 “90% 이상 시청하면 완료로 볼 것인가”, “완료 영상 수가 통계에 반영되는가”처럼 프론트 화면과 백엔드 정책이 동시에 맞아야 했다. 순공시간 API는 “초 단위인지 분 단위인지”, “KST 기준인지”, “세션 종료와 통계/잔디 갱신이 같은 트랜잭션인지”를 확인해야 했다.</p>

<p>즉 API 문서는 엔드포인트 목록이 아니라, 화면·정책·DB·운영 기준을 같이 맞추는 장소가 되어야 했다.</p>

<h2 id="문서가-필요했던-실제-연동-사례">문서가 필요했던 실제 연동 사례</h2>

<p>팀 수정사항 문서에는 실제로 연동 중 발견된 불일치가 남아 있었다. 그대로 공개하면 계정, 주문번호, 금액 같은 값이 섞일 수 있어 블로그에는 문제 유형만 남긴다.</p>

<ul>
  <li>관리자 결제 목록에서 실제 결제 완료 건이 누락됐다. 학생 본인 결제 내역에는 <code class="language-plaintext highlighter-rouge">PAID</code>로 보이지만 관리자 목록에는 나오지 않았다. 원인은 삭제된 구독 플랜과의 조인 가능성이 있었고, 해결 방향은 <code class="language-plaintext highlighter-rouge">LEFT JOIN</code>이나 결제 당시 표시명 스냅샷 보존이었다.</li>
  <li>구독 플랜 benefits 배열에서 “AI 학습 스케줄러 이용 가능” 항목이 빠졌다. 프론트는 이미 6개 혜택 기준으로 배포돼 있었고, 서버 응답은 5개만 내려주고 있었다. 이건 로직 버그라기보다 FE/BE가 같은 상품 계약을 보고 있는지의 문제였다.</li>
  <li>강의 진도율에서 90% 이상 시청 인정 기준과 완료 표시 기준이 어긋났다. 어떤 영상은 95%까지 봤는데 완료가 아니었고, 상단 진도율의 영상 개수도 실제 총 영상 수와 맞지 않았다.</li>
  <li>커뮤니티 전체 피드와 질문 게시판 API의 응답 필드가 달랐다. 같은 질문글인데 전체 피드에서는 과목 정보가 빠지고, 질문 게시판 전용 API에서는 정상 제공됐다.</li>
  <li>SSE 알림 스트림이 60초 뒤 끊겼다. 프론트 로그와 AWS ALB 기본 idle timeout 60초가 맞물려 보여, 서버 heartbeat나 인프라 timeout 조정이 필요한 운영 이슈로 봐야 했다.</li>
  <li>타임테이블 화면은 하루 총 순공시간이 아니라 세션별 시작/종료 시각이 필요했다. 이미 세션 단위 데이터가 있다면 <code class="language-plaintext highlighter-rouge">GET /api/study-timers/sessions?date=YYYY-MM-DD</code> 같은 조회 API를 추가하는 방식으로 계약을 다시 잡을 수 있었다.</li>
</ul>

<p>이 사례들은 모두 “누가 빨리 답장했는가”보다 “어느 문서에 무엇을 남겼는가”가 중요했다. API 표에는 <code class="language-plaintext highlighter-rouge">URL</code>, <code class="language-plaintext highlighter-rouge">METHOD</code>, <code class="language-plaintext highlighter-rouge">개발 상황</code>뿐 아니라 <code class="language-plaintext highlighter-rouge">DB 변경</code>, <code class="language-plaintext highlighter-rouge">캐싱</code>, <code class="language-plaintext highlighter-rouge">이벤트/SSE</code>, <code class="language-plaintext highlighter-rouge">운영 메모</code>가 있어야 같은 문제가 반복될 때 원인을 좁힐 수 있다.</p>

<h2 id="issue와-pr은-작업-로그가-되어야-한다">Issue와 PR은 작업 로그가 되어야 한다</h2>

<p>팀 GitHub 매뉴얼도 단순 사용법보다 작업 로그 관점에서 쓸 만했다. 기본 흐름은 이렇게 잡았다.</p>

<p>원본 문서: <a href="https://github.com/Hard-Click/Hard-Click-BackEnd/blob/ea50993a49340ee2bce8b53b439211c541c4da81/docs/WORKFLOW.md#L5-L24">docs/WORKFLOW.md:5-24</a></p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Issue 생성
-&gt; 담당자 지정
-&gt; 브랜치 생성
-&gt; 작업 및 테스트
-&gt; Pull Request 생성
-&gt; 1명 이상 리뷰
-&gt; merge
-&gt; Issue 상태 변경
</code></pre></div></div>

<p>기능 개발은 <code class="language-plaintext highlighter-rouge">1 Feature = 1 PR</code>로 제한하고, 개발 시작 전 이슈를 먼저 등록하게 했다. PR 본문에는 <code class="language-plaintext highlighter-rouge">Closes #이슈번호</code>를 포함해 머지 시 이슈가 닫히도록 했다. 이 규칙이 있어야 나중에 “이 기능은 누가, 어떤 이슈에서, 어떤 PR로 끝냈는가”를 추적할 수 있다.</p>

<p>이 방식이 완벽한 것은 아니다. Issue를 만드는 행위 자체가 품질을 보장하지는 않는다. 하지만 작업 과정에서 고민, 결정 근거, 결과물 링크가 댓글과 PR에 남으면 인수인계와 회고의 최소 단위가 생긴다.</p>

<h2 id="swagger는-문서가-아니라-api-계약의-출입구다">Swagger는 문서가 아니라 API 계약의 출입구다</h2>

<p><code class="language-plaintext highlighter-rouge">/v3/api-docs</code>가 500으로 죽은 적이 있었다. 겉으로 보면 문서 페이지 하나가 안 열리는 문제지만, 팀 관점에서는 프론트엔드가 API 계약을 확인하는 통로가 막힌 것이다.</p>

<p>원인은 의존성 충돌이었다. <code class="language-plaintext highlighter-rouge">anthropic-java</code>가 끌고 온 <code class="language-plaintext highlighter-rouge">swagger-annotations</code>가 springdoc이 기대하는 annotation 계열과 충돌했다. 그래서 빌드 파일에서 충돌 의존성을 명시적으로 제외했다.</p>

<p>원본 코드: <a href="https://github.com/Hard-Click/Hard-Click-BackEnd/blob/ea50993a49340ee2bce8b53b439211c541c4da81/build.gradle#L39-L73">build.gradle:39-73</a></p>

<div class="language-gradle highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">implementation</span> <span class="s1">'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.16'</span>

<span class="c1">// springdoc의 swagger-annotations-jakarta와 패키지가 겹쳐</span>
<span class="c1">// 구버전 Schema가 먼저 로드되면 /v3/api-docs가 NoSuchMethodError로 죽는다.</span>
<span class="n">implementation</span><span class="o">(</span><span class="s1">'com.anthropic:anthropic-java:2.34.0'</span><span class="o">)</span> <span class="o">{</span>
    <span class="n">exclude</span> <span class="nl">group:</span> <span class="s1">'io.swagger.core.v3'</span><span class="o">,</span> <span class="nl">module:</span> <span class="s1">'swagger-annotations'</span>
<span class="o">}</span>
</code></pre></div></div>

<p>여기서 협업 포인트는 “Swagger를 고쳤다”가 아니다. API 문서가 열리는 상태를 팀 연동의 기본 조건으로 본 것이다. PR에 API 변경 내용을 적고, Swagger에서 실제 스펙을 확인하고, Postman으로 성공과 실패 케이스를 검증하는 흐름이 있어야 문서와 구현의 거리가 줄어든다.</p>

<h2 id="db-마이그레이션도-협업-계약이다">DB 마이그레이션도 협업 계약이다</h2>

<p>DB 변경은 백엔드 내부 작업처럼 보이지만, 실제로는 팀 전체 배포 가능성을 결정한다. Flown LMS에서도 Flyway 마이그레이션 버전 누락으로 운영 서버가 크래시 루프에 빠진 적이 있었다. 기능 하나가 실패한 것이 아니라 애플리케이션 자체가 올라오지 않는 문제였다.</p>

<p>기본 설정은 Hibernate가 스키마를 자동으로 바꾸지 않고, Flyway가 적용한 스키마와 엔티티가 맞는지 검증하게 했다.</p>

<p>원본 코드: <a href="https://github.com/Hard-Click/Hard-Click-BackEnd/blob/ea50993a49340ee2bce8b53b439211c541c4da81/src/main/resources/application.yaml#L34-L58">application.yaml:34-58</a></p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">spring</span><span class="pi">:</span>
  <span class="na">jpa</span><span class="pi">:</span>
    <span class="na">hibernate</span><span class="pi">:</span>
      <span class="c1"># PR CI에서 Flyway 마이그레이션 적용 후 validate 부팅을 검증한다.</span>
      <span class="na">ddl-auto</span><span class="pi">:</span> <span class="s">validate</span>

  <span class="na">flyway</span><span class="pi">:</span>
    <span class="na">enabled</span><span class="pi">:</span> <span class="no">true</span>
    <span class="na">baseline-on-migrate</span><span class="pi">:</span> <span class="no">true</span>
    <span class="na">baseline-version</span><span class="pi">:</span> <span class="m">1</span>
    <span class="na">out-of-order</span><span class="pi">:</span> <span class="no">false</span>
</code></pre></div></div>

<p>팀 문서에는 DB 구조 변경 절차도 따로 뒀다. 순서는 단순했다.</p>

<p>원본 문서: <a href="https://github.com/Hard-Click/Hard-Click-BackEnd/blob/ea50993a49340ee2bce8b53b439211c541c4da81/docs/DB_MIGRATION_RULES.md#L8-L52">docs/DB_MIGRATION_RULES.md:8-52</a>, <a href="https://github.com/Hard-Click/Hard-Click-BackEnd/blob/ea50993a49340ee2bce8b53b439211c541c4da81/docs/DEV_RULES.md#L7-L15">docs/DEV_RULES.md:7-15</a></p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Entity 수정
-&gt; db/migration 에 마이그레이션 SQL 작성
-&gt; 서버 실행
-&gt; Flyway SQL 적용
-&gt; Hibernate validate로 Entity와 DB 일치 검사
-&gt; PR 생성 시 마이그레이션 파일 포함
</code></pre></div></div>

<p>별도 DBA가 없는 팀이라도 DB 변경 권한을 모두에게 열어두면 더 위험했다. 그래서 도메인 개발자는 JPA와 dev DB의 DML 중심으로 작업하고, 스키마 변경은 마이그레이션 담당자가 버전과 내용을 확인하는 식으로 역할을 나눴다. Slack <code class="language-plaintext highlighter-rouge">#db</code>에는 담당자, 희망 버전, 변경 목적, 대상 테이블, nullable, default, index, FK, 기존 API 영향을 쓰게 했다. 형식이 조금 번거롭더라도 “컬럼 하나 추가”가 배포 실패로 이어지는 일을 줄이는 쪽이 더 중요했다.</p>

<p>금지 사항도 명확히 했다. <code class="language-plaintext highlighter-rouge">ddl-auto: update</code> 사용 금지, Entity만 수정하고 마이그레이션 파일을 누락하는 것 금지, DB 콘솔에서 직접 <code class="language-plaintext highlighter-rouge">ALTER/CREATE</code>를 치고 파일을 남기지 않는 것 금지, 이미 공유·적용된 <code class="language-plaintext highlighter-rouge">Vn</code> 파일 수정 금지다. 이미 적용된 마이그레이션을 고쳐서 맞추기보다, 누락 사항은 다음 버전 파일로 남기는 쪽이 추적 가능하다.</p>

<p>다만 운영에서는 병렬 개발 중 마이그레이션 버전 레인이 갈라진 상태에서 낮은 버전이 뒤늦게 들어오는 문제가 있었다. 이때 <code class="language-plaintext highlighter-rouge">out-of-order=false</code>만 고집하면 이미 상위 버전이 적용된 운영 DB에서 누락된 하위 버전을 다시 적용하지 못해 부팅이 막힐 수 있었다.</p>

<p>그래서 운영 프로필에는 장애 복구 관점에서 <code class="language-plaintext highlighter-rouge">out-of-order=true</code>를 둔 상태였다.</p>

<p>원본 코드: <a href="https://github.com/Hard-Click/Hard-Click-BackEnd/blob/ea50993a49340ee2bce8b53b439211c541c4da81/src/main/resources/application-prod.yaml#L10-L17">application-prod.yaml:10-17</a></p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># application-prod.yaml</span>
<span class="na">spring</span><span class="pi">:</span>
  <span class="na">flyway</span><span class="pi">:</span>
    <span class="c1"># 담당자별 버전 레인이 병렬로 머지되어 낮은 버전이 뒤늦게 들어오는 구조라,</span>
    <span class="c1"># out-of-order=false면 "Detected resolved migration not applied to database"로 부팅이 막힌다.</span>
    <span class="c1"># 2026-07-14 프로덕션 장애: V3.3.3 누락으로 크래시 루프 발생.</span>
    <span class="na">out-of-order</span><span class="pi">:</span> <span class="no">true</span>
</code></pre></div></div>

<p>이 선택은 이상적인 정답이라기보다 당시 팀 구조를 반영한 완충장치에 가깝다. 장기적으로는 마이그레이션 번호 정책을 단일화하거나, 도메인별 버전 레인을 명확히 나누고 머지 전에 검증하는 쪽이 더 낫다.</p>

<p>그래서 PR CI에 스키마 드리프트 게이트를 추가했다. 임시 MySQL에 Flyway 마이그레이션을 적용한 뒤, Hibernate <code class="language-plaintext highlighter-rouge">validate</code>로 애플리케이션을 실제 부팅해 본다.</p>

<p>원본 코드: <a href="https://github.com/Hard-Click/Hard-Click-BackEnd/blob/ea50993a49340ee2bce8b53b439211c541c4da81/.github/workflows/pr-ci.yml#L117-L155">.github/workflows/pr-ci.yml:117-155</a></p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">스키마 드리프트 게이트</span>
  <span class="na">env</span><span class="pi">:</span>
    <span class="na">SPRING_PROFILES_ACTIVE</span><span class="pi">:</span> <span class="s">test</span>
    <span class="na">DB_URL</span><span class="pi">:</span> <span class="s">jdbc:mysql://127.0.0.1:3306/Hard-Click</span>
  <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
    <span class="s">JAR=$(ls build/libs/*.jar | grep -v plain | head -1)</span>
    <span class="s">java -jar "$JAR" &gt; app.log 2&gt;&amp;1 &amp;</span>
    <span class="s">APP_PID=$!</span>

    <span class="s">for i in $(seq 1 60); do</span>
      <span class="s">if grep -q "Started .*Application in" app.log; then</span>
        <span class="s">echo "Flyway 적용 + Hibernate validate 부팅 성공"</span>
        <span class="s">kill $APP_PID 2&gt;/dev/null || true</span>
        <span class="s">exit 0</span>
      <span class="s">fi</span>

      <span class="s">if grep -qiE "Schema-validation|SchemaManagementException|Migration .* failed|FlywayException" app.log; then</span>
        <span class="s">echo "스키마 드리프트/마이그레이션 오류 감지"</span>
        <span class="s">tail -n 60 app.log</span>
        <span class="s">kill $APP_PID 2&gt;/dev/null || true</span>
        <span class="s">exit 1</span>
      <span class="s">fi</span>

      <span class="s">sleep 2</span>
    <span class="s">done</span>

    <span class="s">echo "타임아웃: 부팅 실패"</span>
    <span class="s">tail -n 60 app.log</span>
    <span class="s">kill $APP_PID 2&gt;/dev/null || true</span>
    <span class="s">exit 1</span>
</code></pre></div></div>

<p>이 게이트가 있으면 DB 변경은 “내 로컬에서 됐다”가 아니라 “마이그레이션 적용 후 애플리케이션이 뜬다”까지 PR 단계에서 확인된다. 협업에서는 이 차이가 크다. 깨진 스키마 변경이 머지된 뒤 누군가 배포 중에 발견하는 것보다, 머지 전에 자동으로 막히는 편이 훨씬 안전하다.</p>

<h2 id="오류-알림은-담당자를-좁힐-수-있어야-한다">오류 알림은 담당자를 좁힐 수 있어야 한다</h2>

<p>운영 중 500 오류가 발생했을 때 “서버 터졌어요”만 공유되면 조사가 늦어진다. 어떤 도메인에서, 어떤 URL에서, 어떤 예외 타입으로 터졌는지 알림에 같이 붙어야 담당자를 빠르게 좁힐 수 있다.</p>

<p>그래서 전역 예외 처리에서 예상하지 못한 500 오류를 Sentry로 보낼 때 도메인, URL, HTTP method, 예외 타입을 태그로 남겼다.</p>

<p>원본 코드: <a href="https://github.com/Hard-Click/Hard-Click-BackEnd/blob/ea50993a49340ee2bce8b53b439211c541c4da81/src/main/java/com/wanted/backend/global/exception/GlobalExceptionHandler.java#L268-L300">GlobalExceptionHandler.java:268-300</a></p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@ExceptionHandler</span><span class="o">(</span><span class="nc">Exception</span><span class="o">.</span><span class="na">class</span><span class="o">)</span>
<span class="kd">public</span> <span class="nc">ResponseEntity</span><span class="o">&lt;</span><span class="nc">ErrorResponse</span><span class="o">&gt;</span> <span class="nf">handleAllException</span><span class="o">(</span>
        <span class="nc">Exception</span> <span class="n">e</span><span class="o">,</span>
        <span class="nc">HttpServletRequest</span> <span class="n">request</span><span class="o">)</span> <span class="o">{</span>

    <span class="nc">String</span> <span class="n">path</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="na">getRequestURI</span><span class="o">();</span>
    <span class="n">log</span><span class="o">.</span><span class="na">error</span><span class="o">(</span><span class="s">"[System Error] Path: {}, Message: {}"</span><span class="o">,</span> <span class="n">path</span><span class="o">,</span> <span class="n">e</span><span class="o">.</span><span class="na">getMessage</span><span class="o">(),</span> <span class="n">e</span><span class="o">);</span>

    <span class="nc">Sentry</span><span class="o">.</span><span class="na">configureScope</span><span class="o">(</span><span class="n">scope</span> <span class="o">-&gt;</span> <span class="o">{</span>
        <span class="n">scope</span><span class="o">.</span><span class="na">setTag</span><span class="o">(</span><span class="s">"domain"</span><span class="o">,</span> <span class="n">extractDomain</span><span class="o">(</span><span class="n">path</span><span class="o">));</span>
        <span class="n">scope</span><span class="o">.</span><span class="na">setTag</span><span class="o">(</span><span class="s">"exceptionType"</span><span class="o">,</span> <span class="n">e</span><span class="o">.</span><span class="na">getClass</span><span class="o">().</span><span class="na">getSimpleName</span><span class="o">());</span>
        <span class="n">scope</span><span class="o">.</span><span class="na">setTag</span><span class="o">(</span><span class="s">"path"</span><span class="o">,</span> <span class="n">path</span><span class="o">);</span>
        <span class="n">scope</span><span class="o">.</span><span class="na">setTag</span><span class="o">(</span><span class="s">"method"</span><span class="o">,</span> <span class="n">request</span><span class="o">.</span><span class="na">getMethod</span><span class="o">());</span>
    <span class="o">});</span>
    <span class="nc">Sentry</span><span class="o">.</span><span class="na">captureException</span><span class="o">(</span><span class="n">e</span><span class="o">);</span>

    <span class="nc">ErrorResponse</span> <span class="n">response</span> <span class="o">=</span> <span class="nc">ErrorResponse</span><span class="o">.</span><span class="na">create</span><span class="o">()</span>
            <span class="o">.</span><span class="na">errorCode</span><span class="o">(</span><span class="nc">ErrorCode</span><span class="o">.</span><span class="na">INTERNAL_SERVER_ERROR</span><span class="o">.</span><span class="na">getCode</span><span class="o">())</span>
            <span class="o">.</span><span class="na">message</span><span class="o">(</span><span class="nc">ErrorCode</span><span class="o">.</span><span class="na">INTERNAL_SERVER_ERROR</span><span class="o">.</span><span class="na">getMessage</span><span class="o">())</span>
            <span class="o">.</span><span class="na">path</span><span class="o">(</span><span class="n">path</span><span class="o">);</span>

    <span class="k">return</span> <span class="nc">ResponseEntity</span><span class="o">.</span><span class="na">status</span><span class="o">(</span><span class="nc">HttpStatus</span><span class="o">.</span><span class="na">INTERNAL_SERVER_ERROR</span><span class="o">).</span><span class="na">body</span><span class="o">(</span><span class="n">response</span><span class="o">);</span>
<span class="o">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">extractDomain</code>은 URL 첫 구간으로 담당 도메인을 추론한다. 완벽한 매핑은 아니지만, 알림만 보고 “orders 쪽인지”, “quiz 쪽인지”, “identity 쪽인지”를 먼저 나눌 수 있다.</p>

<p>이것도 완성된 운영 체계는 아니다. Sentry 알림은 늘어날수록 피로도가 생기므로 severity, ignore rule, sampling 기준이 필요하다. 다만 작은 팀에서는 첫 단계로 “오류를 조용히 로그에만 남기지 않는다”가 중요했다.</p>

<h2 id="slack-알림은-애플리케이션-밖에서-보낸다">Slack 알림은 애플리케이션 밖에서 보낸다</h2>

<p>운영 알림을 설계할 때 처음에는 Spring Boot가 Slack Webhook을 직접 호출하는 방식도 고민할 수 있었다. 하지만 앱과 Alertmanager가 동시에 Slack을 보내면 어느 경로에서 보낸 알림인지 헷갈리고, 각자 다른 throttle 기준을 가지면서 장애 타임라인이 흐려진다.</p>

<p>그래서 역할을 나눴다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Spring Boot Micrometer
-&gt; Prometheus scrape / alert rule 평가
-&gt; Alertmanager 라우팅
-&gt; Slack
</code></pre></div></div>

<p>Spring Boot는 counter와 timer를 쌓는 데 집중한다. 알림 조건 평가는 Prometheus alert rule이 맡고, 묶기, 반복 알림, Slack 채널 선택은 Alertmanager가 맡는다. alert rule에는 <code class="language-plaintext highlighter-rouge">domain</code> 레이블을 붙이고, Prometheus <code class="language-plaintext highlighter-rouge">external_labels</code>로 <code class="language-plaintext highlighter-rouge">env</code>를 붙여 운영에서는 도메인별 채널로, 테스트나 시연 환경에서는 통합 채널로 보낼 수 있게 했다.</p>

<p>원본 코드: <a href="https://github.com/Hard-Click/Hard-Click-BackEnd/blob/ea50993a49340ee2bce8b53b439211c541c4da81/monitoring/alert-rules/payment.yml#L26-L36">monitoring/alert-rules/payment.yml:26-36</a>, <a href="https://github.com/Hard-Click/Hard-Click-BackEnd/blob/ea50993a49340ee2bce8b53b439211c541c4da81/monitoring/prometheus.yml#L1-L8">monitoring/prometheus.yml:1-8</a></p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">alert</span><span class="pi">:</span> <span class="s">PaymentLatencyP95High</span>
  <span class="na">expr</span><span class="pi">:</span> <span class="pi">|</span>
    <span class="s">histogram_quantile(0.95,</span>
      <span class="s">rate(payment_processing_duration_seconds_bucket[5m])) &gt; 2</span>
  <span class="na">for</span><span class="pi">:</span> <span class="s">5m</span>
  <span class="na">labels</span><span class="pi">:</span>
    <span class="na">severity</span><span class="pi">:</span> <span class="s">warning</span>
    <span class="na">domain</span><span class="pi">:</span> <span class="s">payment</span>
  <span class="na">annotations</span><span class="pi">:</span>
    <span class="na">summary</span><span class="pi">:</span> <span class="s2">"</span><span class="s">[PAYMENT]</span><span class="nv"> </span><span class="s">결제</span><span class="nv"> </span><span class="s">응답</span><span class="nv"> </span><span class="s">P95</span><span class="nv"> </span><span class="s">2초</span><span class="nv"> </span><span class="s">초과"</span>
</code></pre></div></div>

<p>이 구조에서 중요한 건 Slack 메시지 자체가 아니다. 앱은 비즈니스 이벤트와 지표를 남기고, 알림 시스템은 그 지표를 기준으로 담당자와 환경을 좁힌다. 그래야 결제, 주문, 커뮤니티, 인증 같은 도메인이 섞여 있어도 “어느 팀원이 봐야 하는 문제인지”를 알림만 보고 줄일 수 있다.</p>

<h2 id="협업-글에서-빼야-하는-말">협업 글에서 빼야 하는 말</h2>

<p>이번 글을 다시 쓰며 의도적으로 뺀 표현이 있다.</p>

<ul>
  <li>Jira를 정리했으니 협업이 좋아졌다는 식의 문장</li>
  <li>Swagger를 붙였으니 API 협업이 끝났다는 식의 문장</li>
  <li>Sentry를 붙였으니 운영 품질이 완성됐다는 식의 문장</li>
</ul>

<p>도구 이름만 나열하면 협업이 아니라 도구 사용기가 된다. 내가 남기고 싶은 것은 도구 자체가 아니라 기준이다.</p>

<ul>
  <li>API 변경은 PR에서 요청, 응답, 예외, 프론트 주의사항으로 드러나야 한다.</li>
  <li>Swagger는 프론트가 확인하는 계약 표면이므로 500으로 죽으면 연동이 막힌 것으로 본다.</li>
  <li>DB 마이그레이션은 배포 가능성을 바꾸므로 CI에서 validate 부팅까지 확인한다.</li>
  <li>500 오류는 담당 도메인을 좁힐 수 있는 정보와 함께 공유한다.</li>
  <li>SLO는 사용자 기대, 실패 조건, 지표, 알림 조건으로 이어져야 한다.</li>
  <li>Slack 알림은 Spring Boot가 직접 보내지 않고 Prometheus와 Alertmanager 파이프라인에서 라우팅한다.</li>
  <li>수정사항 문서는 불만 목록이 아니라, 화면과 API 계약이 어긋난 사례 저장소로 본다.</li>
</ul>

<h2 id="남은-한계">남은 한계</h2>

<p>아직 부족한 점도 분명하다. Swagger, Notion, Postman을 함께 쓰면 최신 상태를 여러 곳에 반영해야 한다. PR 템플릿은 누락을 줄일 수 있지만 자동 검증은 아니다. API 계약 테스트나 OpenAPI diff, 문서 변경 이력 관리까지 붙이면 더 안정적일 것이다.</p>

<p>Flyway도 마찬가지다. 운영 <code class="language-plaintext highlighter-rouge">out-of-order=true</code>는 당시 장애를 막기 위한 선택이었지만, 마이그레이션 작성 규칙이 정리되지 않으면 비슷한 문제가 반복될 수 있다. 장기적으로는 버전 충돌이 생기지 않는 규칙과 리뷰 기준이 필요하다.</p>

<p>협업은 “좋은 분위기”가 아니라 같은 변경을 같은 기준으로 보는 구조라고 느꼈다. 내가 이 프로젝트에서 한 일은 그 구조를 완성한 것이 아니라, API 계약, 스키마 검증, 오류 알림처럼 깨졌을 때 팀 전체를 멈추게 하는 지점을 코드와 문서 흐름 안에 드러내려 한 것이다.</p>]]></content><author><name>박종준</name></author><category term="협업" /><category term="운영" /><category term="API계약" /><category term="SLO" /><category term="Flyway" /><category term="Alertmanager" /><category term="Sentry" /><summary type="html"><![CDATA[협업은 회의를 많이 하거나 도구를 많이 쓰는 것으로 좋아지지 않았다. Flown LMS를 만들며 실제로 막혔던 지점은 더 단순했다. 프론트엔드는 어떤 요청과 응답을 믿고 개발해야 하는지, 백엔드는 어떤 DB 변경이 배포를 막을 수 있는지, 장애가 났을 때 누가 어떤 정보를 보고 움직여야 하는지가 불명확했다.]]></summary></entry><entry><title type="html">게시글 목록 병목 줄이기: 인덱스, N+1 제거, COUNT 줄이기</title><link href="https://jongjunn.github.io/%EB%B0%B1%EC%97%94%EB%93%9C/%EC%84%B1%EB%8A%A5/2026/08/01/community-performance.html" rel="alternate" type="text/html" title="게시글 목록 병목 줄이기: 인덱스, N+1 제거, COUNT 줄이기" /><published>2026-08-01T10:00:00+00:00</published><updated>2026-08-01T10:00:00+00:00</updated><id>https://jongjunn.github.io/%EB%B0%B1%EC%97%94%EB%93%9C/%EC%84%B1%EB%8A%A5/2026/08/01/community-performance</id><content type="html" xml:base="https://jongjunn.github.io/%EB%B0%B1%EC%97%94%EB%93%9C/%EC%84%B1%EB%8A%A5/2026/08/01/community-performance.html"><![CDATA[<p>커뮤니티 게시글 목록은 사용자가 게시판에 들어오면 가장 먼저 보는 화면이다. 단순 조회처럼 보이지만, 트래픽이 몰리면 서버에서 먼저 티가 난다. Flown LMS에서도 게시글 목록 API를 부하 테스트하면서 DB 커넥션 풀이 꽉 차고, 요청이 30초까지 밀리는 상황을 재현했다.</p>

<p>원인은 하나가 아니었다. 인덱스가 빠진 상태에서 정렬이 느려졌고, 목록을 만든 뒤 작성자 이름과 댓글 수를 다시 조회하면서 N+1이 생겼고, 전체 게시글 수를 매번 세는 count query도 반복됐다.</p>

<p>이 글은 그 병목을 인덱스, 배치 조회, 댓글 수 집계, Redis 캐시로 줄인 과정이다. 숫자는 운영 전체 트래픽이 아니라, 내가 부하 테스트와 모니터링에서 확인한 시나리오 기준으로만 적는다.</p>

<p>먼저 구조를 단순화하면 아래와 같다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Before
게시글 목록 조회
-&gt; 작성자 이름 개별 조회
-&gt; 게시글별 댓글 수 COUNT
-&gt; 댓글순 정렬 시 comments JOIN + GROUP BY
-&gt; 전체 게시글 수 count query 반복

After
게시글 목록 조회
-&gt; 작성자 id batch IN 조회
-&gt; 댓글 수 batch IN 조회 또는 comment_count 사용
-&gt; 목록 조건에 맞춘 복합 인덱스
-&gt; 기본 목록 count query Redis 캐시
</code></pre></div></div>

<p>처음에는 “쿼리 수를 줄이면 된다” 정도로 보였지만, 측정해보니 병목은 단계마다 달랐다. 최신순 목록은 반복 조회와 count cache가 중요했고, 댓글순 목록은 매번 댓글 테이블을 집계해 정렬하는 비용이 더 컸다. 그래서 모든 목록을 같은 방식으로 고치지 않고, 조회 패턴별로 병목을 나눠 봤다.</p>

<h2 id="측정-순서">측정 순서</h2>

<p>부하 테스트를 돌릴 때 처음부터 코드만 보지는 않았다. 먼저 k6에서 P95와 실패율을 보고, Grafana에서 RPS, URI별 duration, DB 커넥션 풀, 캐시 hit/miss를 확인했다. 그 다음 Datadog trace로 느린 구간이 애플리케이션 로직인지 SQL인지 좁혔고, SQL로 좁혀진 경우에는 실행 계획과 인덱스를 확인했다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>k6 P95 / 실패율
-&gt; Grafana RPS / URI별 duration / DB pool / cache hit ratio
-&gt; Datadog trace
-&gt; slow query / EXPLAIN
</code></pre></div></div>

<p>이 순서를 정해두니 “서버가 느리다”는 말을 바로 “어느 쿼리가 커넥션을 오래 잡는가”, “캐시가 실제로 hit 되는가”, “쿼리 수를 줄였는데도 집계 비용이 남았는가”로 바꿀 수 있었다. 특히 캐시는 느린 쿼리를 숨길 수도 있으므로 hit ratio와 원 쿼리 비용을 같이 봐야 했다.</p>

<h2 id="먼저-본-지표">먼저 본 지표</h2>

<p>처음 재현한 상황에서는 게시글 목록 조회가 DB에서 오래 머물렀다. 인덱스가 없는 상태에서는 MySQL이 조건에 맞는 행을 찾고 정렬하기 위해 더 많은 데이터를 훑었고, 느린 쿼리가 DB 커넥션을 오래 붙잡았다. 그 결과 뒤에 들어온 요청도 커넥션을 얻지 못하고 밀렸다.</p>

<p>개선 후 같은 시나리오에서 확인한 값은 이랬다.</p>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th style="text-align: right">확인한 값</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>k6 checks</td>
      <td style="text-align: right">100%</td>
    </tr>
    <tr>
      <td>k6 P95</td>
      <td style="text-align: right">131ms</td>
    </tr>
    <tr>
      <td>Grafana AVG</td>
      <td style="text-align: right">79.7ms</td>
    </tr>
    <tr>
      <td>Grafana P95</td>
      <td style="text-align: right">128.6ms</td>
    </tr>
    <tr>
      <td>Error rate</td>
      <td style="text-align: right">0%</td>
    </tr>
    <tr>
      <td>DB pool used</td>
      <td style="text-align: right">1</td>
    </tr>
    <tr>
      <td>Datadog SQL 수</td>
      <td style="text-align: right">3 queries</td>
    </tr>
    <tr>
      <td>게시글 수 캐시</td>
      <td style="text-align: right">hit 519 / miss 2</td>
    </tr>
  </tbody>
</table>

<p>중요한 건 “몇 ms 빨라졌다”보다 병목의 위치가 바뀌었다는 점이었다. DB 커넥션을 오래 잡고 있던 요청이 줄어들자, 뒤 요청이 줄줄이 밀리는 현상도 같이 사라졌다.</p>

<h2 id="실험은-한-번에-끝나지-않았다">실험은 한 번에 끝나지 않았다</h2>

<p>처음부터 최종 구조를 바로 고른 것은 아니었다. 게시글 목록도 정렬 조건에 따라 성격이 달랐다. 최신순 목록은 count query와 반복 조회를 줄이면 충분히 빨라졌지만, 댓글순 목록은 단일 JOIN으로 줄여도 <code class="language-plaintext highlighter-rouge">COUNT + GROUP BY + ORDER BY</code> 비용이 남았다.</p>

<p>내가 따로 정리해둔 측정 캡처를 다시 보면 차이가 더 분명했다.</p>

<table>
  <thead>
    <tr>
      <th>시나리오</th>
      <th style="text-align: right">관찰한 값</th>
      <th>해석</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>최신순 목록 after</td>
      <td style="text-align: right">k6 P95 32ms / 120.1 req/s / error 0%</td>
      <td>기본 목록은 캐시와 배치 조회만으로도 충분히 안정적이었다.</td>
    </tr>
    <tr>
      <td>최신순 목록 Grafana</td>
      <td style="text-align: right">AVG 22.4ms / P95 33.5ms / cache hit 99.8%</td>
      <td>count cache가 실제로 hit 되는지 지표로 확인했다.</td>
    </tr>
    <tr>
      <td>댓글순 목록 JOIN 초기</td>
      <td style="text-align: right">P95 8.579s / 10.8 req/s</td>
      <td>쿼리 수를 줄여도 댓글수 정렬 비용이 남았다.</td>
    </tr>
    <tr>
      <td>댓글순 목록 JOIN 재측정</td>
      <td style="text-align: right">P95 7.8s / 11.4 req/s / error 0%</td>
      <td>실패는 없지만 SLO에는 못 들어왔다.</td>
    </tr>
    <tr>
      <td>댓글순 목록 Grafana</td>
      <td style="text-align: right">AVG 6.7s / P95 9.8s / DB pool 10</td>
      <td>느린 쿼리가 커넥션을 오래 붙잡았다.</td>
    </tr>
    <tr>
      <td>Datadog JOIN trace</td>
      <td style="text-align: right">요청 526ms 중 JOIN 쿼리 508ms</td>
      <td>병목이 애플리케이션보다 집계 쿼리에 가까웠다.</td>
    </tr>
  </tbody>
</table>

<p>이 결과 때문에 결론이 바뀌었다. “N+1을 없앴다”나 “JOIN으로 합쳤다”에서 끝내면 부족했다. 최신순 목록과 댓글순 목록을 같은 문제로 보면 안 됐고, 댓글순 정렬은 결국 댓글 수를 매번 계산하지 않는 구조까지 가야 했다.</p>

<h2 id="인덱스는-조회-조건과-정렬-기준에-맞춰야-한다">인덱스는 조회 조건과 정렬 기준에 맞춰야 한다</h2>

<p>게시글 목록은 보통 게시판 종류, 공개 상태, 생성일 기준으로 조회된다. 그래서 <code class="language-plaintext highlighter-rouge">posts</code> 테이블에는 목록 조회에 맞춘 복합 인덱스를 뒀다.</p>

<p>원본 코드: <a href="https://github.com/Hard-Click/Hard-Click-BackEnd/blob/ea50993a49340ee2bce8b53b439211c541c4da81/src/main/java/com/wanted/backend/domain/community/infrastructure/persistence/PostJpaEntity.java#L10-L51">PostJpaEntity.java:10-51</a></p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// PostJpaEntity.java</span>
<span class="nd">@Table</span><span class="o">(</span>
        <span class="n">name</span> <span class="o">=</span> <span class="s">"posts"</span><span class="o">,</span>
        <span class="n">indexes</span> <span class="o">=</span> <span class="o">{</span>
                <span class="nd">@Index</span><span class="o">(</span><span class="n">name</span> <span class="o">=</span> <span class="s">"idx_posts_board_status_created"</span><span class="o">,</span> <span class="n">columnList</span> <span class="o">=</span> <span class="s">"board_type, status, created_at"</span><span class="o">),</span>
                <span class="nd">@Index</span><span class="o">(</span><span class="n">name</span> <span class="o">=</span> <span class="s">"idx_posts_board_status_count"</span><span class="o">,</span> <span class="n">columnList</span> <span class="o">=</span> <span class="s">"board_type, status, comment_count"</span><span class="o">)</span>
        <span class="o">}</span>
<span class="o">)</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">PostJpaEntity</span> <span class="o">{</span>
    <span class="nd">@Column</span><span class="o">(</span><span class="n">name</span> <span class="o">=</span> <span class="s">"comment_count"</span><span class="o">,</span> <span class="n">nullable</span> <span class="o">=</span> <span class="kc">false</span><span class="o">)</span>
    <span class="kd">private</span> <span class="kt">int</span> <span class="n">commentCount</span><span class="o">;</span>
<span class="o">}</span>
</code></pre></div></div>

<p>여기서 인덱스를 단순히 “많이 달았다”가 아니라, 실제 조회 패턴에 맞췄다. 최신순 목록은 <code class="language-plaintext highlighter-rouge">board_type</code>, <code class="language-plaintext highlighter-rouge">status</code>, <code class="language-plaintext highlighter-rouge">created_at</code> 조합을 타고, 댓글순 목록은 <code class="language-plaintext highlighter-rouge">board_type</code>, <code class="language-plaintext highlighter-rouge">status</code>, <code class="language-plaintext highlighter-rouge">comment_count</code> 조합을 타게 했다.</p>

<p>인덱스가 빠진 상태에서는 DB가 필요한 행을 빨리 좁히지 못하고 정렬 비용까지 떠안았다. 조회 API의 병목이 애플리케이션 로직처럼 보여도, 첫 번째 확인 지점은 실행 계획과 인덱스였다.</p>

<h2 id="목록-조회에서-n1을-없앴다">목록 조회에서 N+1을 없앴다</h2>

<p>다음 문제는 목록을 가져온 뒤의 추가 조회였다. 게시글 20개를 조회한 다음 작성자 이름, 댓글 수를 각각 다시 가져오면 요청 하나가 여러 쿼리로 쪼개진다. 데이터가 적을 때는 티가 안 나지만, 부하 테스트에서는 이 차이가 바로 커넥션 사용량으로 드러난다.</p>

<p>그래서 게시글 목록을 먼저 가져오고, 필요한 작성자 id와 게시글 id를 모아 한 번씩 배치 조회했다.</p>

<p>원본 코드: <a href="https://github.com/Hard-Click/Hard-Click-BackEnd/blob/ea50993a49340ee2bce8b53b439211c541c4da81/src/main/java/com/wanted/backend/domain/community/application/service/PostQueryService.java#L109-L124">PostQueryService.java:109-124</a></p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// PostQueryService.java</span>
<span class="kd">private</span> <span class="nc">List</span><span class="o">&lt;</span><span class="nc">PostItemResult</span><span class="o">&gt;</span> <span class="nf">getListByBatchIn</span><span class="o">(</span><span class="nc">BoardType</span> <span class="n">boardType</span><span class="o">,</span> <span class="nc">PostSortType</span> <span class="n">sort</span><span class="o">,</span>
                                               <span class="nc">String</span> <span class="n">keyword</span><span class="o">,</span> <span class="kt">int</span> <span class="n">page</span><span class="o">,</span> <span class="kt">int</span> <span class="n">size</span><span class="o">,</span> <span class="kt">boolean</span> <span class="n">isAdmin</span><span class="o">)</span> <span class="o">{</span>
    <span class="nc">List</span><span class="o">&lt;</span><span class="nc">Post</span><span class="o">&gt;</span> <span class="n">posts</span> <span class="o">=</span> <span class="n">boardType</span> <span class="o">!=</span> <span class="kc">null</span>
            <span class="o">?</span> <span class="n">postRepository</span><span class="o">.</span><span class="na">findByBoardType</span><span class="o">(</span><span class="n">boardType</span><span class="o">,</span> <span class="n">sort</span><span class="o">,</span> <span class="n">keyword</span><span class="o">,</span> <span class="n">page</span><span class="o">,</span> <span class="n">size</span><span class="o">)</span>
            <span class="o">:</span> <span class="n">postRepository</span><span class="o">.</span><span class="na">findAll</span><span class="o">(</span><span class="n">sort</span><span class="o">,</span> <span class="n">keyword</span><span class="o">,</span> <span class="n">page</span><span class="o">,</span> <span class="n">size</span><span class="o">);</span>

    <span class="nc">Set</span><span class="o">&lt;</span><span class="nc">Long</span><span class="o">&gt;</span> <span class="n">authorIds</span> <span class="o">=</span> <span class="n">posts</span><span class="o">.</span><span class="na">stream</span><span class="o">().</span><span class="na">map</span><span class="o">(</span><span class="nl">Post:</span><span class="o">:</span><span class="n">getAuthorId</span><span class="o">).</span><span class="na">collect</span><span class="o">(</span><span class="nc">Collectors</span><span class="o">.</span><span class="na">toSet</span><span class="o">());</span>
    <span class="nc">Map</span><span class="o">&lt;</span><span class="nc">Long</span><span class="o">,</span> <span class="nc">String</span><span class="o">&gt;</span> <span class="n">nameMap</span> <span class="o">=</span> <span class="n">memberNamePort</span><span class="o">.</span><span class="na">getNamesByMemberIds</span><span class="o">(</span><span class="n">authorIds</span><span class="o">);</span>

    <span class="nc">List</span><span class="o">&lt;</span><span class="nc">Long</span><span class="o">&gt;</span> <span class="n">postIds</span> <span class="o">=</span> <span class="n">posts</span><span class="o">.</span><span class="na">stream</span><span class="o">().</span><span class="na">map</span><span class="o">(</span><span class="nl">Post:</span><span class="o">:</span><span class="n">getId</span><span class="o">).</span><span class="na">toList</span><span class="o">();</span>
    <span class="nc">Map</span><span class="o">&lt;</span><span class="nc">Long</span><span class="o">,</span> <span class="nc">Long</span><span class="o">&gt;</span> <span class="n">commentCountMap</span> <span class="o">=</span> <span class="n">commentRepository</span><span class="o">.</span><span class="na">countsByPostIds</span><span class="o">(</span><span class="n">postIds</span><span class="o">);</span>

    <span class="k">return</span> <span class="n">posts</span><span class="o">.</span><span class="na">stream</span><span class="o">()</span>
            <span class="o">.</span><span class="na">map</span><span class="o">(</span><span class="n">post</span> <span class="o">-&gt;</span> <span class="n">toItemResult</span><span class="o">(</span><span class="n">post</span><span class="o">,</span> <span class="n">isAdmin</span><span class="o">,</span> <span class="n">nameMap</span><span class="o">,</span> <span class="n">commentCountMap</span><span class="o">))</span>
            <span class="o">.</span><span class="na">toList</span><span class="o">();</span>
<span class="o">}</span>
</code></pre></div></div>

<p>댓글 수는 게시글 id 목록을 <code class="language-plaintext highlighter-rouge">IN</code> 조건으로 넘겨 한 번에 묶었다.</p>

<p>원본 코드: <a href="https://github.com/Hard-Click/Hard-Click-BackEnd/blob/ea50993a49340ee2bce8b53b439211c541c4da81/src/main/java/com/wanted/backend/domain/community/infrastructure/persistence/SpringDataCommentRepository.java#L29-L31">SpringDataCommentRepository.java:29-31</a></p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// SpringDataCommentRepository.java</span>
<span class="nd">@Query</span><span class="o">(</span><span class="s">"SELECT c.postId AS postId, COUNT(c) AS cnt FROM CommentJpaEntity c WHERE c.postId IN :postIds GROUP BY c.postId"</span><span class="o">)</span>
<span class="nc">List</span><span class="o">&lt;</span><span class="nc">CommentCountRow</span><span class="o">&gt;</span> <span class="nf">countsByPostIds</span><span class="o">(</span><span class="nd">@Param</span><span class="o">(</span><span class="s">"postIds"</span><span class="o">)</span> <span class="nc">Collection</span><span class="o">&lt;</span><span class="nc">Long</span><span class="o">&gt;</span> <span class="n">postIds</span><span class="o">);</span>
</code></pre></div></div>

<p>이렇게 바꾸면 게시글 개수가 늘어나도 작성자 조회와 댓글 수 조회가 게시글 수만큼 늘지 않는다. 목록 API에서 가장 먼저 잡아야 할 것은 “한 화면을 만들기 위해 쿼리가 몇 번 나가는가”였다.</p>

<p>비슷한 문제가 댓글 상세 조회에서도 있었다. 댓글이 많은 게시글 하나를 조회할 때 작성자명과 대댓글을 개별로 다시 가져오면, 목록보다 더 눈에 띄게 느려졌다. 별도 k6 측정에서는 댓글 상세 조회가 before 기준 P50 8.0s, P95 10.1s였고, 작성자명과 대댓글을 <code class="language-plaintext highlighter-rouge">IN</code> 조회로 묶은 뒤에는 P50 0.2s, P95 0.4s까지 내려왔다.</p>

<p>여기서 얻은 기준은 단순했다. 화면 하나를 만들 때 반복되는 조회가 보이면 먼저 id를 모으고, DB에는 묶어서 물어봐야 한다.</p>

<h2 id="댓글순-정렬은-매번-count하지-않게-했다">댓글순 정렬은 매번 COUNT하지 않게 했다</h2>

<p>댓글순 목록은 더 까다로웠다. 단순히 <code class="language-plaintext highlighter-rouge">comments</code>를 <code class="language-plaintext highlighter-rouge">LEFT JOIN</code>하고 <code class="language-plaintext highlighter-rouge">COUNT(c.id)</code>로 정렬하면 정확하긴 하지만, 게시글과 댓글이 늘어날수록 정렬 비용이 커진다.</p>

<p>처음에는 JOIN + DTO projection 방식으로 필요한 필드만 조회했다. 이 방식은 쿼리 수를 줄이는 데는 효과가 있었다. 하지만 부하 테스트에서는 댓글순 정렬이 여전히 P95 7~9초대로 남았다. Datadog에서도 요청 526ms 중 JOIN 쿼리만 508ms를 차지하는 trace가 보였다. 즉 병목은 “쿼리 개수”에서 “집계와 정렬 비용”으로 옮겨간 상태였다.</p>

<p>원본 코드: <a href="https://github.com/Hard-Click/Hard-Click-BackEnd/blob/ea50993a49340ee2bce8b53b439211c541c4da81/src/main/java/com/wanted/backend/domain/community/infrastructure/persistence/PostRepositoryAdapter.java#L110-L130">PostRepositoryAdapter.java:110-130</a></p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// PostRepositoryAdapter.java</span>
<span class="k">return</span> <span class="n">em</span><span class="o">.</span><span class="na">createQuery</span><span class="o">(</span><span class="s">"""
        SELECT new com.wanted.backend.domain.community.domain.model.PostSummary(
            p.id, p.boardType, p.subject, p.title, m.name, p.createdAt, p.viewCount, COUNT(c.id), p.isAccepted
        )
        FROM PostJpaEntity p
        JOIN MemberReferenceEntity m ON m.id = p.authorId
        LEFT JOIN CommentJpaEntity c ON c.postId = p.id
        WHERE p.boardType = :boardType AND p.title LIKE :keyword AND p.status = :status
        GROUP BY p.id, p.boardType, p.subject, p.title, m.name, p.createdAt, p.viewCount, p.isAccepted
        ORDER BY COUNT(c.id) DESC
        """</span><span class="o">,</span> <span class="nc">PostSummary</span><span class="o">.</span><span class="na">class</span><span class="o">)</span>
        <span class="o">.</span><span class="na">setFirstResult</span><span class="o">(</span><span class="n">page</span> <span class="o">*</span> <span class="n">size</span><span class="o">)</span>
        <span class="o">.</span><span class="na">setMaxResults</span><span class="o">(</span><span class="n">size</span><span class="o">)</span>
        <span class="o">.</span><span class="na">getResultList</span><span class="o">();</span>
</code></pre></div></div>

<p>이후에는 댓글 수를 게시글에 <code class="language-plaintext highlighter-rouge">comment_count</code>로 들고 가는 방식까지 적용했다. 댓글 생성/삭제 시 게시글의 댓글 수를 같이 갱신하고, 목록에서는 그 값을 기준으로 정렬한다.</p>

<p>원본 코드: <a href="https://github.com/Hard-Click/Hard-Click-BackEnd/blob/ea50993a49340ee2bce8b53b439211c541c4da81/src/main/java/com/wanted/backend/domain/community/infrastructure/persistence/SpringDataPostRepository.java#L42-L49">SpringDataPostRepository.java:42-49</a></p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// SpringDataPostRepository.java</span>
<span class="nd">@Modifying</span>
<span class="nd">@Query</span><span class="o">(</span><span class="s">"UPDATE PostJpaEntity p SET p.commentCount = p.commentCount + 1 WHERE p.id = :postId"</span><span class="o">)</span>
<span class="kt">void</span> <span class="nf">incrementCommentCount</span><span class="o">(</span><span class="nd">@Param</span><span class="o">(</span><span class="s">"postId"</span><span class="o">)</span> <span class="nc">Long</span> <span class="n">postId</span><span class="o">);</span>

<span class="nd">@Modifying</span>
<span class="nd">@Query</span><span class="o">(</span><span class="s">"UPDATE PostJpaEntity p SET p.commentCount = p.commentCount - 1 WHERE p.id = :postId AND p.commentCount &gt; 0"</span><span class="o">)</span>
<span class="kt">void</span> <span class="nf">decrementCommentCount</span><span class="o">(</span><span class="nd">@Param</span><span class="o">(</span><span class="s">"postId"</span><span class="o">)</span> <span class="nc">Long</span> <span class="n">postId</span><span class="o">);</span>
</code></pre></div></div>

<p>댓글 수를 비정규화하면 읽기는 가벼워지지만, 쓰기 쪽 책임이 생긴다. 댓글 생성과 삭제가 실패했을 때 <code class="language-plaintext highlighter-rouge">comment_count</code>가 어긋나지 않게 같은 트랜잭션 안에서 다루어야 한다. 그래서 이 방식은 “무조건 좋다”가 아니라, 읽기 빈도가 높고 정렬 비용이 큰 목록에서 선택한 절충안에 가깝다.</p>

<h2 id="전체-count-query는-캐시했다">전체 count query는 캐시했다</h2>

<p>마지막으로 반복 호출되는 전체 게시글 수 조회를 Redis 캐시에 올렸다. 여기서는 검색어가 없는 기본 목록만 캐시하고, 검색어가 있는 경우는 캐시하지 않았다. 키 종류가 너무 늘어나면 캐시 관리 비용이 더 커질 수 있기 때문이다.</p>

<p>원본 코드: <a href="https://github.com/Hard-Click/Hard-Click-BackEnd/blob/ea50993a49340ee2bce8b53b439211c541c4da81/src/main/java/com/wanted/backend/domain/community/infrastructure/cache/PostCountCache.java#L11-L58">PostCountCache.java:11-58</a></p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// PostCountCache.java</span>
<span class="kd">public</span> <span class="kt">int</span> <span class="nf">count</span><span class="o">(</span><span class="nc">BoardType</span> <span class="n">boardType</span><span class="o">,</span> <span class="nc">String</span> <span class="n">keyword</span><span class="o">)</span> <span class="o">{</span>
    <span class="kt">boolean</span> <span class="n">cacheable</span> <span class="o">=</span> <span class="n">keyword</span> <span class="o">==</span> <span class="kc">null</span> <span class="o">||</span> <span class="n">keyword</span><span class="o">.</span><span class="na">isBlank</span><span class="o">();</span>
    <span class="k">if</span> <span class="o">(!</span><span class="n">cacheable</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">return</span> <span class="nf">load</span><span class="o">(</span><span class="n">boardType</span><span class="o">,</span> <span class="n">keyword</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="nc">Cache</span> <span class="n">cache</span> <span class="o">=</span> <span class="n">cacheManager</span><span class="o">.</span><span class="na">getCache</span><span class="o">(</span><span class="no">CACHE_NAME</span><span class="o">);</span>
    <span class="nc">String</span> <span class="n">key</span> <span class="o">=</span> <span class="n">boardType</span> <span class="o">!=</span> <span class="kc">null</span> <span class="o">?</span> <span class="n">boardType</span><span class="o">.</span><span class="na">name</span><span class="o">()</span> <span class="o">:</span> <span class="s">"ALL"</span><span class="o">;</span>

    <span class="nc">Integer</span> <span class="n">cached</span> <span class="o">=</span> <span class="n">cache</span><span class="o">.</span><span class="na">get</span><span class="o">(</span><span class="n">key</span><span class="o">,</span> <span class="nc">Integer</span><span class="o">.</span><span class="na">class</span><span class="o">);</span>
    <span class="k">if</span> <span class="o">(</span><span class="n">cached</span> <span class="o">!=</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span>
        <span class="n">cacheHits</span><span class="o">.</span><span class="na">increment</span><span class="o">();</span>
        <span class="k">return</span> <span class="n">cached</span><span class="o">;</span>
    <span class="o">}</span>

    <span class="n">cacheMisses</span><span class="o">.</span><span class="na">increment</span><span class="o">();</span>
    <span class="kt">int</span> <span class="n">value</span> <span class="o">=</span> <span class="n">load</span><span class="o">(</span><span class="n">boardType</span><span class="o">,</span> <span class="n">keyword</span><span class="o">);</span>
    <span class="n">cache</span><span class="o">.</span><span class="na">put</span><span class="o">(</span><span class="n">key</span><span class="o">,</span> <span class="n">value</span><span class="o">);</span>
    <span class="k">return</span> <span class="n">value</span><span class="o">;</span>
<span class="o">}</span>
</code></pre></div></div>

<p>캐시는 붙이는 것보다 확인이 더 중요했다. 그래서 hit/miss를 지표로 남겼고, 테스트 중 <code class="language-plaintext highlighter-rouge">post_count_cache hit 519, miss 2</code>까지 확인했다. 캐시가 실제로 읽히는지 보지 않으면, 코드는 바뀌었는데 성능은 그대로인 상태를 놓칠 수 있다.</p>

<h2 id="남은-기준">남은 기준</h2>

<p>이 개선에서 제일 크게 배운 것은 성능 문제를 감으로 고치면 안 된다는 점이다. 처음에는 “JPA가 느린가”, “서버가 부족한가”처럼 보였지만, 지표를 보면 병목은 더 구체적이었다.</p>

<ul>
  <li>인덱스가 없어서 목록 조회가 오래 걸렸다.</li>
  <li>목록 하나를 만들기 위해 추가 쿼리가 반복됐다.</li>
  <li>댓글순 정렬에서 매번 댓글 테이블을 집계했다.</li>
  <li>전체 count query가 같은 조건으로 계속 호출됐다.</li>
</ul>

<p>그래서 개선도 각각의 병목에 맞게 나누었다. 인덱스로 조회 범위를 줄이고, 배치 조회로 N+1을 없애고, 댓글 수를 비정규화해 정렬 비용을 줄이고, 반복 count query는 캐시했다.</p>

<p>아직 남은 한계도 있다. 검색어별 count는 캐시하지 않았고, <code class="language-plaintext highlighter-rouge">comment_count</code>는 댓글 생성/삭제 흐름이 어긋나면 값이 틀어질 수 있다. 성능 수치도 내가 재현한 부하 테스트 기준이므로 모든 운영 상황을 대표하지 않는다.</p>

<p>다만 이 작업 이후에는 게시글 목록 성능을 설명할 때 “빨라졌다”가 아니라, 어떤 쿼리가 줄었고 어떤 지표가 바뀌었는지 말할 수 있게 됐다. 취업 준비용 글로도 이 차이가 중요하다고 생각한다.</p>]]></content><author><name>박종준</name></author><category term="백엔드" /><category term="성능" /><category term="N+1" /><category term="인덱스" /><category term="Redis" /><category term="k6" /><category term="Datadog" /><summary type="html"><![CDATA[커뮤니티 게시글 목록은 사용자가 게시판에 들어오면 가장 먼저 보는 화면이다. 단순 조회처럼 보이지만, 트래픽이 몰리면 서버에서 먼저 티가 난다. Flown LMS에서도 게시글 목록 API를 부하 테스트하면서 DB 커넥션 풀이 꽉 차고, 요청이 30초까지 밀리는 상황을 재현했다.]]></summary></entry></feed>