<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="ko"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://ukukdin.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://ukukdin.github.io/" rel="alternate" type="text/html" hreflang="ko" /><updated>2026-04-21T09:10:08+00:00</updated><id>https://ukukdin.github.io/feed.xml</id><title type="html">Ukukdin’s Tech Blog</title><subtitle>LangChain, CS Study, Spring Boot, Infra 기술 블로그</subtitle><author><name>Ukukdin</name></author><entry><title type="html">루퍼스 부트캠프 3기 후기 — 4년차 백엔드 개발자의 루프팩 10주 회고</title><link href="https://ukukdin.github.io/2026/04/21/loopers-3rd-review/" rel="alternate" type="text/html" title="루퍼스 부트캠프 3기 후기 — 4년차 백엔드 개발자의 루프팩 10주 회고" /><published>2026-04-21T00:00:00+00:00</published><updated>2026-04-21T00:00:00+00:00</updated><id>https://ukukdin.github.io/2026/04/21/loopers-3rd-review</id><content type="html" xml:base="https://ukukdin.github.io/2026/04/21/loopers-3rd-review/"><![CDATA[<p>루퍼스 부트캠프 <strong>루프팩(LOOPPAK) 백엔드 3기</strong>가 1월 31일부터 10주간 달리고 끝난 지 일주일이 됐다.</p>

<p>지인 추천으로 들어갔다. 패X 재직자 부트캠프도 들어봤던 4년차 개발자가 왜 또 부트캠프냐는 질문을 몇 번 받았는데, 다 듣고 난 지금은 짧게 답할 수 있다. 1도 후회하지 않는다.</p>

<p>리뷰를 쓰려니 한 가지가 걸렸다. 루퍼스 후기를 찾아본 사람이라면 알겠지만 칭찬이 많다. 나도 처음엔 좀 의심했다. 그래서 이 글은 감상보다 기록에 가깝게, 주차별로 내 코드와 생각이 어떻게 바뀌었는지 남기려고 한다.</p>

<hr />

<h2 id="왜-루퍼스였고-뭐가-얼마나-달랐나">왜 루퍼스였고, 뭐가 얼마나 달랐나</h2>

<p>“다른 부트캠프랑 뭐가 다르냐”를 가장 많이 들었다. 10주 내내 느낀 걸 네 가지로 추리면 이렇다.</p>

<p><strong>첫째, 과제 스케일이 실무급이다.</strong> 문법 예제가 아니라 10만 건 상품 조회, 10만 명 동시 주문, 일간·주간·월간으로 쪼개진 랭킹을 실제로 만든다. “예제라서 10만이지 진짜는 달라요”가 아니라, 예제 자체를 현실 규모로 깐다. 회사로 돌아가서 비슷한 문제를 만났을 때 “그거 해봤다”가 된다.</p>

<p><strong>둘째, “정상 동작”이 아니라 “장애에서도 돈이 맞는” 코드를 목표로 한다.</strong> PG가 터질 때, Redis가 흔들릴 때, 동시성 경합이 터질 때 — 커리큘럼이 이 지점을 피하지 않는다. Circuit Breaker를 교재로 읽는 것과, 실제로 장애를 복구해보며 쓴 Circuit Breaker는 다르다.</p>

<p><strong>셋째, 답이 아니라 질문이 돌아오는 멘토링이다.</strong> 내 코드를 들고 가면 <em>“이 선택의 반대편엔 뭘 포기한 거죠?”</em> 같은 질문이 돌아온다. 처음엔 답답하다. 그런데 이 형식에 익숙해지면 코드 짜기 전에 스스로 그 질문을 하게 된다. 회사로 돌아가서 이게 가장 오래 간다.</p>

<p><strong>넷째, 10주 뒤에 “글”이 남는다.</strong> 주차마다 정리글을 쓰게 되는 구조라, 10주 후엔 10개의 케이스 스터디가 손에 남는다. 이력서에 “Redis ZSET + Spring Batch 랭킹 시스템 설계” 한 줄이 아니라, 왜 그렇게 설계했는지 직접 쓴 글이 같이 있다.</p>

<p>3기 동기의 약 96%가 끝까지 남았다. 중간에 그만두기 어려운 구조라서가 아니다. 뒷 주차가 앞 주차 위에 쌓여서, 7주차에 배운 이벤트 아키텍처가 8~10주차의 뼈대가 된다. 한 주를 버리면 다음 주가 없다는 감각이 자연스럽게 생긴다.</p>

<hr />

<h2 id="수강-전과-수강-후-내-머릿속이-바뀐-자리들">수강 전과 수강 후, 내 머릿속이 바뀐 자리들</h2>

<h3 id="트랜잭션-경계-묶는-게-안전에서-나누는-게-안전으로">트랜잭션 경계: “묶는 게 안전”에서 “나누는 게 안전”으로</h3>

<p>수강 전의 나는 주문 취소 로직을 한 트랜잭션에 다 묶어 놓고 있었다. 재고 복구, 쿠폰 복원, PG 환불까지. 그래야 안전하다고 생각했다.</p>

<p>문제는 PG가 5초 타임아웃을 내는 순간이었다. 재고와 쿠폰 복구까지 같이 롤백된다. 외부 시스템 하나가 내 비즈니스 로직 전체를 인질로 잡는 구조였다.</p>

<p>수강 후에는 코드를 짜기 전에 먼저 나눈다. 실패해도 되는 것과, 반드시 같이 성공해야 하는 것을.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@EventListener</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">handle</span><span class="o">(</span><span class="nc">OrderCancelledEvent</span> <span class="n">event</span><span class="o">)</span> <span class="o">{</span>
    <span class="n">restoreStock</span><span class="o">(</span><span class="n">event</span><span class="o">);</span>
    <span class="n">restoreCoupon</span><span class="o">(</span><span class="n">event</span><span class="o">);</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="no">AFTER_COMMIT</span><span class="o">)</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">handle</span><span class="o">(</span><span class="nc">OrderCancelledEvent</span> <span class="n">event</span><span class="o">)</span> <span class="o">{</span>
    <span class="n">refundPaymentUseCase</span><span class="o">.</span><span class="na">refundPayment</span><span class="o">(</span><span class="n">event</span><span class="o">.</span><span class="na">orderId</span><span class="o">());</span>
<span class="o">}</span>
</code></pre></div></div>

<p>이렇게 나누고 나니 PG가 10초간 먹통이어도 주문 취소는 정상적으로 끝난다. 재고와 쿠폰은 정확히 돌아오고, 환불은 나중에 재시도하면 된다. 자세한 과정은 <a href="/2026/03/27/7weeksBlog/">7주차 글</a>에 적어뒀다.</p>

<h3 id="성능-개선에도-순서가-있다">성능 개선에도 순서가 있다</h3>

<p>조회가 느리다는 얘기를 들으면 나는 늘 캐시부터 떠올렸다. 그게 제일 빠른 카드인 줄 알았다.</p>

<p>5주차 과제에서 10만 건 상품 데이터를 실제로 만져보며 생각이 바뀌었다. 길부터 닦고, 짐을 줄이고, 그 다음에 지름길을 뚫는다. 먼저 인덱스, 쿼리 플랜부터 본다. 그 다음이 비정규화, 정말 매 요청마다 조인이 필요한지 다시 본다. 캐시는 마지막이다.</p>

<p>지금은 캐시를 꺼내기 전에 먼저 의심부터 한다. 자세한 수치는 <a href="/2026/03/13/5weeksBlog/">5주차 글</a>에 있다.</p>

<h3 id="실시간으로-할-수-있다와-실시간으로-해야-한다는-다르다">“실시간으로 할 수 있다”와 “실시간으로 해야 한다”는 다르다</h3>

<p>9주차에 Redis ZSET으로 일간 랭킹을 만들었다. 10주차에 주간, 월간 랭킹이 과제로 나왔을 때 처음 든 생각은 단순했다. <code class="language-plaintext highlighter-rouge">ZUNIONSTORE</code>로 7일치, 30일치 합치면 되는 거 아닌가.</p>

<p>하지만 “이번 주 인기 상품”에 3초 전 좋아요가 반영되지 않아도 아무도 모른다. 경영진 리포트에 초 단위 실시간성이 필요한 경우는 드물다. 일간은 Redis 실시간으로, 주간·월간은 Spring Batch와 Materialized View로 분리하면서 배운 가장 큰 감각이 이거였다.</p>

<p>이 주차가 수강 전과 수강 후를 가장 크게 가른 지점이라, 다음 섹션에서 따로 적는다.</p>

<hr />

<h2 id="가장-인상-깊었던-프로젝트--랭킹-시스템-설계-910주차">가장 인상 깊었던 프로젝트 — 랭킹 시스템 설계 (9~10주차)</h2>

<p>여러 주차가 좋았지만 9~10주차의 랭킹 시스템 설계를 꼽고 싶다. 이유는 세 가지다.</p>

<p>첫째, 과제가 “기능 구현”이 아니라 “설계 판단”이었다. 랭킹의 본질은 정렬이 아니라 “어떤 시간 범위의 이벤트를 어떤 가중치로 합산할 것인가”라는 정책이었다. 같은 도메인인데 일간과 주간·월간의 요구사항이 다르다는 걸, 실제로 구현하면서 체감했다.</p>

<p>둘째, 한 번 잘못된 인터페이스를 잡았다가 되돌리는 경험을 했다. 처음엔 “랭킹”이라는 이름 하나로 모든 걸 풀려고 했는데, 실시간 로직과 배치 로직이 같이 들어가면서 인터페이스가 뚱뚱해졌다. ISP(Interface Segregation Principle)를 교과서가 아니라 내 손으로 확인한 순간이었다.</p>

<p>셋째, 우아한형제들 기술블로그의 한 문장이 과제 한복판에서 떠올랐다. <em>“실시간으로 처리할 수 있다고 해서 실시간으로 처리해야 하는 것은 아니다.”</em> 이 문장을 이해하는 것과, 이 문장대로 코드를 짜고 시스템을 분리하는 건 완전히 다른 일이었다. 루퍼스는 이 간극을 직접 좁혀보게 해줬다.</p>

<p>4기를 고민 중이시라면, 이런 “기술을 쓸지 말지 판단하는 훈련”을 한다는 점에서 추천하고 싶다. 전체 과정은 <a href="/2026/04/10/9weeksBlog/">9주차</a>와 <a href="/2026/04/17/10weeksBlog/">10주차</a> 글에 있다.</p>

<hr />

<h2 id="강의와-멘토링에서-좋았던-점">강의와 멘토링에서 좋았던 점</h2>

<p>솔직히 커리큘럼 그 자체보다 멘토링 시간이 더 컸다.</p>

<p>멘토님들이 여러 관점에서 적극적으로 질문을 던져주시는데, 그때마다 놀랐다. 내가 이만큼 생각할 때 누군가는 완전히 다른 시야에서 이런 질문을 할 수 있구나. 답을 얻은 것보다 질문을 받아본 경험 자체가 더 오래 남는다.</p>

<p>그러고 나니 코드를 짤 때 두 가지가 자동으로 돌아가기 시작했다. 하나는 “이 선택의 반대편엔 뭘 포기한 거지”라는 trade-off. 다른 하나는 “이 비즈니스가 뭘 원하는지”부터 다시 보는 도메인 감각. 위에 적은 Before/After 세 가지도 실은 이 두 습관의 결과물이다.</p>

<p>강의는 강의대로 좋았다. 6주차 PG 장애 복구, 8주차 선착순 주문 10만 명 — 이런 주제를 교재 수준이 아니라 진짜 장애 상황에서 돈이 맞는 코드를 목표로 다룬다. <a href="/2026/03/20/6weeksBlog/">6주차</a>와 <a href="/2026/04/02/8weeksBlog/">8주차</a> 글에 기록해뒀다.</p>

<hr />

<h2 id="그래도-단점은-있다">그래도 단점은 있다</h2>

<p>후기 글에 단점이 없으면 광고가 되어버리니까, 몇 가지 적어둔다.</p>

<p>회사가 바쁠 때는 잠을 줄여야 한다. 업무 바쁜 주와 과제 마감이 겹치면 수면 시간이 연료가 된다. 재직자로 들어오시는 분은 이 각오가 필요하다.</p>

<p>멘토링 시간이 겹치면 다른 멘토님 세션을 놓친다. 멘토마다 보는 시야가 다른데, 내 시간대와 겹친 분의 관점은 그대로 못 챙기게 된다. 이 부분이 개인적으로 제일 아쉬웠다.</p>

<p>그래도 남는다. 위에 적은 Before/After 세 가지와 질문하는 습관이 내 것으로 남았으니까.</p>

<hr />

<h2 id="마지막으로">마지막으로</h2>

<p>AI로 코드를 생성하는 시대라고 개발자 사이의 간격이 좁혀지진 않는다. AI는 답은 잘 하지만 질문은 잘 하지 못한다. 어느 트랜잭션 경계에서 끊을지, 언제 실시간을 포기할지, 어느 순서로 성능을 풀지 — 결국 질문은 사람의 몫이다.</p>

<p>루퍼스 10주는 그 질문을 조금 더 정확히 던질 수 있게 된 시간이었다. 시작할 때 있던 동기 중 약 96%가 끝까지 남았다. 그 이유가 이 글 어딘가에 있었으면 좋겠다.</p>

<p>3기 여러분 고생하셨고, 4기를 고민 중이신 분들께 이 글이 참고가 되었으면 한다.</p>

<p>— 던킨</p>

<hr />

<p>#루퍼스부트캠프 #루퍼스 #루프팩 #백엔드 #LOOPPAK #루프팩백엔드3기</p>]]></content><author><name>Ukukdin</name></author><category term="Loopers" /><category term="회고" /><category term="루퍼스" /><category term="루퍼스부트캠프" /><category term="루프팩" /><category term="루프팩백엔드3기" /><category term="백엔드" /><category term="LOOPPAK" /><category term="Loopers" /><category term="부트캠프후기" /><summary type="html"><![CDATA[루퍼스 부트캠프(루프팩, LOOPPAK) 백엔드 3기를 수료한 4년차 개발자의 10주 회고. 수강 전후 변화, 가장 인상 깊었던 프로젝트, 멘토링에 대해 담담히 적었다.]]></summary></entry><entry><title type="html">같은 랭킹인데 왜 다르게 풀었을까 — 실시간 Redis에서 배치 MV까지</title><link href="https://ukukdin.github.io/2026/04/17/10weeksBlog/" rel="alternate" type="text/html" title="같은 랭킹인데 왜 다르게 풀었을까 — 실시간 Redis에서 배치 MV까지" /><published>2026-04-17T00:00:00+00:00</published><updated>2026-04-17T00:00:00+00:00</updated><id>https://ukukdin.github.io/2026/04/17/10weeksBlog</id><content type="html" xml:base="https://ukukdin.github.io/2026/04/17/10weeksBlog/"><![CDATA[<blockquote>
  <p><strong>TL;DR</strong>: 일간 랭킹은 Redis ZSET으로, 주간/월간 랭킹은 Spring Batch + Materialized View로 풀었다. “랭킹”이라는 같은 도메인인데 왜 인프라를 나눴는지, 그 과정에서 인터페이스를 한 번 잘못 설계했다가 되돌린 이야기를 정리했다.</p>
</blockquote>

<hr />

<h2 id="1-시작은-단순한-질문이었다">1. 시작은 단순한 질문이었다</h2>

<p>9주차에 Redis ZSET 기반 실시간 일간 랭킹을 만들었다. Kafka Consumer가 좋아요/주문 이벤트를 받아 <code class="language-plaintext highlighter-rouge">ZINCRBY</code>로 점수를 갱신하고, API에서 <code class="language-plaintext highlighter-rouge">ZREVRANGE</code>로 조회하는 구조다.</p>

<p>잘 돌아갔다. 그런데 10주차 과제가 떨어졌다.</p>

<blockquote>
  <p>“주간, 월간 랭킹도 만들어보세요.”</p>
</blockquote>

<p>처음 든 생각은 단순했다. <em>“일간을 7개 합치면 주간이고, 30개 합치면 월간 아닌가?”</em></p>

<p>Redis의 <code class="language-plaintext highlighter-rouge">ZUNIONSTORE</code>로 7일치 키를 합산하면 될 것 같았다. 그런데 이 생각이 틀렸다는 걸 금방 깨달았다.</p>

<hr />

<h2 id="2-왜-redis로-주간월간을-풀면-안-되는가">2. 왜 Redis로 주간/월간을 풀면 안 되는가</h2>

<p><code class="language-plaintext highlighter-rouge">ZUNIONSTORE</code>의 시간복잡도는 <code class="language-plaintext highlighter-rouge">O(N) + O(M log M)</code>이다. N은 입력 키들의 총 원소 수, M은 결과 집합의 크기다. 상품이 10만 건이고 7일치를 합산하면 N은 최대 70만이다.</p>

<p>하지만 진짜 문제는 성능이 아니었다.</p>

<p><strong>주간/월간 랭킹은 실시간일 필요가 없다.</strong> 사용자가 “이번 주 인기 상품”을 볼 때, 3초 전에 발생한 좋아요가 반영되지 않아도 아무도 모른다. 경영진이 보는 주간 리포트에 수 초 단위의 실시간성이 필요한 경우는 거의 없다.</p>

<p>이 시점에서 <a href="https://techblog.woowahan.com/2623/">우아한형제들 기술블로그의 배치 경험기</a>에서 읽었던 문장이 떠올랐다.</p>

<blockquote>
  <p><em>“실시간으로 처리할 수 있다고 해서 실시간으로 처리해야 하는 것은 아니다.”</em></p>
</blockquote>

<p>정확히 이 상황이었다. 실시간으로 풀 <em>수는</em> 있지만, 풀 <em>필요가</em> 없다. 오히려 매 요청마다 7일치를 합산하면 Redis에 불필요한 부하를 주는 셈이다.</p>

<table>
  <thead>
    <tr>
      <th>관점</th>
      <th>일간 랭킹</th>
      <th>주간/월간 랭킹</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>갱신 주기</td>
      <td>이벤트 발생 즉시</td>
      <td>하루 한 번이면 충분</td>
    </tr>
    <tr>
      <td>핵심 가치</td>
      <td><strong>신속성</strong> (UX)</td>
      <td><strong>정확성 &amp; 효율성</strong></td>
    </tr>
    <tr>
      <td>적합한 처리</td>
      <td>실시간 (Redis ZSET)</td>
      <td>배치 (Spring Batch → DB)</td>
    </tr>
  </tbody>
</table>

<p>그래서 결론을 내렸다. <strong>일간은 Redis, 주간/월간은 배치로 분리한다.</strong></p>

<hr />

<h2 id="3-materialized-view라는-선택">3. Materialized View라는 선택</h2>

<p>주간/월간 랭킹은 “미리 계산해둔 조회 전용 테이블”에 넣기로 했다. MySQL에는 PostgreSQL 같은 네이티브 MV 기능이 없으므로, <strong>별도 테이블 + 배치 적재</strong> 방식을 사용한다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- 주간 랭킹 MV</span>
<span class="n">mv_product_rank_weekly</span> <span class="p">(</span>
  <span class="n">product_id</span><span class="p">,</span> <span class="n">year_week</span><span class="p">,</span> 
  <span class="n">like_count</span><span class="p">,</span> <span class="n">order_count</span><span class="p">,</span> <span class="n">view_count</span><span class="p">,</span> 
  <span class="n">score</span><span class="p">,</span> <span class="n">ranking</span><span class="p">,</span>  <span class="c1">-- 순위를 미리 계산해서 저장</span>
  <span class="n">updated_at</span>
<span class="p">)</span>

<span class="c1">-- 월간 랭킹 MV</span>
<span class="n">mv_product_rank_monthly</span> <span class="p">(</span>
  <span class="n">product_id</span><span class="p">,</span> <span class="n">year_month</span><span class="p">,</span> 
  <span class="n">like_count</span><span class="p">,</span> <span class="n">order_count</span><span class="p">,</span> <span class="n">view_count</span><span class="p">,</span> 
  <span class="n">score</span><span class="p">,</span> <span class="n">ranking</span><span class="p">,</span>
  <span class="n">updated_at</span>
<span class="p">)</span>
</code></pre></div></div>

<p>여기서 한 가지 고민이 있었다. <strong><code class="language-plaintext highlighter-rouge">ranking</code>(순위)을 테이블에 저장할 것인가, 조회할 때 계산할 것인가?</strong></p>

<p>조회 시 <code class="language-plaintext highlighter-rouge">ORDER BY score DESC</code>로 정렬해서 순위를 매기는 것도 방법이다. 하지만 MV의 본질은 <em>“복잡한 집계를 미리 계산해두는 것”</em>이다. 조회할 때마다 정렬하면 MV의 의미가 반감된다. 배치에서 TOP 100만 뽑으면서 순위를 미리 매겨두면, 조회는 <code class="language-plaintext highlighter-rouge">WHERE year_week = ? ORDER BY ranking ASC</code>로 인덱스 스캔만 하면 끝이다.</p>

<hr />

<h2 id="4-spring-batch-job-설계--chunk가-맞는-이유">4. Spring Batch Job 설계 — Chunk가 맞는 이유</h2>

<p>배치 처리 모델을 정할 때 세 가지 선택지가 있었다.</p>

<h3 id="선택지-a-tasklet에서-native-query-한-방">선택지 A: Tasklet에서 Native Query 한 방</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Tasklet 안에서 SQL 한 줄로 끝내기</span>
<span class="nd">@Override</span>
<span class="kd">public</span> <span class="nc">RepeatStatus</span> <span class="nf">execute</span><span class="o">(...)</span> <span class="o">{</span>
    <span class="n">jdbcTemplate</span><span class="o">.</span><span class="na">update</span><span class="o">(</span><span class="s">"""
        INSERT INTO mv_product_rank_weekly (...)
        SELECT product_id, ..., RANK() OVER (ORDER BY score DESC)
        FROM product_metrics
        LIMIT 100
    """</span><span class="o">);</span>
    <span class="k">return</span> <span class="nc">RepeatStatus</span><span class="o">.</span><span class="na">FINISHED</span><span class="o">;</span>
<span class="o">}</span>
</code></pre></div></div>

<p>단순하고 빠르다. 하지만 상품이 100만 건이 되면? SQL 한 방에 100만 건을 정렬하고, 트랜잭션 하나에 묶이고, 실패하면 전체 롤백이다.</p>

<h3 id="선택지-b-chunk-oriented-processing">선택지 B: Chunk-Oriented Processing</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Reader(product_metrics에서 score순 읽기)
  → Processor(순위 부여 + MV 엔티티 변환)
    → Writer(MV 테이블 저장)
</code></pre></div></div>

<p>청크 단위로 트랜잭션이 관리되고, 실패 시 해당 청크만 재시도할 수 있다. Spring Batch의 <code class="language-plaintext highlighter-rouge">StepExecution</code>으로 읽은 건수, 쓴 건수를 자동 추적한다.</p>

<h3 id="선택지-c-tasklet-안에서-chunk를-수동-구현">선택지 C: Tasklet 안에서 Chunk를 수동 구현</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Tasklet 안에서 직접 페이징하며 처리</span>
<span class="kt">int</span> <span class="n">offset</span> <span class="o">=</span> <span class="mi">0</span><span class="o">;</span>
<span class="k">while</span> <span class="o">(</span><span class="n">offset</span> <span class="o">&lt;</span> <span class="n">total</span><span class="o">)</span> <span class="o">{</span>
    <span class="nc">List</span><span class="o">&lt;</span><span class="nc">Metrics</span><span class="o">&gt;</span> <span class="n">chunk</span> <span class="o">=</span> <span class="n">repository</span><span class="o">.</span><span class="na">findAll</span><span class="o">(</span><span class="nc">PageRequest</span><span class="o">.</span><span class="na">of</span><span class="o">(</span><span class="n">offset</span><span class="o">,</span> <span class="mi">1000</span><span class="o">));</span>
    <span class="c1">// 직접 처리...</span>
    <span class="n">offset</span> <span class="o">+=</span> <span class="mi">1000</span><span class="o">;</span>
<span class="o">}</span>
</code></pre></div></div>

<p>자유도는 높지만, Batch 프레임워크의 재시작/모니터링 메커니즘을 전부 포기하게 된다.</p>

<p><a href="https://techblog.woowahan.com/2662/">우아한형제들의 Spring Batch와 Querydsl 글</a>에서 대규모 데이터 처리 시 <code class="language-plaintext highlighter-rouge">JpaPagingItemReader</code>의 한계와 최적화 방법을 참고했다. 현재 규모에서는 표준 <code class="language-plaintext highlighter-rouge">JpaPagingItemReader</code>로 충분하지만, 데이터가 커지면 offset 없는 커서 기반 Reader로 전환이 필요하다는 점을 인지하고 있다.</p>

<p><strong>최종적으로 B를 선택했다.</strong> 이유는 두 가지다.</p>

<ol>
  <li>TOP 100만 읽으면 되므로 <code class="language-plaintext highlighter-rouge">maxItemCount(100)</code>과 <code class="language-plaintext highlighter-rouge">pageSize(100)</code>을 맞추면 DB에서 딱 100건만 가져온다. A의 “SQL 한 방”과 성능 차이가 없으면서, 프레임워크의 이점(모니터링, 재시작)을 그대로 쓸 수 있다.</li>
  <li>이번 과제의 학습 목표 자체가 “Chunk-Oriented Processing”이다. 실무에서도 이 패턴을 알아야 한다.</li>
</ol>

<p>그리고 Job 구조는 <strong>두 단계</strong>로 나눴다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Step 1: Cleanup Tasklet  → 해당 주차/월의 기존 데이터 삭제
Step 2: Aggregate Chunk  → Reader → Processor → Writer
</code></pre></div></div>

<p>Cleanup은 단발성 <code class="language-plaintext highlighter-rouge">DELETE</code> 쿼리 하나이므로 Tasklet이 적합하고, 집계는 Chunk가 적합하다. 하나의 Job 안에서 각 Step의 성격에 맞는 처리 모델을 혼합한 것이다.</p>

<hr />

<h2 id="5-인터페이스를-한-번-잘못-설계했다">5. 인터페이스를 한 번 잘못 설계했다</h2>

<p>API에서 주간/월간 랭킹을 제공하려면, 기존 <code class="language-plaintext highlighter-rouge">RankingRepository</code>에 주간/월간 메서드를 추가해야 했다.</p>

<p>처음에는 단순하게 기존 인터페이스를 확장했다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 처음 시도: 기존 인터페이스에 추가</span>
<span class="kd">public</span> <span class="kd">interface</span> <span class="nc">RankingRepository</span> <span class="o">{</span>
    <span class="c1">// 기존 (일간, Redis)</span>
    <span class="nc">List</span><span class="o">&lt;</span><span class="nc">RankedProduct</span><span class="o">&gt;</span> <span class="nf">getTopRankings</span><span class="o">(</span><span class="nc">LocalDate</span> <span class="n">date</span><span class="o">,</span> <span class="kt">int</span> <span class="n">offset</span><span class="o">,</span> <span class="kt">int</span> <span class="n">size</span><span class="o">);</span>
    <span class="kt">long</span> <span class="nf">getTotalCount</span><span class="o">(</span><span class="nc">LocalDate</span> <span class="n">date</span><span class="o">);</span>
    
    <span class="c1">// 추가 (주간/월간, JPA)</span>
    <span class="nc">List</span><span class="o">&lt;</span><span class="nc">RankedProduct</span><span class="o">&gt;</span> <span class="nf">getWeeklyRankings</span><span class="o">(</span><span class="nc">LocalDate</span> <span class="n">date</span><span class="o">,</span> <span class="kt">int</span> <span class="n">offset</span><span class="o">,</span> <span class="kt">int</span> <span class="n">size</span><span class="o">);</span>
    <span class="nc">List</span><span class="o">&lt;</span><span class="nc">RankedProduct</span><span class="o">&gt;</span> <span class="nf">getMonthlyRankings</span><span class="o">(</span><span class="nc">LocalDate</span> <span class="n">date</span><span class="o">,</span> <span class="kt">int</span> <span class="n">offset</span><span class="o">,</span> <span class="kt">int</span> <span class="n">size</span><span class="o">);</span>
<span class="o">}</span>
</code></pre></div></div>

<p>컴파일은 됐다. 하지만 구현체를 보는 순간 위화감을 느꼈다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Repository</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">RedisRankingRepository</span> <span class="kd">implements</span> <span class="nc">RankingRepository</span> <span class="o">{</span>
    
    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">RedisTemplate</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">,</span> <span class="nc">String</span><span class="o">&gt;</span> <span class="n">redisTemplate</span><span class="o">;</span>
    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">ProductRankWeeklyJpaRepository</span> <span class="n">weeklyJpaRepository</span><span class="o">;</span>  <span class="c1">// ← ?!</span>
    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">ProductRankMonthlyJpaRepository</span> <span class="n">monthlyJpaRepository</span><span class="o">;</span> <span class="c1">// ← ?!</span>
    
    <span class="c1">// Redis로 일간 조회...</span>
    <span class="c1">// JPA로 주간/월간 조회...</span>
<span class="o">}</span>
</code></pre></div></div>

<p><strong>이름은 “Redis”인데 JPA를 주입받고 있었다.</strong> 이건 명백한 SRP(단일 책임 원칙) 위반이다.</p>

<p>왜 이런 일이 발생했는지 생각해보면, <strong>하나의 인터페이스에 두 가지 변경 원인</strong>을 밀어넣었기 때문이다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">RankingRepository</code>의 일간 메서드들은 Redis ZSET이 변경되면 바뀐다.</li>
  <li>주간/월간 메서드들은 MV 테이블 스키마가 변경되면 바뀐다.</li>
</ul>

<p>변경 원인이 다르면 인터페이스도 달라야 한다. SOLID의 I(Interface Segregation Principle)가 말하는 바로 그것이다.</p>

<h3 id="수정-인터페이스-분리">수정: 인터페이스 분리</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 일간 랭킹 (Redis) — 기존 그대로</span>
<span class="kd">public</span> <span class="kd">interface</span> <span class="nc">RankingRepository</span> <span class="o">{</span>
    <span class="nc">List</span><span class="o">&lt;</span><span class="nc">RankedProduct</span><span class="o">&gt;</span> <span class="nf">getTopRankings</span><span class="o">(</span><span class="nc">LocalDate</span> <span class="n">date</span><span class="o">,</span> <span class="kt">int</span> <span class="n">offset</span><span class="o">,</span> <span class="kt">int</span> <span class="n">size</span><span class="o">);</span>
    <span class="kt">long</span> <span class="nf">getTotalCount</span><span class="o">(</span><span class="nc">LocalDate</span> <span class="n">date</span><span class="o">);</span>
    <span class="nc">Long</span> <span class="nf">getRank</span><span class="o">(</span><span class="nc">LocalDate</span> <span class="n">date</span><span class="o">,</span> <span class="nc">Long</span> <span class="n">productId</span><span class="o">);</span>
    <span class="nc">Double</span> <span class="nf">getScore</span><span class="o">(</span><span class="nc">LocalDate</span> <span class="n">date</span><span class="o">,</span> <span class="nc">Long</span> <span class="n">productId</span><span class="o">);</span>
<span class="o">}</span>

<span class="c1">// 기간별 랭킹 (DB MV) — 신규</span>
<span class="kd">public</span> <span class="kd">interface</span> <span class="nc">PeriodRankingRepository</span> <span class="o">{</span>
    <span class="nc">List</span><span class="o">&lt;</span><span class="nc">RankedProduct</span><span class="o">&gt;</span> <span class="nf">getWeeklyRankings</span><span class="o">(</span><span class="nc">LocalDate</span> <span class="n">date</span><span class="o">,</span> <span class="kt">int</span> <span class="n">offset</span><span class="o">,</span> <span class="kt">int</span> <span class="n">size</span><span class="o">);</span>
    <span class="kt">long</span> <span class="nf">getWeeklyTotalCount</span><span class="o">(</span><span class="nc">LocalDate</span> <span class="n">date</span><span class="o">);</span>
    <span class="nc">List</span><span class="o">&lt;</span><span class="nc">RankedProduct</span><span class="o">&gt;</span> <span class="nf">getMonthlyRankings</span><span class="o">(</span><span class="nc">LocalDate</span> <span class="n">date</span><span class="o">,</span> <span class="kt">int</span> <span class="n">offset</span><span class="o">,</span> <span class="kt">int</span> <span class="n">size</span><span class="o">);</span>
    <span class="kt">long</span> <span class="nf">getMonthlyTotalCount</span><span class="o">(</span><span class="nc">LocalDate</span> <span class="n">date</span><span class="o">);</span>
<span class="o">}</span>
</code></pre></div></div>

<p>구현체도 깔끔하게 분리됐다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Redis만 아는 구현체</span>
<span class="nd">@Repository</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">RedisRankingRepository</span> <span class="kd">implements</span> <span class="nc">RankingRepository</span> <span class="o">{</span> <span class="o">...</span> <span class="o">}</span>

<span class="c1">// JPA만 아는 구현체</span>
<span class="nd">@Repository</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">JpaPeriodRankingRepository</span> <span class="kd">implements</span> <span class="nc">PeriodRankingRepository</span> <span class="o">{</span> <span class="o">...</span> <span class="o">}</span>
</code></pre></div></div>

<p>Service에서는 두 인터페이스를 주입받아 <code class="language-plaintext highlighter-rouge">period</code>에 따라 라우팅한다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Service</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">RankingQueryService</span> <span class="o">{</span>
    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">RankingRepository</span> <span class="n">rankingRepository</span><span class="o">;</span>           <span class="c1">// 일간 (Redis)</span>
    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">PeriodRankingRepository</span> <span class="n">periodRankingRepository</span><span class="o">;</span> <span class="c1">// 주간/월간 (JPA MV)</span>
    
    <span class="kd">public</span> <span class="nc">PageResult</span><span class="o">&lt;</span><span class="nc">RankingItemInfo</span><span class="o">&gt;</span> <span class="nf">getRankings</span><span class="o">(</span><span class="nc">LocalDate</span> <span class="n">date</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="nc">RankingPeriod</span> <span class="n">period</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">return</span> <span class="nf">switch</span> <span class="o">(</span><span class="n">period</span><span class="o">)</span> <span class="o">{</span>
            <span class="k">case</span> <span class="no">WEEKLY</span>  <span class="o">-&gt;</span> <span class="cm">/* periodRankingRepository.getWeeklyRankings(...) */</span>
            <span class="k">case</span> <span class="no">MONTHLY</span> <span class="o">-&gt;</span> <span class="cm">/* periodRankingRepository.getMonthlyRankings(...) */</span>
            <span class="k">default</span>      <span class="o">-&gt;</span> <span class="cm">/* rankingRepository.getTopRankings(...) */</span>
        <span class="o">};</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>처음에 하나로 합쳤을 때 “이게 맞나?” 싶은 위화감이 있었는데, 그 감각이 맞았다. <strong>이름과 책임이 안 맞으면 뭔가 잘못된 것이다.</strong></p>

<hr />

<h2 id="6-전체-구조--실시간과-배치의-공존">6. 전체 구조 — 실시간과 배치의 공존</h2>

<p>최종적으로 만들어진 구조는 이렇다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[일간 랭킹 — 실시간]
Kafka Event → Commerce Streamer → Redis ZSET (ZINCRBY)
                                        ↓
                              API (ZREVRANGE) → 사용자

[주간/월간 랭킹 — 배치]
product_metrics → Spring Batch Job → MV 테이블 (saveAll)
  (일간 누적)     (Chunk Processing)       ↓
                                   API (JPA 조회) → 사용자
</code></pre></div></div>

<p>같은 “랭킹”이지만, <strong>갱신 주기와 핵심 가치가 다르면 인프라도 달라야 한다.</strong> 일간은 이벤트 하나하나가 즉시 반영되어야 UX가 살아나고, 주간/월간은 하루 한 번 정확하게 집계되면 그만이다.</p>

<p>API 엔드포인트는 하나로 통합했다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GET /api/v1/rankings?period=DAILY&amp;date=20260417&amp;page=0&amp;size=20
GET /api/v1/rankings?period=WEEKLY&amp;date=20260417&amp;page=0&amp;size=20
GET /api/v1/rankings?period=MONTHLY&amp;date=20260417&amp;page=0&amp;size=20
</code></pre></div></div>

<p>클라이언트 입장에서는 <code class="language-plaintext highlighter-rouge">period</code> 파라미터 하나만 바꾸면 된다. 뒤에서 Redis를 보는지 DB를 보는지는 알 필요가 없다.</p>

<hr />

<h2 id="7-회고--10주간-달라진-사고방식">7. 회고 — 10주간 달라진 사고방식</h2>

<p>이번 과제를 하면서 가장 많이 바뀐 건, <strong>“이걸로 풀 수 있다”에서 “이걸로 풀어야 하는가”</strong>로 질문이 바뀐 것이다.</p>

<p>10주 전이었다면 “Redis로 주간도 만들 수 있으니까 Redis로 하자”고 했을 것이다. 지금은 “주간 랭킹의 갱신 주기와 정확성 요구사항이 뭔지”를 먼저 본다. 기술이 아니라 요구사항이 아키텍처를 결정한다.</p>

<p>10주간의 흐름을 돌아보면:</p>

<ul>
  <li><strong>1~3주차</strong>: 도메인 모델링과 계층 분리. “테이블 먼저”에서 “도메인 먼저”로 사고가 바뀌었다.</li>
  <li><strong>4~6주차</strong>: 트랜잭션과 동시성, 외부 시스템 연동. <code class="language-plaintext highlighter-rouge">@Transactional</code> 하나면 충분하다고 생각했는데, 분산 환경에서는 전혀 아니었다.</li>
  <li><strong>7주차</strong>: 이벤트와 Kafka. 동기 호출로 엮인 시스템을 이벤트로 분리하는 순간, 확장성에 눈이 떠졌다.</li>
  <li><strong>8주차</strong>: 대기열 큐. Redis의 다른 자료구조 활용.</li>
  <li><strong>9주차</strong>: Redis ZSET 기반 실시간 집계. “쓰기 최적화”를 고민하기 시작했다.</li>
  <li><strong>10주차</strong>: Spring Batch와 MV. 같은 도메인에서 실시간과 배치를 나누는 판단을 했다.</li>
</ul>

<p><strong>가장 큰 전환점</strong>을 꼽자면, 9주차에서 10주차로 넘어오는 지점이다. 같은 “랭킹”인데 다른 도구를 쓴다는 결정을 하려면, 기술의 장단점만으로는 부족하다. <strong>이 데이터가 얼마나 자주 바뀌어야 하는가, 누가 소비하는가, 틀려도 되는가</strong> — 이런 질문을 할 수 있어야 한다.</p>

<hr />

<h2 id="8-남은-과제">8. 남은 과제</h2>

<p>현재 <code class="language-plaintext highlighter-rouge">product_metrics</code>는 날짜 구분 없는 누적 카운터다. 주간 배치와 월간 배치가 같은 시점의 스냅샷을 읽으므로, 엄밀히 말하면 “이번 주에 새로 발생한 좋아요 수”를 구분하지 못한다.</p>

<p>이를 해결하려면 일별 스냅샷 테이블(<code class="language-plaintext highlighter-rouge">product_metrics_daily</code>)을 도입해야 한다. 주간 집계 = 이번 주 7일치 daily의 SUM, 월간 집계 = 이번 달 daily의 SUM. 이렇게 되면 진정한 기간별 차분 집계가 가능하다.</p>

<p>지금은 “현재 누적 상태의 주기적 스냅샷”이라는 한계를 인지하고 있다. 하지만 데이터 파이프라인은 한 번에 완성하는 게 아니라, 요구사항이 구체화되면서 점진적으로 발전시키는 것이라고 생각한다.</p>

<hr />

<h2 id="참고">참고</h2>

<ul>
  <li><a href="https://techblog.woowahan.com/2662/">우아한형제들 - Spring Batch와 Querydsl</a> — 대규모 데이터 처리 시 JpaPagingItemReader의 한계와 offset 없는 Reader 전략</li>
  <li><a href="https://techblog.woowahan.com/2623/">우아한형제들 - 파일럿 프로젝트를 통한 배치경험기</a> — 300만 건 데이터 처리와 배치 Job 설계 패턴</li>
  <li><a href="https://techblog.woowahan.com/7835/">우아한형제들 - 회원시스템 이벤트기반 아키텍처 구축하기</a> — 이벤트 분리와 도메인 간 의존성 관리</li>
  <li><a href="https://docs.spring.io/spring-batch/reference/">Spring Docs - Spring Batch Reference</a> — Chunk-Oriented Processing 공식 문서</li>
</ul>]]></content><author><name>Ukukdin</name></author><category term="Loopers" /><category term="Tech" /><category term="Redis" /><category term="ZSET" /><category term="SpringBatch" /><category term="MaterializedView" /><category term="Ranking" /><category term="ChunkProcessing" /><category term="ISP" /><category term="Architecture" /><summary type="html"><![CDATA[같은 '랭킹'이지만 일간은 Redis ZSET 실시간으로, 주간/월간은 Spring Batch + MV 배치로 풀었다. '실시간으로 풀 수 있다'와 '실시간으로 풀어야 한다'는 다르다는 것, 그리고 인터페이스를 한 번 잘못 설계했다가 ISP로 되돌린 이야기.]]></summary></entry><entry><title type="html">랭킹 시스템, 단순 정렬이 아니라 ‘시간의 설계’다</title><link href="https://ukukdin.github.io/2026/04/10/9weeksBlog/" rel="alternate" type="text/html" title="랭킹 시스템, 단순 정렬이 아니라 ‘시간의 설계’다" /><published>2026-04-10T00:00:00+00:00</published><updated>2026-04-10T00:00:00+00:00</updated><id>https://ukukdin.github.io/2026/04/10/9weeksBlog</id><content type="html" xml:base="https://ukukdin.github.io/2026/04/10/9weeksBlog/"><![CDATA[<blockquote>
  <p><strong>TL;DR</strong><br />
랭킹 시스템의 본질은 정렬이 아니라 <strong>“어떤 시간 범위의 이벤트를, 어떤 가중치로 합산할 것인가</strong>라는 정책 결정이다. Redis ZSET은 그 정책을 서빙하는 도구일 뿐이다. 이 글에서는 이벤트 집계의 시간축 설계, 텀블링/슬라이딩 윈도우의 트레이드오프, 콜드 스타트와 Score Carry-Over의 진짜 의미, 그리고 실무에서 ZSET만으로는 부족한 이유까지를 다룬다.</p>
</blockquote>

<hr />

<h2 id="0-랭킹의-sotsource-of-truth는-이벤트다">0. 랭킹의 SOT(Source of Truth)는 이벤트다</h2>

<p>랭킹 시스템을 처음 설계할 때 흔히 “어떤 DB에 저장하지?”부터 고민한다. Redis? Elasticsearch? RDB? 하지만 이 질문은 순서가 틀렸다.</p>

<p>랭킹은 <strong>특정 기간 동안 누적된 값을 가지고 줄 세우는 시스템</strong>이다. 그리고 그 “누적된 값”의 원천은 <strong>유저의 행동 이벤트</strong>다. 조회, 좋아요, 주문 — 이 이벤트들이 랭킹의 SOT(System of Record)이며, 이벤트를 어떻게 집계하고 저장하느냐에 따라 랭킹의 표현력과 정확성이 완전히 달라진다.</p>

<p>저장소 선택은 그 다음 문제다. 먼저 물어야 할 질문은 이것이다:</p>

<blockquote>
  <p><strong>“우리는 어떤 시간 범위의 이벤트를, 어떤 기준으로 합산해서 보여줄 것인가?”</strong></p>
</blockquote>

<hr />

<h2 id="1-시간축에-대한-감각--집계-단위가-랭킹의-표현력을-결정한다">1. 시간축에 대한 감각 — 집계 단위가 랭킹의 표현력을 결정한다</h2>

<p>이벤트를 집계하는 시간 단위는 곧 <strong>랭킹이 표현할 수 있는 최소 해상도</strong>다. 이것을 이해하지 못하면 “왜 우리 랭킹은 트렌드를 못 따라가지?”라는 질문에 답할 수 없다.</p>

<h3 id="집계-단위별-표현력">집계 단위별 표현력</h3>

<table>
  <thead>
    <tr>
      <th>집계 단위</th>
      <th>최소 표현 가능 범위</th>
      <th>특징</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>월 단위</strong></td>
      <td>1개월</td>
      <td>“최근 3주” 데이터를 보여줄 수 없다</td>
    </tr>
    <tr>
      <td><strong>주 단위</strong></td>
      <td>1주일</td>
      <td>“최근 3일” 데이터를 보여줄 수 없다</td>
    </tr>
    <tr>
      <td><strong>일 단위</strong></td>
      <td>1일</td>
      <td>30일, 60일, 100일 랭킹 모두 표현 가능</td>
    </tr>
    <tr>
      <td><strong>시간 단위</strong></td>
      <td>1시간</td>
      <td>“최근 3시간 인기 상품” 표현 가능</td>
    </tr>
  </tbody>
</table>

<p><strong>일 단위로 집계하면</strong>, 하루가 하나의 버킷이 된다. 1년이면 365개의 버킷이고, 이 버킷들을 조합하면 7일 랭킹도, 30일 랭킹도, 90일 랭킹도 만들 수 있다.</p>

<p><strong>시간 단위로 집계하면</strong>, 1시간이 하나의 버킷이다. “최근 1시간 급상승 상품”이라는 표현이 가능해진다. 하지만 1분 단위 버킷을 만들면? 하루에 1,440개, 이건 현실적으로 운영이 어렵다.</p>

<p>결국 집계 단위는 <strong>비즈니스 요구사항(BGC)</strong>에 따라 결정되며, 대부분의 이커머스는 <strong>일 단위</strong>로 시작하는 것이 합리적이다. 이번 설계에서도 일 단위를 기본으로 가져갔다.</p>

<h3 id="데이터-규모-감각-잡기">데이터 규모 감각 잡기</h3>

<p>집계 단위를 정했으면, 데이터 규모를 가늠해봐야 한다. 대략적인 계산을 해보자:</p>

<ul>
  <li>DAU 1억 명(10^8)이라 가정</li>
  <li>하루 86,400초 ≈ 약 10만 초로 라운딩</li>
  <li>인당 평균 10개 이벤트 발생 → QPS ≈ 10,000</li>
  <li>이벤트 하나당 약 20바이트</li>
</ul>

<p>하루 데이터량: <code class="language-plaintext highlighter-rouge">20bytes × 10,000 QPS × 86,400초 ≈ 약 17GB</code></p>

<p>365일이면 약 6TB. 트래픽 피크를 3~6배 여유로 잡으면 연간 수십 TB. 이 규모에서는 RDB의 <code class="language-plaintext highlighter-rouge">GROUP BY + ORDER BY</code>가 왜 안 되는지 체감이 온다.</p>

<hr />

<h2 id="2-왜-rdb의-order-by로는-안-되는가">2. 왜 RDB의 ORDER BY로는 안 되는가</h2>

<p>가장 먼저 떠오르는 접근은 당연히 SQL이다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="n">product_id</span><span class="p">,</span> <span class="k">SUM</span><span class="p">(</span><span class="n">score</span><span class="p">)</span> <span class="k">as</span> <span class="n">total</span>
<span class="k">FROM</span> <span class="n">product_metrics</span>
<span class="k">GROUP</span> <span class="k">BY</span> <span class="n">product_id</span>
<span class="k">ORDER</span> <span class="k">BY</span> <span class="n">total</span> <span class="k">DESC</span>
<span class="k">LIMIT</span> <span class="mi">20</span><span class="p">;</span>
</code></pre></div></div>

<p>초기에는 동작한다. 상품 1,000개, 일일 이벤트 10,000건 수준이라면 문제없다. 하지만 이 쿼리가 <strong>어떤 맥락에서 호출되는지</strong>를 생각하면 이야기가 달라진다:</p>

<ul>
  <li>홈 메인 진입 시마다 호출 — DAU 10만이면 하루 수십만 회</li>
  <li>상품 상세 페이지에서 “이 상품은 현재 N위” — 상품 수만큼 곱연산</li>
  <li>카테고리별, 시간대별 랭킹까지 확장되면 쿼리 조합이 폭발</li>
</ul>

<p><strong>읽기 빈도가 압도적으로 높은 랭킹이라는 도메인 특성</strong>에서, 쓰기 시점에 정렬을 완료해두는 구조가 필요하다. 이것이 Redis ZSET을 선택한 핵심 이유다.</p>

<hr />

<h2 id="3-redis-zset--삽입이-곧-정렬이지만-만능은-아니다">3. Redis ZSET — “삽입이 곧 정렬”이지만, 만능은 아니다</h2>

<p>Redis Sorted Set은 <code class="language-plaintext highlighter-rouge">(member, score)</code> 쌍을 score 기준으로 <strong>항상 정렬된 상태</strong>로 유지한다. 삽입/수정 O(log N), Top-N 조회 O(log N + M).</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ZINCRBY ranking:all:20260410 0.7 product:101   // 점수 누적
ZREVRANGE ranking:all:20260410 0 19 WITHSCORES  // Top 20 조회
ZREVRANK ranking:all:20260410 product:101       // 특정 상품 순위
</code></pre></div></div>

<table>
  <thead>
    <tr>
      <th>방식</th>
      <th>장점</th>
      <th>단점</th>
      <th>적합 시나리오</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>DB <code class="language-plaintext highlighter-rouge">ORDER BY</code></td>
      <td>정합성 보장</td>
      <td>조회마다 집계 비용</td>
      <td>초기/소규모</td>
    </tr>
    <tr>
      <td>캐시 Map + 정렬</td>
      <td>구현 단순</td>
      <td>매 요청마다 O(N log N)</td>
      <td>중규모</td>
    </tr>
    <tr>
      <td><strong>Redis ZSET</strong></td>
      <td>삽입 시 정렬 완료</td>
      <td>필터링 불가, 메모리 상주</td>
      <td>대규모 트래픽</td>
    </tr>
  </tbody>
</table>

<h3 id="zset의-실무적-한계--필터링이-안-된다">ZSET의 실무적 한계 — 필터링이 안 된다</h3>

<p>여기서 솔직하게 짚고 넘어갈 것이 있다. ZSET은 <strong>정렬된 데이터를 제공하는 데는 탁월하지만, 그 안에서 조건 필터링을 할 수 없다</strong>.</p>

<p>예를 들어, 100만 개 상품이 ZSET에 있는데 “여성 의류 카테고리만” 보고 싶다면? ZSET에는 그런 기능이 없다. 전체를 꺼내서 애플리케이션에서 필터링해야 한다.</p>

<p>실무에서 랭킹이 단순한 “전체 Top-N”을 넘어서는 순간 — 카테고리별, 성별, 연령대별 랭킹이 필요해지는 순간 — Redis ZSET만으로는 부족해진다. 실제로 현업에서는 <strong>Elasticsearch</strong>, <strong>NoSQL</strong>, 혹은 전용 검색/추천 엔진을 함께 사용하는 경우가 많다.</p>

<p>그럼에도 ZSET을 선택한 이유는, 이번 설계의 스코프가 <strong>“전체 상품 일간 랭킹”이라는 단일 차원</strong>이고, 이 범위에서는 ZSET이 가장 심플하면서도 성능이 보장되는 선택이기 때문이다. 가장 중요한 것은 <strong>스케일이 늘어났을 때 교체할 수 있는 구조</strong>로 만드는 것이다.</p>

<hr />

<h2 id="4-아키텍처--이벤트가-랭킹이-되기까지">4. 아키텍처 — 이벤트가 랭킹이 되기까지</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[유저 행동]
    │
    ▼
[commerce-api] ── 조회/좋아요/주문 이벤트 발행 ──▶ [Kafka Topic]
                                                        │
                                                        ▼
                                                [commerce-collector]
                                                    │         │
                                                    ▼         ▼
                                            product_metrics   Redis ZSET
                                              (R7 구축)       (이번 주차)
                                                              │
                                                              ▼
                                                [commerce-api]
                                                GET /rankings/top
                                                GET /products/{id}/rank
</code></pre></div></div>

<p>collector가 이벤트를 소비하면서 <strong>두 저장소에 동시 반영</strong>한다. <code class="language-plaintext highlighter-rouge">product_metrics</code>는 장기 분석용 원본, Redis ZSET은 실시간 서빙용 뷰. 이 분리가 주는 이점:</p>

<ul>
  <li>ZSET이 날아가도 product_metrics에서 재구축 가능 <strong>(복원력)</strong></li>
  <li>API 서버는 Redis만 바라보면 됨 <strong>(성능 격리)</strong></li>
  <li>각 저장소는 자신의 도메인에 최적화 <strong>(관심사 분리)</strong></li>
</ul>

<h3 id="실시간-vs-준실시간--etl-파이프라인의-두-가지-스타일">실시간 vs 준실시간 — ETL 파이프라인의 두 가지 스타일</h3>

<p>여기서 잠깐, 데이터 처리 방식에 대해 넓은 시야로 보자.</p>

<p><strong>실시간 이벤트 처리</strong>: Kafka Consumer가 이벤트를 즉시 소비하고 Redis에 반영. 우리가 이번에 구현한 방식이다.</p>

<p><strong>준실시간/배치 처리</strong>: Spark 같은 도구로 S3에 쌓인 로그를 주기적으로(5분, 10분) 읽어 집계. 무신사가 이 방식이라고 한다.</p>

<p>어떤 방식이 맞는지는 비즈니스 요구에 달렸다. 29cm처럼 완전 초실시간이 필요한 곳도 있고, 5분 갱신으로 충분한 곳도 있다. 다만 데이터 규모가 커지면 로그성 데이터를 RDB에 직접 넣으면 터질 수 있으므로, 오프라인 파이프라인(Spark 등)을 통한 처리가 필수가 된다.</p>

<h3 id="kafka-vs-redis--쓰기-처리량의-차이">Kafka vs Redis — 쓰기 처리량의 차이</h3>

<p>한 가지 더 짚자면, <strong>쓰기 처리량에서 Kafka가 Redis보다 월등히 높다</strong>. Kafka는 프로듀서가 데이터를 벌크로 모아 보내는 구조이기 때문이다. 만 건의 이벤트를 Redis에 하나씩 쏘는 것과, Kafka에 벌크로 한 번에 보내는 것은 차원이 다르다.</p>

<p>그래서 이벤트 수집 단계에서 Kafka를 거치고, Consumer에서 Redis에 쓰는 구조가 합리적인 것이다. Consumer 쪽에서도 배치 리스너를 통해 100~200건씩 모아 처리하면 Redis에 대한 네트워크 RTT를 크게 줄일 수 있다.</p>

<hr />

<h2 id="5-인기를-수치화하기--정규화와-가중치의-설계">5. “인기”를 수치화하기 — 정규화와 가중치의 설계</h2>

<h3 id="왜-단순-합산이-안-되는가">왜 단순 합산이 안 되는가</h3>

<p>“인기 있는 상품”이란 무엇인가? 조회수가 높으면? 주문수가 높으면?</p>

<p>문제는 <strong>각 지표의 스케일이 다르다</strong>는 것이다. 조회수는 수만 단위, 주문수는 수십 단위. 이걸 단순히 더하면 조회수가 전체 스코어를 잡아먹는다. 클릭 100과 조회 100만을 더하면, 클릭의 의미가 완전히 사라진다.</p>

<h3 id="정규화--서로-다른-스케일을-같은-척도로">정규화 — 서로 다른 스케일을 같은 척도로</h3>

<p>각 지표를 <strong>0~1 사이의 값으로 변환</strong>해야 비교와 합산이 의미를 갖는다.</p>

<p><strong>Min-Max 정규화</strong>가 흔히 쓰이지만, 실무에서는 함정이 있다. 대부분의 상품 가격이 10만원 이하인데 2,400만원짜리 가구가 하나 있다면? Min-Max 기준으로 나머지 상품은 전부 0에 수렴한다. 낮은 가격대의 변별력이 완전히 사라지는 것이다.</p>

<p>이런 경우 <strong>Saturation 방식</strong>이 더 적합하다. Max 값을 몰라도 적용 가능하고, 극단적인 아웃라이어에 의해 나머지 데이터가 뭉개지지 않는다.</p>

<h3 id="가중치--비즈니스-중요도의-수치화">가중치 — 비즈니스 중요도의 수치화</h3>

<p>정규화된 지표에 <strong>서비스 전략에 맞는 가중치</strong>를 곱한다:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Score(product) = W(view) × Norm(view) 
              + W(like) × Norm(like) 
              + W(order) × Norm(order)
</code></pre></div></div>

<table>
  <thead>
    <tr>
      <th>시그널</th>
      <th>가중치</th>
      <th>판단 근거</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>조회 (view)</td>
      <td>0.1</td>
      <td>발생 빈도가 압도적으로 높아 높은 가중치 시 전체 스코어 지배</td>
    </tr>
    <tr>
      <td>좋아요 (like)</td>
      <td>0.2</td>
      <td>관심의 표현이지만 구매 결정과는 거리가 있음</td>
    </tr>
    <tr>
      <td>주문 (order)</td>
      <td>0.7</td>
      <td>유저가 지갑을 열었다는 것은 가장 강력한 신호</td>
    </tr>
  </tbody>
</table>

<p>총합을 1.0으로 맞춘 것은 의도적이다. 새로운 시그널(장바구니 담기, 공유, 리뷰 등)이 추가될 때 기존 가중치를 비례적으로 재분배하기 쉬운 구조를 유지하기 위해서다.</p>

<p>솔직히 이 가중치가 <strong>“정답”인지는 모른다</strong>. 실제 운영이라면 A/B 테스트로 CTR, CVR을 모니터링하며 튜닝해야 한다. 다만 “왜 이 비율인가”에 대한 논리적 근거를 갖추고 시작하는 것과, 감으로 때려 넣는 것은 완전히 다르다.</p>

<h3 id="랭킹-스코어의-고도화--실무에서는-여기서-끝이-아니다">랭킹 스코어의 고도화 — 실무에서는 여기서 끝이 아니다</h3>

<p>현업의 랭킹 스코어에는 훨씬 많은 요소가 들어간다:</p>

<ul>
  <li><strong>유저 이벤트</strong> (클릭, 조회, 구매 — 우리가 이번에 구현한 것)</li>
  <li><strong>LLM 판단 스코어</strong> (상품 품질, 이미지 매력도 등의 AI 평가)</li>
  <li><strong>객단가 부스팅</strong> (매출 기여도 가중)</li>
  <li><strong>어뷰징 방지 로직</strong> (비정상 클릭 패턴 필터링)</li>
  <li><strong>광고 비딩 스코어</strong> (광고 시스템과 연동 시)</li>
</ul>

<p>특히 광고 시스템에서는 단순히 돈을 많이 낸 순서가 아니라 <strong>타겟팅 적합성</strong>까지 고려한 비딩이 이루어진다. 남성 속옷 광고를 여성에게 보여주면 ROAS(Return On Ad Spend)가 나오지 않아 광고주가 이탈하기 때문이다. 랭킹과 광고 시스템은 결국 같은 뿌리에서 나온다.</p>

<hr />

<h2 id="6-시간의-양자화--텀블링-윈도우와-슬라이딩-윈도우">6. 시간의 양자화 — 텀블링 윈도우와 슬라이딩 윈도우</h2>

<h3 id="누적만-하면-왜-문제인가">누적만 하면 왜 문제인가</h3>

<p>처음에는 모든 점수를 하나의 ZSET에 누적했다. 며칠 지나니 문제가 보였다: <strong>초기에 대량 이벤트가 발생한 상품이 영원히 상위를 독점한다.</strong></p>

<p>이것이 <strong>롱테일(Long Tail) 문제</strong>다. 이미 상위에 있는 상품은 더 많은 노출 → 더 많은 클릭과 구매 → 격차 확대. 신상품은 아무리 좋아도 이 벽을 넘기 어렵다.</p>

<p>해법은 시간을 양자화하는 것이다. 두 가지 방식이 있다.</p>

<h3 id="텀블링-윈도우-tumbling-window">텀블링 윈도우 (Tumbling Window)</h3>

<p>특정 시간 단위로 <strong>딱 끊어서</strong> 집계하는 방식이다. 우리가 구현한 일별 키 전략이 이것이다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ranking:all:20260410   // 4월 10일의 이벤트만 집계
ranking:all:20260411   // 4월 11일의 이벤트만 집계
</code></pre></div></div>

<p><strong>장점</strong>: 구현이 심플하고 키 관리가 명확하다.<br />
<strong>단점</strong>: 윈도우가 넘어가는 순간 데이터가 0이 된다 → <strong>콜드 스타트 발생</strong>.</p>

<h3 id="슬라이딩-윈도우-sliding-window">슬라이딩 윈도우 (Sliding Window)</h3>

<p>데이터가 들어오고 뒤에서 빠지며, <strong>최근 N시간/N일의 데이터가 항상 유지</strong>되는 방식이다. 프로메테우스 같은 모니터링 시스템이 이 방식을 쓴다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[현재 시점]
◀──────── 최근 24시간 ────────▶
항상 24시간분의 데이터가 유지됨
</code></pre></div></div>

<p><strong>장점</strong>: 콜드 스타트가 발생하지 않는다.<br />
<strong>단점</strong>: 구현이 복잡하다. 슬라이딩 윈도우의 데이터는 라운딩되어 있어 정확한 데이터가 아닐 수 있으며, 확대해서 보면 평탄화된 그래프가 보인다.</p>

<h3 id="실무에서의-선택">실무에서의 선택</h3>

<p>29cm에서는 <strong>1시간짜리 버킷</strong>을 사용해서 일간/주간/월간 실시간 급상승 랭킹을 만들었다고 한다. 1시간 버킷 24개를 합산하면 일간, 168개면 주간이 되는 구조다. 하지만 이 계산 로직은 상당히 복잡해지며, 이런 수준의 테크닉을 사용하는 커머스 회사는 드물다.</p>

<p>우리는 <strong>일 단위 텀블링 윈도우 + Score Carry-Over</strong>라는 조합을 선택했다. 구현 복잡도와 표현력 사이의 합리적인 트레이드오프다.</p>

<hr />

<h2 id="7-키-설계--ttl과-네이밍-컨벤션">7. 키 설계 — TTL과 네이밍 컨벤션</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ranking:{scope}:{yyyyMMdd}
</code></pre></div></div>

<h3 id="ttl은-왜-2일인가">TTL은 왜 2일인가</h3>

<p>일간 키이므로 이론적으로 24시간이면 충분하지만, <strong>48시간(2일)</strong>으로 설정했다:</p>

<ul>
  <li>Score Carry-Over 시 전날 키를 참조해야 하므로, 전날 키가 살아 있어야 한다</li>
  <li>운영 중 디버깅이나 보정이 필요할 때 여유분이 필요하다</li>
  <li><strong>시간 윈도우의 1.5~2배</strong>가 안정적인 TTL 전략이라는 것은 실무에서 반복적으로 확인한 패턴이다</li>
</ul>

<h3 id="scope를-넣은-이유">scope를 넣은 이유</h3>

<p>지금은 <code class="language-plaintext highlighter-rouge">all</code>만 있지만, 카테고리별(<code class="language-plaintext highlighter-rouge">ranking:electronics:20260410</code>), 지역별 랭킹이 추가될 때 키 구조를 변경하지 않아도 된다.</p>

<hr />

<h2 id="8-콜드-스타트와-score-carry-over--생각보다-깊은-주제">8. 콜드 스타트와 Score Carry-Over — 생각보다 깊은 주제</h2>

<h3 id="단순한-0점-시작-문제가-아니다">단순한 “0점 시작” 문제가 아니다</h3>

<p>콜드 스타트 문제를 “자정에 점수가 0이 되니까 랭킹이 비어 보인다”로만 이해하면 절반만 아는 것이다.</p>

<p>Score Carry-Over를 하는 이유는 두 가지다:</p>

<p><strong>첫째, 콜드 스타트 완화.</strong> 텀블링 윈도우 방식에서 새 날이 시작되면 모든 상품이 0점이다. 새벽 1시에 앱을 켠 유저는 텅 빈 랭킹을 본다. 어제 1위였던 상품도, 신규 상품도 같은 출발선이다.</p>

<p><strong>둘째, 랭킹의 표현력 향상.</strong> 이것이 더 중요한 이유인데, 특히 <strong>패션 커머스처럼 트렌드 반영이 핵심인 도메인</strong>에서 그렇다.</p>

<p>주간 랭킹을 만든다고 생각해보자. 7일치 일간 버킷을 동일한 비중으로 합산하면, 6일 전의 데이터와 오늘의 데이터가 같은 무게를 갖는다. 한국의 4계절처럼 시즌이 빠르게 바뀌는 패션 커머스에서, 일주일 전 인기였던 봄 아우터와 오늘 급부상 중인 여름 린넨 셔츠가 같은 가중치라면? <strong>최신 트렌드가 묻힌다.</strong></p>

<p>Score Carry-Over에 <strong>감쇠 계수</strong>를 적용하면, 오래된 데이터일수록 영향력이 줄어들면서 자연스럽게 <strong>최신 트렌드가 부각</strong>된다. 이것이 단순 합산 대비 Carry-Over가 갖는 진짜 가치다.</p>

<h3 id="zunionstore로-구현하기">ZUNIONSTORE로 구현하기</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ZUNIONSTORE ranking:all:20260411 1 ranking:all:20260410 WEIGHTS 0.1 AGGREGATE SUM
</code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">ranking:all:20260410</code>의 모든 멤버와 스코어를 가져온다</li>
  <li>각 스코어에 0.1(10%)을 곱한다</li>
  <li>결과를 <code class="language-plaintext highlighter-rouge">ranking:all:20260411</code>에 저장한다</li>
</ul>

<h3 id="왜-10인가">왜 10%인가</h3>

<table>
  <thead>
    <tr>
      <th>Carry-Over 비율</th>
      <th>효과</th>
      <th>문제</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>50% 이상</td>
      <td>어제 랭킹이 오늘을 지배</td>
      <td>일별 키 분리의 의미 퇴색</td>
    </tr>
    <tr>
      <td>10%</td>
      <td>출발 보너스 + 당일 역전 가능</td>
      <td>균형점</td>
    </tr>
    <tr>
      <td>1% 이하</td>
      <td>콜드 스타트 완화 효과 미미</td>
      <td>거의 0점 시작과 동일</td>
    </tr>
  </tbody>
</table>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[4월 10일 최종]               [4월 11일 시작 (Carry-Over)]
product:101 → 1000점    →    product:101 → 100점
product:202 → 500점     →    product:202 → 50점
product:303 → 200점     →    product:303 → 20점
</code></pre></div></div>

<p>오전 중으로 실제 이벤트가 쌓이면 Carry-Over 점수의 영향은 자연스럽게 희석된다.</p>

<h3 id="만약-슬라이딩-윈도우였다면">만약 슬라이딩 윈도우였다면?</h3>

<p>시간 단위 버킷을 24개 유지하는 슬라이딩 윈도우 방식이었다면 Carry-Over가 필요 없었을 수 있다. 항상 최근 24시간의 데이터가 존재하니까.</p>

<p>하지만 그 방식은 <strong>계산 로직이 복잡해지고</strong>, 정확히 24시간이 아닌 23시간 10분 같은 어중간한 데이터가 될 수 있다. 1분짜리 버킷을 만들면 하루에 1,440개 — 현실적이지 않다. 결국 라운딩이 들어가고, 이 라운딩이 랭킹의 대수 법칙에 큰 영향을 주지 않는다는 합의 하에 적절한 단위를 선택하게 된다.</p>

<p><strong>개발에 “정답”이란 없다. 항상 다른 선택지가 있고, 트레이드오프가 있다.</strong></p>

<h3 id="실행-시점--스케줄러">실행 시점 — 스케줄러</h3>

<p>Carry-Over는 매일 23:50에 실행하는 것이 이상적이다:</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Scheduled</span><span class="o">(</span><span class="n">cron</span> <span class="o">=</span> <span class="s">"0 50 23 * * *"</span><span class="o">)</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">prepareNextDayRanking</span><span class="o">()</span> <span class="o">{</span>
    <span class="nc">String</span> <span class="n">today</span> <span class="o">=</span> <span class="nc">LocalDate</span><span class="o">.</span><span class="na">now</span><span class="o">().</span><span class="na">format</span><span class="o">(</span><span class="nc">DateTimeFormatter</span><span class="o">.</span><span class="na">BASIC_ISO_DATE</span><span class="o">);</span>
    <span class="nc">String</span> <span class="n">tomorrow</span> <span class="o">=</span> <span class="nc">LocalDate</span><span class="o">.</span><span class="na">now</span><span class="o">().</span><span class="na">plusDays</span><span class="o">(</span><span class="mi">1</span><span class="o">).</span><span class="na">format</span><span class="o">(</span><span class="nc">DateTimeFormatter</span><span class="o">.</span><span class="na">BASIC_ISO_DATE</span><span class="o">);</span>
    
    <span class="nc">String</span> <span class="n">srcKey</span> <span class="o">=</span> <span class="s">"ranking:all:"</span> <span class="o">+</span> <span class="n">today</span><span class="o">;</span>
    <span class="nc">String</span> <span class="n">destKey</span> <span class="o">=</span> <span class="s">"ranking:all:"</span> <span class="o">+</span> <span class="n">tomorrow</span><span class="o">;</span>
    
    <span class="c1">// ZUNIONSTORE를 통해 전날 점수의 10%를 시드</span>
    <span class="c1">// TTL 2일 설정</span>
    <span class="n">redisTemplate</span><span class="o">.</span><span class="na">expire</span><span class="o">(</span><span class="n">destKey</span><span class="o">,</span> <span class="nc">Duration</span><span class="o">.</span><span class="na">ofDays</span><span class="o">(</span><span class="mi">2</span><span class="o">));</span>
<span class="o">}</span>
</code></pre></div></div>

<hr />

<h2 id="9-상품-삭제와-랭킹-정합성--이벤트-드리븐의-현실">9. 상품 삭제와 랭킹 정합성 — 이벤트 드리븐의 현실</h2>

<p>실무에서 반드시 마주치는 문제가 있다: <strong>“랭킹에 있는 상품이 삭제되면?”</strong></p>

<p>상품팀에서 상품을 삭제하거나 품절 처리했는데, 랭킹에는 여전히 노출된다. 유저가 클릭하면 404. 이건 심각한 UX 문제다.</p>

<p>이 문제를 해결하려면 <strong>상품 삭제 이벤트가 전사적으로 전파</strong>되어야 한다. 이벤트 드리븐 아키텍처가 필수적인 이유 중 하나다. 하지만 각 팀(랭킹, 검색, 추천, 전시)의 데이터 처리 타이밍이 다를 수 있어 <strong>일관성 유지가 어렵다</strong>.</p>

<p>규모가 커지면 이 문제를 전담하는 <strong>“전시팀”</strong>이 생긴다. 모든 데이터가 전시 레이어를 통해 노출되면 일관성을 유지할 수 있다.</p>

<p>한 가지 더 — 품절/삭제된 상품을 랭킹에서 즉시 제거할지, “판매 종료”로 표시할지는 <strong>개발자가 아니라 PO(Product Owner)의 결정</strong>이다. 개발자가 할 일은 <strong>어떤 결정이 내려와도 쉽게 변경할 수 있는 구조를 미리 만들어두는 것</strong>이다. 이런 변경 가능성을 예측하고 준비해두면 높은 평가를 받는다.</p>

<hr />

<h2 id="10-api-설계--랭킹을-어떻게-서빙할-것인가">10. API 설계 — 랭킹을 어떻게 서빙할 것인가</h2>

<h3 id="top-n-조회">Top-N 조회</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GET /api/v1/rankings?date=20260410&amp;size=20&amp;page=1
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">ZREVRANGE</code>로 Top-N을 가져온 뒤, 상품 ID 목록으로 상세 정보를 조회해서 합쳐 응답한다.</p>

<h3 id="개별-상품-순위-조회">개별 상품 순위 조회</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GET /api/v1/products/{id}/rank
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">ZREVRANK</code>로 O(log N)에 조회. 랭킹 미진입 상품은 null 반환.</p>

<h3 id="view와-impression의-구분--이벤트-로그-설계의-디테일">View와 Impression의 구분 — 이벤트 로그 설계의 디테일</h3>

<p>랭킹에 쓰이는 “조회 이벤트”를 설계할 때 한 가지 더 고민해야 할 것이 있다.</p>

<p>상품 목록이 3열로 나올 때, 핸드폰 기종에 따라 마지막 열이 화면에 잘릴 수 있다. 이걸 “조회”로 칠 것인가? <strong>이미지의 몇 퍼센트가 노출되어야 “봤다”고 칠 것인가?</strong></p>

<p>이 기준을 Impression 비율이라 하며, 이 기준에 따라 조회 이벤트의 정의 자체가 달라진다. 단순해 보이지만, 이 기준이 랭킹 스코어의 정확도에 직접적으로 영향을 미친다.</p>

<hr />

<h2 id="11-돌아보며--기술적-판단은-항상-트레이드오프다">11. 돌아보며 — 기술적 판단은 항상 트레이드오프다</h2>

<h3 id="이번에-내린-판단들의-기록">이번에 내린 판단들의 기록</h3>

<table>
  <thead>
    <tr>
      <th>판단</th>
      <th>선택</th>
      <th>대안</th>
      <th>근거</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>저장소</td>
      <td>Redis ZSET</td>
      <td>ES, NoSQL</td>
      <td>단일 차원 전체 랭킹에서 가장 심플</td>
    </tr>
    <tr>
      <td>시간 윈도우</td>
      <td>텀블링 (일 단위)</td>
      <td>슬라이딩 (시간 단위)</td>
      <td>구현 복잡도와 표현력의 균형</td>
    </tr>
    <tr>
      <td>콜드 스타트</td>
      <td>Carry-Over 10%</td>
      <td>슬라이딩 윈도우</td>
      <td>텀블링 선택의 논리적 귀결</td>
    </tr>
    <tr>
      <td>가중치</td>
      <td>view 0.1 / like 0.2 / order 0.7</td>
      <td>균등 배분</td>
      <td>비즈니스 임팩트 기반 차등</td>
    </tr>
    <tr>
      <td>키 TTL</td>
      <td>2일</td>
      <td>1일</td>
      <td>Carry-Over 참조 + 운영 여유</td>
    </tr>
  </tbody>
</table>

<h3 id="로직보다-중요한-것은-로직을-쉽게-바꿀-수-있는-구조">로직보다 중요한 것은 “로직을 쉽게 바꿀 수 있는 구조”</h3>

<p>멘토링에서 가장 인상 깊었던 말이 있다:</p>

<blockquote>
  <p>“로직 질문이 많을수록, 로직 자체보다 로직을 쉽게 변경할 수 있는 테스트 가능한 구조로 아키텍처를 짜야 한다.”</p>
</blockquote>

<p>가중치가 바뀔 수 있다. 집계 단위가 바뀔 수 있다. Carry-Over 비율이 바뀔 수 있다. 랭킹은 “정답이 없기에 계속 튜닝해 나가는” 시스템이다. 그래서 <strong>각 정책 값이 외부에서 주입 가능하고, 변경 시 테스트로 검증할 수 있는 구조</strong>가 코드의 정교함보다 중요하다.</p>

<h3 id="랭킹은-정적-vs-동적의-줄다리기">랭킹은 “정적 vs 동적”의 줄다리기</h3>

<p>랭킹은 정적이어야 한다는 관점이 있다 — 유저가 볼 때마다 순위가 바뀌면 신뢰를 잃는다. 반대로, 유저가 원하는 것에 따라 실시간으로 변해야 한다는 관점도 있다 — 지금 이 순간 핫한 것을 보여줘야 구매가 일어난다.</p>

<p>정답은 없다. <strong>서비스의 성격, 유저의 기대, 비즈니스 목표</strong>에 따라 그 줄다리기의 균형점이 달라질 뿐이다.</p>

<hr />

<p><em>이번 글은 “어떻게 만들었는가”보다 “왜 그렇게 판단했는가”에 집중했다. 코드는 6개월이면 레거시가 되지만, 판단의 근거는 다음 시스템을 설계할 때도 유효하다. 그래서 기록한다.</em></p>]]></content><author><name>Ukukdin</name></author><category term="Loopers" /><category term="Tech" /><category term="Redis" /><category term="SortedSet" /><category term="Ranking" /><category term="Kafka" /><category term="ColdStart" /><category term="TimeSeries" /><category term="Architecture" /><summary type="html"><![CDATA[이커머스 랭킹은 '줄 세우기'가 아니라 '시간을 어떻게 자를 것인가'의 문제다. 이벤트 집계, 시간 양자화, 콜드 스타트, 그리고 실무에서 Redis ZSET이 만능이 아닌 이유까지.]]></summary></entry><entry><title type="html">폭증하는 트래픽을 어느정도 나는 고려해서 설계를 할 수 있는가?</title><link href="https://ukukdin.github.io/2026/04/02/8weeksBlog/" rel="alternate" type="text/html" title="폭증하는 트래픽을 어느정도 나는 고려해서 설계를 할 수 있는가?" /><published>2026-04-02T00:00:00+00:00</published><updated>2026-04-02T00:00:00+00:00</updated><id>https://ukukdin.github.io/2026/04/02/8weeksBlog</id><content type="html" xml:base="https://ukukdin.github.io/2026/04/02/8weeksBlog/"><![CDATA[<blockquote>
  <p><strong>TL;DR</strong>: 선착순 주문에 10만 명이 몰리는 상황을 Redis 대기열로 풀었다. 처음에는 “Redis니까 빠르겠지”로 시작했는데, 동시성 구멍이 줄줄이 터졌다. Lua 스크립트로 원자성을 확보하고, 100ms 단위 분산 발급으로 Thundering Herd를 완화하고, 토큰 소비를 원자 연산으로 바꾸기까지의 과정을 정리했다.</p>
</blockquote>

<hr />

<h2 id="1-문제-선착순-주문이라는-폭탄">1. 문제: “선착순 주문”이라는 폭탄</h2>

<p>이커머스에서 선착순 주문은 늘 문제다. 평소 TPS가 50인 서비스에 갑자기 10만 명이 동시에 <code class="language-plaintext highlighter-rouge">POST /orders</code>를 누른다. DB 커넥션 풀은 40개다. 나머지 99,960명의 요청은 어디로 가는가?</p>

<p>답은 간단하다. <strong>커넥션 풀이 고갈되고, 타임아웃이 연쇄하고, 서비스 전체가 멈춘다.</strong></p>

<p>선착순 주문뿐만이 아니다. 타임세일, 한정판 드랍, 쿠폰 발급 — 트래픽이 순간적으로 폭증하는 시나리오는 실무에서 반복적으로 나타난다. 이 문제를 풀기 위해 대기열 시스템을 설계했다.</p>

<hr />

<h2 id="2-핵심-아이디어-줄-세우기">2. 핵심 아이디어: “줄 세우기”</h2>

<p>대기열의 본질은 단순하다. <strong>서버가 처리할 수 있는 속도로만 사용자를 들여보내는 것.</strong></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[사용자 10만명] → [대기열 (FIFO)] → [토큰 발급 (18명/100ms)] → [주문 API]
</code></pre></div></div>

<ol>
  <li>사용자가 <code class="language-plaintext highlighter-rouge">POST /queue/enter</code>로 대기열에 진입한다.</li>
  <li>스케줄러가 100ms마다 앞에서 18명씩 꺼내서 <strong>입장 토큰</strong>을 발급한다.</li>
  <li>토큰을 받은 사용자만 <code class="language-plaintext highlighter-rouge">POST /orders</code>를 호출할 수 있다.</li>
  <li>토큰이 없으면 인터셉터에서 403으로 차단한다.</li>
</ol>

<p>이렇게 하면 주문 API에 도달하는 트래픽이 초당 ~180명으로 제한된다. DB 커넥션 풀(40개)이 감당할 수 있는 수준이다.</p>

<hr />

<h2 id="3-숫자를-먼저-정했다">3. 숫자를 먼저 정했다</h2>

<p>설계 전에 시스템이 감당할 수 있는 한계를 먼저 계산했다. 감(感)이 아니라 <strong>숫자</strong>로 시작해야 한다.</p>

<h3 id="하류-시스템-처리량-역산">하류 시스템 처리량 역산</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>DB 커넥션 풀 (HikariCP max)     = 40개
주문 1건 평균 처리 시간           = ~200ms (비관적 락 + 쿠폰 검증 + 저장 + 이벤트)
이론적 최대 TPS                  = 40 / 0.2 = 200 TPS
안전 마진 적용 (70%)             = 200 × 0.7 = 140 TPS
</code></pre></div></div>

<p>실제 설정에서는 <strong>175 TPS</strong>로 잡았다. Tomcat max threads(200)는 커넥션 풀(40)보다 넉넉하므로 병목은 DB 커넥션이다. 커넥션 풀이 병목이라는 사실을 먼저 파악한 것이 나머지 설계의 출발점이 되었다.</p>

<h3 id="thundering-herd-완화">Thundering Herd 완화</h3>

<p>여기서 중요한 판단이 있었다. 175명을 <strong>1초에 한 번</strong> 발급할 것인가, <strong>100ms마다 나눠서</strong> 발급할 것인가?</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>AS-IS: 1초마다 175명 동시 발급 → 175명이 동시에 POST /orders → 커넥션 풀 순간 고갈
TO-BE: 100ms마다 ~18명씩 발급 → 10회에 걸쳐 분산 → 순간 부하 10배 감소
</code></pre></div></div>

<table>
  <thead>
    <tr>
      <th>설정</th>
      <th>값</th>
      <th>산정 근거</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>스케줄러 주기</td>
      <td>100ms</td>
      <td>1초를 10구간으로 분할</td>
    </tr>
    <tr>
      <td>배치 크기</td>
      <td>18명</td>
      <td>175 / 10 = 17.5 → 올림</td>
    </tr>
    <tr>
      <td>토큰 TTL</td>
      <td>300초 (5분)</td>
      <td>주문 작성(~1분) + 결제(~1분) + 여유(3분)</td>
    </tr>
    <tr>
      <td>최대 대기열</td>
      <td>100,000명</td>
      <td>100,000 / 175 ≈ 571초 ≈ ~9.5분 대기</td>
    </tr>
  </tbody>
</table>

<p>9.5분이 넘으면 사용자 이탈률이 급격히 올라가므로, 10만 명을 넘으면 <code class="language-plaintext highlighter-rouge">QUEUE_FULL</code>로 거절하기로 했다. “모두 받아주겠다”는 것보다 “솔직하게 거절하겠다”는 것이 더 나은 UX라고 판단했다.</p>

<hr />

<h2 id="4-redis를-선택한-이유--그리고-처음에-잘못-쓴-이유">4. Redis를 선택한 이유 — 그리고 처음에 잘못 쓴 이유</h2>

<p>대기열 저장소로 Redis Sorted Set을 선택했다. 이유는 명확하다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">ZADD</code>: O(log N) 삽입 + FIFO 보장 (score 기반 정렬)</li>
  <li><code class="language-plaintext highlighter-rouge">ZPOPMIN</code>: O(log N) + O(M) 으로 앞에서 N명 추출</li>
  <li><code class="language-plaintext highlighter-rouge">ZRANK</code>: O(log N) 으로 내 순번 조회</li>
  <li>인메모리이므로 10만 명 수준은 메모리 수 MB로 처리 가능</li>
</ul>

<p><strong>하지만 “Redis니까 빠르고 안전하겠지”는 착각이었다.</strong></p>

<p>처음 구현은 이랬다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 처음 구현 — 원자성이 없다</span>
<span class="kd">public</span> <span class="kt">long</span> <span class="nf">enter</span><span class="o">(</span><span class="nc">Long</span> <span class="n">userId</span><span class="o">)</span> <span class="o">{</span>
    <span class="k">if</span> <span class="o">(</span><span class="n">entryTokenRepository</span><span class="o">.</span><span class="na">exists</span><span class="o">(</span><span class="n">userId</span><span class="o">))</span> <span class="k">throw</span> <span class="no">ALREADY_HAS_TOKEN</span><span class="o">;</span>   <span class="c1">// ① 토큰 확인</span>
    <span class="k">if</span> <span class="o">(</span><span class="n">waitingQueueRepository</span><span class="o">.</span><span class="na">getTotalSize</span><span class="o">()</span> <span class="o">&gt;=</span> <span class="n">maxSize</span><span class="o">)</span> <span class="k">throw</span> <span class="no">FULL</span><span class="o">;</span>   <span class="c1">// ② 크기 확인</span>
    <span class="kt">double</span> <span class="n">score</span> <span class="o">=</span> <span class="nc">System</span><span class="o">.</span><span class="na">currentTimeMillis</span><span class="o">();</span>                          <span class="c1">// ③ score 생성</span>
    <span class="n">waitingQueueRepository</span><span class="o">.</span><span class="na">add</span><span class="o">(</span><span class="n">userId</span><span class="o">,</span> <span class="n">score</span><span class="o">);</span>                          <span class="c1">// ④ ZADD</span>
    <span class="k">return</span> <span class="n">waitingQueueRepository</span><span class="o">.</span><span class="na">getRank</span><span class="o">(</span><span class="n">userId</span><span class="o">);</span>                      <span class="c1">// ⑤ 순번 반환</span>
<span class="o">}</span>
</code></pre></div></div>

<p>이 코드에는 <strong>세 가지 동시성 구멍</strong>이 있었다.</p>

<h3 id="구멍-1-크기-검사와-삽입-사이의-틈">구멍 1: 크기 검사와 삽입 사이의 틈</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Thread A: ② getTotalSize() → 99,999 (OK)
Thread B: ② getTotalSize() → 99,999 (OK)
Thread A: ④ ZADD → 100,000번째
Thread B: ④ ZADD → 100,001번째 ← maxQueueSize 초과!
</code></pre></div></div>

<p>크기 확인과 삽입이 별도 명령이므로, 두 스레드가 동시에 통과할 수 있다.</p>

<h3 id="구멍-2-systemcurrenttimemillis-충돌">구멍 2: <code class="language-plaintext highlighter-rouge">System.currentTimeMillis()</code> 충돌</h3>

<p>같은 밀리초에 두 명이 진입하면 score가 동일하다. Redis Sorted Set은 동일 score일 때 member를 사전순으로 정렬하므로, <strong>먼저 요청한 사람이 뒤로 밀릴 수 있다.</strong> FIFO가 깨진다.</p>

<h3 id="구멍-3-토큰-확인과-진입-사이의-틈">구멍 3: 토큰 확인과 진입 사이의 틈</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Thread A: ① exists(userId) → false
Scheduler: 토큰 발급 (userId에게 토큰 발급)
Thread A: ④ ZADD → 대기열에도 있고 토큰도 있는 상태!
</code></pre></div></div>

<hr />

<h2 id="5-lua-스크립트로-원자성-확보">5. Lua 스크립트로 원자성 확보</h2>

<p>위 세 가지 구멍을 모두 막으려면, 토큰 확인 → 크기 검사 → score 생성 → 삽입 → 순번 반환을 <strong>하나의 원자 연산</strong>으로 묶어야 한다. Redis의 Lua 스크립트가 정확히 이 용도다.</p>

<div class="language-lua highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- ENTER_SCRIPT: 대기열 진입 (원자적)</span>
<span class="c1">-- KEYS: [waiting-queue, waiting-queue:seq]</span>
<span class="c1">-- ARGV: [userId, maxQueueSize, tokenKeyPrefix]</span>

<span class="c1">-- 1. 토큰 보유 여부 확인</span>
<span class="k">if</span> <span class="n">redis</span><span class="p">.</span><span class="n">call</span><span class="p">(</span><span class="s1">'EXISTS'</span><span class="p">,</span> <span class="n">ARGV</span><span class="p">[</span><span class="mi">3</span><span class="p">]</span> <span class="o">..</span> <span class="n">ARGV</span><span class="p">[</span><span class="mi">1</span><span class="p">])</span> <span class="o">==</span> <span class="mi">1</span> <span class="k">then</span>
    <span class="k">return</span> <span class="o">-</span><span class="mi">2</span>  <span class="c1">-- ALREADY_HAS_TOKEN</span>
<span class="k">end</span>

<span class="c1">-- 2. 이미 대기열에 있는지 확인 (멱등성)</span>
<span class="kd">local</span> <span class="n">rank</span> <span class="o">=</span> <span class="n">redis</span><span class="p">.</span><span class="n">call</span><span class="p">(</span><span class="s1">'ZRANK'</span><span class="p">,</span> <span class="n">KEYS</span><span class="p">[</span><span class="mi">1</span><span class="p">],</span> <span class="n">ARGV</span><span class="p">[</span><span class="mi">1</span><span class="p">])</span>
<span class="k">if</span> <span class="n">rank</span> <span class="k">then</span> <span class="k">return</span> <span class="n">rank</span> <span class="k">end</span>

<span class="c1">-- 3. 대기열 크기 확인</span>
<span class="k">if</span> <span class="nb">tonumber</span><span class="p">(</span><span class="n">redis</span><span class="p">.</span><span class="n">call</span><span class="p">(</span><span class="s1">'ZCARD'</span><span class="p">,</span> <span class="n">KEYS</span><span class="p">[</span><span class="mi">1</span><span class="p">]))</span> <span class="o">&gt;=</span> <span class="nb">tonumber</span><span class="p">(</span><span class="n">ARGV</span><span class="p">[</span><span class="mi">2</span><span class="p">])</span> <span class="k">then</span>
    <span class="k">return</span> <span class="o">-</span><span class="mi">1</span>  <span class="c1">-- QUEUE_FULL</span>
<span class="k">end</span>

<span class="c1">-- 4. 단조 증가 score 생성 (INCR로 충돌 불가)</span>
<span class="kd">local</span> <span class="n">seq</span> <span class="o">=</span> <span class="n">redis</span><span class="p">.</span><span class="n">call</span><span class="p">(</span><span class="s1">'INCR'</span><span class="p">,</span> <span class="n">KEYS</span><span class="p">[</span><span class="mi">2</span><span class="p">])</span>

<span class="c1">-- 5. 삽입 + 순번 반환</span>
<span class="n">redis</span><span class="p">.</span><span class="n">call</span><span class="p">(</span><span class="s1">'ZADD'</span><span class="p">,</span> <span class="n">KEYS</span><span class="p">[</span><span class="mi">1</span><span class="p">],</span> <span class="s1">'NX'</span><span class="p">,</span> <span class="n">seq</span><span class="p">,</span> <span class="n">ARGV</span><span class="p">[</span><span class="mi">1</span><span class="p">])</span>
<span class="k">return</span> <span class="n">redis</span><span class="p">.</span><span class="n">call</span><span class="p">(</span><span class="s1">'ZRANK'</span><span class="p">,</span> <span class="n">KEYS</span><span class="p">[</span><span class="mi">1</span><span class="p">],</span> <span class="n">ARGV</span><span class="p">[</span><span class="mi">1</span><span class="p">])</span>
</code></pre></div></div>

<p>Redis는 Lua 스크립트를 <strong>단일 스레드에서 원자적으로</strong> 실행한다. 스크립트 실행 중에는 다른 명령이 끼어들 수 없다. 이 한 가지 특성이 위의 세 가지 구멍을 모두 막는다.</p>

<p><code class="language-plaintext highlighter-rouge">System.currentTimeMillis()</code> 대신 <code class="language-plaintext highlighter-rouge">INCR</code>로 단조 증가하는 시퀀스를 score로 사용한 것도 핵심이다. 같은 밀리초에 100명이 진입해도 score가 전부 다르므로 FIFO가 보장된다.</p>

<p>같은 원리로 토큰 발급(pop + save)과 토큰 소비(검증 + 삭제)도 Lua로 원자화했다.</p>

<div class="language-lua highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- CONSUME_IF_MATCHES_SCRIPT: 토큰 검증 + 삭제를 한 번에</span>
<span class="kd">local</span> <span class="n">stored</span> <span class="o">=</span> <span class="n">redis</span><span class="p">.</span><span class="n">call</span><span class="p">(</span><span class="s1">'GET'</span><span class="p">,</span> <span class="n">KEYS</span><span class="p">[</span><span class="mi">1</span><span class="p">])</span>
<span class="k">if</span> <span class="ow">not</span> <span class="n">stored</span> <span class="k">then</span> <span class="k">return</span> <span class="o">-</span><span class="mi">1</span> <span class="k">end</span>           <span class="c1">-- 토큰 없음 (만료)</span>
<span class="k">if</span> <span class="n">stored</span> <span class="o">==</span> <span class="n">ARGV</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span> <span class="k">then</span>
    <span class="n">redis</span><span class="p">.</span><span class="n">call</span><span class="p">(</span><span class="s1">'DEL'</span><span class="p">,</span> <span class="n">KEYS</span><span class="p">[</span><span class="mi">1</span><span class="p">])</span>
    <span class="k">return</span> <span class="mi">1</span>                                <span class="c1">-- 소비 성공</span>
<span class="k">end</span>
<span class="k">return</span> <span class="mi">0</span>                                    <span class="c1">-- 토큰 불일치</span>
</code></pre></div></div>

<p>이 스크립트 하나로 <strong>동일 토큰으로 동시에 두 번 주문하면 정확히 한 번만 성공</strong>하는 것을 보장한다.</p>

<hr />

<h2 id="6-토큰-생명주기--실패해도-괜찮은-구조">6. 토큰 생명주기 — 실패해도 괜찮은 구조</h2>

<p>토큰의 생명주기를 설계할 때 가장 고민한 부분은 <strong>“주문이 실패하면 토큰은 어떻게 되는가?”</strong>였다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[진입] → [대기] → [토큰 발급 (TTL 5분)] → [토큰 소비] → [주문 생성]
                                              ↓ (주문 실패 시)
                                          [토큰 복원]
</code></pre></div></div>

<p>처음에는 OrderService 안에서 <code class="language-plaintext highlighter-rouge">@Transactional</code> 경계 내에 토큰 삭제를 넣었다. 하지만 Redis 삭제와 DB 트랜잭션은 서로 다른 시스템이므로 원자성이 보장되지 않는다.</p>

<ul>
  <li>Redis 삭제 성공 → DB 커밋 실패 → <strong>토큰은 사라졌는데 주문은 없는 상태</strong></li>
  <li>Redis 삭제 실패 → DB 커밋 성공 → <strong>주문은 됐는데 토큰이 남아서 중복 주문 가능</strong></li>
</ul>

<p>이 문제를 인터셉터 패턴으로 풀었다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="kd">class</span> <span class="nc">EntryTokenInterceptor</span> <span class="kd">implements</span> <span class="nc">HandlerInterceptor</span> <span class="o">{</span>
    
    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="kt">boolean</span> <span class="nf">preHandle</span><span class="o">(...)</span> <span class="o">{</span>
        <span class="nc">String</span> <span class="n">token</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="na">getHeader</span><span class="o">(</span><span class="s">"X-Entry-Token"</span><span class="o">);</span>
        <span class="c1">// Lua 스크립트로 검증 + 삭제를 원자적으로 수행</span>
        <span class="n">validateEntryTokenUseCase</span><span class="o">.</span><span class="na">consume</span><span class="o">(</span><span class="n">userId</span><span class="o">,</span> <span class="n">token</span><span class="o">);</span>
        <span class="c1">// 복원용으로 저장</span>
        <span class="n">request</span><span class="o">.</span><span class="na">setAttribute</span><span class="o">(</span><span class="s">"consumed-token"</span><span class="o">,</span> <span class="n">token</span><span class="o">);</span>
        <span class="k">return</span> <span class="kc">true</span><span class="o">;</span>
    <span class="o">}</span>
    
    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">afterCompletion</span><span class="o">(...,</span> <span class="nc">Exception</span> <span class="n">ex</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">ex</span> <span class="o">!=</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span>
            <span class="c1">// 주문 실패 시 토큰 복원 (TTL 유지)</span>
            <span class="nc">String</span> <span class="n">token</span> <span class="o">=</span> <span class="o">(</span><span class="nc">String</span><span class="o">)</span> <span class="n">request</span><span class="o">.</span><span class="na">getAttribute</span><span class="o">(</span><span class="s">"consumed-token"</span><span class="o">);</span>
            <span class="n">entryTokenRepository</span><span class="o">.</span><span class="na">save</span><span class="o">(</span><span class="n">userId</span><span class="o">,</span> <span class="n">token</span><span class="o">,</span> <span class="n">remainingTtl</span><span class="o">);</span>
        <span class="o">}</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">preHandle</code>: 주문 API 진입 전에 토큰을 <strong>원자적으로 소비</strong>(Lua로 검증+삭제 동시 수행)</li>
  <li><code class="language-plaintext highlighter-rouge">afterCompletion</code>: 주문이 실패하면 토큰을 <strong>복원</strong></li>
</ul>

<p>이렇게 하면 토큰 소비가 OrderService의 트랜잭션 경계 밖에서 일어나므로, DB 트랜잭션 실패와 Redis 상태 불일치 문제가 해소된다. 최악의 경우에도 토큰 TTL(5분)이 안전망 역할을 한다.</p>

<hr />

<h2 id="7-동시성-테스트--되겠지는-테스트가-아니다">7. 동시성 테스트 — “되겠지”는 테스트가 아니다</h2>

<p>대기열 시스템에서 단위 테스트만으로는 부족하다. 동시에 50명이 진입하고, 동시에 같은 토큰으로 주문하고, 스케줄러가 동시에 돌아가는 상황을 테스트해야 한다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Test</span>
<span class="kt">void</span> <span class="n">동일_토큰으로_동시_주문시_정확히_1건만_성공한다</span><span class="o">()</span> <span class="o">{</span>
    <span class="c1">// 50 threads가 동시에 같은 토큰으로 consume 시도</span>
    <span class="nc">CountDownLatch</span> <span class="n">startLatch</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">CountDownLatch</span><span class="o">(</span><span class="mi">1</span><span class="o">);</span>
    <span class="nc">CountDownLatch</span> <span class="n">doneLatch</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">CountDownLatch</span><span class="o">(</span><span class="mi">50</span><span class="o">);</span>
    <span class="nc">ConcurrentLinkedQueue</span><span class="o">&lt;</span><span class="nc">Throwable</span><span class="o">&gt;</span> <span class="n">errors</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">ConcurrentLinkedQueue</span><span class="o">&lt;&gt;();</span>
    <span class="nc">AtomicInteger</span> <span class="n">successCount</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">AtomicInteger</span><span class="o">(</span><span class="mi">0</span><span class="o">);</span>
    
    <span class="k">for</span> <span class="o">(</span><span class="kt">int</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="o">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="mi">50</span><span class="o">;</span> <span class="n">i</span><span class="o">++)</span> <span class="o">{</span>
        <span class="n">executor</span><span class="o">.</span><span class="na">submit</span><span class="o">(()</span> <span class="o">-&gt;</span> <span class="o">{</span>
            <span class="n">startLatch</span><span class="o">.</span><span class="na">await</span><span class="o">();</span>  <span class="c1">// 모든 스레드가 동시에 시작</span>
            <span class="k">try</span> <span class="o">{</span>
                <span class="n">queueService</span><span class="o">.</span><span class="na">consume</span><span class="o">(</span><span class="n">userId</span><span class="o">,</span> <span class="n">token</span><span class="o">);</span>
                <span class="n">successCount</span><span class="o">.</span><span class="na">incrementAndGet</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">errors</span><span class="o">.</span><span class="na">add</span><span class="o">(</span><span class="n">e</span><span class="o">);</span>
            <span class="o">}</span> <span class="k">finally</span> <span class="o">{</span>
                <span class="n">doneLatch</span><span class="o">.</span><span class="na">countDown</span><span class="o">();</span>
            <span class="o">}</span>
        <span class="o">});</span>
    <span class="o">}</span>
    
    <span class="n">startLatch</span><span class="o">.</span><span class="na">countDown</span><span class="o">();</span>  <span class="c1">// 동시 출발</span>
    <span class="n">assertThat</span><span class="o">(</span><span class="n">doneLatch</span><span class="o">.</span><span class="na">await</span><span class="o">(</span><span class="mi">10</span><span class="o">,</span> <span class="no">SECONDS</span><span class="o">)).</span><span class="na">isTrue</span><span class="o">();</span>
    <span class="n">assertThat</span><span class="o">(</span><span class="n">successCount</span><span class="o">.</span><span class="na">get</span><span class="o">()).</span><span class="na">isEqualTo</span><span class="o">(</span><span class="mi">1</span><span class="o">);</span>  <span class="c1">// 정확히 1건</span>
    <span class="n">assertThat</span><span class="o">(</span><span class="n">errors</span><span class="o">).</span><span class="na">hasSize</span><span class="o">(</span><span class="mi">49</span><span class="o">);</span>                <span class="c1">// 나머지는 전부 실패</span>
<span class="o">}</span>
</code></pre></div></div>

<p>여기서 중요한 건 <code class="language-plaintext highlighter-rouge">ConcurrentLinkedQueue</code>로 <strong>실패를 수집</strong>하는 것이다. 처음에는 예외를 그냥 삼키고 <code class="language-plaintext highlighter-rouge">doneLatch</code>만 확인했는데, 이러면 스레드 일부가 예상과 다른 이유로 실패해도 테스트가 녹색이 된다. 거짓 양성(false positive)은 동시성 테스트에서 가장 위험한 결과다.</p>

<hr />

<h2 id="8-코드-리뷰에서-배운-것">8. 코드 리뷰에서 배운 것</h2>

<p>이 시스템은 처음 PR을 올린 뒤 코드 리뷰에서 상당한 지적을 받았다. 그 과정에서 배운 것들을 정리한다.</p>

<h3 id="배운-것-1-나중에-lua로-바꿔야지는-없다">배운 것 1: “나중에 Lua로 바꿔야지”는 없다</h3>

<p>처음에는 Java에서 여러 Redis 명령을 순차 호출하고, “성능 이슈가 생기면 Lua로 바꾸자”고 생각했다. 하지만 성능이 아니라 <strong>정합성</strong>이 문제였다. 동시성 버그는 “나중에”가 아니라 <strong>트래픽이 폭증하는 바로 그 순간</strong>에 터진다. 대기열 시스템은 트래픽 폭증을 전제로 만드는 것이므로, 원자성은 최초 설계부터 확보해야 했다.</p>

<h3 id="배운-것-2-validate와-consume은-분리하면-안-된다">배운 것 2: validate()와 consume()은 분리하면 안 된다</h3>

<p>처음에는 인터셉터에서 <code class="language-plaintext highlighter-rouge">validate(token)</code> → OrderService에서 <code class="language-plaintext highlighter-rouge">delete(token)</code>으로 분리했다. “검증은 검증, 삭제는 삭제”라는 깔끔한 분리처럼 보였다. 하지만 그 사이에 <strong>같은 토큰으로 두 번째 요청이 들어오면</strong> 둘 다 검증을 통과한다. 검증과 소비는 반드시 원자 연산이어야 한다. <code class="language-plaintext highlighter-rouge">consumeIfMatches</code> Lua 스크립트가 이 교훈의 결과물이다.</p>

<h3 id="배운-것-3-masterreplica-분리는-대기열에서-독이-될-수-있다">배운 것 3: Master/Replica 분리는 대기열에서 독이 될 수 있다</h3>

<p>읽기 성능을 위해 Redis Master/Replica를 분리했는데, 토큰 발급(Master 쓰기) 직후 토큰 검증(Replica 읽기)을 하면 <strong>복제 지연</strong> 때문에 “토큰 없음”이 나올 수 있다. Read-your-writes가 필요한 경로에서는 Replica가 아니라 Master에서 읽어야 한다. “읽기는 무조건 Replica”라는 규칙은 대기열에서는 틀렸다.</p>

<hr />

<h2 id="9-이-경험이-실무에서-어떻게-쓰일까">9. 이 경험이 실무에서 어떻게 쓰일까</h2>

<p>이번에 배운 것의 핵심은 기술이 아니라 <strong>사고방식</strong>이다.</p>

<p><strong>“이 시스템에 10만 명이 동시에 몰리면 어디가 먼저 터지는가?”</strong></p>

<p>이 질문을 먼저 하고, 숫자를 먼저 계산하고, 병목을 먼저 파악한 뒤에 코드를 짜야 한다. DB 커넥션 풀 40개라는 제약을 모른 채 대기열을 설계하면, 대기열을 통과한 뒤에 결국 같은 문제가 터진다.</p>

<p>그리고 동시성 문제는 <strong>“일어날 수 있다”는 건 “반드시 일어난다”</strong>와 같다. 특히 트래픽이 몰리는 순간에. “확률이 낮으니까 괜찮겠지”는 선착순 주문에서 가장 위험한 생각이다.</p>

<hr />

<h2 id="10-남은-과제">10. 남은 과제</h2>

<p>현재 구현에는 인지하고 있는 한계가 있다.</p>

<ul>
  <li><strong>토큰 복원의 불완전함</strong>: 인터셉터의 <code class="language-plaintext highlighter-rouge">afterCompletion</code>에서 토큰을 복원하지만, 서버 프로세스 자체가 죽으면 복원이 불가능하다. 최종 안전망은 TTL(5분)이지만, 그 사이 해당 슬롯은 사실상 낭비된다.</li>
  <li><strong>단일 Redis 의존</strong>: Redis 장애 시 대기열 전체가 멈춘다. Redis Sentinel이나 Cluster로 HA를 확보하거나, 대기열 장애 시 직접 주문을 허용하는 폴백 정책이 필요하다.</li>
  <li><strong>대기 시간 추정의 한계</strong>: <code class="language-plaintext highlighter-rouge">순번 / 초당 처리량</code>은 순수 추정치다. 토큰 미사용(만료)이 많으면 실제 대기 시간은 더 짧고, 하류 시스템 장애가 나면 더 길어진다.</li>
</ul>

<p>이런 한계를 인식하고 있다는 것 자체가, 10주 전의 나와 다른 점이라고 생각한다. 예전에는 “동작하면 끝”이었다면, 지금은 “이게 어떤 상황에서 깨지는가”를 먼저 본다.</p>

<hr />

<h2 id="참고">참고</h2>

<ul>
  <li><a href="https://techblog.woowahan.com/2711/">우아한형제들 - 선착순 이벤트 서버 생존기</a> — 대규모 트래픽 처리를 위한 대기열 설계와 정산 배치</li>
  <li><a href="https://techblog.woowahan.com/2662/">우아한형제들 - Spring Batch와 Querydsl</a> — 대규모 데이터 처리 성능 최적화</li>
  <li><a href="https://zdnet.co.kr/view/?no=20131119174125">카카오 - Redis, 잘못 쓰면 망한다</a> — Redis 안티패턴과 주의사항</li>
  <li><a href="https://ji5485.github.io/post/2024-01-10/develop-ranking-system-using-redis-sorted-set/">Redis Sorted Set을 활용한 랭킹 시스템 개발하기</a> — ZSET 기반 실시간 랭킹 구현 사례</li>
</ul>]]></content><author><name>Ukukdin</name></author><category term="Loopers" /><category term="Tech" /><category term="Redis" /><category term="RabbitMQ" /><category term="event" /><category term="sync" /><category term="async" /><summary type="html"><![CDATA[비동기 트랜잭션 흐름을 학습하고, 시스템 결합도를 낮춰보자]]></summary></entry><entry><title type="html">이벤트를 발행하는 건 쉬웠다 — 어디서 끊을지 판단하는 게 어려웠다</title><link href="https://ukukdin.github.io/2026/03/27/7weeksBlog/" rel="alternate" type="text/html" title="이벤트를 발행하는 건 쉬웠다 — 어디서 끊을지 판단하는 게 어려웠다" /><published>2026-03-27T00:00:00+00:00</published><updated>2026-03-27T00:00:00+00:00</updated><id>https://ukukdin.github.io/2026/03/27/7weeksBlog</id><content type="html" xml:base="https://ukukdin.github.io/2026/03/27/7weeksBlog/"><![CDATA[<blockquote>
  <p><strong>TL;DR</strong>: 이커머스에 이벤트 기반 아키텍처를 도입했다. 이벤트를 발행하는 코드는 한 줄이었지만, <code class="language-plaintext highlighter-rouge">@EventListener</code>와 <code class="language-plaintext highlighter-rouge">@TransactionalEventListener</code> 중 뭘 쓸지, DB 커밋과 Kafka 발행 사이의 이벤트 유실을 어떻게 막을지, 선착순 쿠폰에서 중복 발급을 어떻게 막을지 — 진짜 어려운 건 “경계”를 정하는 일이었다.</p>
</blockquote>

<hr />

<h2 id="1-왜-이벤트가-필요했나">1. 왜 이벤트가 필요했나</h2>

<p>주문 취소 하나에 재고 복구, 쿠폰 복원, PG 환불이 엮여 있었다. 코드로 보면 이랬다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Transactional</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">cancelOrder</span><span class="o">(</span><span class="nc">Long</span> <span class="n">orderId</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">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="n">order</span><span class="o">.</span><span class="na">cancel</span><span class="o">();</span>
    <span class="n">stockService</span><span class="o">.</span><span class="na">restore</span><span class="o">(</span><span class="n">order</span><span class="o">);</span>         <span class="c1">// 재고 복구</span>
    <span class="n">couponService</span><span class="o">.</span><span class="na">restore</span><span class="o">(</span><span class="n">order</span><span class="o">);</span>        <span class="c1">// 쿠폰 복원</span>
    <span class="n">pgClient</span><span class="o">.</span><span class="na">refund</span><span class="o">(</span><span class="n">order</span><span class="o">);</span>              <span class="c1">// PG 환불 ← 외부 API</span>
<span class="o">}</span>
</code></pre></div></div>

<p>이 코드의 문제는 <strong>PG 환불이 5초 타임아웃을 내면 재고 복구와 쿠폰 복원까지 롤백된다</strong>는 것이다. PG가 장애면 주문 취소 자체가 불가능해진다. 외부 시스템 하나가 내부 비즈니스 로직 전체를 인질로 잡는 구조다.</p>

<p>이벤트 기반 아키텍처를 도입한 이유는 간단하다. <strong>“실패해도 괜찮은 것”과 “반드시 함께 성공해야 하는 것”을 분리하기 위해서.</strong></p>

<hr />

<h2 id="2-첫-번째-판단--같은-트랜잭션-vs-커밋-후-분리">2. 첫 번째 판단 — 같은 트랜잭션 vs 커밋 후 분리</h2>

<p>Spring에서 이벤트 리스너는 두 가지다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">@EventListener</code>: 같은 트랜잭션에서 동기 실행. 리스너가 실패하면 <strong>원래 트랜잭션도 롤백</strong>.</li>
  <li><code class="language-plaintext highlighter-rouge">@TransactionalEventListener(AFTER_COMMIT)</code>: 커밋 성공 후 실행. 리스너가 실패해도 <strong>원래 트랜잭션은 이미 커밋됨</strong>.</li>
</ul>

<p>어느 것을 쓸지는 <strong>“이 로직이 실패하면 원래 작업도 실패해야 하는가?”</strong>로 결정했다.</p>

<table>
  <thead>
    <tr>
      <th>로직</th>
      <th>실패 시 주문 취소도 실패해야 하는가?</th>
      <th>리스너 선택</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>재고 복구</td>
      <td><strong>예</strong> — 복구 안 되면 재고 불일치</td>
      <td><code class="language-plaintext highlighter-rouge">@EventListener</code> (같은 TX)</td>
    </tr>
    <tr>
      <td>쿠폰 복원</td>
      <td><strong>예</strong> — 복원 안 되면 쿠폰 유실</td>
      <td><code class="language-plaintext highlighter-rouge">@EventListener</code> (같은 TX)</td>
    </tr>
    <tr>
      <td>PG 환불</td>
      <td><strong>아니오</strong> — 환불 실패해도 취소는 성공해야 함</td>
      <td><code class="language-plaintext highlighter-rouge">@TransactionalEventListener</code> (AFTER_COMMIT)</td>
    </tr>
  </tbody>
</table>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 핵심 로직 — 같은 트랜잭션 (데이터 정합성 필수)</span>
<span class="nd">@EventListener</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">handle</span><span class="o">(</span><span class="nc">OrderCancelledEvent</span> <span class="n">event</span><span class="o">)</span> <span class="o">{</span>
    <span class="n">restoreStock</span><span class="o">(</span><span class="n">event</span><span class="o">);</span>
    <span class="n">restoreCoupon</span><span class="o">(</span><span class="n">event</span><span class="o">);</span>
<span class="o">}</span>

<span class="c1">// 부가 로직 — 커밋 후 별도 처리 (외부 API, 재시도 가능)</span>
<span class="nd">@TransactionalEventListener</span><span class="o">(</span><span class="n">phase</span> <span class="o">=</span> <span class="no">AFTER_COMMIT</span><span class="o">)</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">handle</span><span class="o">(</span><span class="nc">OrderCancelledEvent</span> <span class="n">event</span><span class="o">)</span> <span class="o">{</span>
    <span class="n">refundPaymentUseCase</span><span class="o">.</span><span class="na">refundPayment</span><span class="o">(</span><span class="n">event</span><span class="o">.</span><span class="na">orderId</span><span class="o">());</span>
<span class="o">}</span>
</code></pre></div></div>

<p>이렇게 나누니까 PG가 10초간 장애를 내도 주문 취소는 정상적으로 완료되고, 재고와 쿠폰은 정확하게 복구된다. PG 환불은 나중에 재시도하면 된다.</p>

<hr />

<h2 id="3-좋아요-집계에서-배운-것--집계-실패가-좋아요를-롤백시켰다">3. 좋아요 집계에서 배운 것 — 집계 실패가 좋아요를 롤백시켰다</h2>

<p>비슷한 문제가 좋아요에서도 터졌다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 처음 코드</span>
<span class="nd">@EventListener</span>  <span class="c1">// ← 같은 트랜잭션</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">handle</span><span class="o">(</span><span class="nc">ProductLikedEvent</span> <span class="n">event</span><span class="o">)</span> <span class="o">{</span>
    <span class="n">productRepository</span><span class="o">.</span><span class="na">incrementLikeCount</span><span class="o">(</span><span class="n">event</span><span class="o">.</span><span class="na">productId</span><span class="o">());</span>
<span class="o">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">incrementLikeCount</code>에서 예외가 발생하면 <strong>좋아요 등록 자체가 롤백</strong>된다. 사용자 입장에서는 하트를 눌렀는데 아무 반응이 없는 상황이다. 좋아요 수 집계가 1초 늦어도 비즈니스에 문제없지만, 좋아요 자체가 안 되면 UX가 깨진다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 수정 후</span>
<span class="nd">@Transactional</span>  <span class="c1">// 독립 트랜잭션 (AFTER_COMMIT이므로 기존 TX 밖)</span>
<span class="nd">@TransactionalEventListener</span><span class="o">(</span><span class="n">phase</span> <span class="o">=</span> <span class="no">AFTER_COMMIT</span><span class="o">)</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">handle</span><span class="o">(</span><span class="nc">ProductLikedEvent</span> <span class="n">event</span><span class="o">)</span> <span class="o">{</span>
    <span class="n">productRepository</span><span class="o">.</span><span class="na">incrementLikeCount</span><span class="o">(</span><span class="n">event</span><span class="o">.</span><span class="na">productId</span><span class="o">());</span>
<span class="o">}</span>
</code></pre></div></div>

<p>핵심 변경은 두 가지다.</p>

<ol>
  <li><code class="language-plaintext highlighter-rouge">AFTER_COMMIT</code> → 좋아요가 커밋된 후에 실행. 집계가 실패해도 좋아요는 성공.</li>
  <li><code class="language-plaintext highlighter-rouge">@Transactional</code> on method → AFTER_COMMIT 핸들러는 기존 트랜잭션이 끝난 뒤 실행되므로, 별도 트랜잭션을 열어야 DB에 쓸 수 있다.</li>
</ol>

<p>이 경험에서 얻은 기준이 하나 생겼다.</p>

<blockquote>
  <p><strong>“이 부가 로직이 실패해서 사용자의 핵심 행위가 취소되는 게 말이 되는가?”</strong></p>

  <p>말이 안 되면 AFTER_COMMIT이다.</p>
</blockquote>

<hr />

<h2 id="4-outbox-pattern--db는-커밋됐는데-kafka는-실패하면">4. Outbox Pattern — DB는 커밋됐는데 Kafka는 실패하면?</h2>

<p>좋아요가 성공하면 Kafka를 통해 <code class="language-plaintext highlighter-rouge">product_metrics</code>의 좋아요 수를 갱신해야 한다. 처음에는 단순하게 했다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. BEGIN TRANSACTION
2. INSERT INTO likes (...)           ← DB 저장
3. COMMIT                            ← 성공
4. kafkaTemplate.send(...)           ← 네트워크 타임아웃 → 이벤트 유실!
</code></pre></div></div>

<p>DB 커밋은 됐는데 Kafka 발행이 실패하면, 좋아요는 등록됐지만 메트릭스에는 반영 안 된다. 반대로 Kafka를 트랜잭션 안에 넣으면, Kafka 장애 시 좋아요 자체가 실패한다. <strong>어느 쪽이든 문제다.</strong></p>

<p>이걸 Transactional Outbox Pattern으로 풀었다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[같은 트랜잭션 — 원자성 보장]
1. BEGIN TRANSACTION
2. INSERT INTO likes (...)           ← 비즈니스 데이터
3. INSERT INTO outbox_events (...)   ← 이벤트를 DB 테이블에 저장
4. COMMIT                            ← 둘 다 성공하거나 둘 다 실패

[별도 프로세스 — OutboxEventPublisher @Scheduled(1초)]
5. SELECT * FROM outbox_events WHERE status = 'PENDING'
6. kafkaTemplate.send(topic, key, message)
7. UPDATE outbox_events SET status = 'PUBLISHED'
   → 실패 시 다음 폴링에서 재시도 → At Least Once 보장
</code></pre></div></div>

<p>비즈니스 데이터와 이벤트가 같은 DB 트랜잭션에 저장되므로 <strong>“데이터는 있는데 이벤트는 없는” 상태가 불가능</strong>하다. 발행은 별도 프로세스가 폴링으로 처리하고, 실패하면 다음 번에 재시도한다.</p>

<p>대안으로 Debezium CDC를 검토했지만, 학습 목적과 현재 규모에서는 폴링이 적절하다고 판단했다. 1초 지연은 <code class="language-plaintext highlighter-rouge">product_metrics</code> 갱신에서 허용 가능한 수준이다.</p>

<h3 id="어떤-이벤트를-kafka로-보내는가">어떤 이벤트를 Kafka로 보내는가?</h3>

<p>이벤트를 무조건 Kafka로 보내는 게 아니다. <strong>“다른 시스템(commerce-streamer)이 이 이벤트를 알아야 하는가?”</strong>로 판단했다.</p>

<table>
  <thead>
    <tr>
      <th>이벤트</th>
      <th style="text-align: center">ApplicationEvent</th>
      <th style="text-align: center">Kafka</th>
      <th>판단 근거</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>ProductLikedEvent</td>
      <td style="text-align: center">O</td>
      <td style="text-align: center">O</td>
      <td>좋아요 수 → product_metrics (다른 시스템)</td>
    </tr>
    <tr>
      <td>OrderCreatedEvent</td>
      <td style="text-align: center">O</td>
      <td style="text-align: center">O</td>
      <td>판매량 → product_metrics (다른 시스템)</td>
    </tr>
    <tr>
      <td>OrderCancelledEvent</td>
      <td style="text-align: center">O</td>
      <td style="text-align: center"><strong>X</strong></td>
      <td>재고/쿠폰 복구는 같은 시스템 내부 처리</td>
    </tr>
    <tr>
      <td>UserActivityEvent</td>
      <td style="text-align: center">O</td>
      <td style="text-align: center"><strong>X</strong></td>
      <td>로그 기록은 같은 시스템에서 완결</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="5-선착순-쿠폰--redis-incr만-믿으면-중복이-난다">5. 선착순 쿠폰 — Redis INCR만 믿으면 중복이 난다</h2>

<p>선착순 쿠폰 발급은 Kafka를 통한 비동기 처리로 설계했다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>사용자 → POST /coupons/{id}/issue
         → 202 Accepted (requestId 반환, 즉시 응답)
         → Outbox → Kafka → CouponIssueConsumer → 실제 발급
         → GET /coupons/{id}/issue-status (폴링으로 결과 확인)
</code></pre></div></div>

<p>문제는 Consumer에서의 <strong>중복 방어</strong>였다. Kafka는 At Least Once를 보장하므로, 같은 메시지가 두 번 올 수 있다. 게다가 같은 사용자가 버튼을 연타하면 여러 요청이 들어온다.</p>

<p>처음에는 Redis <code class="language-plaintext highlighter-rouge">INCR</code>로 수량만 체크했다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 처음 코드 — 구멍이 있다</span>
<span class="nc">Long</span> <span class="n">count</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">increment</span><span class="o">(</span><span class="n">key</span><span class="o">);</span>
<span class="k">if</span> <span class="o">(</span><span class="n">count</span> <span class="o">&gt;</span> <span class="n">maxIssuance</span><span class="o">)</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">decrement</span><span class="o">(</span><span class="n">key</span><span class="o">);</span>  <span class="c1">// 롤백</span>
    <span class="k">return</span><span class="o">;</span>
            <span class="o">}</span>
            <span class="n">userCouponRepository</span><span class="o">.</span><span class="na">save</span><span class="o">(...);</span>  <span class="c1">// 발급</span>
</code></pre></div></div>

<p>이것만으로는 <strong>같은 유저가 2개 받는 걸 막지 못한다.</strong> Kafka 메시지가 2번 도착하면 <code class="language-plaintext highlighter-rouge">INCR</code>이 2번 성공하고, 같은 유저에게 쿠폰이 2장 발급된다.</p>

<h3 id="3중-방어로-해결">3중 방어로 해결</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Layer 1: Kafka 메시지 멱등 — 같은 eventId 재처리 방지</span>
<span class="k">if</span> <span class="o">(</span><span class="n">eventHandledRepository</span><span class="o">.</span><span class="na">existsById</span><span class="o">(</span><span class="n">eventId</span><span class="o">))</span> <span class="k">return</span><span class="o">;</span>

<span class="c1">// Layer 2: 유저 중복 방지 — 이미 발급받은 유저 차단</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">userCouponRepository</span><span class="o">.</span><span class="na">existsByCouponIdAndUserId</span><span class="o">(</span><span class="n">couponId</span><span class="o">,</span> <span class="n">userId</span><span class="o">))</span> <span class="o">{</span>
        <span class="n">request</span><span class="o">.</span><span class="na">markRejected</span><span class="o">(</span><span class="s">"Already issued"</span><span class="o">);</span>
    <span class="k">return</span><span class="o">;</span>
            <span class="o">}</span>

<span class="c1">// Layer 3: 수량 제한 — Redis INCR 원자적 카운터</span>
<span class="nc">Long</span> <span class="n">count</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">increment</span><span class="o">(</span><span class="n">key</span><span class="o">);</span>
<span class="k">if</span> <span class="o">(</span><span class="n">count</span> <span class="o">&gt;</span> <span class="n">maxIssuance</span><span class="o">)</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">decrement</span><span class="o">(</span><span class="n">key</span><span class="o">);</span>
    <span class="n">request</span><span class="o">.</span><span class="na">markRejected</span><span class="o">(</span><span class="s">"Quota exceeded"</span><span class="o">);</span>
    <span class="k">return</span><span class="o">;</span>
            <span class="o">}</span>

<span class="c1">// 모든 체크 통과 → 발급</span>
            <span class="n">userCouponRepository</span><span class="o">.</span><span class="na">save</span><span class="o">(...);</span>
<span class="n">request</span><span class="o">.</span><span class="na">markSuccess</span><span class="o">();</span>
</code></pre></div></div>

<table>
  <thead>
    <tr>
      <th>Layer</th>
      <th>방어 대상</th>
      <th>수단</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1</td>
      <td>Kafka 재전송 (같은 메시지 2회)</td>
      <td><code class="language-plaintext highlighter-rouge">event_handled</code> 테이블 (eventId PK)</td>
    </tr>
    <tr>
      <td>2</td>
      <td>같은 유저 연타 (다른 메시지, 같은 유저)</td>
      <td><code class="language-plaintext highlighter-rouge">user_coupons</code> 유니크 제약 사전 체크</td>
    </tr>
    <tr>
      <td>3</td>
      <td>총 발급 수량 초과</td>
      <td>Redis <code class="language-plaintext highlighter-rouge">INCR</code> 원자적 카운터</td>
    </tr>
  </tbody>
</table>

<p>동시성 테스트로 검증했다: 200명 동시 요청 + 100개 수량 제한 → <strong>정확히 100개만 발급</strong>, 같은 유저 5번 요청 → <strong>1장만 발급</strong>.</p>

<hr />

<h2 id="6-안티패턴-6개를-고쳤다">6. 안티패턴 6개를 고쳤다</h2>

<p>PR을 올리고 나서 코드 리뷰에서, 그리고 스스로 돌아보면서 발견한 안티패턴들이 있었다.</p>

<h3 id="6-1-클래스-레벨-transactional">6-1. 클래스 레벨 @Transactional</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Before — 읽기 전용 메서드도 트랜잭션이 열린다</span>
<span class="nd">@Service</span>
<span class="nd">@Transactional</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">OrderService</span> <span class="o">{</span> <span class="o">...</span> <span class="o">}</span>

<span class="c1">// After — 쓰기 메서드에만 명시</span>
<span class="nd">@Service</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">OrderService</span> <span class="o">{</span>
    <span class="nd">@Transactional</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">createOrder</span><span class="o">(...)</span> <span class="o">{</span> <span class="o">...</span> <span class="o">}</span>

    <span class="c1">// 읽기 메서드는 @Transactional 없음</span>
    <span class="kd">public</span> <span class="nc">Order</span> <span class="nf">getOrder</span><span class="o">(...)</span> <span class="o">{</span> <span class="o">...</span> <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>13개 서비스에서 클래스 레벨 <code class="language-plaintext highlighter-rouge">@Transactional</code>을 제거했다. 불필요한 트랜잭션은 커넥션 점유 시간을 늘린다.</p>

<h3 id="6-2-consumer의-protected-transactional이-동작-안-함">6-2. Consumer의 protected @Transactional이 동작 안 함</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Before — Spring 프록시가 protected 메서드를 가로채지 못함</span>
<span class="nd">@Transactional</span>
<span class="kd">protected</span> <span class="kt">void</span> <span class="nf">processRecord</span><span class="o">(</span><span class="nc">ConsumerRecord</span><span class="o">&lt;...&gt;</span> <span class="n">record</span><span class="o">)</span> <span class="o">{</span> <span class="o">...</span> <span class="o">}</span>

<span class="c1">// After — TransactionTemplate으로 프로그래밍 방식 트랜잭션</span>
<span class="n">records</span><span class="o">.</span><span class="na">forEach</span><span class="o">(</span><span class="n">record</span> <span class="o">-&gt;</span>
        <span class="n">transactionTemplate</span><span class="o">.</span><span class="na">executeWithoutResult</span><span class="o">(</span><span class="n">status</span> <span class="o">-&gt;</span> <span class="n">processRecord</span><span class="o">(</span><span class="n">record</span><span class="o">))</span>
        <span class="o">);</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">@Transactional</code>은 Spring AOP 프록시 기반이라 <code class="language-plaintext highlighter-rouge">public</code> 메서드에서만 동작한다. <code class="language-plaintext highlighter-rouge">protected</code>에 붙이면 <strong>트랜잭션 없이 실행</strong>된다. 3개 Consumer에서 모두 <code class="language-plaintext highlighter-rouge">TransactionTemplate</code>으로 교체했다.</p>

<h3 id="6-3-outbox-루프에서-break--continue">6-3. Outbox 루프에서 break → continue</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Before — 하나 실패하면 나머지 전부 발행 중단</span>
<span class="k">for</span> <span class="o">(</span><span class="nc">OutboxJpaEntity</span> <span class="n">event</span> <span class="o">:</span> <span class="n">pendingEvents</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">try</span> <span class="o">{</span> <span class="n">send</span><span class="o">(</span><span class="n">event</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="k">break</span><span class="o">;</span> <span class="o">}</span>  <span class="c1">// ← 전체 중단!</span>
        <span class="o">}</span>

<span class="c1">// After — 실패한 건만 건너뛰고 계속 진행</span>
        <span class="k">for</span> <span class="o">(</span><span class="nc">OutboxJpaEntity</span> <span class="n">event</span> <span class="o">:</span> <span class="n">pendingEvents</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">try</span> <span class="o">{</span> <span class="n">send</span><span class="o">(</span><span class="n">event</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">warn</span><span class="o">(</span><span class="s">"발행 실패: {}"</span><span class="o">,</span> <span class="n">event</span><span class="o">.</span><span class="na">getId</span><span class="o">());</span>
        <span class="k">continue</span><span class="o">;</span>  <span class="c1">// ← 나머지는 계속 발행</span>
        <span class="o">}</span>
        <span class="o">}</span>
</code></pre></div></div>

<p>이벤트 A의 발행 실패가 이벤트 B, C, D의 발행까지 막으면 안 된다. A는 다음 폴링에서 재시도하면 된다.</p>

<hr />

<h2 id="7-이번-주에-가장-많이-바뀐-사고방식">7. 이번 주에 가장 많이 바뀐 사고방식</h2>

<p>이벤트를 도입하기 전에는 모든 로직이 하나의 <code class="language-plaintext highlighter-rouge">@Transactional</code> 안에 있었다. 재고 차감, 쿠폰 검증, 주문 저장, PG 결제, 이벤트 발행 — 전부 한 트랜잭션이다. 하나가 실패하면 전부 롤백된다. 단순하고 안전해 보였다.</p>

<p>하지만 외부 시스템이 하나만 추가되면 이 “단순함”은 “취약함”이 된다. PG 타임아웃 하나가 모든 걸 멈춘다.</p>

<p>이벤트 기반 아키텍처를 도입하면서 배운 건 <strong>“모든 걸 한 번에 성공시키려 하지 마라”</strong>는 것이다. 핵심은 같은 트랜잭션에서 확실히 성공시키고, 부가적인 것은 커밋 후에 최선을 다해 처리하되 실패하면 재시도한다. 이것이 Eventually Consistent의 본질이라는 걸 코드로 체감했다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Before: "모든 게 성공하거나, 모든 게 실패하거나"
After:  "핵심은 반드시 성공. 부가는 최선을 다하되, 실패하면 나중에"
</code></pre></div></div>

<p>이게 “이벤트를 발행하는 건 쉬웠다”고 한 이유다. 진짜 어려운 건 <strong>어디까지를 “핵심”으로 볼 것인가, 어디서부터 “부가”로 분리할 것인가</strong>를 판단하는 일이었다.</p>

<hr />

<h2 id="참고">참고</h2>

<ul>
  <li><a href="https://techblog.woowahan.com/7835/">우아한형제들 - 회원시스템 이벤트기반 아키텍처 구축하기</a> — 3가지 이벤트 종류와 3가지 구독자 계층 정의</li>
  <li><a href="https://techblog.woowahan.com/2711/">우아한형제들 - 잊을만 하면 돌아오는 정산 신병들</a> — 대규모 정산 배치와 이벤트 흐름</li>
  <li><a href="https://docs.spring.io/spring-framework/reference/core/beans/context-introduction.html#context-functionality-events">Spring Docs - Application Events</a> — @EventListener, @TransactionalEventListener 공식 문서</li>
</ul>]]></content><author><name>Ukukdin</name></author><category term="Loopers" /><category term="Tech" /><category term="kafka" /><category term="RabbitMQ" /><category term="Redis" /><category term="sync" /><category term="async" /><summary type="html"><![CDATA[비동기 트랜잭션 흐름을 학습하고, 시스템 결합도를 낮춰보자]]></summary></entry><entry><title type="html">장애전파를 막는 방법: 내 잘못 아닌데요.</title><link href="https://ukukdin.github.io/2026/03/20/6weeksBlog/" rel="alternate" type="text/html" title="장애전파를 막는 방법: 내 잘못 아닌데요." /><published>2026-03-20T00:00:00+00:00</published><updated>2026-03-20T00:00:00+00:00</updated><id>https://ukukdin.github.io/2026/03/20/6weeksBlog</id><content type="html" xml:base="https://ukukdin.github.io/2026/03/20/6weeksBlog/"><![CDATA[<h1 id="pg-연동에서-돈이-맞는-코드를-만들기까지--결제-장애복구-회고">PG 연동에서 “돈이 맞는 코드”를 만들기까지 — 결제 장애복구 회고</h1>

<blockquote>
  <p>결제 시스템에서 가장 중요한 건 “정상 동작하는 코드”가 아니라 “장애 상황에서도 돈이 맞는 코드”입니다.
이 글은 외부 PG사 연동 과정에서 겪은 동시성 버그, Circuit Breaker 설정 시행착오, 그리고 “멱등성이 만능이 아니었다”는 깨달음을 기록합니다.</p>
</blockquote>

<hr />

<h2 id="들어가며-장애는-전파된다">들어가며: 장애는 전파된다</h2>

<p>커머스 서비스에서 주문과 결제는 분리된 시스템입니다. 주문 서비스(Commerce API)가 외부 PG사에 결제를 요청하고, PG가 카드사와 통신하여 승인/거절 결과를 돌려줍니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[사용자] → [Commerce API] → [PG사] → [카드사]
</code></pre></div></div>

<p><img src="https://martinfowler.com/bliki/images/circuitBreaker/sketch.png" alt="Cascading Failure 개념도 — 하나의 서비스 장애가 연쇄적으로 전파되는 구조" />
<em>출처: <a href="https://martinfowler.com/bliki/CircuitBreaker.html">Martin Fowler - Circuit Breaker Pattern</a></em></p>

<p>이 구조에서 PG가 장애가 나면 어떻게 될까요?</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>PG 장애 발생
  → Commerce API가 PG 응답을 5초간 대기 (타임아웃)
  → 스레드가 대기 상태로 점유됨
  → 다른 사용자의 요청도 처리 못함
  → Commerce API도 장애 💥  (장애 전파)
</code></pre></div></div>

<p>PG 하나가 죽었을 뿐인데, 주문 조회, 상품 목록 등 <strong>결제와 무관한 기능까지 전부 멈춥니다</strong>. 이것이 <strong>장애 전파(Cascading Failure)</strong>입니다.</p>

<p>처음에는 단순하게 생각했습니다. “PG 호출 실패하면 에러 반환하면 되지 않나?” 하지만 실제로 구현하면서 마주치는 질문들은 훨씬 복잡했습니다.</p>

<ul>
  <li>PG가 응답을 안 주면? → 타임아웃까지 스레드가 묶임</li>
  <li>응답은 왔는데 타임아웃으로 처리되면? → 결제는 됐는데 우리는 실패로 인식</li>
  <li>콜백이 유실되면? → 주문이 영원히 “결제 대기중”</li>
  <li>콜백과 스케줄러가 같은 주문을 동시에 처리하면? → 재고 이중 복구</li>
  <li>PG가 장애인데 계속 요청을 보내야 하나? → 장애를 악화시킴</li>
</ul>

<p>이 글에서는 Spring Boot + OpenFeign + Resilience4j 환경에서 이 질문들에 하나씩 답을 찾아간 과정을 공유합니다. 화해 기술 블로그의 <a href="https://blog.hwahae.co.kr/all/tech/14541">“내부통신에 서킷브레이커 적용하기”</a> 글에서도 비슷한 고민과 해결 과정을 확인할 수 있습니다.</p>

<h3 id="pg-simulator--왜-가상-서비스를-만들었는가">PG Simulator — 왜 가상 서비스를 만들었는가</h3>

<p>실제 PG사(토스페이먼츠, NHN KCP 등)를 연동하면 장애 상황을 <strong>재현할 수 없습니다</strong>. “PG가 40% 확률로 실패하는 상황”을 실제 PG로 테스트할 수는 없으니까요.</p>

<p>그래서 PG의 핵심 동작을 시뮬레이션하는 <strong>PG Simulator</strong>를 Kotlin으로 직접 만들었습니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Commerce API (Java, port 8080) ←→ PG Simulator (Kotlin, port 8082)
</code></pre></div></div>

<p>PG Simulator는 실제 PG사의 동작을 모사합니다:</p>

<table>
  <thead>
    <tr>
      <th>실제 PG</th>
      <th>PG Simulator</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>카드사 통신 후 승인/거절</td>
      <td>확률 기반 승인(70%)/거절(30%)</td>
    </tr>
    <tr>
      <td>결제 처리 후 콜백 전송</td>
      <td>비동기 이벤트로 콜백 전송</td>
    </tr>
    <tr>
      <td>네트워크 지연</td>
      <td>100~500ms 랜덤 지연</td>
    </tr>
    <tr>
      <td>서버 장애</td>
      <td><strong>40% 확률로 500 에러 반환</strong></td>
    </tr>
  </tbody>
</table>

<p>특히 <strong>40% 실패율</strong>은 의도적으로 높게 설정한 것입니다. 이 환경에서 Circuit Breaker, Retry, Fallback이 정상 동작하면, 실제 PG(실패율 1% 미만)에서는 더 안정적으로 동작할 것이라는 판단입니다.</p>

<blockquote>
  <p>이후 글에서 “PG”라고 표현하는 것은 모두 이 PG Simulator를 의미합니다. 실제 PG사 연동 시에는 인증(API Key), 서명 검증, 멱등키 등 추가 고려 사항이 있습니다.</p>
</blockquote>

<hr />

<h2 id="0장-resilience4j--외부-서비스-장애에-대비하는-도구-상자">0장. Resilience4j — 외부 서비스 장애에 대비하는 도구 상자</h2>

<h3 id="왜-resilience4j인가">왜 Resilience4j인가?</h3>

<p>장애 전파를 막기 위한 패턴으로 가장 유명한 것이 Netflix의 <strong>Hystrix</strong>입니다. 하지만 Hystrix는 2018년에 유지보수 모드에 들어갔고, Netflix 스스로 <a href="https://github.com/Netflix/Hystrix#hystrix-status">Resilience4j를 대안으로 권장</a>합니다.</p>

<table>
  <thead>
    <tr>
      <th>비교</th>
      <th>Hystrix</th>
      <th>Resilience4j</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>상태</td>
      <td>유지보수 모드 (2018~)</td>
      <td>활발한 개발 중</td>
    </tr>
    <tr>
      <td>의존성</td>
      <td>RxJava 필수</td>
      <td>순수 Java, 외부 의존성 없음</td>
    </tr>
    <tr>
      <td>Spring Cloud 통합</td>
      <td>spring-cloud-netflix (deprecated)</td>
      <td>spring-cloud-circuitbreaker (공식)</td>
    </tr>
    <tr>
      <td>설정 방식</td>
      <td>코드 기반</td>
      <td>yaml + 어노테이션 (선언적)</td>
    </tr>
  </tbody>
</table>

<p>Resilience4j는 <strong>경량</strong>이고 <strong>Spring Boot와의 통합이 깔끔</strong>합니다. 어노테이션 하나(<code class="language-plaintext highlighter-rouge">@CircuitBreaker</code>, <code class="language-plaintext highlighter-rouge">@Retry</code>)로 적용할 수 있고, yaml로 설정을 외부화할 수 있어 운영 중 튜닝이 용이합니다.</p>

<p><img width="1369" height="707" alt="image" src="https://github.com/user-attachments/assets/2a3043aa-a625-4806-9189-f5d6afa63ecc" /></p>

<h3 id="sliding-window--장애를-어떻게-감지하는가">Sliding Window — 장애를 어떻게 감지하는가?</h3>

<p>Circuit Breaker가 “장애 상태”를 판단하려면 최근 요청의 성공/실패를 추적해야 합니다. 이를 위해 <strong>슬라이딩 윈도우(Sliding Window)</strong> 알고리즘을 사용합니다.</p>

<p>Resilience4j는 두 가지 방식을 제공합니다:</p>

<table>
  <thead>
    <tr>
      <th>방식</th>
      <th>기준</th>
      <th>장점</th>
      <th>단점</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Count-Based</strong></td>
      <td>최근 N건</td>
      <td>구현 단순, 트래픽 무관하게 동작</td>
      <td>트래픽이 적으면 오래된 데이터로 판단</td>
    </tr>
    <tr>
      <td><strong>Time-Based</strong></td>
      <td>최근 N초</td>
      <td>실시간 트래픽 반영</td>
      <td>트래픽이 적으면 샘플 부족</td>
    </tr>
  </tbody>
</table>

<p><img width="1344" height="705" alt="image" src="https://github.com/user-attachments/assets/8d1f50b8-4134-4daf-82b5-e9a3ae9d725e" /></p>

<p>화해 기술 블로그에서는 실시간 트래픽 관찰이 적합하다고 판단하여 Time-Based를 선택했습니다. 저는 PG Simulator 환경에서 트래픽이 일정하지 않으므로 <strong>Count-Based(최근 10건 기준)</strong>를 선택했습니다. 10건이면 정상 거절률(~30%)과 시스템 장애를 구분하기에 충분한 샘플입니다.</p>

<blockquote>
  <p>Sliding Window에 대한 자세한 설명은 <a href="https://resilience4j.readme.io/docs/circuitbreaker">Resilience4j 공식 문서 - CircuitBreaker</a>에서 확인할 수 있습니다.</p>
</blockquote>

<p>이제 Resilience4j의 핵심 패턴 3가지를 설명하고, 이후 장에서 이것들을 실전에 적용하면서 <strong>어떤 문제를 만났고 어떻게 해결했는지</strong> 이야기합니다.</p>

<h3 id="retry--실패하면-다시-시도한다">Retry — 실패하면 다시 시도한다</h3>

<p>네트워크는 완벽하지 않습니다. 일시적인 패킷 유실, 서버의 순간적인 과부하 등으로 요청이 실패할 수 있습니다. 이런 <strong>일시적 장애</strong>는 다시 시도하면 성공하는 경우가 많습니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1회차: PG 호출 → 타임아웃 ❌
       500ms 대기
2회차: PG 호출 → 타임아웃 ❌
       1000ms 대기 (Exponential Backoff: 대기 시간이 2배씩 증가)
3회차: PG 호출 → 성공 ✅
</code></pre></div></div>

<p>Spring에서는 어노테이션 하나로 적용할 수 있습니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Retry</span><span class="o">(</span><span class="n">name</span> <span class="o">=</span> <span class="s">"pg-simulator"</span><span class="o">)</span>
<span class="kd">public</span> <span class="nc">PaymentResult</span> <span class="nf">requestPayment</span><span class="o">(...)</span> <span class="o">{</span>
    <span class="k">return</span> <span class="n">pgClient</span><span class="o">.</span><span class="na">createPayment</span><span class="o">(</span><span class="n">request</span><span class="o">);</span>
<span class="o">}</span>
</code></pre></div></div>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># application.yml</span>
<span class="na">resilience4j</span><span class="pi">:</span>
  <span class="na">retry</span><span class="pi">:</span>
    <span class="na">instances</span><span class="pi">:</span>
      <span class="na">pg-simulator</span><span class="pi">:</span>
        <span class="na">max-attempts</span><span class="pi">:</span> <span class="m">3</span>                      <span class="c1"># 최대 3회 시도</span>
        <span class="na">wait-duration</span><span class="pi">:</span> <span class="s">500ms</span>                 <span class="c1"># 초기 대기 500ms</span>
        <span class="na">enable-exponential-backoff</span><span class="pi">:</span> <span class="no">true</span>     <span class="c1"># 점진적 증가</span>
        <span class="na">exponential-backoff-multiplier</span><span class="pi">:</span> <span class="m">2</span>    <span class="c1"># 500ms → 1s → 2s</span>
</code></pre></div></div>

<p><strong>하지만 재시도는 만능이 아닙니다.</strong> PG가 완전히 죽은 상태라면 아무리 재시도해도 실패합니다. 오히려 이미 과부하인 PG에 요청을 더 보내서 <strong>상황을 악화</strong>시킵니다. 그래서 Circuit Breaker가 필요합니다.</p>

<h3 id="circuit-breaker--장애가-퍼지지-않도록-차단한다">Circuit Breaker — 장애가 퍼지지 않도록 차단한다</h3>

<p>전기의 <strong>차단기(두꺼비집)</strong> 를 생각하면 됩니다. 과전류가 흐르면 차단기가 내려가서 전기를 끊습니다. 불편하지만 화재를 막습니다.</p>

<p>소프트웨어에서도 같은 상황이 발생합니다:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Circuit Breaker가 없으면:
PG 장애 → 모든 요청이 5초씩 타임아웃 대기 → 스레드 풀 고갈 → 우리 서버도 장애 💥

Circuit Breaker가 있으면:
PG 장애 → 실패율 50% 초과 감지 → PG 호출 차단 → 즉시 Fallback 응답 → 우리 서버는 정상 ✅
</code></pre></div></div>

<p>3가지 상태를 순환합니다:
<img width="1365" height="682" alt="image" src="https://github.com/user-attachments/assets/f97d6edd-6150-4392-9908-df14ea7ba5b9" /></p>

<ul>
  <li><strong>Closed:</strong> 정상. 모든 요청이 PG로 전달됩니다.</li>
  <li><strong>Open:</strong> 장애 감지. PG를 호출하지 않고 <strong>즉시 Fallback을 실행</strong>합니다. PG에 복구 시간을 줍니다.</li>
  <li><strong>Half-Open:</strong> 대기 시간 후, 소수의 요청만 PG에 보내서 복구 여부를 확인합니다.</li>
</ul>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@CircuitBreaker</span><span class="o">(</span><span class="n">name</span> <span class="o">=</span> <span class="s">"pg-simulator"</span><span class="o">,</span> <span class="n">fallbackMethod</span> <span class="o">=</span> <span class="s">"requestPaymentFallback"</span><span class="o">)</span>
<span class="nd">@Retry</span><span class="o">(</span><span class="n">name</span> <span class="o">=</span> <span class="s">"pg-simulator"</span><span class="o">)</span>
<span class="kd">public</span> <span class="nc">PaymentResult</span> <span class="nf">requestPayment</span><span class="o">(...)</span> <span class="o">{</span>
    <span class="k">return</span> <span class="n">pgClient</span><span class="o">.</span><span class="na">createPayment</span><span class="o">(</span><span class="n">request</span><span class="o">);</span>
<span class="o">}</span>
</code></pre></div></div>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">resilience4j</span><span class="pi">:</span>
  <span class="na">circuitbreaker</span><span class="pi">:</span>
    <span class="na">instances</span><span class="pi">:</span>
      <span class="na">pg-simulator</span><span class="pi">:</span>
        <span class="na">sliding-window-size</span><span class="pi">:</span> <span class="m">10</span>              <span class="c1"># 최근 10건 기준</span>
        <span class="na">failure-rate-threshold</span><span class="pi">:</span> <span class="m">50</span>           <span class="c1"># 실패율 50% 초과 시 Open</span>
        <span class="na">wait-duration-in-open-state</span><span class="pi">:</span> <span class="s">5s</span>      <span class="c1"># Open 후 5초 대기</span>
        <span class="na">permitted-number-of-calls-in-half-open-state</span><span class="pi">:</span> <span class="m">3</span>  <span class="c1"># 3건만 시험</span>
</code></pre></div></div>

<p><strong>Retry와 Circuit Breaker의 실행 순서:</strong></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>요청 → [Circuit Breaker] → [Retry] → 실제 PG 호출

CircuitBreaker [
    Retry [
        PG 호출 → 실패 → 재시도 → 실패 → 재시도 → 실패
    ] → Retry 소진 → 최종 실패
] → CircuitBreaker가 실패 1건으로 기록 → 누적 실패율 계산
</code></pre></div></div>

<p>중요한 점은 <strong>Retry가 모두 소진된 후의 최종 결과만 Circuit Breaker에 기록</strong>된다는 것입니다. 1회차 실패 후 2회차에 성공했다면, Circuit Breaker에는 “성공”으로 기록됩니다. 일시적 장애는 Retry가 처리하고, 지속적 장애만 Circuit Breaker가 감지하는 구조입니다.</p>

<h3 id="fallback--실패해도-사용자에게-의미-있는-응답을-준다">Fallback — 실패해도 사용자에게 의미 있는 응답을 준다</h3>

<p>Circuit Breaker가 Open이거나 Retry가 모두 실패하면, <strong>원래 로직 대신 실행되는 대체 로직</strong>입니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 원래 로직: PG에 결제 요청</span>
<span class="nd">@CircuitBreaker</span><span class="o">(</span><span class="n">name</span> <span class="o">=</span> <span class="s">"pg-simulator"</span><span class="o">,</span> <span class="n">fallbackMethod</span> <span class="o">=</span> <span class="s">"requestPaymentFallback"</span><span class="o">)</span>
<span class="kd">public</span> <span class="nc">PaymentResult</span> <span class="nf">requestPayment</span><span class="o">(...)</span> <span class="o">{</span>
    <span class="k">return</span> <span class="n">pgClient</span><span class="o">.</span><span class="na">createPayment</span><span class="o">(</span><span class="n">request</span><span class="o">);</span>  <span class="c1">// PG 장애 시 실패</span>
<span class="o">}</span>

<span class="c1">// Fallback: PG 장애 시 실행</span>
<span class="kd">private</span> <span class="nc">PaymentResult</span> <span class="nf">requestPaymentFallback</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="c1">// 500 에러 대신 "결제 처리 중"을 반환</span>
    <span class="k">return</span> <span class="k">new</span> <span class="nf">PaymentResult</span><span class="o">(</span><span class="kc">null</span><span class="o">,</span> <span class="no">PENDING</span><span class="o">,</span> <span class="s">"결제 처리 중"</span><span class="o">);</span>
<span class="o">}</span>
</code></pre></div></div>

<p>Fallback의 핵심은 <strong>“실패를 사용자에게 그대로 보여주지 않는 것”</strong>입니다. “서버 오류입니다”(500) 대신 “결제 처리 중입니다”라고 응답하면, 나중에 콜백이나 스케줄러가 실제 결과를 보정할 수 있습니다.</p>

<p>Fallback 전략은 도메인마다 다릅니다:</p>

<table>
  <thead>
    <tr>
      <th>도메인</th>
      <th>Fallback 전략</th>
      <th>이유</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>상품 조회</td>
      <td>캐시된 데이터 반환</td>
      <td>약간 오래된 데이터라도 보여주는 게 나음</td>
    </tr>
    <tr>
      <td>추천 시스템</td>
      <td>인기 상품 목록 반환</td>
      <td>개인화 실패해도 기본 추천이라도</td>
    </tr>
    <tr>
      <td><strong>결제</strong></td>
      <td><strong>PENDING 반환 + 보조 수단으로 보정</strong></td>
      <td>돈이 관련되므로 “처리 중” 상태로 두고 나중에 확인</td>
    </tr>
  </tbody>
</table>

<h3 id="세-가지-패턴을-조합하면">세 가지 패턴을 조합하면</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>사용자 결제 요청
  │
  ▼
[Retry] 일시적 장애 → 재시도로 복구 시도 (500ms → 1s → 2s)
  │
  ▼ (Retry 소진)
[Circuit Breaker] 지속적 장애 → PG 호출 차단, 장애 전파 방지
  │
  ▼ (Open 상태)
[Fallback] 사용자에게 "결제 처리 중" 응답 → 콜백/스케줄러가 나중에 보정
</code></pre></div></div>

<p>이론은 깔끔합니다. 하지만 실제로 적용하면서 <strong>“이론대로 안 되는 지점”</strong>들을 만났습니다. 다음 장부터 그 이야기를 합니다.</p>

<hr />

<h2 id="1장-전체-구조--왜-3중-방어인가">1장. 전체 구조 — 왜 3중 방어인가</h2>

<h3 id="결제-흐름-설계">결제 흐름 설계</h3>

<p>PG 연동의 핵심은 <strong>“요청은 동기, 처리는 비동기”</strong>라는 점입니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[사용자] → POST /payments → [Commerce API] → POST /payments → [PG]
                                                                 ↓
                                                          비동기 결제 처리
                                                          (승인/거절 판정)
                                                                 ↓
[사용자] ← 200 OK ← [Commerce API] ← POST /callback ← [PG]
</code></pre></div></div>

<p>사용자가 결제 요청을 보내면 PG는 “접수했다”는 응답만 줍니다. 실제 승인/거절은 비동기로 처리되고, 결과는 <strong>콜백</strong>으로 전달됩니다.</p>

<p>여기서 문제가 생깁니다. 콜백이 유실되면? PG가 장애면? 이를 대비해 3중 방어 구조를 설계했습니다.
<img src="/defence.png" alt="3중 방어 구조" /></p>

<p><strong>단일 방어 수단은 각각 실패할 수 있습니다:</strong></p>

<table>
  <thead>
    <tr>
      <th>방어 수단</th>
      <th>실패 시나리오</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Retry만</td>
      <td>PG 전면 장애 → 재시도가 오히려 부하를 가중</td>
    </tr>
    <tr>
      <td>Callback만</td>
      <td>네트워크 장애로 콜백 유실, 서버 재시작 중 콜백 수신 불가</td>
    </tr>
    <tr>
      <td>Scheduler만</td>
      <td>5분 지연 → 사용자가 결제 결과를 모르는 시간이 너무 김</td>
    </tr>
  </tbody>
</table>

<p>3중 방어를 조합하면 각 계층의 약점을 다른 계층이 보완합니다.</p>

<hr />

<h2 id="2장-첫-번째-시행착오--멱등성-가드면-충분하지-않나">2장. 첫 번째 시행착오 — “멱등성 가드면 충분하지 않나?”</h2>

<h3 id="처음-작성한-코드">처음 작성한 코드</h3>

<p>주문의 결제 상태를 업데이트하는 코드를 처음 이렇게 작성했습니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="kt">void</span> <span class="nf">failPayment</span><span class="o">(</span><span class="nc">Long</span> <span class="n">orderId</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">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="c1">// 일반 SELECT</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">CoreException</span><span class="o">(</span><span class="nc">ErrorType</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">getStatus</span><span class="o">()</span> <span class="o">!=</span> <span class="nc">OrderStatus</span><span class="o">.</span><span class="na">PAYMENT_PENDING</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">return</span><span class="o">;</span>  <span class="c1">// 멱등성 가드: 이미 처리된 주문은 무시</span>
    <span class="o">}</span>

    <span class="nc">Order</span> <span class="n">failed</span> <span class="o">=</span> <span class="n">order</span><span class="o">.</span><span class="na">failPayment</span><span class="o">();</span>
    <span class="n">orderRepository</span><span class="o">.</span><span class="na">save</span><span class="o">(</span><span class="n">failed</span><span class="o">);</span>
    <span class="n">eventPublisher</span><span class="o">.</span><span class="na">publishEvents</span><span class="o">(</span><span class="n">failed</span><span class="o">);</span>  <span class="c1">// OrderCancelledEvent → 재고 복구</span>
<span class="o">}</span>
</code></pre></div></div>

<p>논리적으로는 완벽해 보입니다. <code class="language-plaintext highlighter-rouge">PAYMENT_PENDING</code>이 아니면 return하니까, 두 번 호출해도 안전하겠죠?</p>

<h3 id="동시성-테스트에서-터진-버그">동시성 테스트에서 터진 버그</h3>

<p>“5건의 동시 실패 콜백” 테스트를 작성했습니다. 재고가 15개인 상품에 대해 5개를 주문하고, 결제 실패 시 5개가 복구되어 20개가 되어야 합니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 기대: 재고 15 + 복구 5 = 20</span>
<span class="c1">// 실제: 재고 15 + 복구 10 = 25  💥</span>
</code></pre></div></div>

<p>재고가 25개가 됐습니다. 재고 복구가 2번 실행된 것입니다.</p>

<h3 id="원인-분석">원인 분석</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Thread A: findById(1) → PAYMENT_PENDING 읽음
Thread B: findById(1) → PAYMENT_PENDING 읽음  ← 같은 시점에 같은 상태!

Thread A: status != PENDING? → false → failPayment() → 재고 복구 ①
Thread B: status != PENDING? → false → failPayment() → 재고 복구 ②  💥
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">findById()</code>는 일반 SELECT입니다. 두 스레드가 동시에 실행하면 <strong>둘 다 <code class="language-plaintext highlighter-rouge">PAYMENT_PENDING</code>을 봅니다</strong>. 멱등성 가드는 메모리에서만 동작하기 때문에 DB 레벨의 동시성을 제어하지 못합니다.</p>

<h3 id="해결-비관적-락">해결: 비관적 락</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="kt">void</span> <span class="nf">failPayment</span><span class="o">(</span><span class="nc">Long</span> <span class="n">orderId</span><span class="o">)</span> <span class="o">{</span>
    <span class="c1">// SELECT FOR UPDATE → 행 수준 락 획득</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">findByIdWithLock</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">CoreException</span><span class="o">(</span><span class="nc">ErrorType</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">getStatus</span><span class="o">()</span> <span class="o">!=</span> <span class="nc">OrderStatus</span><span class="o">.</span><span class="na">PAYMENT_PENDING</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">return</span><span class="o">;</span>  <span class="c1">// 이제 진짜 안전한 멱등성 가드</span>
    <span class="o">}</span>

    <span class="nc">Order</span> <span class="n">failed</span> <span class="o">=</span> <span class="n">order</span><span class="o">.</span><span class="na">failPayment</span><span class="o">();</span>
    <span class="n">orderRepository</span><span class="o">.</span><span class="na">save</span><span class="o">(</span><span class="n">failed</span><span class="o">);</span>
    <span class="n">eventPublisher</span><span class="o">.</span><span class="na">publishEvents</span><span class="o">(</span><span class="n">failed</span><span class="o">);</span>
<span class="o">}</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Thread A: findByIdWithLock(1) → PENDING 읽음 + 행 락 🔒
Thread B: findByIdWithLock(1) → 대기...
Thread A: failPayment() → FAILED 저장 + 재고 복구 → 커밋 → 락 해제
Thread B: findByIdWithLock(1) → FAILED 읽음 (커밋된 최신 데이터)
Thread B: status != PENDING → return ✅
</code></pre></div></div>

<h3 id="배운-것">배운 것</h3>

<p><strong>멱등성 가드는 “논리적 방어”일 뿐, DB 레벨 동시성 제어(<code class="language-plaintext highlighter-rouge">SELECT FOR UPDATE</code>)와 함께 써야 실제로 동작합니다.</strong></p>

<p>단일 스레드에서 테스트하면 절대 발견할 수 없는 버그입니다. 결제처럼 “실패하면 돈이 안 맞는” 도메인에서는 반드시 동시성 테스트를 작성해야 합니다.</p>

<hr />

<h2 id="3장-두-번째-시행착오--circuit-breaker-대기-시간">3장. 두 번째 시행착오 — Circuit Breaker 대기 시간</h2>

<h3 id="30초의-함정">30초의 함정</h3>

<p>Circuit Breaker의 <code class="language-plaintext highlighter-rouge">wait-duration-in-open-state</code>를 처음에 30초로 설정했습니다. Resilience4j 공식 문서의 예제가 60초였으니 절반인 30초면 적당하다고 생각했습니다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">wait-duration-in-open-state</span><span class="pi">:</span> <span class="s">30s</span>
</code></pre></div></div>

<p>하지만 결제 도메인의 특성을 간과했습니다.</p>

<h3 id="사용자-관점에서-생각해보기">사용자 관점에서 생각해보기</h3>

<p>PG 장애가 발생하면 이런 일이 벌어집니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>10:00:00 - 결제 실패율 50% 초과 → Circuit Open
10:00:00 ~ 10:00:30 - 모든 결제 요청이 Fallback으로 빠짐
                       사용자에게 "결제 처리 중" 메시지만 30초간 노출
10:00:30 - Half-Open → 3건 시험 → 성공 → Closed
</code></pre></div></div>

<p><strong>30초 동안 모든 사용자가 결제를 할 수 없습니다.</strong> 쇼핑몰에서 30초는 사용자가 결제를 포기하고 다른 플랫폼으로 이동하기 충분한 시간입니다.</p>

<h3 id="5초로-변경한-이유">5초로 변경한 이유</h3>

<table>
  <thead>
    <tr>
      <th>대기 시간</th>
      <th>사용자 경험</th>
      <th>PG 부하</th>
      <th>판단</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1초</td>
      <td>즉시 재시도</td>
      <td>Open의 의미 없음</td>
      <td>❌</td>
    </tr>
    <tr>
      <td><strong>5초</strong></td>
      <td><strong>짧은 대기</strong></td>
      <td><strong>적절한 쿨다운</strong></td>
      <td><strong>✅</strong></td>
    </tr>
    <tr>
      <td>30초</td>
      <td>결제 불가 상태 지속</td>
      <td>PG 충분히 쉼</td>
      <td>❌ (결제 도메인)</td>
    </tr>
  </tbody>
</table>

<p>결정적인 이유는 <strong>콜백 + 스케줄러라는 보조 수단이 있기 때문</strong>입니다. Circuit Breaker가 5초 후에 빠르게 닫혀서 PG에 요청을 보내도, 설령 다시 실패하더라도 콜백과 스케줄러가 최종적으로 상태를 보정합니다.</p>

<p>Circuit Breaker는 “PG를 보호하기 위한 장치”이지만, 결제 도메인에서는 <strong>“사용자를 보호하는 것”이 더 중요</strong>합니다.</p>

<hr />

<h2 id="4장-세-번째-시행착오--retry-전략">4장. 세 번째 시행착오 — Retry 전략</h2>

<h3 id="고정-간격의-문제">고정 간격의 문제</h3>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># 처음 설정</span>
<span class="na">max-attempts</span><span class="pi">:</span> <span class="m">2</span>
<span class="na">wait-duration</span><span class="pi">:</span> <span class="s">1s</span>  <span class="c1"># 고정 간격</span>
</code></pre></div></div>

<p>코드 자체는 문제없이 동작합니다. 하지만 서버가 여러 대일 때를 생각해보면:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>10:00:00.000 - 서버 A: PG 호출 실패
10:00:00.005 - 서버 B: PG 호출 실패
10:00:00.010 - 서버 C: PG 호출 실패

10:00:01.000 - 서버 A: 재시도 ← 동시!
10:00:01.005 - 서버 B: 재시도 ← 동시!
10:00:01.010 - 서버 C: 재시도 ← 동시!
</code></pre></div></div>

<p>모든 서버가 <strong>정확히 1초 후에 동시에 재시도</strong>합니다. PG가 과부하 상태인데 여러 서버가 같은 타이밍에 몰려오면 상황이 악화됩니다. 이를 <strong>Thundering Herd</strong> 문제라고 합니다.</p>

<h3 id="exponential-backoff로-변경">Exponential Backoff로 변경</h3>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">max-attempts</span><span class="pi">:</span> <span class="m">3</span>
<span class="na">wait-duration</span><span class="pi">:</span> <span class="s">500ms</span>
<span class="na">enable-exponential-backoff</span><span class="pi">:</span> <span class="no">true</span>
<span class="na">exponential-backoff-multiplier</span><span class="pi">:</span> <span class="m">2</span>
<span class="c1"># 1회차: 500ms 대기 → 2회차: 1s 대기 → 3회차: 바로 실행</span>
</code></pre></div></div>

<p>점진적으로 대기 시간을 늘려서 PG에 복구 시간을 줍니다. 완벽한 해결은 아닙니다. Jitter(랜덤 지연)를 추가하면 서버 간 재시도 타이밍을 분산할 수 있지만, 현재 Resilience4j 설정으로는 기본 Exponential Backoff까지만 적용 가능합니다.</p>

<blockquote>
  <p>Exponential Backoff와 Jitter 전략의 비교 분석은 <a href="https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/">AWS Architecture Blog - Exponential Backoff And Jitter</a>에서 그래프와 함께 자세히 확인할 수 있습니다.</p>
</blockquote>

<h3 id="왜-3회인가">왜 3회인가?</h3>

<p>결제 도메인에서 재시도 횟수를 보수적으로 잡는 이유가 있습니다.</p>

<p>PG가 실제로는 결제를 승인했는데 <strong>응답만 타임아웃으로 실패</strong>한 경우를 생각해보세요. 클라이언트(우리)는 실패로 판단하고 재시도합니다. PG에 멱등키(idempotency key)가 없으면 <strong>같은 카드로 같은 금액이 두 번 결제</strong>됩니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[Commerce API] → POST /payments (orderId=1, amount=50000) → [PG: 승인 ✅]
                ← 타임아웃 (응답 유실)
[Commerce API] → POST /payments (orderId=1, amount=50000) → [PG: 또 승인 ✅]  💥
</code></pre></div></div>

<p>이중 결제는 사용자 신뢰를 완전히 무너뜨리는 사고입니다. 재시도는 최소한으로 하되, 콜백과 스케줄러로 보정하는 전략이 더 안전합니다.</p>

<hr />

<h2 id="5장-fallback의-구조적-모순">5장. Fallback의 구조적 모순</h2>

<h3 id="모순을-알면서도-남겨둔-이유">모순을 알면서도 남겨둔 이유</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">private</span> <span class="nc">PaymentResult</span> <span class="nf">requestPaymentFallback</span><span class="o">(</span><span class="nc">UserId</span> <span class="n">userId</span><span class="o">,</span> <span class="nc">PaymentCommand</span> <span class="n">command</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="k">try</span> <span class="o">{</span>
        <span class="c1">// Circuit Breaker가 열린 이유가 PG 장애인데... 또 PG를 호출?</span>
        <span class="k">return</span> <span class="nf">toPaymentResult</span><span class="o">(</span><span class="n">getPaymentStatus</span><span class="o">(</span><span class="n">userId</span><span class="o">,</span> <span class="n">command</span><span class="o">.</span><span class="na">orderId</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">ex</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">return</span> <span class="k">new</span> <span class="nf">PaymentResult</span><span class="o">(</span><span class="kc">null</span><span class="o">,</span> <span class="nc">PaymentStatus</span><span class="o">.</span><span class="na">PENDING</span><span class="o">,</span> <span class="s">"PG 일시적 장애로 결제 대기 중"</span><span class="o">);</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>PG 장애로 Circuit이 열렸는데, Fallback에서 <strong>같은 PG의 조회 API를 호출</strong>합니다. 모순입니다.</p>

<p><strong>그래도 유지한 이유:</strong></p>

<p>PG 내부적으로 결제 생성(<code class="language-plaintext highlighter-rouge">POST /payments</code>)과 조회(<code class="language-plaintext highlighter-rouge">GET /payments</code>)는 다른 시스템일 수 있습니다.</p>

<table>
  <thead>
    <tr>
      <th>시나리오</th>
      <th>생성 API</th>
      <th>조회 API</th>
      <th>Fallback 효과</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>PG 쓰기 서버만 장애</td>
      <td>❌</td>
      <td>✅</td>
      <td>실제 결제 상태를 반환할 수 있음</td>
    </tr>
    <tr>
      <td>PG 전면 장애</td>
      <td>❌</td>
      <td>❌</td>
      <td>두 번째 catch → PENDING 반환 (안전)</td>
    </tr>
    <tr>
      <td>네트워크 단절</td>
      <td>❌</td>
      <td>❌</td>
      <td>두 번째 catch → PENDING 반환 (안전)</td>
    </tr>
  </tbody>
</table>

<p>최악의 추가 비용은 read-timeout(5초) 1회입니다. 전면 장애 시에도 두 번째 catch에서 안전하게 PENDING을 반환하므로, 시도할 가치가 있다고 판단했습니다.</p>

<p><strong>개선 방향:</strong></p>

<p>조회 API에 별도 Circuit Breaker(<code class="language-plaintext highlighter-rouge">pg-simulator-query</code>)를 적용하면 전면 장애 시 즉시 Fallback으로 빠질 수 있습니다. 또는 Fallback에서 PG 재호출을 아예 제거하고 즉시 PENDING을 반환하는 방식도 있습니다. 어느 쪽이 맞는지는 실제 PG의 장애 패턴(부분 장애 빈도)에 따라 달라집니다.</p>

<hr />

<h2 id="6장-설정값에는-근거가-있어야-한다">6장. 설정값에는 근거가 있어야 한다</h2>

<p>“왜 이 숫자인가?”라는 질문에 “다른 데서 이렇게 하길래”는 좋은 답이 아닙니다.</p>

<h3 id="circuit-breaker">Circuit Breaker</h3>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">sliding-window-size</span><span class="pi">:</span> <span class="m">10</span>
<span class="na">failure-rate-threshold</span><span class="pi">:</span> <span class="m">50</span>
</code></pre></div></div>

<p><strong>왜 10건, 50%인가?</strong></p>

<p>PG Simulator의 정상 거절률을 먼저 계산합니다.</p>
<ul>
  <li>한도 초과: 20%</li>
  <li>카드 오류: 10%</li>
  <li>정상 거절률: <strong>~30%</strong></li>
</ul>

<p>10건 중 3건은 정상적인 거절입니다. 임계치를 30%로 설정하면 정상 상태에서도 Circuit이 열립니다. 50%로 설정하면 10건 중 5건 초과(6건 이상)가 실패해야 열리므로, 정상 거절과 시스템 장애를 구분할 수 있습니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>정상 상태: 10건 중 3건 실패 (30%) → Closed ✅
시스템 장애: 10건 중 7건 실패 (70%) → Open ✅
경계 구간: 10건 중 5건 실패 (50%) → Closed (여유분)
</code></pre></div></div>

<p><strong>운영 환경에서 바꿔야 할 것:</strong></p>
<ul>
  <li>TPS가 높으면 sliding-window-size를 키워야 합니다. 10건은 TPS 1~10 수준에서 적합합니다.</li>
  <li>실제 PG의 거절률을 모니터링한 후 failure-rate-threshold를 (거절률 + 15%) 정도로 조정해야 합니다.</li>
</ul>

<h3 id="스케줄러">스케줄러</h3>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">fixedDelay</span><span class="pi">:</span> <span class="s">300_000</span>  <span class="c1"># 5분</span>
<span class="na">BATCH_SIZE</span><span class="pi">:</span> <span class="m">100</span>
<span class="na">PENDING_THRESHOLD</span><span class="pi">:</span> <span class="s">5분</span>
</code></pre></div></div>

<p><strong>왜 5분 주기인가?</strong></p>

<p>콜백이 주된 복구 수단입니다. PG가 정상이면 콜백은 1분 내에 도착합니다. 5분은 “콜백이 정말 유실되었다”를 확인하기 충분한 시간입니다. 1분 주기는 콜백이 아직 도착 중인 건을 불필요하게 PG에 재조회하게 됩니다.</p>

<p><strong>왜 100건 배치인가?</strong></p>

<p>스케줄러가 PG 조회 API를 호출합니다. 100건 × read-timeout(5초) = 최악 8분 20초. 스케줄러 주기(5분)와 겹칠 수 있지만, <code class="language-plaintext highlighter-rouge">fixedDelay</code>(이전 실행 완료 후 5분)이므로 겹치지 않습니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>fixedRate:  |--실행--|--5분--|--실행--|  ← 이전 실행이 끝나지 않으면 겹침
fixedDelay: |--실행--|--5분--|--실행--|  ← 이전 실행 완료 후 5분 대기
</code></pre></div></div>

<p>무제한 조회는 PENDING 주문이 수만 건일 때 메모리와 PG 부하를 유발하므로 100건으로 제한했습니다.</p>

<hr />

<h2 id="7장-코드-리뷰에서-배운-것들">7장. 코드 리뷰에서 배운 것들</h2>

<p>PR 제출 후 코드 리뷰에서 12건의 문제가 발견되었습니다. 처음에 “이 정도면 충분하다”고 생각했지만, 리뷰를 통해 <strong>운영 관점에서의 빈틈</strong>을 많이 발견했습니다.</p>

<h3 id="이건-진짜-위험했다--critical">“이건 진짜 위험했다” — Critical</h3>

<p><strong>동시성 경합으로 재고 이중 복구 (2장에서 설명)</strong></p>

<p>이건 코드 리뷰가 아니라 동시성 테스트에서 발견했습니다. 만약 테스트 없이 배포했다면 <strong>사용자의 돈이 안 맞는 사고</strong>가 발생했을 것입니다.</p>

<h3 id="모르고-있었다--major">“모르고 있었다” — Major</h3>

<p><strong><code class="language-plaintext highlighter-rouge">@RequestAttribute</code> 속성 이름 미지정</strong></p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// AS-IS: 기본값 "userId"를 찾음 → 인터셉터가 설정한 "authenticatedUserId"와 불일치</span>
<span class="kd">public</span> <span class="nc">ResponseEntity</span><span class="o">&lt;</span><span class="nc">Void</span><span class="o">&gt;</span> <span class="nf">createOrder</span><span class="o">(</span><span class="nd">@RequestAttribute</span> <span class="nc">UserId</span> <span class="n">userId</span><span class="o">,</span> <span class="o">...)</span>

<span class="c1">// TO-BE</span>
<span class="kd">public</span> <span class="nc">ResponseEntity</span><span class="o">&lt;</span><span class="nc">Void</span><span class="o">&gt;</span> <span class="nf">createOrder</span><span class="o">(</span><span class="nd">@RequestAttribute</span><span class="o">(</span><span class="s">"authenticatedUserId"</span><span class="o">)</span> <span class="nc">UserId</span> <span class="n">userId</span><span class="o">,</span> <span class="o">...)</span>
</code></pre></div></div>

<p>인터셉터에서 <code class="language-plaintext highlighter-rouge">request.setAttribute("authenticatedUserId", ...)</code>로 설정하는데, <code class="language-plaintext highlighter-rouge">@RequestAttribute</code>에 이름을 생략하면 파라미터명(<code class="language-plaintext highlighter-rouge">userId</code>)으로 찾습니다. <code class="language-plaintext highlighter-rouge">createOrder</code>만 이 방식이고, 다른 메서드들은 <code class="language-plaintext highlighter-rouge">request.getAttribute("authenticatedUserId")</code>로 직접 꺼내서 문제가 없었습니다. <strong>같은 컨트롤러 안에서 두 가지 방식이 섞여있는 것</strong>이 근본 원인입니다.</p>

<p><strong>결제 상태를 String으로 관리</strong></p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// AS-IS: 오타 한 글자로 결제가 틀어짐</span>
<span class="k">if</span> <span class="o">(</span><span class="s">"SUCESS"</span><span class="o">.</span><span class="na">equals</span><span class="o">(</span><span class="n">command</span><span class="o">.</span><span class="na">status</span><span class="o">()))</span> <span class="o">{</span>  <span class="c1">// SUCCESS 오타 → 절대 true가 안 됨</span>
    <span class="n">updateOrderPaymentUseCase</span><span class="o">.</span><span class="na">completePayment</span><span class="o">(</span><span class="n">orderId</span><span class="o">);</span>
<span class="o">}</span>

<span class="c1">// TO-BE: 컴파일 타임에 잡힘</span>
<span class="k">switch</span> <span class="o">(</span><span class="n">command</span><span class="o">.</span><span class="na">status</span><span class="o">())</span> <span class="o">{</span>
    <span class="k">case</span> <span class="no">SUCCESS</span> <span class="o">-&gt;</span> <span class="n">updateOrderPaymentUseCase</span><span class="o">.</span><span class="na">completePayment</span><span class="o">(</span><span class="n">orderId</span><span class="o">);</span>
    <span class="k">case</span> <span class="no">FAILED</span> <span class="o">-&gt;</span> <span class="n">updateOrderPaymentUseCase</span><span class="o">.</span><span class="na">failPayment</span><span class="o">(</span><span class="n">orderId</span><span class="o">);</span>
    <span class="k">case</span> <span class="no">PENDING</span> <span class="o">-&gt;</span> <span class="o">{</span> <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">PaymentStatus</code> enum을 만들고, 컨트롤러 경계에서 <code class="language-plaintext highlighter-rouge">PaymentStatus.from(String)</code>으로 파싱하도록 변경했습니다. 잘못된 상태값이 들어오면 서비스 내부가 아니라 <strong>컨트롤러에서 즉시 <code class="language-plaintext highlighter-rouge">VALIDATION_ERROR</code></strong>를 반환합니다.</p>

<h3 id="운영에서-터질-뻔했다--보안운영">“운영에서 터질 뻔했다” — 보안/운영</h3>

<p><strong>카드번호 로그 노출</strong></p>

<p>Java의 record는 기본 <code class="language-plaintext highlighter-rouge">toString()</code>이 모든 필드를 출력합니다. 장애 분석 중 로그를 그대로 남기면 카드번호가 평문으로 노출됩니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// record 기본 toString()</span>
<span class="c1">// "CreatePayment[orderId=1, cardType=VISA, cardNo=1234-5678-9012-3456, ...]"</span>

<span class="c1">// 오버라이드 후</span>
<span class="c1">// "CreatePayment[orderId=1, cardType=VISA, cardNo=****-****-****-3456, ...]"</span>
</code></pre></div></div>

<p><strong>PG 장애를 500으로 분류</strong></p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// AS-IS: PG 장애 = 내부 서버 오류? → 알림 폭주, 원인 분리 불가</span>
<span class="no">PAYMENT_REQUEST_FAILED</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="o">...)</span>

<span class="c1">// TO-BE: 외부 서비스 장애는 502</span>
<span class="no">PAYMENT_REQUEST_FAILED</span><span class="o">(</span><span class="nc">HttpStatus</span><span class="o">.</span><span class="na">BAD_GATEWAY</span><span class="o">,</span> <span class="o">...)</span>
</code></pre></div></div>

<p>운영 환경에서 500과 502를 분리하면 “우리 코드 문제 vs 외부 PG 문제”를 대시보드에서 즉시 구분할 수 있습니다.</p>

<hr />

<h2 id="8장-아직-해결하지-못한-문제">8장. 아직 해결하지 못한 문제</h2>

<p>완벽한 코드는 없습니다. 의도적으로 미적용한 부분과 그 이유를 솔직하게 남깁니다.</p>

<h3 id="콜백-엔드포인트에-인증이-없다">콜백 엔드포인트에 인증이 없다</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@PostMapping</span><span class="o">(</span><span class="s">"/callback"</span><span class="o">)</span>
<span class="kd">public</span> <span class="nc">ResponseEntity</span><span class="o">&lt;</span><span class="nc">Void</span><span class="o">&gt;</span> <span class="nf">handleCallback</span><span class="o">(</span><span class="nd">@RequestBody</span> <span class="nc">CallbackRequest</span> <span class="n">request</span><span class="o">)</span> <span class="o">{</span>
    <span class="c1">// 누구나 호출 가능 — 결제 없이 주문 완료 가능 💥</span>
<span class="o">}</span>
</code></pre></div></div>

<p>공격자가 <code class="language-plaintext highlighter-rouge">{"orderId": "1", "status": "SUCCESS"}</code>를 보내면 결제 없이 주문이 완료됩니다. 운영 환경에서는 <strong>HMAC 서명 검증</strong> 또는 <strong>IP 화이트리스트</strong>가 필수입니다.</p>

<h3 id="결제-이력이-commerce-api에-없다">결제 이력이 Commerce API에 없다</h3>

<p>현재 결제 정보(transactionKey, 카드번호, 금액)는 PG에만 저장됩니다. PG가 장애나면 결제 이력 조회가 불가능합니다. 운영 환경에서는 Commerce API에 Payment 엔티티를 추가하여 로컬에 결제 이력을 저장해야 합니다.</p>

<h3 id="스케줄러-다중-인스턴스-중복-실행">스케줄러 다중 인스턴스 중복 실행</h3>

<p><code class="language-plaintext highlighter-rouge">@Scheduled</code>는 서버가 여러 대일 때 모든 인스턴스에서 동시에 실행됩니다. 비관적 락 덕분에 정합성은 보장되지만, 불필요한 PG 호출과 DB 락 경합이 발생합니다. ShedLock(Redis 분산 락)으로 단일 인스턴스만 실행되도록 해야 합니다.</p>

<hr />

<h2 id="9장-동시성-테스트--무엇을-증명했는가">9장. 동시성 테스트 — 무엇을 증명했는가</h2>

<p>“테스트 5종 통과”보다 중요한 것은 <strong>각 테스트가 무엇을 증명하는지</strong>입니다.</p>

<table>
  <thead>
    <tr>
      <th>시나리오</th>
      <th>증명하는 것</th>
      <th>이 테스트가 없으면?</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>10건 동시 주문</td>
      <td>비관적 락 순서 보장</td>
      <td>재고가 음수로 갈 수 있음</td>
    </tr>
    <tr>
      <td>5건 중복 콜백</td>
      <td>멱등성 + 비관적 락 조합</td>
      <td>주문 상태가 여러 번 전이</td>
    </tr>
    <tr>
      <td>콜백 + 상태 조회 동시</td>
      <td>콜백 경로와 스케줄러 경로의 경합 안전성</td>
      <td>같은 주문이 SUCCESS와 FAILED로 동시 처리</td>
    </tr>
    <tr>
      <td><strong>5건 동시 실패 콜백</strong></td>
      <td><strong>재고 이중 복구 방지</strong></td>
      <td><strong>돈이 안 맞음</strong> (이 글의 2장)</td>
    </tr>
    <tr>
      <td>Circuit Breaker 장애</td>
      <td>Fallback → PENDING 반환</td>
      <td>사용자에게 500 에러 노출</td>
    </tr>
  </tbody>
</table>

<p>4번 테스트가 가장 중요합니다. 이 테스트가 없었다면 2장의 버그를 발견하지 못했을 것입니다.</p>

<hr />

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

<h3 id="결제-코드의-체크리스트">결제 코드의 체크리스트</h3>

<p>이번 경험을 통해 결제 도메인 코드를 작성할 때 반드시 확인해야 할 체크리스트를 정리했습니다.</p>

<p><strong>동시성</strong></p>
<ul class="task-list">
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />상태 변경에 DB 레벨 락이 적용되어 있는가? (멱등성 가드만으로는 부족)</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />동시성 테스트를 작성했는가? (단일 스레드 테스트로는 발견 불가)</li>
</ul>

<p><strong>장애 복원력</strong></p>
<ul class="task-list">
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />외부 서비스 호출에 타임아웃이 설정되어 있는가?</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Circuit Breaker 설정값에 근거가 있는가? (정상 거절률 기반)</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Fallback이 또 다른 장애 지점이 되지 않는가?</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />모든 경로가 실패해도 사용자에게 의미 있는 응답을 반환하는가?</li>
</ul>

<p><strong>보안</strong></p>
<ul class="task-list">
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />카드번호가 로그에 평문으로 노출되지 않는가?</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />콜백 엔드포인트에 인증/검증이 있는가?</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />외부 입력값이 서비스 내부까지 검증 없이 전파되지 않는가?</li>
</ul>

<p><strong>운영</strong></p>
<ul class="task-list">
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />내부 오류(500)와 외부 장애(502)가 분리되어 있는가?</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />스케줄러가 다중 인스턴스 환경에서 안전한가?</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />설정값(Circuit Breaker, Retry, 스케줄러 주기)을 모니터링 기반으로 튜닝할 준비가 되어 있는가?</li>
</ul>

<h3 id="두려움의-원인은-검증-부족이었다">두려움의 원인은 검증 부족이었다</h3>

<p>처음 결제 연동을 시작할 때 막연한 두려움이 있었습니다. “외부 서비스와 통신하는 코드를 내가 잘 짤 수 있을까?”, “장애 나면 어떡하지?”</p>

<p>화해 기술 블로그에서 비슷한 이야기를 읽었습니다.</p>

<blockquote>
  <p><em>“막연한 두려움을 가졌던 이유는 근거와 더불어 검증이 충분하지 않기 때문이라 느꼈습니다.”</em></p>
</blockquote>

<p>돌이켜보면 맞는 말입니다. 두려움이 줄어든 시점은 코드를 완성했을 때가 아니라, <strong>동시성 테스트에서 재고 이중 복구 버그를 발견하고 고쳤을 때</strong>였습니다. “이 테스트가 통과하면 최소한 돈은 맞는다”는 확신이 생기니 배포가 무섭지 않았습니다.</p>

<p>설정값도 마찬가지입니다. Circuit Breaker의 <code class="language-plaintext highlighter-rouge">wait-duration</code>을 30초에서 5초로 바꿀 때, “5초면 너무 짧지 않나?”라는 불안감이 있었습니다. 하지만 “콜백 + 스케줄러가 보조 수단이므로 Circuit Breaker가 빨리 닫혀도 최종 정합성은 보장된다”는 근거를 세우니 확신이 생겼습니다.</p>

<p><strong>근거 없는 설정은 불안하고, 검증 없는 코드는 두렵습니다.</strong> 결제처럼 “실패하면 돈이 안 맞는” 도메인에서는 코드를 작성하는 시간보다 <strong>검증하는 시간이 더 중요</strong>하다는 것을 배웠습니다.</p>

<p>물론 이 프로젝트는 PG Simulator라는 가상 서비스를 만들어 연동한 것이므로, 실제 PG사 연동과는 차이가 있습니다. 실제 운영에서는 PG사의 API 인증, 서명 검증, 멱등키, 정산 연동 등 훨씬 더 많은 고려 사항이 존재합니다. 하지만 <strong>장애 복원력의 핵심 패턴(Circuit Breaker, Retry, Fallback)과 동시성 안전의 원칙</strong>은 가상 서비스든 실제 서비스든 동일합니다. 이 경험이 실제 PG 연동을 앞둔 분들에게 기초 체력이 되길 바랍니다.</p>

<p>긴 글 읽어주셔서 감사합니다.</p>

<hr />

<p><strong>참고 자료</strong></p>
<ul>
  <li><a href="https://martinfowler.com/bliki/CircuitBreaker.html">Martin Fowler - Circuit Breaker Pattern</a></li>
  <li><a href="https://resilience4j.readme.io/docs">Resilience4j 공식 문서</a></li>
  <li><a href="https://blog.hwahae.co.kr/all/tech/14541">화해 기술 블로그 - 내부통신에 서킷브레이커 적용하기</a></li>
</ul>]]></content><author><name>Ukukdin</name></author><category term="Loopers" /><category term="QA" /><summary type="html"><![CDATA[내 잘못 아니여도 책임져야지?]]></summary></entry><entry><title type="html">선택의 순간은 언제인가?</title><link href="https://ukukdin.github.io/2026/03/13/5weeksBlog/" rel="alternate" type="text/html" title="선택의 순간은 언제인가?" /><published>2026-03-13T00:00:00+00:00</published><updated>2026-03-13T00:00:00+00:00</updated><id>https://ukukdin.github.io/2026/03/13/5weeksBlog</id><content type="html" xml:base="https://ukukdin.github.io/2026/03/13/5weeksBlog/"><![CDATA[<h1 id="길부터-닦고-짐을-줄이고-지름길을-뚫어라">길부터 닦고, 짐을 줄이고, 지름길을 뚫어라</h1>

<blockquote>
  <p>10만건 상품 데이터 기반 조회 성능 개선기 — 인덱스, 비정규화, 캐시를 순서대로 적용하며 배운 것들</p>
</blockquote>

<hr />

<h2 id="왜-이-글을-쓰게-되었는가">왜 이 글을 쓰게 되었는가</h2>

<p>살다 보면 뭘 하든 뭔가를 포기해야 한다. 점심 메뉴를 고를 때도, 이직을 결정할 때도. 코드도 별반 다를 게 없었다.</p>

<p>4주차까지 좋아요 수 기반 정렬, 브랜드 필터링, 인기 상품 조회를 만들고 나서 궁금해졌다. “데이터가 10만건이면 어떻게 되지?” EXPLAIN ANALYZE를 돌려보니 모든 쿼리가 Seq Scan이었다. 10ms 넘는 쿼리들이 줄줄이 나왔다.</p>

<p>문제를 찾고 나서 바로 답이 나오진 않았다. 인덱스를 걸까? 캐시를 넣을까? 비정규화를 할까? 셋 다 “성능 개선”이라는 같은 목표인데, 각각 잃는 게 달랐다.</p>

<hr />

<h2 id="문제-발견--고민의-시작">문제 발견 — 고민의 시작</h2>

<p>내가 마주한 병목은 세 가지였다.</p>

<table>
  <thead>
    <tr>
      <th>문제</th>
      <th>증상</th>
      <th>체감</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>인덱스 부재</td>
      <td>모든 쿼리 Seq Scan, 300ms ~ 500ms+</td>
      <td>“왜 이렇게 느리지?”</td>
    </tr>
    <tr>
      <td>Lost Update</td>
      <td>좋아요 + 어드민 수정 동시 발생 시 데이터 유실</td>
      <td>“좋아요 눌렀는데 왜 안 올라가지?”</td>
    </tr>
    <tr>
      <td>캐시 없음</td>
      <td>동일 요청이 반복되어도 매번 DB 조회</td>
      <td>“같은 페이지를 왜 계속 DB에서?”</td>
    </tr>
  </tbody>
</table>

<p>문제를 나열하고 나니 다음 고민이 찾아왔다. <strong>뭐부터 해야 하지?</strong></p>

<p>이전 회사 솔루션으로 MAU 900만 은행 서비스를 운영할 때는, 로그인과 송금/결제 응답을 1초 이내로 줘야 해서 바로 Redis 캐싱을 도입했었다. 또한 하드웨어적으로 너무나도 빵빵해서 일반적으로 서비스회사들의 대용량 트래픽 처리하는 방식과는 거리감이 있었다.<br />
하지만 이커머스에서 점진적으로 성장하는 단계에, 상품 10만건이 쌓였다고 바로 Redis를 쓰는 게 맞을까?</p>

<p>극초기 스타트업이 홍보가 잘되어서 갑자기 상품이 쏟아진 상황을 상상해봤다. 시간이 급하면 캐싱부터 때리는 게 빠른 선택일 수 있다. <br />하지만 시간이 있다면? 쿼리 성능부터 잡는 게 나중에 발목 안 잡히는 방법이라고 생각했다.</p>

<p>실무에서는 보통 slow query log를 켜놓고 문제가 되는 쿼리부터 잡아간다. PostgreSQL은 <code class="language-plaintext highlighter-rouge">log_min_duration_statement</code>를 설정하면 지정 시간(예: 500ms) 이상 걸린 쿼리를 전부 기록해준다. MySQL도 <code class="language-plaintext highlighter-rouge">slow_query_log = ON</code>으로 같은 걸 할 수 있다. 운영 중인 서비스라면 이 로그를 보고 인덱스를 보완해나가는 형태로 접근하는 게 일반적이다. 처음부터 완벽하게 인덱스를 설계하는 게 아니라, 느린 쿼리가 잡히면 그때 대응하는 거다.</p>

<p>결국 내가 내린 판단 기준은 이거였다.</p>

<blockquote>
  <p><strong>비용이 적은 것부터 하자. 인덱스 → 비정규화 → 캐시.</strong></p>

  <p>길부터 닦고(인덱스), 짐을 줄이고(비정규화), 그래도 부족하면 지름길을 뚫자(캐시).</p>
</blockquote>

<hr />

<h2 id="step-1-인덱스--길부터-닦자">Step 1: 인덱스 — 길부터 닦자</h2>

<h3 id="인덱스가-뭔지-잠깐-짚고-가자">인덱스가 뭔지 잠깐 짚고 가자</h3>

<p>인덱스는 데이터의 <strong>위치 정보</strong>를 따로 저장해두는 거다. 책의 목차처럼 원하는 데이터가 어디 있는지 바로 찾아갈 수 있게 해준다.</p>

<p>인덱스가 없으면? 테이블 처음부터 끝까지 순서대로 훑어야 한다(Seq Scan). 데이터가 100건이면 상관없는데, 10만건이 되면 매번 전부 읽는 비용이 느껴지기 시작한다. 참고로 스펙이 좋아도 3만건 이상의 데이터에서 필요 없는 행까지 풀스캔하면 느릴 수밖에 없다.</p>

<p><strong>복합 인덱스</strong>는 여러 컬럼을 하나의 인덱스로 묶는 건데, 여기서 <strong>컬럼 순서가 중요하다.</strong> 인덱스는 지정한 순서대로 정렬되어 있어서, 순서에 따라 타는 범위가 달라진다.</p>

<p>일반적으로 인덱스 컬럼 순서는 이렇게 잡는다:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. = (등호) 조건 컬럼 → 먼저
2. range (&gt;, &lt;, BETWEEN) 조건 컬럼 → 다음
3. ORDER BY 컬럼 → 마지막
</code></pre></div></div>

<p>range 조건이 앞에 오면 그 뒤 컬럼은 인덱스를 제대로 못 탄다. 그래서 범위 비교는 뒤로 빼는 게 낫다.</p>

<p>그리고 인덱스에는 <strong>읽기와 쓰기의 트레이드오프</strong>가 있다. 인덱스를 걸수록 조회는 빨라지지만, INSERT/UPDATE/DELETE 때마다 인덱스도 같이 갱신해야 해서 쓰기가 느려진다. 둘 다 빠르게 만드는 건 불가능하다. 그래서 조회가 빈번한 곳에만 건다.</p>

<p>실무에서 인덱스 개수는 테이블 성격에 따라 다르다:</p>

<table>
  <thead>
    <tr>
      <th>테이블 유형</th>
      <th>인덱스 수</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>단순한 테이블</td>
      <td>3~5개</td>
    </tr>
    <tr>
      <td>서비스 핵심 테이블</td>
      <td>5~10개</td>
    </tr>
    <tr>
      <td>조회/분석/검색 목적 테이블</td>
      <td>10개 이상</td>
    </tr>
  </tbody>
</table>

<blockquote>
  <p>참고로 토스 같은 곳은 한 테이블에 컬럼이 40개 가까이 되는 경우도 있다고 한다. 그만큼 인덱스 설계가 복잡해진다.</p>
</blockquote>

<h3 id="인덱스-설계가-어려운-이유">인덱스 설계가 어려운 이유</h3>

<p>인덱스 설계가 단순히 “이 컬럼에 걸까 말까” 수준이면 좋겠는데, 실제로는 그렇지 않다. 복합 인덱스에서는 컬럼의 조합뿐 아니라 <strong>순서</strong>가 성능을 결정한다.</p>

<p>에어비앤비가 VLDB 2025에 발표한 논문(Sam Lightstone, Ping Wang)에서 재밌는 수치를 봤다. 테이블이 12개이고 각 테이블에 인덱싱 가능한 컬럼이 10개만 있어도, 가능한 인덱스 조합의 수는 약 10^84개라고 한다. 관측 가능한 우주의 원자 수가 약 10^80개니까, 그걸 넘는 숫자다.<br /> 에어비앤비는 이 문제를 풀기 위해 SQL:Trek이라는 자동 인덱스 설계 도구를 만들었다. 프로덕션 DB를 건드리지 않고 5% 샘플링한 시뮬레이션 DB에서 후보 인덱스를 실제로 생성하고 EXPLAIN으로 평가하는 방식인데, 특정 워크로드에서 50,000배 성능 개선을 끌어냈다고 한다.</p>

<p>물론 내 프로젝트에서 10^84개의 조합을 고민할 일은 없다. 하지만 “인덱스는 그냥 걸면 되는 거 아니야?”라고 생각했던 내게, <br /> 이게 왜 엔지니어링인지 느끼게 해준 글이었다. <br />테이블 4개에 인덱스 4개 거는 것만으로도 선택도, 컬럼 순서, 쓰기 비용을 전부 따져야 했으니까.</p>

<h3 id="첫-번째-시도-일반-복합-인덱스">첫 번째 시도: 일반 복합 인덱스</h3>

<p>먼저 떠오른 건 일반 복합 인덱스였다. <code class="language-plaintext highlighter-rouge">(deleted_at, brand_id, like_count)</code> 같은 조합으로 걸면 되지 않을까?</p>

<p>EXPLAIN ANALYZE를 돌려봤다. <strong>여전히 Seq Scan.</strong></p>

<p>원인은 <code class="language-plaintext highlighter-rouge">deleted_at IS NULL</code>의 선택도였다. 전체 데이터의 95%가 활성 상태(deleted_at이 NULL)라서 플래너가 “어차피 거의 다 읽어야 하니 인덱스 안 타는 게 낫겠다”고 판단한 것이다.</p>

<blockquote>
  <p>여기서 Covering Index도 고려해봤다. 조회에 필요한 컬럼을 전부 인덱스 노드에 포함시키면, 테이블까지 갔다올 필요 없이 인덱스만으로 결과를 돌려줄 수 있다. 조회 비용을 극단적으로 낮춰야 할 때 쓰는 방법인데, 이번 케이스에서는 Partial Index로 충분했기 때문에 적용하지 않았다. <br /> 참고로 에어비앤비의 SQL:Trek도 Covering Index를 안티패턴으로 분류한다. 특정 쿼리에는 최적이지만 테이블 전체 스토리지가 두 배 가까이 늘고 쓰기 비용이 커져서, 워크로드 전체로 보면 손해인 경우가 많다는 이유다.</p>
</blockquote>

<h3 id="두-번째-시도-partial-index">두 번째 시도: Partial Index</h3>

<p><code class="language-plaintext highlighter-rouge">WHERE deleted_at IS NULL</code> 조건을 인덱스 자체에 넣는 Partial Index를 적용했다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">INDEX</span> <span class="n">idx_products_active_brand_likes</span>
    <span class="k">ON</span> <span class="n">products</span> <span class="p">(</span><span class="n">brand_id</span><span class="p">,</span> <span class="n">like_count</span> <span class="k">DESC</span><span class="p">)</span>
    <span class="k">WHERE</span> <span class="n">deleted_at</span> <span class="k">IS</span> <span class="k">NULL</span><span class="p">;</span>
</code></pre></div></div>

<p>결과:</p>

<table>
  <thead>
    <tr>
      <th>쿼리</th>
      <th>AS-IS</th>
      <th>TO-BE</th>
      <th>개선율</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>브랜드+좋아요순</td>
      <td>10.757ms (Seq Scan)</td>
      <td><strong>0.082ms</strong> (Index Scan)</td>
      <td><strong>131x</strong></td>
    </tr>
    <tr>
      <td>가격순</td>
      <td>11.092ms (Parallel Seq)</td>
      <td><strong>0.549ms</strong> (Index Scan)</td>
      <td><strong>20x</strong></td>
    </tr>
    <tr>
      <td>최신순</td>
      <td>11.774ms (Parallel Seq)</td>
      <td><strong>0.143ms</strong> (Index Scan)</td>
      <td><strong>82x</strong></td>
    </tr>
    <tr>
      <td>좋아요순</td>
      <td>11.549ms (Parallel Seq)</td>
      <td><strong>0.157ms</strong> (Index Scan)</td>
      <td><strong>74x</strong></td>
    </tr>
  </tbody>
</table>

<p>다만 JPA의 <code class="language-plaintext highlighter-rouge">@Index</code>는 Partial Index의 <code class="language-plaintext highlighter-rouge">WHERE</code> 절을 지원하지 않는다. SQL 마이그레이션 스크립트로 따로 관리해야 했다. JPA 편의성을 포기하고 20~131배 성능을 가져갔다.</p>

<p><strong>배운 점</strong>: 인덱스를 걸었는데 안 탈 수 있다. 플래너가 왜 그런 판단을 하는지(선택도)를 모르면 인덱스를 걸어놓고도 Seq Scan에 머문다. 실무에서도 인덱스를 처음부터 완벽하게 잡기보다, slow query log로 느린 쿼리를 잡아가며 조정해나가는 게 현실적인 접근이다.</p>

<hr />

<h2 id="step-2-비정규화--짐을-줄이자">Step 2: 비정규화 — 짐을 줄이자</h2>

<p>인덱스로 조회는 해결했는데, 좋아요 쪽에 다른 문제가 있었다.</p>

<h3 id="기존-방식의-문제">기존 방식의 문제</h3>

<p>기존 좋아요 처리는 이랬다:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. SELECT FOR UPDATE (Product 전체 조회 + 비관적 락)
2. 메모리에서 likeCount + 1
3. UPDATE (Product 모든 컬럼 저장)
</code></pre></div></div>

<p>이게 왜 문제냐면, 어드민이 동시에 상품 정보(이름, 가격)를 수정하면 <strong>한쪽 변경이 날아간다.</strong> 좋아요가 Product 전체를 읽고 전체를 덮어쓰니까, 어드민이 바꾼 가격이 사라지는 거다. Lost Update 문제.</p>

<h3 id="선택지">선택지</h3>

<p><strong>A. 비관적 락 유지 + 좋아요 전용 테이블 분리</strong></p>
<ul>
  <li>장점: 정규화 유지</li>
  <li>단점: JOIN 비용, 테이블 늘어남, 락은 여전히 필요</li>
</ul>

<p><strong>B. 원자적 SQL UPDATE</strong></p>
<ul>
  <li>장점: 락 불필요, 다른 필드에 영향 없음, 쿼리 수 줄어듦</li>
  <li>단점: like_count가 products 테이블에 비정규화</li>
</ul>

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

<p>B를 골랐다. DB에서 <code class="language-plaintext highlighter-rouge">like_count = like_count + 1</code>을 실행하면, 어드민이 상품명을 바꾸든 가격을 바꾸든 likeCount 컬럼에만 영향이 간다. 락도 필요 없어졌다.</p>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th>AS-IS</th>
      <th>TO-BE</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>쿼리 수/요청</td>
      <td>SELECT FOR UPDATE + UPDATE (전체)</td>
      <td>SELECT + UPDATE (1컬럼)</td>
    </tr>
    <tr>
      <td>비관적 락</td>
      <td>2회/요청</td>
      <td><strong>0회</strong></td>
    </tr>
    <tr>
      <td>Lost Update</td>
      <td>위험 있음</td>
      <td><strong>없음</strong></td>
    </tr>
  </tbody>
</table>

<p>like_count를 products 테이블에 두는 비정규화를 받아들이고, 동시성 안전과 성능을 둘 다 가져갔다. 정규화가 깔끔하긴 하지만 실제로 터지는 문제 앞에서는 실용적인 쪽을 택했다.</p>

<h3 id="더-큰-트래픽이라면">더 큰 트래픽이라면?</h3>

<p>지금은 원자적 UPDATE로 충분하지만, 트래픽이 훨씬 커지면 다른 방법도 있다.</p>

<p><strong>Sharded Counter</strong> 방식이 그중 하나다. 하나의 카운터 row에 모든 요청이 몰리면 row-level lock 경합이 생기는데, 카운터를 여러 조각(shard)으로 나누면 동시 처리 성능을 높일 수 있다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>product_like_counter { product_id, shard_id, count }

shard 0: count = 42
shard 1: count = 38
shard 2: count = 45
→ 총 좋아요 수 = SUM(count) = 125
</code></pre></div></div>

<p>쓰기 시에는 랜덤 shard에 UPDATE하고, 읽기 시에는 SUM으로 합산한다. Redis를 쓸 수 있는 환경이면 Redis에서 카운트를 모아두는 것도 방법이다. 서버가 날아가도 Redis에 남아있으니까.</p>

<p>또 하나는 <strong>Materialized View</strong> 같은 접근인데, 이건 스냅샷이라기보다 read model에 가깝다. 이벤트로 비동기적으로 조회용 테이블을 만들어두는 방식이다. CQRS에서 쓰는 패턴과 비슷하다.</p>

<p>지금 규모에서는 원자적 UPDATE가 맞지만, 이런 선택지가 있다는 걸 알아두면 나중에 대응할 때 덜 당황한다.</p>

<hr />

<h2 id="step-3-캐시--지름길을-뚫자">Step 3: 캐시 — 지름길을 뚫자</h2>

<p>인덱스와 비정규화로 쿼리 하나하나는 빨라졌다. 그런데 같은 상품 목록을 수백 명이 동시에 요청하면? DB가 아무리 빨라도 반복 조회는 부하다.</p>

<p>캐시를 넣기로 했다. 근데 캐시도 종류가 있다.</p>

<p><strong>로컬 캐시</strong>(Caffeine, Ehcache 등)는 애플리케이션 메모리에 데이터를 올려두는 방식이다. 네트워크를 안 타니까 빠르다. 카카오페이 기술 블로그에서 본 사례가 인상적이었는데, 카카오페이는 상품·통신사·혜택 같은 메타 정보 조회에 로컬 캐시를 쓴다. Redis까지 갔다올 필요 없이 서버 메모리에서 바로 꺼내는 거다. 대신 서버가 여러 대면 서버마다 캐시가 따로 있으니까, A 서버에선 바뀐 데이터가 B 서버에선 안 바뀌어 있는 문제가 생긴다.</p>

<p><strong>글로벌 캐시</strong>(Redis)는 서버들이 하나의 캐시를 공유한다. 어느 서버에서 조회해도 같은 데이터가 나온다. 대신 매번 네트워크를 타야 한다.</p>

<p>나는 Redis를 골랐다. 이유는 단순하다. 이커머스에서 상품 상세 페이지는 어느 서버에서 열어도 같은 가격, 같은 좋아요 수가 보여야 한다. 서버별로 다른 값을 보여주는 건 사용자 경험 측면에서 받아들이기 어려웠다. 네트워크 비용은 있지만, 데이터 정합성을 포기하는 것보다는 낫다고 판단했다.</p>

<p>물론 카카오페이처럼 “변경이 거의 없는 메타 정보”라면 로컬 캐시가 더 나은 선택일 수 있다. 자주 안 바뀌니까 서버 간 불일치가 발생할 확률 자체가 낮고, 네트워크 비용을 아끼는 게 이득이다. 결국 <strong>캐시에 뭘 담느냐</strong>에 따라 답이 달라진다. 이것도 트레이드오프다.</p>

<p>한 가지 먼저 짚고 갈 점이 있다. <strong>캐싱은 버전 관리가 중요하다.</strong> 상품 정보 같은 걸 캐시에만 의존하면, 실제 DB 데이터와 점점 멀어질 수 있다. 캐시는 어디까지나 “빠르게 돌려주는 복사본”이지, 원본이 아니다. 이걸 잊으면 정합성 문제가 생긴다.</p>

<h3 id="고민-1-캐시를-언제-지울-것인가">고민 1: 캐시를 언제 지울 것인가</h3>

<p>처음에는 <code class="language-plaintext highlighter-rouge">@CacheEvict</code>를 붙였다. 근데 이게 문제가 있었다.</p>

<p><code class="language-plaintext highlighter-rouge">@CacheEvict</code>은 AOP 프록시에서 동작해서 <strong>트랜잭션 커밋보다 먼저 실행</strong>된다. 이런 일이 생길 수 있다:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Thread 1: 좋아요 → likeCount UPDATE (아직 미커밋)
Thread 1: 캐시 삭제 (@CacheEvict, 커밋 전 실행)
Thread 2: 캐시 조회 → MISS → DB에서 이전 값 읽음 (미커밋이니까)
Thread 2: 이전 값으로 캐시 다시 채움
Thread 1: 커밋
→ 캐시에는 이전 값이 TTL 동안 박혀있음
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">@TransactionalEventListener(AFTER_COMMIT)</code> 패턴으로 바꿨다. 커밋이 끝난 다음에 캐시를 지우니까 이 문제가 없어진다.</p>

<p>다만 <code class="language-plaintext highlighter-rouge">@TransactionalEventListener</code>는 트랜잭션 없는 환경(단위 테스트)에서 안 돌아간다. <code class="language-plaintext highlighter-rouge">isSynchronizationActive()</code> 체크를 넣어서 트랜잭션이 없으면 바로 evict하도록 분기를 추가해야 했다.</p>

<h3 id="고민-2-좋아요-한-번에-목록-캐시를-통째로-날릴-것인가">고민 2: 좋아요 한 번에 목록 캐시를 통째로 날릴 것인가</h3>

<p>처음에는 좋아요가 들어오면 <code class="language-plaintext highlighter-rouge">@CacheEvict(allEntries=true)</code>로 목록 캐시를 통째로 비웠다. 좋아요 수가 바뀌면 인기순 정렬 결과가 달라지니까 논리적으로는 맞다.</p>

<p>근데 트래픽이 늘면 <strong>캐시가 없을 때보다 더 느려진다.</strong></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>좋아요 1회 발생
→ 목록 캐시 전체 삭제 (page 0, 1, 2, ... 모든 sort 조합)
→ 직후 수백 요청이 동시에 Cache MISS
→ 전부 DB 조회 (Cache Stampede)
→ Redis 왕복 + DB 비용 = 캐시 없을 때보다 느림
</code></pre></div></div>

<p>이 Cache Stampede를 막는 방법은 여러 가지가 있다. 캐시 저장 앞에 락을 걸어서 한 번에 하나의 요청만 DB를 치게 하고, 나머지는 캐시가 채워질 때까지 기다리게 하는 방법도 있다. 하지만 이번에는 더 단순하게 접근했다.</p>

<p>목록 캐시는 좋아요/주문 시 안 지우고 <strong>TTL 1분으로 알아서 갱신</strong>되게 했다. 상품 상세는 바로 evict해서 정확한 값을 주고, 목록의 좋아요 수는 최대 1분 늦을 수 있다.</p>

<p>이걸 분산 시스템에서는 <strong>Eventual Consistency</strong>라고 부른다. 각 시점에서는 데이터가 다를 수 있지만, 시간이 지나면 결국 같아진다는 거다. 카카오페이도 로컬 캐시에서 같은 판단을 했다. 서버 간 데이터 불일치를 허용하되 TTL 1시간으로 최종적으로 맞춰지게 한 거다. 그쪽은 메타 정보라 1시간이 괜찮았고, 나는 상품 목록이라 1분으로 잡았다. 숫자는 다르지만 판단의 뼈대는 같다 — <strong>“실시간 정확성 vs 시스템 안정성” 사이에서 후자를 골랐다.</strong></p>

<p>“목록에서 좋아요 수가 1분 늦을 수 있다”를 받아들이고, Stampede를 막았다. 커머스에서 목록의 좋아요 수가 1분 차이 나는 건 사용자가 알기 어렵다. 근데 Stampede로 페이지가 느려지면 바로 느낀다.</p>

<h3 id="고민-3-redis가-죽으면">고민 3: Redis가 죽으면?</h3>

<p>캐시는 성능을 위해 얹은 거지, 핵심이 아니다. Redis가 죽었다고 서비스가 멈추면 안 된다.</p>

<p><code class="language-plaintext highlighter-rouge">SafeCacheErrorHandler</code>를 만들어서 Redis 예외를 잡고 DB로 넘어가게 했다. 예외를 잡으면 장애를 모를 수 있어서 Micrometer 카운터(<code class="language-plaintext highlighter-rouge">cache.errors</code>)로 모니터링을 붙였다.</p>

<p>처음에는 로그에 <code class="language-plaintext highlighter-rouge">exception.getMessage()</code>만 남겼는데, 이러면 연결 오류인지 직렬화 오류인지 모른다. <code class="language-plaintext highlighter-rouge">exception</code> 자체를 넘겨서 stack trace를 남기도록 고쳤다. 대신 캐시 key는 로그에서 뺐다. key에 userId 같은 게 들어갈 수 있어서.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// AS-IS: 메시지만, key 노출</span>
<span class="n">log</span><span class="o">.</span><span class="na">warn</span><span class="o">(</span><span class="s">"캐시 조회 실패 - cache: {}, key: {}, error: {}"</span><span class="o">,</span> <span class="n">cache</span><span class="o">.</span><span class="na">getName</span><span class="o">(),</span> <span class="n">key</span><span class="o">,</span> <span class="n">exception</span><span class="o">.</span><span class="na">getMessage</span><span class="o">());</span>

<span class="c1">// TO-BE: stack trace 보존, key 제거</span>
<span class="n">log</span><span class="o">.</span><span class="na">warn</span><span class="o">(</span><span class="s">"캐시 조회 실패 - cache: {}"</span><span class="o">,</span> <span class="n">cache</span><span class="o">.</span><span class="na">getName</span><span class="o">(),</span> <span class="n">exception</span><span class="o">);</span>
</code></pre></div></div>

<p>로그를 자세히 남기고 싶은데 보안 때문에 못 남기는 것도 결국 트레이드오프다.</p>

<h3 id="고민-4-aftercommit에서-캐시-삭제가-실패하면">고민 4: afterCommit에서 캐시 삭제가 실패하면?</h3>

<p>코드 리뷰에서 나온 지적이다. <code class="language-plaintext highlighter-rouge">afterCommit()</code> 안에서 <code class="language-plaintext highlighter-rouge">Cache.evict()</code>가 터지면 예외가 올라가서 클라이언트에 500이 간다. 근데 주문이랑 재고는 이미 DB에 들어간 상태다. 고객은 주문이 됐는데 에러 화면을 보는 셈이다.</p>

<p><code class="language-plaintext highlighter-rouge">CacheErrorHandler</code>는 <code class="language-plaintext highlighter-rouge">@CacheEvict</code> 같은 어노테이션에만 동작하고, 직접 호출한 <code class="language-plaintext highlighter-rouge">Cache.evict()</code>는 안 잡아준다. 그래서 afterCommit 블록마다 try-catch를 넣었다. 캐시 실패해도 로그만 남기고 응답은 정상으로 내보낸다.</p>

<h3 id="고민-5-브랜드-삭제할-때-전체-캐시를-밀어야-하나">고민 5: 브랜드 삭제할 때 전체 캐시를 밀어야 하나</h3>

<p>브랜드를 삭제하면 그 브랜드 상품들이 soft-delete된다. 처음에는 <code class="language-plaintext highlighter-rouge">detailCache.clear()</code>로 상품 상세 캐시를 전부 비웠다.</p>

<p>브랜드 A 하나 지웠을 뿐인데 브랜드 B, C 상품 캐시까지 다 날아간다. 목록 캐시에서 Stampede 막아놓고, 상세 캐시에서 같은 걸 반복하고 있었다.</p>

<p><code class="language-plaintext highlighter-rouge">BrandProductsDeletedEvent</code>를 만들어서 삭제된 상품의 productId 목록을 이벤트에 담았다. 캐시 핸들러는 그 ID만 evict한다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// AS-IS: 브랜드 A 삭제 → 모든 상품 캐시 소멸</span>
<span class="n">detailCache</span><span class="o">.</span><span class="na">clear</span><span class="o">();</span>

<span class="c1">// TO-BE: 브랜드 A 삭제 → A 상품만 evict, B/C 캐시는 그대로</span>
<span class="n">event</span><span class="o">.</span><span class="na">deletedProductIds</span><span class="o">().</span><span class="na">forEach</span><span class="o">(</span><span class="k">this</span><span class="o">::</span><span class="n">evictProductDetail</span><span class="o">);</span>
</code></pre></div></div>

<h3 id="고민-6-다음-단계는-뭘까">고민 6: 다음 단계는 뭘까</h3>

<p>지금은 단일 서버에 Redis 한 대다. 캐시 무효화가 단순하다. 이벤트 발행하고, 같은 서버 안에서 evict하면 끝이다.</p>

<p>근데 카카오페이 기술 블로그를 보면서 “서버가 여러 대가 되면 어떻게 되지?”를 생각해봤다. 카카오페이는 상품·통신사·혜택 같은 메타 정보를 <strong>로컬 캐시</strong>에 담는다. 네트워크를 안 타니까 빠른데, 서버가 여러 대면 서버마다 캐시가 따로라서 불일치가 생긴다. 이걸 <strong>Redis Pub/Sub</strong>으로 풀었다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>서버 A: 상품 수정 → Redis Pub/Sub에 이벤트 발행
서버 B: 이벤트 수신 → 로컬 캐시 evict
서버 C: 이벤트 수신 → 로컬 캐시 evict
</code></pre></div></div>

<p>여기서도 트레이드오프가 있다. Redis Pub/Sub은 메시지를 저장하지 않는다. 구독 시점에 서버가 잠깐 죽어있으면 그 이벤트는 날아간다. 카카오페이는 이걸 받아들였다. 어차피 TTL이 있으니 최악의 경우에도 TTL 지나면 갱신된다. 메시지 유실이 걱정되면 Kafka나 RabbitMQ를 쓰면 되지만, 그만큼 인프라 복잡도가 올라간다. 카카오페이는 “메타 정보는 자주 안 바뀌니까 유실돼도 괜찮다”고 판단한 거다.</p>

<p>내 프로젝트는 단일 서버에 Redis 글로벌 캐시라서 이 문제를 직접 겪진 않는다. 하지만 나중에 트래픽이 늘어서 서버를 늘리고, 네트워크 비용을 줄이려고 로컬 캐시를 도입하게 되면 이 구조가 필요해진다. Sharded Counter를 언급한 것과 같은 이유다 — 지금 당장은 아니지만, 다음 단계가 뭔지 알아두면 그때 가서 덜 당황한다.</p>

<hr />

<h2 id="테스트-환경">테스트 환경</h2>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th>사양</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Machine</td>
      <td>MacBook Pro 18,3 (2021)</td>
    </tr>
    <tr>
      <td>Chip</td>
      <td>Apple M1 Pro (10코어 — 8P + 2E)</td>
    </tr>
    <tr>
      <td>Memory</td>
      <td>32 GB</td>
    </tr>
    <tr>
      <td>Java</td>
      <td>OpenJDK 21.0.6 LTS</td>
    </tr>
    <tr>
      <td>Spring Boot</td>
      <td>3.4.4</td>
    </tr>
    <tr>
      <td>DB</td>
      <td>PostgreSQL 16.13 (Docker)</td>
    </tr>
    <tr>
      <td>Cache</td>
      <td>Redis (Docker)</td>
    </tr>
    <tr>
      <td>데이터 규모</td>
      <td>상품 10만건</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="최종-결과">최종 결과</h2>

<h3 id="쿼리-성능-explain-analyze">쿼리 성능 (EXPLAIN ANALYZE)</h3>

<table>
  <thead>
    <tr>
      <th>쿼리</th>
      <th>AS-IS</th>
      <th>TO-BE</th>
      <th>개선율</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>브랜드+좋아요순</td>
      <td>10.757ms</td>
      <td><strong>0.082ms</strong></td>
      <td><strong>131x</strong></td>
    </tr>
    <tr>
      <td>가격순+OFFSET</td>
      <td>11.092ms</td>
      <td><strong>0.549ms</strong></td>
      <td><strong>20x</strong></td>
    </tr>
    <tr>
      <td>최신순</td>
      <td>11.774ms</td>
      <td><strong>0.143ms</strong></td>
      <td><strong>82x</strong></td>
    </tr>
    <tr>
      <td>좋아요순</td>
      <td>11.549ms</td>
      <td><strong>0.157ms</strong></td>
      <td><strong>74x</strong></td>
    </tr>
  </tbody>
</table>

<h3 id="좋아요-동시성">좋아요 동시성</h3>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th>AS-IS</th>
      <th>TO-BE</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>비관적 락</td>
      <td>2회/요청</td>
      <td>0회</td>
    </tr>
    <tr>
      <td>Lost Update</td>
      <td>위험 있음</td>
      <td>없음</td>
    </tr>
    <tr>
      <td>쿼리 수</td>
      <td>3개</td>
      <td>2개</td>
    </tr>
  </tbody>
</table>

<h3 id="캐시-정합성">캐시 정합성</h3>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th>AS-IS</th>
      <th>TO-BE</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>evict 시점</td>
      <td>커밋 전</td>
      <td>커밋 후</td>
    </tr>
    <tr>
      <td>Stampede 위험</td>
      <td>있음</td>
      <td>없음 (TTL 갱신)</td>
    </tr>
    <tr>
      <td>Redis 장애 시</td>
      <td>500 에러</td>
      <td>DB fallback</td>
    </tr>
  </tbody>
</table>

<p>273개 테스트 전체 통과.</p>

<hr />

<h2 id="회고">회고</h2>

<p>이번에 제일 많이 한 생각은 “정답이 없다”는 거였다.</p>

<ul>
  <li>Partial Index는 JPA 편의성을 포기하고 131배 성능을 가져갔다.</li>
  <li>비정규화는 정규화의 깔끔함을 포기하고 동시성 안전을 가져갔다.</li>
  <li>캐시 TTL은 실시간 정확도를 포기하고 Stampede를 막았다.</li>
  <li>Redis 글로벌 캐시는 네트워크 비용을 받아들이고 데이터 정합성을 가져갔다.</li>
  <li>예외를 잡는 건 디버깅 편의를 포기하고 서비스 안정성을 가져갔다.</li>
</ul>

<p>뭘 선택하든 뭔가를 잃었고, 중요한 건 <strong>뭘 포기하고 뭘 가져가는지 알고 고르는 것</strong>이었다.</p>

<p>카카오페이 기술 블로그를 보면서 비슷한 걸 느꼈다. 카카오페이는 로컬 캐시를 쓰면서 서버 간 불일치를 Eventual Consistency로 받아들였고, 나는 Redis 글로벌 캐시를 쓰면서 네트워크 비용을 받아들였다. 둘 다 “완벽한 정합성”을 포기한 건 같은데, 포기한 지점이 다르다. 캐시에 뭘 담느냐, 서비스 특성이 뭐냐에 따라 같은 문제에도 답이 달라지는 거다.</p>

<p>실무에서도 마찬가지일 거다. 시간이 급하면 캐싱부터 때릴 수도 있다. 시간이 있으면 인덱스부터 닦는 게 나중에 안 물린다. 어떤 순서가 맞느냐가 아니라, <strong>지금 내 상황에서 뭘 포기하는 게 제일 덜 아픈지</strong> 판단할 수 있으면 된다고 생각한다.</p>

<p>코드도 인생처럼, 완벽한 선택은 없다. 다만 알고 고른 선택이 있을 뿐이다.</p>]]></content><author><name>Ukukdin</name></author><category term="Loopers" /><category term="QA" /><summary type="html"><![CDATA[인생에도 선택의 순간이 있듯, 코드도 그러하다]]></summary></entry><entry><title type="html">상품 조회 성능 최적화 — 인덱스, 비정규화, 캐시 테스트 결과</title><link href="https://ukukdin.github.io/2026/03/13/cache/" rel="alternate" type="text/html" title="상품 조회 성능 최적화 — 인덱스, 비정규화, 캐시 테스트 결과" /><published>2026-03-13T00:00:00+00:00</published><updated>2026-03-13T00:00:00+00:00</updated><id>https://ukukdin.github.io/2026/03/13/cache</id><content type="html" xml:base="https://ukukdin.github.io/2026/03/13/cache/"><![CDATA[<h1 id="상품-조회-성능-최적화--인덱스-비정규화-캐시">상품 조회 성능 최적화 — 인덱스, 비정규화, 캐시</h1>

<blockquote>
  <p>10만건 데이터 기반 상품 목록/상세 API 성능 개선 전과정</p>
</blockquote>

<hr />

<h2 id="테스트-환경">테스트 환경</h2>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th>사양</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Machine</strong></td>
      <td>MacBook Pro 18,3 (2021)</td>
    </tr>
    <tr>
      <td><strong>Chip</strong></td>
      <td>Apple M1 Pro (10코어 — 8P + 2E)</td>
    </tr>
    <tr>
      <td><strong>Memory</strong></td>
      <td>32 GB</td>
    </tr>
    <tr>
      <td><strong>OS</strong></td>
      <td>macOS Sonoma (Darwin 23.4.0)</td>
    </tr>
    <tr>
      <td><strong>Java</strong></td>
      <td>OpenJDK 21.0.6 LTS</td>
    </tr>
    <tr>
      <td><strong>Spring Boot</strong></td>
      <td>3.4.4</td>
    </tr>
    <tr>
      <td><strong>DB</strong></td>
      <td>PostgreSQL 16.13 (Docker)</td>
    </tr>
    <tr>
      <td><strong>Cache</strong></td>
      <td>Redis (Docker)</td>
    </tr>
    <tr>
      <td><strong>Docker</strong></td>
      <td>26.0.0</td>
    </tr>
    <tr>
      <td><strong>데이터 규모</strong></td>
      <td>상품 10만건</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="목차">목차</h2>

<ol>
  <li><a href="#1-배경-및-목표">배경 및 목표</a></li>
  <li><a href="#2-step-1-인덱스-최적화">Step 1: 인덱스 최적화</a></li>
  <li><a href="#3-step-2-좋아요-동기화-구조-개선">Step 2: 좋아요 동기화 구조 개선</a></li>
  <li><a href="#4-step-3-redis-캐시-적용">Step 3: Redis 캐시 적용</a></li>
  <li><a href="#5-테스트-검증">테스트 검증</a></li>
  <li><a href="#6-최종-성능-비교">최종 성능 비교</a></li>
  <li><a href="#7-회고">회고</a></li>
</ol>

<hr />

<h2 id="1-배경-및-목표">1. 배경 및 목표</h2>

<p>커머스 서비스의 상품 목록/상세 조회 API에서 다음과 같은 성능 병목이 확인되었습니다.</p>

<table>
  <thead>
    <tr>
      <th>문제</th>
      <th>증상</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>인덱스 부재</td>
      <td>10만건 기준 모든 쿼리가 Seq Scan, 10ms+ 소요</td>
    </tr>
    <tr>
      <td>Lost Update</td>
      <td>좋아요 + 어드민 수정 동시 발생 시 데이터 유실 가능</td>
    </tr>
    <tr>
      <td>캐시 없음</td>
      <td>동일 요청이 반복되어도 매번 DB 조회</td>
    </tr>
  </tbody>
</table>

<p><strong>목표</strong>: 인덱스 → 비정규화(원자적 UPDATE) → 캐시를 순차 적용하며, 각 단계별 AS-IS / TO-BE를 비교합니다.</p>

<h3 id="환경">환경</h3>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th>값</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>DB</td>
      <td>PostgreSQL 16 (Docker)</td>
    </tr>
    <tr>
      <td>데이터</td>
      <td>100,000건 (20 브랜드 x 5,000 상품)</td>
    </tr>
    <tr>
      <td>활성 상품</td>
      <td>94,993건 / 삭제 상품 5,007건 (5%)</td>
    </tr>
    <tr>
      <td>캐시</td>
      <td>Redis 6.x (Master-Replica)</td>
    </tr>
    <tr>
      <td>프레임워크</td>
      <td>Spring Boot 3 + JPA</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="2-step-1-인덱스-최적화">2. Step 1: 인덱스 최적화</h2>

<h3 id="분석-대상-쿼리-4가지">분석 대상 쿼리 4가지</h3>

<table>
  <thead>
    <tr>
      <th>쿼리</th>
      <th>API</th>
      <th>패턴</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Q1</td>
      <td>브랜드별 인기상품</td>
      <td><code class="language-plaintext highlighter-rouge">WHERE brand_id = ? AND deleted_at IS NULL ORDER BY like_count DESC</code></td>
    </tr>
    <tr>
      <td>Q2</td>
      <td>가격순 목록</td>
      <td><code class="language-plaintext highlighter-rouge">WHERE deleted_at IS NULL ORDER BY price ASC LIMIT 20 OFFSET ?</code></td>
    </tr>
    <tr>
      <td>Q3</td>
      <td>최신순 (기본)</td>
      <td><code class="language-plaintext highlighter-rouge">WHERE deleted_at IS NULL ORDER BY created_at DESC</code></td>
    </tr>
    <tr>
      <td>Q4</td>
      <td>전체 인기순</td>
      <td><code class="language-plaintext highlighter-rouge">WHERE deleted_at IS NULL ORDER BY like_count DESC</code></td>
    </tr>
  </tbody>
</table>

<h3 id="as-is-인덱스-없음-pk만-존재">AS-IS: 인덱스 없음 (PK만 존재)</h3>

<p>모든 쿼리가 <strong>Seq Scan + in-memory heapsort</strong>로 동작합니다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>-- Q1: 브랜드 필터 + 좋아요순
Seq Scan on products  (cost=0.00..3230.00 rows=4514)
  Filter: ((deleted_at IS NULL) AND (brand_id = 1))
  Rows Removed by Filter: 95247
  Buffers: shared hit=1980
Execution Time: 10.757 ms
</code></pre></div></div>

<ul>
  <li>10만건 전체를 스캔한 뒤 95,247건을 필터링으로 버림</li>
  <li>남은 4,753건을 메모리에서 heapsort</li>
</ul>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>-- Q3: 최신순 정렬
Parallel Seq Scan on products  (cost=0.00..2568.24 rows=55825)
  Filter: (deleted_at IS NULL)
  Sort Method: top-N heapsort  Memory: 33kB
Execution Time: 11.774 ms
</code></pre></div></div>

<table>
  <thead>
    <tr>
      <th>쿼리</th>
      <th>실행 시간</th>
      <th>스캔 방식</th>
      <th>버퍼</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Q1 (브랜드+좋아요순)</td>
      <td>10.757ms</td>
      <td>Seq Scan</td>
      <td>1,980</td>
    </tr>
    <tr>
      <td>Q2 (가격순+OFFSET)</td>
      <td>11.092ms</td>
      <td>Parallel Seq Scan</td>
      <td>2,016</td>
    </tr>
    <tr>
      <td>Q3 (최신순)</td>
      <td>11.774ms</td>
      <td>Parallel Seq Scan</td>
      <td>2,016</td>
    </tr>
    <tr>
      <td>Q4 (좋아요순)</td>
      <td>11.549ms</td>
      <td>Parallel Seq Scan</td>
      <td>2,016</td>
    </tr>
  </tbody>
</table>

<h3 id="첫-시도-일반-복합-인덱스-실패">첫 시도: 일반 복합 인덱스 (실패)</h3>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">INDEX</span> <span class="n">idx_products_deleted_price</span> <span class="k">ON</span> <span class="n">products</span> <span class="p">(</span><span class="n">deleted_at</span><span class="p">,</span> <span class="n">price</span><span class="p">);</span>
<span class="k">CREATE</span> <span class="k">INDEX</span> <span class="n">idx_products_deleted_likes</span> <span class="k">ON</span> <span class="n">products</span> <span class="p">(</span><span class="n">deleted_at</span><span class="p">,</span> <span class="n">like_count</span> <span class="k">DESC</span><span class="p">);</span>
</code></pre></div></div>

<p><strong>결과</strong>: Q2~Q4에서 여전히 Seq Scan. <code class="language-plaintext highlighter-rouge">deleted_at IS NULL</code>의 선택도가 95%로 너무 높아 <strong>플래너가 인덱스를 선택하지 않음</strong>.</p>

<p>일반 복합 인덱스에서 <code class="language-plaintext highlighter-rouge">deleted_at</code>을 선두 컬럼으로 놓으면, <code class="language-plaintext highlighter-rouge">NULL</code> 값의 비율이 95%이므로 인덱스로 필터링해도 거의 전체를 읽어야 합니다. PostgreSQL 플래너는 이 경우 Seq Scan이 더 효율적이라고 판단합니다.</p>

<h3 id="to-be-partial-index-적용">TO-BE: Partial Index 적용</h3>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- 활성 상품만 인덱싱 (WHERE deleted_at IS NULL)</span>
<span class="k">CREATE</span> <span class="k">INDEX</span> <span class="n">idx_products_active_brand_likes</span>
    <span class="k">ON</span> <span class="n">products</span> <span class="p">(</span><span class="n">brand_id</span><span class="p">,</span> <span class="n">like_count</span> <span class="k">DESC</span><span class="p">)</span> <span class="k">WHERE</span> <span class="n">deleted_at</span> <span class="k">IS</span> <span class="k">NULL</span><span class="p">;</span>

<span class="k">CREATE</span> <span class="k">INDEX</span> <span class="n">idx_products_active_price</span>
    <span class="k">ON</span> <span class="n">products</span> <span class="p">(</span><span class="n">price</span> <span class="k">ASC</span><span class="p">)</span> <span class="k">WHERE</span> <span class="n">deleted_at</span> <span class="k">IS</span> <span class="k">NULL</span><span class="p">;</span>

<span class="k">CREATE</span> <span class="k">INDEX</span> <span class="n">idx_products_active_likes</span>
    <span class="k">ON</span> <span class="n">products</span> <span class="p">(</span><span class="n">like_count</span> <span class="k">DESC</span><span class="p">)</span> <span class="k">WHERE</span> <span class="n">deleted_at</span> <span class="k">IS</span> <span class="k">NULL</span><span class="p">;</span>

<span class="k">CREATE</span> <span class="k">INDEX</span> <span class="n">idx_products_active_created</span>
    <span class="k">ON</span> <span class="n">products</span> <span class="p">(</span><span class="n">created_at</span> <span class="k">DESC</span><span class="p">)</span> <span class="k">WHERE</span> <span class="n">deleted_at</span> <span class="k">IS</span> <span class="k">NULL</span><span class="p">;</span>
</code></pre></div></div>

<p><strong>Partial Index를 선택한 이유:</strong></p>

<ol>
  <li><strong>인덱스 크기 축소</strong>: 5% soft-deleted 행 제외</li>
  <li><strong>정렬 키 직접 노출</strong>: <code class="language-plaintext highlighter-rouge">(price) WHERE deleted_at IS NULL</code>로 인덱스 자체가 정렬 순서 반영</li>
  <li><strong>플래너 친화적</strong>: <code class="language-plaintext highlighter-rouge">WHERE deleted_at IS NULL</code> 조건이 인덱스 조건과 정확히 매칭 → Index Scan 유도</li>
  <li><strong>JPA <code class="language-plaintext highlighter-rouge">@Index</code> 미사용 사유</strong>: Partial Index의 <code class="language-plaintext highlighter-rouge">WHERE</code> 절은 JPA 표준으로 표현 불가 → SQL 마이그레이션 스크립트로 관리</li>
</ol>

<p>적용 후 EXPLAIN ANALYZE:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>-- Q1: 브랜드 필터 + 좋아요순
Index Scan using idx_products_active_brand_likes on products
  Index Cond: (brand_id = 1)
  Buffers: shared hit=22 read=2
Execution Time: 0.082 ms    ← 10.757ms → 0.082ms (131x 개선)
</code></pre></div></div>

<h3 id="전후-비교">전후 비교</h3>

<table>
  <thead>
    <tr>
      <th>쿼리</th>
      <th>AS-IS</th>
      <th>TO-BE</th>
      <th>개선율</th>
      <th>스캔 전환</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Q1 (브랜드+좋아요순)</td>
      <td>10.757ms</td>
      <td><strong>0.082ms</strong></td>
      <td><strong>131x</strong></td>
      <td>Seq Scan → Index Scan</td>
    </tr>
    <tr>
      <td>Q2 (가격순+OFFSET)</td>
      <td>11.092ms</td>
      <td><strong>0.549ms</strong></td>
      <td><strong>20x</strong></td>
      <td>Parallel Seq → Index Scan</td>
    </tr>
    <tr>
      <td>Q3 (최신순)</td>
      <td>11.774ms</td>
      <td><strong>0.143ms</strong></td>
      <td><strong>82x</strong></td>
      <td>Parallel Seq → Index Scan</td>
    </tr>
    <tr>
      <td>Q4 (좋아요순)</td>
      <td>11.549ms</td>
      <td><strong>0.157ms</strong></td>
      <td><strong>74x</strong></td>
      <td>Parallel Seq → Index Scan</td>
    </tr>
  </tbody>
</table>

<p><strong>버퍼(I/O) 사용량:</strong></p>

<table>
  <thead>
    <tr>
      <th>쿼리</th>
      <th>AS-IS</th>
      <th>TO-BE</th>
      <th>감소율</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Q1</td>
      <td>1,980</td>
      <td>24</td>
      <td><strong>98.8%</strong></td>
    </tr>
    <tr>
      <td>Q2</td>
      <td>2,016</td>
      <td>250</td>
      <td>87.6%</td>
    </tr>
    <tr>
      <td>Q3</td>
      <td>2,016</td>
      <td>22</td>
      <td><strong>98.9%</strong></td>
    </tr>
    <tr>
      <td>Q4</td>
      <td>2,016</td>
      <td>22</td>
      <td><strong>98.9%</strong></td>
    </tr>
  </tbody>
</table>

<p><strong>인덱스 오버헤드:</strong></p>

<table>
  <thead>
    <tr>
      <th>인덱스</th>
      <th>크기</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>idx_products_active_brand_likes</td>
      <td>1,264 kB</td>
    </tr>
    <tr>
      <td>idx_products_active_created</td>
      <td>856 kB</td>
    </tr>
    <tr>
      <td>idx_products_active_likes</td>
      <td>784 kB</td>
    </tr>
    <tr>
      <td>idx_products_active_price</td>
      <td>672 kB</td>
    </tr>
    <tr>
      <td><strong>총 추가 오버헤드</strong></td>
      <td><strong>3,576 kB</strong></td>
    </tr>
  </tbody>
</table>

<p>3.5MB의 인덱스 추가로 모든 핵심 쿼리에서 20~131배 성능 개선을 달성했습니다.</p>

<hr />

<h2 id="3-step-2-좋아요-동기화-구조-개선">3. Step 2: 좋아요 동기화 구조 개선</h2>

<h3 id="as-is-read-modify-write-안티패턴">AS-IS: Read-Modify-Write 안티패턴</h3>

<p>기존 좋아요 처리 흐름:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>LikeService.like()
  ├─ findProductWithLock(productId)     ← 1차 비관적 락 (SELECT FOR UPDATE)
  ├─ existsByUserIdAndProductId()       ← 중복 체크
  ├─ Like.create() → save()            ← likes 테이블 INSERT
  └─ publishEvents(like)
      └─ LikeEventHandler.handle()
          ├─ findActiveByIdWithLock()   ← 2차 비관적 락 (동일 트랜잭션)
          ├─ product.increaseLikeCount()← 메모리에서 likeCount + 1
          └─ productRepository.save()   ← 전체 엔티티 UPDATE
</code></pre></div></div>

<p><strong>문제 1: Lost Update</strong></p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- 실제 발생하는 SQL: 모든 필드를 덮어씀</span>
<span class="k">UPDATE</span> <span class="n">products</span> <span class="k">SET</span> <span class="n">brand_id</span><span class="o">=?</span><span class="p">,</span> <span class="n">name</span><span class="o">=?</span><span class="p">,</span> <span class="n">price</span><span class="o">=?</span><span class="p">,</span> <span class="n">sale_price</span><span class="o">=?</span><span class="p">,</span>
    <span class="n">stock_quantity</span><span class="o">=?</span><span class="p">,</span> <span class="n">like_count</span><span class="o">=?</span><span class="p">,</span> <span class="n">description</span><span class="o">=?</span><span class="p">,</span> <span class="n">updated_at</span><span class="o">=?</span><span class="p">,</span> <span class="n">deleted_at</span><span class="o">=?</span>
<span class="k">WHERE</span> <span class="n">id</span> <span class="o">=</span> <span class="o">?</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>시간   Thread A (좋아요)                Thread B (어드민 수정)
─────────────────────────────────────────────────────────
t1     READ product (price=100k)
t2                                     READ product (price=100k)
t3                                     price=120k → SAVE (전체 UPDATE)
t4     likeCount+1 → SAVE (전체 UPDATE)
       → price가 100k로 복원됨 ← Lost Update!
</code></pre></div></div>

<p><strong>문제 2: 이중 비관적 락</strong></p>

<p>같은 트랜잭션 내에서 동일 row에 대해 2번 <code class="language-plaintext highlighter-rouge">SELECT FOR UPDATE</code> → 불필요한 오버헤드.</p>

<h3 id="to-be-원자적-sql-update">TO-BE: 원자적 SQL UPDATE</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>LikeService.like()
  ├─ validateProductExists(productId)    ← 단순 조회 (락 없음)
  ├─ existsByUserIdAndProductId()        ← 중복 체크
  ├─ Like.create() → save()
  └─ publishEvents(like)
      └─ LikeEventHandler.handle()
          └─ productRepository.incrementLikeCount(productId) ← 원자적 UPDATE
</code></pre></div></div>

<p><strong>핵심 변경: JPA Repository에 원자적 쿼리 추가</strong></p>

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

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

<p><strong>발생하는 SQL:</strong></p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- AS-IS: 전체 엔티티 덮어쓰기 (11개 컬럼)</span>
<span class="k">UPDATE</span> <span class="n">products</span> <span class="k">SET</span> <span class="n">brand_id</span><span class="o">=?</span><span class="p">,</span> <span class="n">name</span><span class="o">=?</span><span class="p">,</span> <span class="n">price</span><span class="o">=?</span><span class="p">,</span> <span class="n">sale_price</span><span class="o">=?</span><span class="p">,</span>
    <span class="n">stock_quantity</span><span class="o">=?</span><span class="p">,</span> <span class="n">like_count</span><span class="o">=?</span><span class="p">,</span> <span class="n">description</span><span class="o">=?</span><span class="p">,</span> <span class="n">updated_at</span><span class="o">=?</span><span class="p">,</span> <span class="n">deleted_at</span><span class="o">=?</span>
<span class="k">WHERE</span> <span class="n">id</span> <span class="o">=</span> <span class="o">?</span>

<span class="c1">-- TO-BE: 단일 컬럼 원자적 업데이트</span>
<span class="k">UPDATE</span> <span class="n">products</span> <span class="k">SET</span> <span class="n">like_count</span> <span class="o">=</span> <span class="n">like_count</span> <span class="o">+</span> <span class="mi">1</span> <span class="k">WHERE</span> <span class="n">id</span> <span class="o">=</span> <span class="o">?</span>
</code></pre></div></div>

<p><strong>도메인 인터페이스에 의도 표현 (DDD)</strong></p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// ProductRepository (도메인 레이어)</span>
<span class="kt">void</span> <span class="nf">incrementLikeCount</span><span class="o">(</span><span class="nc">Long</span> <span class="n">productId</span><span class="o">);</span>
<span class="kt">void</span> <span class="nf">decrementLikeCount</span><span class="o">(</span><span class="nc">Long</span> <span class="n">productId</span><span class="o">);</span>
</code></pre></div></div>

<p>도메인 인터페이스에 <code class="language-plaintext highlighter-rouge">incrementLikeCount</code>라는 메서드명으로 비즈니스 의도를 표현하고, 원자적 SQL은 인프라 레이어의 구현 세부사항으로 캡슐화했습니다.</p>

<p><strong>LikeService — 비관적 락 제거</strong></p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// AS-IS: 비관적 락으로 상품 조회</span>
<span class="kd">private</span> <span class="nc">Product</span> <span class="nf">findProductWithLock</span><span class="o">(</span><span class="nc">Long</span> <span class="n">productId</span><span class="o">)</span> <span class="o">{</span>
    <span class="k">return</span> <span class="n">productRepository</span><span class="o">.</span><span class="na">findActiveByIdWithLock</span><span class="o">(</span><span class="n">productId</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">CoreException</span><span class="o">(</span><span class="nc">ErrorType</span><span class="o">.</span><span class="na">PRODUCT_NOT_FOUND</span><span class="o">));</span>
<span class="o">}</span>

<span class="c1">// TO-BE: 단순 존재 확인 (락 불필요)</span>
<span class="kd">private</span> <span class="kt">void</span> <span class="nf">validateProductExists</span><span class="o">(</span><span class="nc">Long</span> <span class="n">productId</span><span class="o">)</span> <span class="o">{</span>
    <span class="n">productRepository</span><span class="o">.</span><span class="na">findActiveById</span><span class="o">(</span><span class="n">productId</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">CoreException</span><span class="o">(</span><span class="nc">ErrorType</span><span class="o">.</span><span class="na">PRODUCT_NOT_FOUND</span><span class="o">));</span>
<span class="o">}</span>
</code></pre></div></div>

<h3 id="전후-비교-1">전후 비교</h3>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th>AS-IS (Read-Modify-Write)</th>
      <th>TO-BE (Atomic UPDATE)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>쿼리 수/좋아요</td>
      <td>SELECT FOR UPDATE x2 + UPDATE (전체)</td>
      <td>SELECT + UPDATE (likeCount만)</td>
    </tr>
    <tr>
      <td>비관적 락</td>
      <td>2회/요청</td>
      <td>0회</td>
    </tr>
    <tr>
      <td>Lost Update 위험</td>
      <td><strong>있음</strong> (어드민 동시 수정)</td>
      <td><strong>없음</strong></td>
    </tr>
    <tr>
      <td>UPDATE 대상</td>
      <td>모든 컬럼 (11개)</td>
      <td>likeCount 1개</td>
    </tr>
    <tr>
      <td>likeCount 음수 방지</td>
      <td>없음</td>
      <td><code class="language-plaintext highlighter-rouge">AND p.likeCount &gt; 0</code> 조건</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="4-step-3-redis-캐시-적용">4. Step 3: Redis 캐시 적용</h2>

<h3 id="as-is-캐시-없음">AS-IS: 캐시 없음</h3>

<p>모든 요청이 DB 직접 조회:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GET /api/v1/products/1
    → ProductQueryService.getProduct()
    → DB SELECT (매번 실행)
    → 응답
</code></pre></div></div>

<h3 id="to-be-redis-캐시-레이어">TO-BE: Redis 캐시 레이어</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GET /api/v1/products/1
    → @Cacheable("product", key="#productId")
    ├─ Cache HIT → Redis GET → 즉시 반환 (sub-ms)
    └─ Cache MISS → DB SELECT → Redis SET (TTL 5분) → 반환
</code></pre></div></div>

<h3 id="캐시-키-설계-및-ttl">캐시 키 설계 및 TTL</h3>

<table>
  <thead>
    <tr>
      <th>캐시</th>
      <th>키 패턴</th>
      <th>TTL</th>
      <th>설계 이유</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>상품 상세</td>
      <td><code class="language-plaintext highlighter-rouge">product::{id}</code></td>
      <td>5분</td>
      <td>개별 단위 무효화 가능, 변경 시 즉시 evict</td>
    </tr>
    <tr>
      <td>상품 목록</td>
      <td><code class="language-plaintext highlighter-rouge">products::brand:{brandId}:sort:{sort}:page:{page}:size:{size}</code></td>
      <td>1분</td>
      <td>조합이 많아 TTL 짧게, 변경 시 전체 evict</td>
    </tr>
    <tr>
      <td>브랜드 목록</td>
      <td><code class="language-plaintext highlighter-rouge">brands::{key}</code></td>
      <td>10분</td>
      <td>변경 빈도 낮음</td>
    </tr>
  </tbody>
</table>

<h3 id="cacheconfig-핵심-구현">CacheConfig 핵심 구현</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Configuration</span>
<span class="nd">@EnableCaching</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">CacheConfig</span> <span class="kd">implements</span> <span class="nc">CachingConfigurer</span> <span class="o">{</span>

    <span class="nd">@Bean</span>
    <span class="kd">public</span> <span class="nc">CacheManager</span> <span class="nf">cacheManager</span><span class="o">(</span><span class="nc">RedisConnectionFactory</span> <span class="n">connectionFactory</span><span class="o">)</span> <span class="o">{</span>
        <span class="c1">// 캐시 전용 ObjectMapper (PROPERTY 방식 타입 정보)</span>
        <span class="nc">ObjectMapper</span> <span class="n">redisObjectMapper</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">ObjectMapper</span><span class="o">();</span>
        <span class="n">redisObjectMapper</span><span class="o">.</span><span class="na">registerModule</span><span class="o">(</span><span class="k">new</span> <span class="nc">JavaTimeModule</span><span class="o">());</span>
        <span class="n">redisObjectMapper</span><span class="o">.</span><span class="na">disable</span><span class="o">(</span><span class="nc">SerializationFeature</span><span class="o">.</span><span class="na">WRITE_DATES_AS_TIMESTAMPS</span><span class="o">);</span>
        <span class="n">redisObjectMapper</span><span class="o">.</span><span class="na">activateDefaultTyping</span><span class="o">(</span>
                <span class="nc">BasicPolymorphicTypeValidator</span><span class="o">.</span><span class="na">builder</span><span class="o">()</span>
                        <span class="o">.</span><span class="na">allowIfBaseType</span><span class="o">(</span><span class="nc">Object</span><span class="o">.</span><span class="na">class</span><span class="o">).</span><span class="na">build</span><span class="o">(),</span>
                <span class="nc">ObjectMapper</span><span class="o">.</span><span class="na">DefaultTyping</span><span class="o">.</span><span class="na">EVERYTHING</span><span class="o">,</span>
                <span class="nc">JsonTypeInfo</span><span class="o">.</span><span class="na">As</span><span class="o">.</span><span class="na">PROPERTY</span>   <span class="c1">// ← WRAPPER_ARRAY가 아닌 PROPERTY 방식</span>
        <span class="o">);</span>

        <span class="c1">// 캐시별 독립 TTL</span>
        <span class="nc">Map</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">,</span> <span class="nc">RedisCacheConfiguration</span><span class="o">&gt;</span> <span class="n">cacheConfigs</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="no">PRODUCT_DETAIL</span><span class="o">,</span> <span class="n">defaultConfig</span><span class="o">.</span><span class="na">entryTtl</span><span class="o">(</span><span class="nc">Duration</span><span class="o">.</span><span class="na">ofMinutes</span><span class="o">(</span><span class="mi">5</span><span class="o">)),</span>
            <span class="no">PRODUCT_LIST</span><span class="o">,</span>   <span class="n">defaultConfig</span><span class="o">.</span><span class="na">entryTtl</span><span class="o">(</span><span class="nc">Duration</span><span class="o">.</span><span class="na">ofMinutes</span><span class="o">(</span><span class="mi">1</span><span class="o">)),</span>
            <span class="no">BRAND_LIST</span><span class="o">,</span>     <span class="n">defaultConfig</span><span class="o">.</span><span class="na">entryTtl</span><span class="o">(</span><span class="nc">Duration</span><span class="o">.</span><span class="na">ofMinutes</span><span class="o">(</span><span class="mi">10</span><span class="o">))</span>
        <span class="o">);</span>

        <span class="k">return</span> <span class="nc">RedisCacheManager</span><span class="o">.</span><span class="na">builder</span><span class="o">(</span><span class="n">connectionFactory</span><span class="o">)</span>
                <span class="o">.</span><span class="na">cacheDefaults</span><span class="o">(</span><span class="n">defaultConfig</span><span class="o">.</span><span class="na">entryTtl</span><span class="o">(</span><span class="nc">Duration</span><span class="o">.</span><span class="na">ofMinutes</span><span class="o">(</span><span class="mi">5</span><span class="o">)))</span>
                <span class="o">.</span><span class="na">withInitialCacheConfigurations</span><span class="o">(</span><span class="n">cacheConfigs</span><span class="o">)</span>
                <span class="o">.</span><span class="na">build</span><span class="o">();</span>
    <span class="o">}</span>

    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="nc">CacheErrorHandler</span> <span class="nf">errorHandler</span><span class="o">()</span> <span class="o">{</span>
        <span class="k">return</span> <span class="k">new</span> <span class="nf">SafeCacheErrorHandler</span><span class="o">();</span>  <span class="c1">// Redis 장애 시 DB fallback</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p><strong>ObjectMapper 설정 포인트:</strong></p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">DefaultTyping.EVERYTHING</code> + <code class="language-plaintext highlighter-rouge">JsonTypeInfo.As.PROPERTY</code>: Record 타입도 정확한 역직렬화 보장</li>
  <li><code class="language-plaintext highlighter-rouge">JavaTimeModule</code>: <code class="language-plaintext highlighter-rouge">LocalDateTime</code> 직렬화 지원</li>
  <li>애플리케이션 메인 <code class="language-plaintext highlighter-rouge">ObjectMapper</code>와 분리하여 캐시 전용으로 사용</li>
</ul>

<h3 id="캐시-무효화-전략">캐시 무효화 전략</h3>

<p><strong>상품 CRUD:</strong></p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// ProductService</span>
<span class="nd">@CacheEvict</span><span class="o">(</span><span class="n">value</span> <span class="o">=</span> <span class="no">PRODUCT_LIST</span><span class="o">,</span> <span class="n">allEntries</span> <span class="o">=</span> <span class="kc">true</span><span class="o">)</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">createProduct</span><span class="o">(</span><span class="nc">ProductCreateCommand</span> <span class="n">command</span><span class="o">)</span> <span class="o">{</span> <span class="o">...</span> <span class="o">}</span>

<span class="nd">@Caching</span><span class="o">(</span><span class="n">evict</span> <span class="o">=</span> <span class="o">{</span>
    <span class="nd">@CacheEvict</span><span class="o">(</span><span class="n">value</span> <span class="o">=</span> <span class="no">PRODUCT_DETAIL</span><span class="o">,</span> <span class="n">key</span> <span class="o">=</span> <span class="s">"#command.productId()"</span><span class="o">),</span>
    <span class="nd">@CacheEvict</span><span class="o">(</span><span class="n">value</span> <span class="o">=</span> <span class="no">PRODUCT_LIST</span><span class="o">,</span> <span class="n">allEntries</span> <span class="o">=</span> <span class="kc">true</span><span class="o">)</span>
<span class="o">})</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">updateProduct</span><span class="o">(</span><span class="nc">ProductUpdateCommand</span> <span class="n">command</span><span class="o">)</span> <span class="o">{</span> <span class="o">...</span> <span class="o">}</span>

<span class="nd">@Caching</span><span class="o">(</span><span class="n">evict</span> <span class="o">=</span> <span class="o">{</span>
    <span class="nd">@CacheEvict</span><span class="o">(</span><span class="n">value</span> <span class="o">=</span> <span class="no">PRODUCT_DETAIL</span><span class="o">,</span> <span class="n">key</span> <span class="o">=</span> <span class="s">"#productId"</span><span class="o">),</span>
    <span class="nd">@CacheEvict</span><span class="o">(</span><span class="n">value</span> <span class="o">=</span> <span class="no">PRODUCT_LIST</span><span class="o">,</span> <span class="n">allEntries</span> <span class="o">=</span> <span class="kc">true</span><span class="o">)</span>
<span class="o">})</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">deleteProduct</span><span class="o">(</span><span class="nc">Long</span> <span class="n">productId</span><span class="o">)</span> <span class="o">{</span> <span class="o">...</span> <span class="o">}</span>
</code></pre></div></div>

<p><strong>좋아요 이벤트:</strong></p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// LikeEventHandler</span>
<span class="nd">@EventListener</span>
<span class="nd">@Caching</span><span class="o">(</span><span class="n">evict</span> <span class="o">=</span> <span class="o">{</span>
    <span class="nd">@CacheEvict</span><span class="o">(</span><span class="n">value</span> <span class="o">=</span> <span class="no">PRODUCT_DETAIL</span><span class="o">,</span> <span class="n">key</span> <span class="o">=</span> <span class="s">"#event.productId()"</span><span class="o">),</span>
    <span class="nd">@CacheEvict</span><span class="o">(</span><span class="n">value</span> <span class="o">=</span> <span class="no">PRODUCT_LIST</span><span class="o">,</span> <span class="n">allEntries</span> <span class="o">=</span> <span class="kc">true</span><span class="o">)</span>
<span class="o">})</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">handle</span><span class="o">(</span><span class="nc">ProductLikedEvent</span> <span class="n">event</span><span class="o">)</span> <span class="o">{</span>
    <span class="n">productRepository</span><span class="o">.</span><span class="na">incrementLikeCount</span><span class="o">(</span><span class="n">event</span><span class="o">.</span><span class="na">productId</span><span class="o">());</span>
<span class="o">}</span>
</code></pre></div></div>

<p><strong>주문 (재고 감소):</strong></p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// OrderService</span>
<span class="nd">@Caching</span><span class="o">(</span><span class="n">evict</span> <span class="o">=</span> <span class="o">{</span>
    <span class="nd">@CacheEvict</span><span class="o">(</span><span class="n">value</span> <span class="o">=</span> <span class="no">PRODUCT_DETAIL</span><span class="o">,</span> <span class="n">allEntries</span> <span class="o">=</span> <span class="kc">true</span><span class="o">),</span>
    <span class="nd">@CacheEvict</span><span class="o">(</span><span class="n">value</span> <span class="o">=</span> <span class="no">PRODUCT_LIST</span><span class="o">,</span> <span class="n">allEntries</span> <span class="o">=</span> <span class="kc">true</span><span class="o">)</span>
<span class="o">})</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">createOrder</span><span class="o">(</span><span class="nc">UserId</span> <span class="n">userId</span><span class="o">,</span> <span class="nc">OrderCommand</span> <span class="n">command</span><span class="o">)</span> <span class="o">{</span> <span class="o">...</span> <span class="o">}</span>
</code></pre></div></div>

<p><strong>브랜드 삭제 cascade:</strong></p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// BrandDeletedEventHandler</span>
<span class="nd">@EventListener</span>
<span class="nd">@Caching</span><span class="o">(</span><span class="n">evict</span> <span class="o">=</span> <span class="o">{</span>
    <span class="nd">@CacheEvict</span><span class="o">(</span><span class="n">value</span> <span class="o">=</span> <span class="no">PRODUCT_DETAIL</span><span class="o">,</span> <span class="n">allEntries</span> <span class="o">=</span> <span class="kc">true</span><span class="o">),</span>
    <span class="nd">@CacheEvict</span><span class="o">(</span><span class="n">value</span> <span class="o">=</span> <span class="no">PRODUCT_LIST</span><span class="o">,</span> <span class="n">allEntries</span> <span class="o">=</span> <span class="kc">true</span><span class="o">)</span>
<span class="o">})</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">handle</span><span class="o">(</span><span class="nc">BrandDeletedEvent</span> <span class="n">event</span><span class="o">)</span> <span class="o">{</span>
    <span class="c1">// 브랜드 하위 모든 상품 soft-delete</span>
<span class="o">}</span>
</code></pre></div></div>

<table>
  <thead>
    <tr>
      <th>이벤트</th>
      <th>무효화 대상</th>
      <th>전략</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>상품 수정/삭제</td>
      <td><code class="language-plaintext highlighter-rouge">product::{id}</code> + <code class="language-plaintext highlighter-rouge">products::*</code></td>
      <td>해당 상세 + 목록 전체</td>
    </tr>
    <tr>
      <td>상품 생성</td>
      <td><code class="language-plaintext highlighter-rouge">products::*</code></td>
      <td>목록 전체</td>
    </tr>
    <tr>
      <td>좋아요 등록/취소</td>
      <td><code class="language-plaintext highlighter-rouge">product::{id}</code> + <code class="language-plaintext highlighter-rouge">products::*</code></td>
      <td>해당 상세 + 목록 전체</td>
    </tr>
    <tr>
      <td>주문 (재고 감소)</td>
      <td><code class="language-plaintext highlighter-rouge">product::*</code> + <code class="language-plaintext highlighter-rouge">products::*</code></td>
      <td>상세/목록 전체 (다수 상품 영향)</td>
    </tr>
    <tr>
      <td>브랜드 삭제</td>
      <td><code class="language-plaintext highlighter-rouge">product::*</code> + <code class="language-plaintext highlighter-rouge">products::*</code></td>
      <td>상세/목록 전체 (cascade)</td>
    </tr>
  </tbody>
</table>

<h3 id="redis-장애-대응-safecacheerrorhandler">Redis 장애 대응: SafeCacheErrorHandler</h3>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="kd">class</span> <span class="nc">SafeCacheErrorHandler</span> <span class="kd">implements</span> <span class="nc">CacheErrorHandler</span> <span class="o">{</span>
    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">handleCacheGetError</span><span class="o">(</span><span class="nc">RuntimeException</span> <span class="n">exception</span><span class="o">,</span> <span class="nc">Cache</span> <span class="n">cache</span><span class="o">,</span> <span class="nc">Object</span> <span class="n">key</span><span class="o">)</span> <span class="o">{</span>
        <span class="n">log</span><span class="o">.</span><span class="na">warn</span><span class="o">(</span><span class="s">"Redis 캐시 조회 실패 - cache: {}, key: {}"</span><span class="o">,</span> <span class="n">cache</span><span class="o">.</span><span class="na">getName</span><span class="o">(),</span> <span class="n">key</span><span class="o">);</span>
        <span class="c1">// 예외를 던지지 않음 → @Cacheable이 Cache MISS로 처리 → DB fallback</span>
    <span class="o">}</span>
    <span class="c1">// put, evict, clear도 동일하게 경고 로그만 남기고 무시</span>
<span class="o">}</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Redis 정상 → Cache HIT → 즉시 반환 (DB 부하 0)
Redis 장애 → SafeCacheErrorHandler → warn 로그 → DB fallback (서비스 정상)
</code></pre></div></div>

<hr />

<h2 id="5-테스트-검증">5. 테스트 검증</h2>

<h3 id="테스트-결과-273개-전체-통과">테스트 결과: 273개 전체 통과</h3>

<p>모든 변경 후 전체 테스트를 실행하여 273개 테스트가 100% 통과했습니다.</p>

<h3 id="발견한-문제와-수정">발견한 문제와 수정</h3>

<h4 id="문제-1-databasecleanup-mysql-문법-오류">문제 1: DatabaseCleanUp MySQL 문법 오류</h4>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// AS-IS (MySQL 전용): PostgreSQL에서 PSQLException 발생</span>
<span class="n">entityManager</span><span class="o">.</span><span class="na">createNativeQuery</span><span class="o">(</span><span class="s">"SET FOREIGN_KEY_CHECKS = 0"</span><span class="o">).</span><span class="na">executeUpdate</span><span class="o">();</span>
<span class="n">entityManager</span><span class="o">.</span><span class="na">createNativeQuery</span><span class="o">(</span><span class="s">"TRUNCATE TABLE `"</span> <span class="o">+</span> <span class="n">table</span> <span class="o">+</span> <span class="s">"`"</span><span class="o">).</span><span class="na">executeUpdate</span><span class="o">();</span>
<span class="n">entityManager</span><span class="o">.</span><span class="na">createNativeQuery</span><span class="o">(</span><span class="s">"SET FOREIGN_KEY_CHECKS = 1"</span><span class="o">).</span><span class="na">executeUpdate</span><span class="o">();</span>

<span class="c1">// TO-BE (PostgreSQL 호환): CASCADE로 FK 제약 무시</span>
<span class="n">entityManager</span><span class="o">.</span><span class="na">createNativeQuery</span><span class="o">(</span>
    <span class="s">"TRUNCATE TABLE "</span> <span class="o">+</span> <span class="n">table</span> <span class="o">+</span> <span class="s">" RESTART IDENTITY CASCADE"</span>
<span class="o">).</span><span class="na">executeUpdate</span><span class="o">();</span>
</code></pre></div></div>

<h4 id="문제-2-likeservicetest-mock-불일치">문제 2: LikeServiceTest mock 불일치</h4>

<p>비관적 락 제거 후 <code class="language-plaintext highlighter-rouge">findActiveByIdWithLock</code> 대신 <code class="language-plaintext highlighter-rouge">findActiveById</code>를 사용하므로, 단위 테스트의 mock stubbing을 일괄 수정했습니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// AS-IS</span>
<span class="n">when</span><span class="o">(</span><span class="n">productRepository</span><span class="o">.</span><span class="na">findActiveByIdWithLock</span><span class="o">(</span><span class="mi">1L</span><span class="o">)).</span><span class="na">thenReturn</span><span class="o">(</span><span class="nc">Optional</span><span class="o">.</span><span class="na">of</span><span class="o">(</span><span class="n">product</span><span class="o">));</span>

<span class="c1">// TO-BE</span>
<span class="n">when</span><span class="o">(</span><span class="n">productRepository</span><span class="o">.</span><span class="na">findActiveById</span><span class="o">(</span><span class="mi">1L</span><span class="o">)).</span><span class="na">thenReturn</span><span class="o">(</span><span class="nc">Optional</span><span class="o">.</span><span class="na">of</span><span class="o">(</span><span class="n">product</span><span class="o">));</span>
</code></pre></div></div>

<h4 id="문제-3-캐시-무효화-누락-stockconcurrencytest-productapie2etest">문제 3: 캐시 무효화 누락 (StockConcurrencyTest, ProductApiE2ETest)</h4>

<table>
  <thead>
    <tr>
      <th>테스트</th>
      <th>증상</th>
      <th>원인</th>
      <th>수정</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>StockConcurrencyTest x2</td>
      <td><code class="language-plaintext highlighter-rouge">expected: 0, but was: 100</code></td>
      <td>주문으로 재고 감소 시 상품 캐시 미무효화</td>
      <td>OrderService에 <code class="language-plaintext highlighter-rouge">@CacheEvict</code> 추가</td>
    </tr>
    <tr>
      <td>ProductApiE2ETest x1</td>
      <td>삭제된 상품이 200 반환</td>
      <td>브랜드 cascade 삭제 시 캐시 미무효화</td>
      <td>BrandDeletedEventHandler에 <code class="language-plaintext highlighter-rouge">@CacheEvict</code> 추가</td>
    </tr>
  </tbody>
</table>

<p>캐시를 도입할 때 <strong>모든 데이터 변경 경로</strong>에서 무효화가 누락되지 않았는지 확인해야 합니다. 상품 CRUD에만 evict를 적용하고, 주문(재고 감소)과 브랜드 삭제(cascade) 경로를 놓친 것이 원인이었습니다.</p>

<h4 id="문제-4-cacheconfig-json-역직렬화-오류">문제 4: CacheConfig JSON 역직렬화 오류</h4>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>// 초기 설정: WRAPPER_ARRAY 방식 (기본값)
objectMapper.activateDefaultTyping(..., DefaultTyping.NON_FINAL)

// 오류: PUT은 성공하지만 GET에서 역직렬화 실패
// "Unexpected token (START_OBJECT), expected START_ARRAY"
</code></pre></div></div>

<p>원인: <code class="language-plaintext highlighter-rouge">NON_FINAL</code> + 기본 <code class="language-plaintext highlighter-rouge">WRAPPER_ARRAY</code> 방식은 JSON을 <code class="language-plaintext highlighter-rouge">["타입", {데이터}]</code> 배열로 저장하는데, Record 타입과 호환되지 않았습니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 수정: EVERYTHING + PROPERTY 방식</span>
<span class="n">redisObjectMapper</span><span class="o">.</span><span class="na">activateDefaultTyping</span><span class="o">(</span>
    <span class="nc">BasicPolymorphicTypeValidator</span><span class="o">.</span><span class="na">builder</span><span class="o">()</span>
        <span class="o">.</span><span class="na">allowIfBaseType</span><span class="o">(</span><span class="nc">Object</span><span class="o">.</span><span class="na">class</span><span class="o">).</span><span class="na">build</span><span class="o">(),</span>
    <span class="nc">ObjectMapper</span><span class="o">.</span><span class="na">DefaultTyping</span><span class="o">.</span><span class="na">EVERYTHING</span><span class="o">,</span>
    <span class="nc">JsonTypeInfo</span><span class="o">.</span><span class="na">As</span><span class="o">.</span><span class="na">PROPERTY</span>    <span class="c1">// {"@class":"...", "field":"value"} 방식</span>
<span class="o">);</span>
</code></pre></div></div>

<hr />

<h2 id="6-최종-성능-비교">6. 최종 성능 비교</h2>

<h3 id="phase별-전체-비교">Phase별 전체 비교</h3>

<table>
  <thead>
    <tr>
      <th>Phase</th>
      <th>적용 내용</th>
      <th>핵심 변경</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Phase 0</td>
      <td>현재 상태 (PK만)</td>
      <td>Seq Scan, 비관적 락 2회, 캐시 없음</td>
    </tr>
    <tr>
      <td>Phase 1</td>
      <td>Partial Index 4개</td>
      <td>Index Scan 전환, 20~131x 개선</td>
    </tr>
    <tr>
      <td>Phase 2</td>
      <td>원자적 SQL UPDATE</td>
      <td>Lost Update 해결, 비관적 락 0회</td>
    </tr>
    <tr>
      <td>Phase 3</td>
      <td>Redis 캐시</td>
      <td>DB 조회 생략, sub-ms 응답</td>
    </tr>
  </tbody>
</table>

<h3 id="쿼리-성능">쿼리 성능</h3>

<table>
  <thead>
    <tr>
      <th>쿼리</th>
      <th>Phase 0</th>
      <th>Phase 1</th>
      <th>개선율</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>브랜드+좋아요순</td>
      <td>10.757ms (Seq Scan)</td>
      <td><strong>0.082ms</strong> (Index Scan)</td>
      <td><strong>131x</strong></td>
    </tr>
    <tr>
      <td>가격순+OFFSET</td>
      <td>11.092ms (Parallel Seq)</td>
      <td><strong>0.549ms</strong> (Index Scan)</td>
      <td><strong>20x</strong></td>
    </tr>
    <tr>
      <td>최신순</td>
      <td>11.774ms (Parallel Seq)</td>
      <td><strong>0.143ms</strong> (Index Scan)</td>
      <td><strong>82x</strong></td>
    </tr>
    <tr>
      <td>좋아요순</td>
      <td>11.549ms (Parallel Seq)</td>
      <td><strong>0.157ms</strong> (Index Scan)</td>
      <td><strong>74x</strong></td>
    </tr>
  </tbody>
</table>

<h3 id="캐시-적용-후-기대-성능">캐시 적용 후 기대 성능</h3>

<table>
  <thead>
    <tr>
      <th>시나리오</th>
      <th>Phase 1 (인덱스만)</th>
      <th>Phase 3 (인덱스+캐시)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>상품 상세 (캐시 HIT)</td>
      <td>~0.1ms (DB)</td>
      <td><strong>&lt;0.5ms</strong> (Redis GET)</td>
    </tr>
    <tr>
      <td>상품 목록 (캐시 HIT)</td>
      <td>~0.5ms (DB)</td>
      <td><strong>&lt;0.5ms</strong> (Redis GET)</td>
    </tr>
    <tr>
      <td>Redis 장애 시</td>
      <td>~0.1ms</td>
      <td>~0.1ms (DB fallback)</td>
    </tr>
  </tbody>
</table>

<h3 id="좋아요-동기화">좋아요 동기화</h3>

<table>
  <thead>
    <tr>
      <th>항목</th>
      <th>AS-IS</th>
      <th>TO-BE</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>비관적 락</td>
      <td>2회/요청</td>
      <td><strong>0회</strong></td>
    </tr>
    <tr>
      <td>UPDATE 대상</td>
      <td>모든 컬럼 (11개)</td>
      <td><strong>likeCount 1개</strong></td>
    </tr>
    <tr>
      <td>Lost Update 위험</td>
      <td>있음</td>
      <td><strong>없음</strong></td>
    </tr>
    <tr>
      <td>SQL 수/요청</td>
      <td>3개</td>
      <td><strong>2개</strong></td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="7-아키텍트-리뷰-기반-구조-개선">7. 아키텍트 리뷰 기반 구조 개선</h2>

<p>초기 캐시 구현 후 대규모 트래픽 관점에서 구조적 리스크를 점검한 결과, 3가지 핵심 문제를 발견하고 수정했습니다.</p>

<h3 id="문제-1-cacheevict과-transactional의-실행-순서-불일치">문제 1: <code class="language-plaintext highlighter-rouge">@CacheEvict</code>과 <code class="language-plaintext highlighter-rouge">@Transactional</code>의 실행 순서 불일치</h3>

<p><strong>AS-IS 문제:</strong></p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>t1: LikeService.like() → 트랜잭션 시작
t2: Like INSERT + incrementLikeCount() 실행
t3: @CacheEvict 실행 → Redis에서 캐시 삭제됨
t4: (다른 스레드) Cache MISS → DB 조회 → 아직 커밋 전이므로 이전 값으로 캐시 재생성
t5: 트랜잭션 커밋 → DB 값 변경 확정
    → 캐시에는 이전 값이 TTL 동안 고정됨
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">@CacheEvict</code>은 AOP 프록시 레벨에서 동작하므로 <strong>트랜잭션 커밋보다 먼저 실행</strong>됩니다. 이 사이에 다른 스레드가 캐시를 다시 채우면 stale 데이터가 TTL 동안 고정됩니다.</p>

<p><strong>TO-BE 해결:</strong></p>

<p><code class="language-plaintext highlighter-rouge">@EventListener</code>(DB 작업) + <code class="language-plaintext highlighter-rouge">@TransactionalEventListener(AFTER_COMMIT)</code>(캐시 무효화)로 책임을 분리했습니다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// LikeEventHandler — DB 작업만 수행 (트랜잭션 내)</span>
<span class="nd">@EventListener</span>
<span class="kd">public</span> <span class="kt">void</span> <span class="nf">handle</span><span class="o">(</span><span class="nc">ProductLikedEvent</span> <span class="n">event</span><span class="o">)</span> <span class="o">{</span>
    <span class="n">productRepository</span><span class="o">.</span><span class="na">incrementLikeCount</span><span class="o">(</span><span class="n">event</span><span class="o">.</span><span class="na">productId</span><span class="o">());</span>
<span class="o">}</span>

<span class="c1">// ProductCacheEvictHandler — 커밋 후 캐시 무효화 (신규)</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">handleLiked</span><span class="o">(</span><span class="nc">ProductLikedEvent</span> <span class="n">event</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="nc">CacheConfig</span><span class="o">.</span><span class="na">PRODUCT_DETAIL</span><span class="o">);</span>
    <span class="k">if</span> <span class="o">(</span><span class="n">cache</span> <span class="o">!=</span> <span class="kc">null</span><span class="o">)</span> <span class="n">cache</span><span class="o">.</span><span class="na">evict</span><span class="o">(</span><span class="n">event</span><span class="o">.</span><span class="na">productId</span><span class="o">());</span>
<span class="o">}</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>t1: 트랜잭션 시작
t2: @EventListener → incrementLikeCount() 실행
t3: 트랜잭션 커밋 ← DB 값 확정
t4: @TransactionalEventListener(AFTER_COMMIT) → 캐시 삭제
t5: (다른 스레드) Cache MISS → DB 조회 → 커밋된 최신 값으로 캐시 생성 ✅
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">ProductService</code>, <code class="language-plaintext highlighter-rouge">OrderService</code>에도 동일하게 <code class="language-plaintext highlighter-rouge">TransactionSynchronizationManager.registerSynchronization(afterCommit)</code>을 적용하여 모든 캐시 evict가 트랜잭션 커밋 이후에 실행되도록 변경했습니다.</p>

<h3 id="문제-2-product_list-allentriestrue--cache-stampede-위험">문제 2: <code class="language-plaintext highlighter-rouge">PRODUCT_LIST</code> <code class="language-plaintext highlighter-rouge">allEntries=true</code> — Cache Stampede 위험</h3>

<p><strong>AS-IS 문제:</strong></p>

<p>좋아요 1회 → <code class="language-plaintext highlighter-rouge">products::*</code> 전체 캐시 삭제 → 수백 개의 목록 조회 요청이 동시에 Cache MISS → DB에 동시 쿼리 폭증 (Cache Stampede).</p>

<p>트래픽이 증가하면 좋아요 TPS도 증가하므로, <strong>목록 캐시의 실효성이 사라지고</strong> stampede가 반복됩니다.</p>

<p><strong>TO-BE 해결:</strong></p>

<p>좋아요/주문 경로에서 <code class="language-plaintext highlighter-rouge">PRODUCT_LIST</code> 전체 무효화를 제거했습니다.</p>

<table>
  <thead>
    <tr>
      <th>이벤트</th>
      <th>AS-IS 무효화</th>
      <th>TO-BE 무효화</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>좋아요 등록/취소</td>
      <td><code class="language-plaintext highlighter-rouge">product::{id}</code> + <code class="language-plaintext highlighter-rouge">products::*</code></td>
      <td><code class="language-plaintext highlighter-rouge">product::{id}</code> <strong>만</strong></td>
    </tr>
    <tr>
      <td>주문 (재고 감소)</td>
      <td><code class="language-plaintext highlighter-rouge">product::*</code> + <code class="language-plaintext highlighter-rouge">products::*</code></td>
      <td><code class="language-plaintext highlighter-rouge">product::{affected_ids}</code> <strong>만</strong></td>
    </tr>
    <tr>
      <td>브랜드 삭제</td>
      <td><code class="language-plaintext highlighter-rouge">product::*</code> + <code class="language-plaintext highlighter-rouge">products::*</code></td>
      <td><code class="language-plaintext highlighter-rouge">product::*</code> + <code class="language-plaintext highlighter-rouge">products::*</code> (유지)</td>
    </tr>
    <tr>
      <td>상품 CRUD (어드민)</td>
      <td>유지</td>
      <td>유지</td>
    </tr>
  </tbody>
</table>

<ul>
  <li>목록 캐시의 좋아요 수/재고는 <strong>TTL 1분 이내에 자연 갱신</strong> (eventual consistency)</li>
  <li>상품 상세 캐시는 변경 즉시 evict하여 <strong>정확한 값 보장</strong></li>
  <li>상품 CRUD, 브랜드 삭제는 어드민 작업(빈도 낮음)이므로 <code class="language-plaintext highlighter-rouge">allEntries=true</code> 유지</li>
</ul>

<p>이 설계는 “좋아요 count가 목록에서 최대 1분 지연될 수 있다”는 <strong>비즈니스 트레이드오프</strong>를 수용합니다. 대신 Cache Stampede 위험을 근본적으로 제거합니다.</p>

<h3 id="문제-3-캐시-장애의-가시성-부재">문제 3: 캐시 장애의 가시성 부재</h3>

<p><strong>AS-IS 문제:</strong></p>

<p><code class="language-plaintext highlighter-rouge">SafeCacheErrorHandler</code>가 모든 예외를 warn 로그로 삼키므로, Redis가 반쯤 죽어도 서비스는 “정상”으로 보입니다. 적중률 저하, evict 실패를 인지하는 시점이 “고객이 느린 응답을 체감할 때”가 됩니다.</p>

<p><strong>TO-BE 해결:</strong></p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// SafeCacheErrorHandler — Micrometer 메트릭 추가</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">SafeCacheErrorHandler</span> <span class="kd">implements</span> <span class="nc">CacheErrorHandler</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">MeterRegistry</span> <span class="n">meterRegistry</span><span class="o">;</span>

    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">handleCacheGetError</span><span class="o">(</span><span class="nc">RuntimeException</span> <span class="n">exception</span><span class="o">,</span> <span class="nc">Cache</span> <span class="n">cache</span><span class="o">,</span> <span class="nc">Object</span> <span class="n">key</span><span class="o">)</span> <span class="o">{</span>
        <span class="n">log</span><span class="o">.</span><span class="na">warn</span><span class="o">(</span><span class="s">"Redis 캐시 조회 실패 - cache: {}, key: {}"</span><span class="o">,</span> <span class="n">cache</span><span class="o">.</span><span class="na">getName</span><span class="o">(),</span> <span class="n">key</span><span class="o">);</span>
        <span class="nc">Counter</span><span class="o">.</span><span class="na">builder</span><span class="o">(</span><span class="s">"cache.errors"</span><span class="o">)</span>
                <span class="o">.</span><span class="na">tag</span><span class="o">(</span><span class="s">"cache"</span><span class="o">,</span> <span class="n">cache</span><span class="o">.</span><span class="na">getName</span><span class="o">())</span>
                <span class="o">.</span><span class="na">tag</span><span class="o">(</span><span class="s">"operation"</span><span class="o">,</span> <span class="s">"get"</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="o">}</span>
    <span class="c1">// put, evict, clear도 동일</span>
<span class="o">}</span>
</code></pre></div></div>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// CacheConfig — 통계 활성화</span>
<span class="k">return</span> <span class="nc">RedisCacheManager</span><span class="o">.</span><span class="na">builder</span><span class="o">(</span><span class="n">connectionFactory</span><span class="o">)</span>
        <span class="o">.</span><span class="na">cacheDefaults</span><span class="o">(</span><span class="n">defaultConfig</span><span class="o">)</span>
        <span class="o">.</span><span class="na">withInitialCacheConfigurations</span><span class="o">(</span><span class="n">cacheConfigs</span><span class="o">)</span>
        <span class="o">.</span><span class="na">enableStatistics</span><span class="o">()</span>   <span class="c1">// ← Micrometer 캐시 통계 노출</span>
        <span class="o">.</span><span class="na">build</span><span class="o">();</span>
</code></pre></div></div>

<p>Prometheus <code class="language-plaintext highlighter-rouge">/actuator/prometheus</code> 엔드포인트에서 확인 가능한 메트릭:</p>

<table>
  <thead>
    <tr>
      <th>메트릭</th>
      <th>용도</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">cache_gets_total{result="hit"}</code></td>
      <td>캐시 적중 수</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">cache_gets_total{result="miss"}</code></td>
      <td>캐시 미스 수</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">cache_puts_total</code></td>
      <td>캐시 저장 수</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">cache_evictions_total</code></td>
      <td>캐시 무효화 수</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">cache.errors{cache, operation}</code></td>
      <td>캐시 오류 수 (커스텀)</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="8-회고">8. 회고</h2>

<h3 id="적용한-원칙">적용한 원칙</h3>

<p><strong>DDD:</strong></p>
<ul>
  <li>도메인 레이어의 <code class="language-plaintext highlighter-rouge">ProductRepository</code> 인터페이스에 <code class="language-plaintext highlighter-rouge">incrementLikeCount</code> 의도 표현</li>
  <li>원자적 SQL, 캐시 설정은 인프라 레이어의 구현 세부사항으로 캡슐화</li>
  <li>Like → ProductLikedEvent → LikeEventHandler(DB) / ProductCacheEvictHandler(캐시) 관심사 분리</li>
</ul>

<p><strong>SOLID:</strong></p>
<ul>
  <li>SRP: <code class="language-plaintext highlighter-rouge">LikeEventHandler</code>(DB 변경), <code class="language-plaintext highlighter-rouge">ProductCacheEvictHandler</code>(캐시 무효화) 각각 단일 책임</li>
  <li>OCP: 새로운 캐시 대상 추가 시 <code class="language-plaintext highlighter-rouge">@Cacheable</code> 어노테이션만 추가</li>
  <li>DIP: 도메인 인터페이스에 의존, 구현은 인프라 레이어</li>
</ul>

<p><strong>안티패턴 해결:</strong></p>
<ul>
  <li>Read-Modify-Write → 원자적 SQL UPDATE</li>
  <li>이중 비관적 락 → 락 제거</li>
  <li>전체 엔티티 덮어쓰기 → 단일 컬럼 UPDATE</li>
  <li>캐시 evict 타이밍 불일치 → <code class="language-plaintext highlighter-rouge">AFTER_COMMIT</code> 패턴</li>
  <li>Cache Stampede 위험 → 목록 캐시의 TTL 기반 갱신</li>
</ul>

<h3 id="배운-점">배운 점</h3>

<ol>
  <li>
    <p><strong>Partial Index의 위력</strong>: 일반 복합 인덱스가 실패한 곳에서 Partial Index가 성공. 선택도가 높은 조건(<code class="language-plaintext highlighter-rouge">deleted_at IS NULL</code> = 95%)에서는 해당 조건 자체를 인덱스 필터로 분리하는 것이 효과적.</p>
  </li>
  <li>
    <p><strong>캐시 무효화는 모든 변경 경로를 커버해야 한다</strong>: CRUD에만 evict를 적용하고 주문(재고 감소), 브랜드 삭제(cascade) 경로를 놓쳐 테스트 실패. 캐시를 도입할 때 데이터가 변경되는 <strong>모든 진입점</strong>을 파악하는 것이 핵심.</p>
  </li>
  <li>
    <p><strong><code class="language-plaintext highlighter-rouge">@CacheEvict</code>과 <code class="language-plaintext highlighter-rouge">@Transactional</code>은 같은 AOP 레벨</strong>: 캐시 evict이 트랜잭션 커밋보다 먼저 발생할 수 있다. <code class="language-plaintext highlighter-rouge">@TransactionalEventListener(AFTER_COMMIT)</code> 또는 <code class="language-plaintext highlighter-rouge">TransactionSynchronizationManager</code>를 사용하여 커밋 후 evict을 보장해야 한다.</p>
  </li>
  <li>
    <p><strong><code class="language-plaintext highlighter-rouge">allEntries=true</code>는 트래픽에 비례하는 시한폭탄</strong>: 좋아요 1회 = 목록 캐시 전체 소멸. 트래픽이 증가하면 캐시가 없는 것보다 나쁜 상태(Redis 왕복 + DB 쿼리)가 된다. 비즈니스 관점에서 <strong>어느 수준의 eventual consistency를 허용할지</strong> 결정하는 것이 기술적 해결보다 선행되어야 한다.</p>
  </li>
  <li>
    <p><strong>모니터링 없는 SafeCacheErrorHandler는 위험</strong>: 예외를 삼키는 패턴은 장애 격리에 유효하지만, 메트릭 없이 사용하면 <strong>문제를 은폐</strong>한다. 캐시 적중률과 오류율에 대한 가시성은 운영의 전제 조건이다.</p>
  </li>
  <li>
    <p><strong>ObjectMapper 분리</strong>: 애플리케이션 메인 ObjectMapper와 Redis 캐시용 ObjectMapper를 분리해야 함. 캐시는 타입 정보(<code class="language-plaintext highlighter-rouge">@class</code>)를 포함해야 역직렬화가 가능하지만, API 응답에는 타입 정보가 불필요.</p>
  </li>
</ol>

<h3 id="파일-변경-요약">파일 변경 요약</h3>

<table>
  <thead>
    <tr>
      <th>파일</th>
      <th>변경</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">scripts/mock-data-100k.sql</code></td>
      <td>신규 — 10만건 테스트 데이터</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">scripts/V5__create_product_indexes.sql</code></td>
      <td>신규 — Partial Index 마이그레이션</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">infrastructure/cache/CacheConfig.java</code></td>
      <td>신규 — RedisCacheManager + TTL + enableStatistics</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">infrastructure/cache/SafeCacheErrorHandler.java</code></td>
      <td>신규 — Redis 장애 격리 + Micrometer 메트릭</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">infrastructure/cache/ProductCacheEvictHandler.java</code></td>
      <td>신규 — 트랜잭션 커밋 후 캐시 무효화</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ProductJpaRepository.java</code></td>
      <td>수정 — 원자적 increment/decrement 쿼리</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ProductRepository.java</code></td>
      <td>수정 — 도메인 인터페이스에 원자적 메서드</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ProductRepositoryImpl.java</code></td>
      <td>수정 — 원자적 메서드 구현</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">LikeEventHandler.java</code></td>
      <td>수정 — DB 작업만 수행, @CacheEvict 제거</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">LikeService.java</code></td>
      <td>수정 — 비관적 락 → 단순 조회</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ProductQueryService.java</code></td>
      <td>수정 — @Cacheable 적용</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ProductService.java</code></td>
      <td>수정 — afterCommit 캐시 무효화</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">OrderService.java</code></td>
      <td>수정 — afterCommit 개별 상품 캐시 무효화</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">BrandDeletedEventHandler.java</code></td>
      <td>수정 — DB 작업만 수행, @CacheEvict 제거</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">BrandService.java</code></td>
      <td>수정 — @CacheEvict 제거 (이벤트 핸들러로 위임)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">DatabaseCleanUp.java</code></td>
      <td>수정 — MySQL → PostgreSQL 문법</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">LikeServiceTest.java</code></td>
      <td>수정 — mock 변경 (findActiveById)</td>
    </tr>
  </tbody>
</table>]]></content><author><name>Ukukdin</name></author><category term="Loopers" /><category term="QA" /><summary type="html"><![CDATA[10만건 데이터 기반 상품 목록/상세 API 성능 개선 전과정]]></summary></entry><entry><title type="html">3월 첫째 주 회고록</title><link href="https://ukukdin.github.io/2026/03/08/review-gultto/" rel="alternate" type="text/html" title="3월 첫째 주 회고록" /><published>2026-03-08T00:00:00+00:00</published><updated>2026-03-08T00:00:00+00:00</updated><id>https://ukukdin.github.io/2026/03/08/review-gultto</id><content type="html" xml:base="https://ukukdin.github.io/2026/03/08/review-gultto/"><![CDATA[<h2 id="3월-첫째-주의-회고록">3월 첫째 주의 회고록</h2>

<h3 id="ai가-일을-해줬다-그런데-그게-괜찮은-건가">AI가 일을 해줬다. 그런데 그게 괜찮은 건가?</h3>

<p>이번 주 회사에서 네이버 금융 클라우드 기반 eKYC 개발을 맡았다. 한국 이용자의 2차 인증 — 신분증, 운전면허증, 여권, 외국인등록증으로 진위여부를 판별하는 기능이다. eKYC + ApiGateway + Object Storage를 연동해서 minimum PoC를 진행했고, 결과적으로 잘 돌아갔다.</p>

<p>문제는 이 과정에서 내가 한 게 뭔지 모르겠다는 거다.</p>

<p>Java+Spring만 쓰다가 TypeScript+Nest.js 환경으로 바뀌니 시간 안에 끝내기가 어려웠다. 그래서 클로드 코드에 거의 전부를 맡겨버렸다. 코드는 나왔고, PoC는 통과했다. 하지만 그 코드가 왜 그렇게 작성되었는지, Nest.js의 어떤 패턴이 적용된 건지 나는 제대로 설명할 수 없다.</p>

<p>결과물만 놓고 보면 성공이다. 그런데 이걸 “내가 해냈다”고 말할 수 있나? 만약 다음에 비슷한 걸 AI 없이 해야 한다면, 나는 할 수 있나? 이 질문에 자신 있게 “예”라고 답하지 못하는 게 찝찝하다.</p>

<h3 id="남은-과제들">남은 과제들</h3>

<p>eKYC 자체는 거의 끝났지만, 아직 남은 게 있다.</p>

<ul>
  <li><strong>OCR을 통한 개인정보 추출 → admin 페이지 노출</strong>: 어느 정도 방향은 잡았지만 마무리가 필요하다.</li>
  <li><strong>코드리뷰</strong>: 동료들의 리뷰가 남아 있다. AI가 짠 코드를 리뷰 받는다는 게 조금 묘한 기분이다. 내가 설명할 수 있어야 하니까, 리뷰 전에 코드를 한 줄씩 다시 읽어봐야겠다.</li>
  <li><strong>CI/CD</strong>: CI는 GitHub Actions로 결정했지만, CD는 회사 정책상 Jenkins를 써야 한다고 했다. Jenkins는 써본 적이 없어서 고민하다가 개발자 커뮤니티에 물어봤더니 NCP의 SourcePipeline을 쓰면 된다는 답을 얻었다. 팀장님께 보고하니 그렇게 진행하라고 했다.</li>
  <li><strong>DB 마이그레이션</strong>: NCP PostgreSQL로 올리는 것도 남아 있다.</li>
</ul>

<p>할 일이 많지만, 하나씩 쳐내면 된다. 급하게 동시에 하려다 보면 또 AI한테 다 던지게 될 거다.</p>

<h3 id="협업에-대한-생각">협업에 대한 생각</h3>

<p>2월 말에 새로운 동료가 합류했다. 3년 차 개발자인데, 업무 소통이 거의 없다. 말수가 적은 건 성격이니 상관없다. 하지만 업무 진행 상황을 공유하지 않는 건 다른 문제다.</p>

<p>데드라인도 주고, 가이드라인도 문서로 만들어서 전달했다. 그런데 묻기 전까지는 진행 상황을 말하지 않는다. 내가 하나하나 물어봐야 어디까지 했는지 알 수 있다. 이게 반복되니 소모적이다.</p>

<p>그런데 여기서 한 발 더 생각해봤다. 나도 처음 어딘가에 합류했을 때 이랬을 수도 있지 않나? 환경이 낯설면 먼저 말을 꺼내기가 어렵다. “좋은 동료가 좋은 환경을 만든다”고 믿는다면, 내가 먼저 소통 구조를 만들어줘야 하는 건 아닐까. 예를 들어 매일 10분 스탠드업을 제안한다거나, 진행 상황 공유 템플릿을 만든다거나. 불만만 가지고 있으면 아무것도 바뀌지 않을 거다.</p>

<p>물론 쉬운 일은 아니다. 나도 4년 차일 뿐이고, 리드 역할이 익숙하지 않다. 하지만 “이 사람이 바뀌어야 한다”고만 생각하면 끝이 없다.</p>

<h3 id="지금-내-상태를-정직하게-보면">지금 내 상태를 정직하게 보면</h3>

<p>이번 주도 솔직히 게으르게 보낸 시간이 많았다. 공부해야 할 때 안 했고, 해야 한다는 걸 알면서도 미뤘다.</p>

<p>지금 내 앞에 놓인 것들:</p>
<ul>
  <li>월요일마다 “JVM 밑바닥부터 파헤치기” 스터디</li>
  <li>루퍼스 과제와 학습</li>
  <li>회사 업무 (eKYC 마무리, CI/CD, DB)</li>
</ul>

<p>양이 많은 건 사실이다. 하지만 “많아서 못 한다”는 말은 결국 우선순위를 못 정하고 있다는 뜻이기도 하다. 전부 다 똑같은 무게로 안고 가니까 어느 것도 깊게 못 하는 거다.</p>

<p>다음 주에는 하나를 정해서 깊게 파보려고 한다. 전부 다 조금씩 하는 것보다, 하나라도 “이건 내가 확실히 안다”고 말할 수 있는 게 낫다. 그게 뭔지는 아직 정하지 못했지만, 적어도 이렇게 글로 써놓으면 다음 주의 내가 기억하겠지.</p>

<h3 id="앞으로-한주-동안-내가-해야될것들은">앞으로 한주 동안 내가 해야될것들은??</h3>
<blockquote>
  <p>이번주는 레디스를 어느정도 습득하고 공부해야된다.
TypeScript + Nest.js를 공부해야된다.</p>
</blockquote>]]></content><author><name>Ukukdin</name></author><category term="WIL" /><category term="회고" /><summary type="html"><![CDATA[AI가 일을 대신 해준 한 주. 결과는 나왔지만, 나는 성장했는가?]]></summary></entry><entry><title type="html">루퍼스 4주차의 회고</title><link href="https://ukukdin.github.io/2026/03/08/wil-week4/" rel="alternate" type="text/html" title="루퍼스 4주차의 회고" /><published>2026-03-08T00:00:00+00:00</published><updated>2026-03-08T00:00:00+00:00</updated><id>https://ukukdin.github.io/2026/03/08/wil-week4</id><content type="html" xml:base="https://ukukdin.github.io/2026/03/08/wil-week4/"><![CDATA[<h1 id="회고-루퍼스-4주-차">[회고] 루퍼스 4주 차</h1>

<blockquote>
  <p><strong>“왜 비관적 락을 사용하셨나요?”</strong>
면접에서 이 질문을 들을 때마다 늘 외운 대답을 했었다. 근데 이렇게 하면 설득이 안 되더라. 진지하게 고민하지 않고, 왜 그 선택을 했는지 스스로 납득하지 못한 채 답하니까 당연한 결과였다.</p>
</blockquote>

<h2 id="이번-주에-새로-배운-것">이번 주에 새로 배운 것</h2>

<blockquote>
  <p>동시성 제어와 낙관적 락/비관적 락, 그리고 JDK에서 제공하는 동시성 도구들</p>
</blockquote>

<h3 id="jdk-동시성-도구들">JDK 동시성 도구들</h3>

<p>이번 주에 렌 멘토님의 멘토링을 듣고 JDK가 제공하는 동시성 관련 도구들을 쭉 훑었다. <code class="language-plaintext highlighter-rouge">notify()</code>/<code class="language-plaintext highlighter-rouge">wait()</code>, <code class="language-plaintext highlighter-rouge">synchronized</code>, <code class="language-plaintext highlighter-rouge">Lock</code>, 세마포어, 뮤텍스, <code class="language-plaintext highlighter-rouge">volatile</code>, <code class="language-plaintext highlighter-rouge">ThreadLocal</code>, <code class="language-plaintext highlighter-rouge">Atomic Class</code>, CAS 알고리즘 등등. 사실 키워드 자체는 이전에도 들어봤지만, 이것들이 각각 <strong>어떤 문제를 해결하기 위해 존재하는지</strong> 관점에서 정리해본 건 이번이 처음이었다.</p>

<h3 id="가상스레드와-pinning-문제">가상스레드와 pinning 문제</h3>

<p>이번에 인상 깊었던 건 가상스레드 관련 내용이다.</p>

<ul>
  <li><strong>가상스레드</strong>: JVM 안에서 동작하는 스레드로, 캐리어스레드 위에서 실행된다.</li>
  <li><strong>플랫폼스레드(커널스레드)</strong>: OS에서 관리하는 스레드.</li>
</ul>

<p>과거의 고민이 “N개의 스레드를 어떻게 효율적으로 쓰지?”였다면, 지금은 “무한개의 가상스레드에서 안 터지고 잘 사용할 수 있을까?”로 바뀌었다는 게 재밌었다.</p>

<p>특히 <code class="language-plaintext highlighter-rouge">synchronized</code>가 OS 단에서 동작하기 때문에, 가상스레드 위에서 실행하더라도 캐리어스레드에 고정(pinning)되는 현상이 생긴다는 걸 처음 알았다. 실무에서 가상스레드를 도입할 때 이걸 모르면 오히려 성능이 나빠질 수 있겠다고 느꼈다.</p>

<h2 id="이런-고민이-있었어요">이런 고민이 있었어요</h2>

<p><strong>처음엔</strong> “결제할 때는 무조건 비관적 락”이라고 생각했다. 낙관적 락은 버전으로 관리하니까 돈 관련 로직에는 안 맞다고 믿었다. 돈 = 비관적 락이라는 고정관념이 박혀 있었다.</p>
<ul>
  <li>돈이란 비용, 즉 쿠폰이든 송금, 결제 등 돈에 관련된것들</li>
</ul>

<p><strong>근데 배워보니까</strong> 루퍼스에서는 “락을 쓸 때 절대 정답은 없다”고 했다. 도메인별로, 적재적소에 잘 섞어서 사용하는 거라고. 예를 들어 쿠폰 발급처럼 충돌이 드문 경우엔 낙관적 락이 더 효율적일 수 있고, 실시간 잔액 차감처럼 충돌이 잦은 경우엔 비관적 락이 맞을 수 있다.</p>

<p><strong>그래서 지금 고민하는 건</strong> “어떤 상황에서 어떤 락을 선택해야 하는가”에 대한 나만의 기준을 만드는 것이다. 아직 명확한 답은 없지만, 최소한 “무조건 비관적 락”이라는 생각에서는 벗어났다.</p>

<h2 id="앞으로-실무에서-써먹을-수-있을-것-같은-포인트">앞으로 실무에서 써먹을 수 있을 것 같은 포인트</h2>

<ul>
  <li>지금 해외송금 스타트업에 다니고 있는데, 송금 로직에서 동시성 제어와 정합성이 핵심이다. 이번에 배운 락 선택 기준을 실제 송금 플로우에 적용해볼 수 있을 것 같다.</li>
  <li>서비스 런칭 때 신규 고객 대상 쿠폰 발급이 있을 텐데, 동시성 제어를 어떻게 가져갈지 이번 학습 내용을 바로 적용해볼 수 있겠다.</li>
  <li>멘토가 “전략 패턴을 활용해서 각각의 할인 정책을 어떻게 도메인에 녹일 수 있는지 고민해보라”고 했다. 단순히 락만 고르는 게 아니라, 할인 정책 자체를 유연하게 설계하는 것도 함께 고민해봐야겠다.</li>
</ul>

<h2 id="이번-주-메타-회고--ai-의존과-집중도">이번 주 메타 회고 — AI 의존과 집중도</h2>

<p>솔직히 이번 주는 AI에 너무 의존했다. AI에게 물어보고 답을 받으면 “아 그렇구나” 하고 넘어가는 패턴이 반복됐다. 코드를 타이핑하는 속도가 느려지는 건 괜찮지만, <strong>개념이 헷갈리거나 트레이드오프를 스스로 판단 못 하는 건</strong> 문제다.</p>

<p>이직한 지 얼마 안 돼서 새 환경 적응에 에너지를 많이 쓴 것도 사실이다. 스타트업이고 언어도 다르다 보니 루퍼스에 온전히 집중하기 어려웠다.</p>

<p>하지만 “바빠서”를 이유로 매주 같은 고민만 반복하고 있다는 걸 인식했다. 그래서 다음 주부터는 구체적으로 이렇게 해보려 한다:</p>

<h2 id="다음-주에-해보고-싶은-것">다음 주에 해보고 싶은 것</h2>

<ul>
  <li><strong>하루 30분 개념 정리 시간 확보</strong>: AI 없이, 이번 주 배운 동시성 도구들을 직접 정리해본다.</li>
  <li><strong>낙관적 락 vs 비관적 락 판단 기준표 만들기</strong>: 충돌 빈도, 트랜잭션 길이, 데이터 중요도 등 기준을 잡아보고, 실무 송금 도메인에 대입해본다.</li>
  <li><strong>멘토 피드백 반영</strong>: 전략 패턴으로 할인 정책을 분리하는 구조를 간단하게라도 설계해본다.</li>
</ul>]]></content><author><name>Ukukdin</name></author><category term="WIL" /><category term="회고" /><summary type="html"><![CDATA[비관적 락이 정답이라는 고정관념이 깨진 주, 그리고 AI 의존에서 벗어나기 위한 고민]]></summary></entry></feed>