<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>haylee</title>
    <link>https://haylee.tistory.com/</link>
    <description>개발하면서 보고 듣고 느낀 것들을 정리하는중</description>
    <language>ko</language>
    <pubDate>Thu, 2 Jul 2026 13:37:20 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>haylee</managingEditor>
    <image>
      <title>haylee</title>
      <url>https://tistory1.daumcdn.net/tistory/7498145/attach/9c764415c9b4436d94335436068edbfb</url>
      <link>https://haylee.tistory.com</link>
    </image>
    <item>
      <title>[Loop:PAK] 마지막 회고</title>
      <link>https://haylee.tistory.com/19</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;세상에 벌써 마지막 회고라니..&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;7월 어느 날 시작한 프로젝트가 벌써 10주가 지나 대단원의 막을 내리게 되었다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;한여름에 시작할 땐 언제 10주가 지나지 했는데, 이젠 살짝 춥기도 한 가을의 초입이라니 시간이 참 빠르다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;루프팩을 마무리하면서 느낀 것들을 정리해보려고 한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;사실 루프팩을 시작하기 직전 6월, 회사에서 정리해고를 당했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;SNS에서만 보던 일이 나에게 일어날 줄은 몰랐는데..&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;잘 가다가 갑자기 모르는 길 한복판에 내던져진 기분이었다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;한동안 방향도 잃고 방황도 조금 했던 것 같다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;그러던 중 친구에게 루퍼스에서 이 과정을 진행한다는 소식을 듣게 되었다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;마침 시간도 뜨고 퇴직금도 생겼겠다.. 같이 해보자는 친구의 권유로 시작하게 되었다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;사실 나는 작년에 비슷한 플랫폼에서 비슷한 코스를 진행한 적이 있었다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;하지만 그땐 솔직히 목적지향에 가까웠다고 생각한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;어떤 기술을 쓰는지가 중요했고, 당장 눈앞의 과제를 해결하는 데만 급급했고,&amp;nbsp;왜 그 기술을 쓰는지, 어떤 사이드 이펙트가 발생하는지에 대한 고민은 적었다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;물론 그 과정에서도 성장은 있었지만, 처음 기대한 만큼의 성과를 내진 못했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;그때의 경험을 반면교사 삼아서 이번엔 다르게 해보려 노력했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;단순히 어떤 기술을 썼다가 아닌, 왜 써야 하는지를 더 생각해보려 노력했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;왜 하필 그 기술인지, 그 선택으로 어떤 트레이드오프가 생기는지, 그리고 스스로 적정선을 어디쯤 잡아야 하는지를 의식적으로 생각하려 노력했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;생각나는 몇 가지 선택들이 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;a style=&quot;color: #333333;&quot; href=&quot;https://haylee.tistory.com/3&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;첫 번째는 테스트 코드에 DCI 패턴을 적용했을 때이다.&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;예전에는 테스트 코드를 거의 작성하지 않았고, 개발이 급해지면 필요성을 제일 먼저 제외하곤 했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;이번에는 테스트를 단순히 동작 확인만이 아니라 명세와 의도를 드러내는 도구로 바라보려 했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;DCI (Describe-Context-It) 패턴을 적용하면서 테스트만 읽어도 어떤 시나리오를 다루는지 보이도록 구성하려 했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;덕분에 이 코드가 왜 필요한지, 어떤 상황을 검증하는지에 대해 좀 더 명확해졌다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;물론 아직 가독성에 대한 부분은 좀 더 개선해야겠지만 적어도 테스트는 부차적인 것이라는 예전 생각과는 많이 달라진 것 같다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;624&quot; data-start=&quot;436&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;a style=&quot;color: #333333;&quot; href=&quot;https://haylee.tistory.com/11&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;두 번째는 읽기 성능을 개선했을 때였다.&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;624&quot; data-start=&quot;436&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;처음엔 애플리케이션 로직을 고쳐봤는데 생각보다 효과가 미미해 충격먹은게 아직도 기억에 남는다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;624&quot; data-start=&quot;436&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;그다음 DB 인덱스를 적용해 크게 성능이 개선되었지만, 대용량 트래픽 상황을 가정했을 땐 부족하다고 생각해 Redis 캐시까지 추가하게 되었다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;624&quot; data-start=&quot;436&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;캐시는 분명 빠르지만 그만큼 Redis에 강하게 의존하게 되고 그만큼 장애 지점과 관리 포인트가 많아진다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;624&quot; data-start=&quot;436&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;이런 트레이드오프를 생각하면서 정말 필요한 지점에만 Redis를 추가하도록 해야겠다고 생각했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;805&quot; data-start=&quot;683&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;805&quot; data-start=&quot;683&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;928&quot; data-start=&quot;835&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;a style=&quot;color: #333333;&quot; href=&quot;https://haylee.tistory.com/15&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;마지막으로 트랜잭션을 분리했던 작업도 기억에 남는다.&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;928&quot; data-start=&quot;835&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;강하게 결합된 로직을 트랜잭션 단위로 나누고 내부 이벤트로 분리한 다음 Kafka까지 확장했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;928&quot; data-start=&quot;835&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;이 과정에서 어디까지가 핵심 로직이고 어디부터가 부가 로직인지 계속 고민했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;928&quot; data-start=&quot;835&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;괜히 분리해서 관리 포인트와 보상 트랜잭션 등 부가로직만 늘어나는 게 아닌가 걱정도 됐다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;928&quot; data-start=&quot;835&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;그럼에도 도메인 간 결합도를 낮추고 경계를 명확히 함으로써 더 확장성 있는 구조를 얻었다고 생각한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;1060&quot; data-start=&quot;945&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1060&quot; data-start=&quot;945&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1060&quot; data-start=&quot;945&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;과거의 나는 당장 돌아가게 만드는 것에 집중했다면, 이번에는 왜 이걸 선택하는지, 어떤 트레이드오프가 따르는지를 먼저 생각했다는 점이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;436&quot; data-start=&quot;318&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;결국 중요한 건 기술 자체가 아니라 선택의 맥락과 기준이라는 걸 배웠다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;436&quot; data-start=&quot;318&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;정합성, 확장성, 유지보수성 같은 단어들이 비로소 나의 고민거리가 되었다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;436&quot; data-start=&quot;318&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;루프팩은 끝이 났지만, 지금 이 시점은 마치 끝이 아니라 새로운 출발선 같이 느껴진다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;10주 동안 겨우 문제를 어떻게 바라봐야할지 감을 잡았을 뿐이니까&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;앞으로 어떤 문제를 만나더라도 이번에 얻은 시각으로 접근하도록 계속 노력해야겠다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;그리고 언젠가는 그때 배운 것들을 이렇게 실제 서비스에 녹여냈다고 당당하게 말할 수 있는 날이 오길 바란다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;그리고 벌써 다음 기수를 모집 중이던데&amp;nbsp;약간 시원섭섭한 기분이랄까..&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;하지만 혹시나 나처럼 헤메고 있다면 루프팩 코스를 정말 추천하고싶다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;아직 세미나 전이던데 그거만 들어도 문제를 바라보는 관점이 조금은 달라질 거라 생각한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #1a5490;&quot;&gt;&lt;a style=&quot;color: #1a5490;&quot; href=&quot;https://www.loopers.im/&quot;&gt;https://www.loopers.im/&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;343&quot; data-start=&quot;267&quot; data-ke-size=&quot;size16&quot;&gt;혹시 금액이 고민이라면 할인 레퍼럴 코드( &lt;span style=&quot;color: #1a5490;&quot;&gt;&lt;b&gt;SMQPH &lt;/b&gt;&lt;/span&gt;)도 있으니 참고하시길..!&lt;/p&gt;</description>
      <category>Project/회고</category>
      <author>haylee</author>
      <guid isPermaLink="true">https://haylee.tistory.com/19</guid>
      <comments>https://haylee.tistory.com/19#entry19comment</comments>
      <pubDate>Fri, 19 Sep 2025 17:19:32 +0900</pubDate>
    </item>
    <item>
      <title>Spring Batch로 랭킹 집계 확장하기</title>
      <link>https://haylee.tistory.com/18</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;저번에 만든 일간 랭킹 기능에 주간, 월간 랭킹을 추가하게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일간 랭킹까지만 있을 때는 &lt;b&gt;Kafka Consumer + Redis ZSET&lt;/b&gt;으로 충분했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실시간 이벤트를 소비하고 점수를 바로 올리는 구조라 빠르고 직관적이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 문제는 주간, 월간 랭킹을 일간 랭킹과 동일하게 가져갈 순 없었다는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis에만 데이터를 적재하려니 TTL을 길게 가져가야 해서 그만큼 비용과 관리 부담이 커졌고, 결국 영속성과 안정성이 보장하는 DB에 데이터를 저장하기로 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미 product_metrics 테이블에 일간 데이터가 쌓이고 있었기 때문에 이를 기반으로 주간/월간 집계를 만들기로 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 그냥 호출 시마다 &lt;b&gt;SUM / GROUP BY&lt;/b&gt;로 계산하면 되지 않을까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 그렇게 하면 매번 대량 데이터를 스캔해야해서 조회 속도가 느려질 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;랭킹은 사용자가 바로 보고 싶어 하는 정보이고 그만큼 트래픽도 많이 몰리는 기능 중 하나이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그만큼 조회가 느려지거나 타임아웃이 나면 서비스 신뢰를 잃을 수 있다고 생각했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 &lt;b&gt;조회 전용 테이블(Materialized View)&lt;/b&gt; 을 따로 두는 게 맞다고 판단했고 MySQL은 MV 기능이 없기 때문에 주간/월간 집계 테이블을 직접 만들게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;배치 시스템 설계&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;집계 시스템은 &lt;b&gt;Spring Batch&lt;/b&gt;를 이용했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;흐름은 &lt;b&gt;일간 데이터 &amp;rarr; 배치 집계 &amp;rarr; 주간/월간 테이블 적재&lt;/b&gt; 순서로 설계했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;배치 모델&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Batch는 두 가지 모델을 제공한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Chunk-Oriented Processing&lt;/b&gt;: 대량 데이터를 안정적으로 처리하기 적합&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Tasklet&lt;/b&gt;: 단발성 작업(정리, 삭제, 초기화 등)에 적합&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;랭킹 집계는 안정성이 중요했기 때문에 &lt;b&gt;Chunk-Oriented Processing&lt;/b&gt;을 선택했다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Bean(&quot;weeklyAggregateStep&quot;)
public Step aggregateStep() {
    return new StepBuilder(&quot;weeklyStep&quot;, jobRepository)
        .&amp;lt;RankingAggregateResult, WeeklyRanking&amp;gt;chunk(1000, transactionManager)
        .reader(weeklyReader)
        .processor(weeklyProcessor)
        .writer(weeklyWriter)
        .build();
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;delphi&quot; data-ke-language=&quot;delphi&quot;&gt;&lt;code&gt;flowchart LR
    A[Reader] --&amp;gt; B[Processor]
    B --&amp;gt; C[Writer]
    C --&amp;gt; D[(DB: Materialized View)]

    subgraph Chunk
        A --&amp;gt; B --&amp;gt; C
    end

    style D fill:#fdf5e6,stroke:#333,stroke-width:1px&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;집계 단위&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 MV 테이블에 적재하는 과정에서 순위까지 집계해 저장할까 고민했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 Chunk 단위 집계 과정에서 정확도가 깨질 수 있다고 생각했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 일단 &lt;b&gt;score&lt;/b&gt;만 저장하고 조회 시 정렬로 순위를 계산하는 방식을 선택했다.&lt;/p&gt;
&lt;pre class=&quot;n1ql&quot;&gt;&lt;code&gt;SELECT product_id, score
FROM MV
ORDER BY score DESC
LIMIT 10;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일단 이렇게 작성하고 보니 배치 단계를 두 단계로 나눠서 순위는 적재가 완료된 후에 후처리로 업데이트해 줘도 되지 않을까 하는 생각도 들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 후처리 단계(Tasklet)를 추가해 순위를 업데이트하는 방법도 고려했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종적으로 MV 테이블은 다음처럼 설계했다.&lt;/p&gt;
&lt;pre class=&quot;delphi&quot; data-ke-language=&quot;delphi&quot;&gt;&lt;code&gt;erDiagram
    PRODUCT {
        bigint id PK
    }

    PRODUCT_METRICS {
        bigint product_id PK
        date metric_date PK
        bigint sales_count
        bigint like_count
        bigint view_count
    }

    MV_PRODUCT_RANK_WEEKLY {
		bigint id PK
        bigint product_id 
        varchar year_week 
        decimal total_score
        bigint sales_count
        bigint like_count
        bigint view_count
        datetime aggregated_at
    }

    MV_PRODUCT_RANK_MONTHLY {
		bigint id PK
        bigint product_id 
        varchar year_month 
        decimal total_score
        bigint sales_count
        bigint like_count
        bigint view_count
        datetime aggregated_at
    }

    PRODUCT ||--o{ PRODUCT_METRICS : aggregates
    PRODUCT ||--o{ MV_PRODUCT_RANK_WEEKLY : ranks
    PRODUCT ||--o{ MV_PRODUCT_RANK_MONTHLY : ranks&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;스케줄링 주기&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 단순히 주간은 주 1회, 월간은 월 1회만 집계하려 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 이 경우에는 9월 15일에 월간 랭킹을 조회했는데 아무 데이터도 안 나오지 않는 &lt;b&gt;콜드스타트 문제&lt;/b&gt;가 발생했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 매일 1회씩 배치를 돌려 집계 데이터를 점진적으로 쌓도록 수정했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모니터링도 중요해서 JobExecution 로그와 StepExecution 상태를 기록해 두었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;마무리&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에 배치를 구현하면서 실시간과 배치는 경쟁 관계가 아니라 보완 관계라는 걸 알게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실시간은 빠르지만 무겁고 배치는 느리지만 안정적이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저번주에 고민했던 어디까지 실시간으로 가져갈지와 연결되는 부분이 있었다고 생각한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 걸 실시간으로 가져갈 수는 없기 때문에 둘을 적절히 조합해야 서비스가 안정적으로 굴러갈 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배치를 처음 적용하다 보니 아쉬움도 남았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 의문에 대해서도 저 고민을 많이 해봐야 할 것 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;순위까지 저장해 조회 성능을 더 끌어올릴 수 있었을까?&lt;/li&gt;
&lt;li&gt;실패했을 때 재처리 전략은 어떻게 가져가야 할까?&lt;/li&gt;
&lt;li&gt;대규모 트래픽 상황에서도 안정적으로 동작할 수 있을까?&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Study/Architecture</category>
      <author>haylee</author>
      <guid isPermaLink="true">https://haylee.tistory.com/18</guid>
      <comments>https://haylee.tistory.com/18#entry18comment</comments>
      <pubDate>Fri, 19 Sep 2025 15:30:26 +0900</pubDate>
    </item>
    <item>
      <title>실시간 상품 랭킹? Redis ZSET만 쓰면 끝인 줄 알았는데요</title>
      <link>https://haylee.tistory.com/17</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;이커머스 프로젝트를 진행하면서 &lt;b&gt;실시간 상품 랭킹&lt;/b&gt;을 설계하게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 어지간한 이커머스에는 다 있는 기능이라 쉽게 끝날 거라 생각했지만&amp;hellip; 역시나 고민할 부분이 정말 많았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;왜 실시간 랭킹인가?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 상품을 조회하고, 좋아요를 누르고, 구매한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때마다 &lt;b&gt;바로바로 갱신되는 랭킹&lt;/b&gt;을 보여주고 싶었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에는 단순히 DB 테이블에 데이터를 쌓고 조회하는 방식이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 트래픽이 늘어난다고 생각하면 단순 집계 쿼리는 곧 심각한 병목으로 이어질 것 같았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 &lt;b&gt;실시간 반영 + 빠른 조회&lt;/b&gt;를 만족시킬 수 있는 &lt;b&gt;Redis&lt;/b&gt;를 선택했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Redis ZSET&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis의 &lt;b&gt;Sorted Set (ZSET)&lt;/b&gt; 은 &lt;b&gt;score&lt;/b&gt; 기반으로 자동 정렬되는 자료구조다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;랭킹도 점수를 기반으로 정렬하기 때문에 아주 적합하다고 생각했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;ZSET 구조 특징&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;(member, score)&lt;/b&gt; 쌍을 score 기준으로 정렬된 상태로 유지&lt;/li&gt;
&lt;li&gt;삽입/수정: O(logN)&lt;/li&gt;
&lt;li&gt;Top-N 조회: O(N)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;주요 연산&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;ZADD&lt;/b&gt; key score member : score와 함께 member 저장 (중복 시 갱신)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;ZREVRANGE&lt;/b&gt; key 0 &lt;b&gt;N&lt;/b&gt; WITHSCORES : Top-N 조회&lt;/li&gt;
&lt;li&gt;&lt;b&gt;ZREVRANK&lt;/b&gt; key member : 특정 멤버의 순위 조회&lt;/li&gt;
&lt;li&gt;&lt;b&gt;ZSCORE&lt;/b&gt; key member : 특정 멤버의 점수 조회&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ZSET 사용 시 이벤트 발생 시 점수 누적은 이렇게 할 수 있었다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;# 조회, 좋아요, 구매 이벤트
ZINCRBY ranking:20250912 1 product:1
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조회는 다음처럼 할 수 있었다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;ZRANGE ranking:20250912 0 9 REV WITHSCORES
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 작성할 경우 TOP 10 랭킹 목록을 조회할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지만 보면 끝난 것 같았지만..?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;시간의 양자화: 며칠 전에 본 상품이 왜 오늘도 1등이지?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실시간이라고 해서 무작정 데이터를 누적하면 오래된 상품만 상위에 고정되는 롱테일 현상이 발생한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 막기 위해 날짜별로 key를 분리했다.&lt;/p&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;ranking:product:all:{yyyyMMdd}
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;ranking:20250911 &amp;rarr; 2025년 9월 11일 랭킹&lt;/li&gt;
&lt;li&gt;ranking:20250912 &amp;rarr; 2025년 9월 12일 랭킹&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 TTL은 2일로 설정했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;너무 길게 가져가면 메모리 낭비가 심해지고, 너무 짧으면 비교/추세 분석이 어려워지기 때문에 2일 정도가 균형점이라고 생각해 설정했다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  &lt;b&gt;롱테일 현상&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특정 상품이 한번 상위에 오르면 데이터가 무한히 누적되면서 계속 상위에 남는 문제.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;가중치 설계: 조회만 많은데 1등이라고요?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 단순히 &lt;b&gt;좋아요=1, 조회=1, 주문=1&lt;/b&gt; 로 점수를 올렸다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그랬더니 조회수만 많은 상품들이 상위 랭킹을 차지했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋아요나 조회도 중요하지만, 진짜 인기 상품은 결국 &lt;b&gt;실제로 주문된 상품&lt;/b&gt;이라고 생각했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 이벤트별로 가중치를 조정했다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;조회 = 1
좋아요 = 3
구매 = 6
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 점수가 지나치게 커지면 문제가 또 발생했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis ZSET의 score는 &lt;b&gt;double&lt;/b&gt; 타입이기 때문에 값이 과도하게 커지면 정확성 손실 문제가 생길 수 있고 작은 변화가 무의미해질 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종적으로는 &lt;b&gt;좋아요:조회:주문 = 0.3 : 0.1 : 0.6&lt;/b&gt; 비율을 설정해 단순 노출이 아니라 실제 반응이 좋은 상품이 상위 랭킹에 오르도록 조정했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;대량 데이터 처리: 초당 수만 건 이걸 Redis가 다요?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기에는 이벤트가 들어올 때마다 곧바로 &lt;b&gt;ZINCRBY&lt;/b&gt;를 호출했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 운영을 가정하면 초당 수천~수만 건이 몰릴 수 있고, 그럴 경우 Redis CPU와 네트워크 모두에 심각한 부하가 생긴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 Consumer 로직을 개편했다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@KafkaListener(...)
public void consume(List&amp;lt;ConsumerRecord&amp;lt;String, KafkaMessage&amp;lt;?&amp;gt;&amp;gt;&amp;gt; records, Acknowledgment ack) {
    Map&amp;lt;Long, ProductMetricsCount&amp;gt; aggregate = new HashMap&amp;lt;&amp;gt;();

    for (ConsumerRecord&amp;lt;String, KafkaMessage&amp;lt;?&amp;gt;&amp;gt; record : records) {
        KafkaMessage&amp;lt;?&amp;gt; message = record.value();
        Map&amp;lt;String, Object&amp;gt; payload = (Map&amp;lt;String, Object&amp;gt;) message.payload();

        Long productId = getValue(payload, &quot;productId&quot;, Long.class);
        Integer quantity = getValue(payload, &quot;quantity&quot;, Integer.class);

        aggregate.computeIfAbsent(productId, id -&amp;gt; new ProductMetricsCount())
                .apply(message.type(), quantity != null ? quantity : 0);
    }

    if (!aggregate.isEmpty()) {
        productMetricsService.bulkUpdate(aggregate);
        productRankingCacheService.updateRanking(aggregate);
    }

    ack.acknowledge();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;개선 포인트&lt;/b&gt;&lt;/h4&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Consumer가 batch 메시지를 읽고&lt;/li&gt;
&lt;li&gt;productId 단위로 점수 집계&lt;/li&gt;
&lt;li&gt;최종 집계 결과만 Redis에 반영&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과적으로 Redis 호출 횟수가 크게 줄어 부하도 완화될 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB upsert 처리도 같은 전략을 적용했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;콜드스타트: 랭킹 화면이 비어있다구요?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드를 다 짜고 테스트를 돌렸는데 조회 API가 아무것도 반환하지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #666666;&quot;&gt;&lt;b&gt;당연함. 초기 데이터가 없었기 때문.&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;더미 데이터를 넣고 끝내려 했지만..?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문득 운영 환경에서는 &lt;b&gt;날짜가 바뀌는 순간에도 같은 문제가 발생&lt;/b&gt;할 수 있다는 걸 깨달았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자정 이후 첫 사용자가 빈 화면을 마주한다면, 단순히 기능이 안 돌아가는 게 아니라 랭킹 자체에 대한 신뢰를 잃을 수 있다고 생각했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일단 대체해서 보여줄 데이터가 있으면 좋겠지? 그리고 아예 미리 데이터를 채워둘 수 있다면 더 좋을 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 고민을 통해 두 가지 방법을 생각했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Fallback&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전날 데이터도 유저의 실제 행동이므로 기본 신뢰도는 확보할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오늘 데이터가 비어 있으면 &lt;b&gt;전날 데이터를 대신&lt;/b&gt; 보여준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Warm-up&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전날 23:30에 전일 TOP100을 뽑아 &lt;b&gt;다음날 key에 미리 적재&lt;/b&gt;한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단, 그대로 적재하면 또 롱테일 문제가 생기므로 일정 비율로 희석해 반영했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;덕분에 날짜가 바뀌어도 유저는 빈 화면이 아니라 어느 정도 신뢰성 있는 순위를 보게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;마무리&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 상품을 정렬해서 보여주기만 하면 끝날 줄 알았던 랭킹 기능이 이렇게 많은 고민을 안겨줄 줄은 몰랐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis ZSET이라는 딱 맞는 도구가 있었어도 누적 데이터 관리, 대용량 처리, 초기 데이터 문제까지 신경 써야 할 게 정말 많았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기술은 쓰면 쓸수록 고려해야 할 변수가 늘어난다는 말을 매주 체감하고 있는 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 작업에서 특히 많이 고민한 건 &lt;b&gt;진짜 실시간이란 무엇인가?&lt;/b&gt; 였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;어디까지를 실시간이라고 할 수 있을까?&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;몇 초, 몇 분 단위의 지연은 허용해도 될까?&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생각보다 그 경계는 명확하지 않았고 사실 아직 나만의 답도 찾진 못한 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 사용자가 사용할 서비스니까 사용자가 느끼기에 충분히 빠르고 자연스러운 수준에서 답을 찾아야 하지 않을까..&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋은 기능이란 단순히 더 최신 기술, 더 좋은 기술을 쓰는 것보단 사용자 입장에서 자연스럽고 신뢰할 수 있는 기능이지 않을까 생각해 보게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어쩌면 기능과 기술을 설계하는 건 사용자 경험을 설계한다고 봐도 되지 않을까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아직 답은 다 못 찾았지만 이런 시행착오들이 쌓여서 내가 만드는 서비스를 조금은 더 좋아지게 만들 수 있을 것 같다.&lt;/p&gt;</description>
      <category>Study/Architecture</category>
      <author>haylee</author>
      <guid isPermaLink="true">https://haylee.tistory.com/17</guid>
      <comments>https://haylee.tistory.com/17#entry17comment</comments>
      <pubDate>Fri, 12 Sep 2025 16:20:44 +0900</pubDate>
    </item>
    <item>
      <title>내부 이벤트를 넘어 Kafka 기반 이벤트 파이프라인으로</title>
      <link>https://haylee.tistory.com/16</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;내부 이벤트의 한계&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지난주에는 이벤트를 통해 결합도를 줄이는 구조를 만들었다. 주문은 주문만 알도록 하고, 결제나 쿠폰 사용 같은 후속 작업은 이벤트 핸들러가 이어받도록 분리했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 애플리케이션 내부 이벤트만으로는 해결할 수 없는 문제들이 남아있었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;신뢰성 부족&lt;/b&gt;: 예외가 나면 단순히 로그만 남고 이벤트 자체가 유실될 수 있음&lt;/li&gt;
&lt;li&gt;&lt;b&gt;확장성 제약&lt;/b&gt;: 하나의 애플리케이션 안에서만 소비할 수 있어 별도의 서비스가 이벤트를 받을 수 없음&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 서비스 경계를 넘어 전달할 수 있는 이벤트 파이프라인이 필요했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;외부 이벤트 브로커가 만족해야 하는 요구사항을 정리해 보면 다음과 같았다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;At-Least-Once 전달 보장&lt;/li&gt;
&lt;li&gt;순서 보장&lt;/li&gt;
&lt;li&gt;재시도와 DLQ&lt;/li&gt;
&lt;li&gt;소비자 그룹 확장성&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 모든 요구사항을 충족해주는 도구가 바로 &lt;b&gt;Kafka&lt;/b&gt;였고, 그래서 이번 주는 &lt;b&gt;Kafka 기반 이벤트 파이프라인&lt;/b&gt;을 설계해보기로 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Producer 설계&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;시스템 구조 설계&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;[ commerce-api(Producer) ]   &amp;rarr;   [ Kafka Topics ]   &amp;rarr;   [ Consumer App ]
                                                         ├─ Event Log 기록
                                                         ├─ Cache 무효화
                                                         └─ 상품 Metrics 집계
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Producer와 Consumer를 같은 앱 안에 둘 수도 있었지만 주체를 명확히 하고 장애 격리 측면에서도 분리하는 것이 낫다고 생각해 별도의 앱으로 분리하게 되었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Producer&lt;/b&gt;는 이벤트가 발생했다는 사실만 외부로 발행&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Consumer&lt;/b&gt;는 그걸 이어받아 후속 처리만 전담&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Producer 위치&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 Publisher 로직 안에서 이벤트를 &lt;b&gt;ApplicationEventPublisher&lt;/b&gt;와 &lt;b&gt;KafkaTemplate&lt;/b&gt; 두 갈래로 동시에 발행하는 방법을 생각했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 &lt;i&gt;하나의 퍼블리셔 안에서 두 번 퍼블리싱하는 게 과연 괜찮을까? 만약 하나는 성공하고 다른 하나는 실패한다면?&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내부 이벤트는 진행되는데 외부 이벤트는 사라져 버리거나, 반대로 외부 이벤트만 남고 내부 처리는 실패하는 어정쩡한 상태가 만들어질 수 있을 것 같았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 카프카 발행은 애초에 내부 이벤트와 분리된 외부 시스템 호출로 보는 게 맞다고 판단했다. 카프카는 REST API처럼 외부 리소스에 요청을 던지는 것에 가깝다고 생각해 트랜잭션을 아예 분리해 handler 로직 안에서 발행하는 방식이 더 자연스럽다고 생각했다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class LikeEventHandler {

    private final ProductService productService;
    private final KafkaTemplate&amp;lt;Object, Object&amp;gt; kafkaTemplate;

    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handleLikeAdded(LikeAddEvent event) {
        productService.increaseLike(event.getProductId());
    }

    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void publishLikeAddEvent(LikeAddEvent event) {
        KafkaMessage&amp;lt;LikeAddEvent&amp;gt; message = KafkaMessage.of(event, &quot;LIKE_ADD&quot;);
        kafkaTemplate.send(&quot;catalog-events&quot;, event.getProductId(), message);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Topic 분리 전략&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;토픽을 어디까지 잘게 나눌지도 고민이 많았다. 지나치게 세분화하면 관리 비용이 커질 것이고, 너무 뭉쳐두면 컨슈머 내에서 별도의 후속 처리가 필요해 복잡도가 올라갈 것 같았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 도메인 단위로 묶되 이벤트 타입을 같이 전달해 컨슈머 내에서 구분하는 방식을 사용해 보기로 했다. catalog-events 토픽으로 재고, 좋아요, 상품 수정 이벤트 메시지를 보내고, 메시지에 이벤트 타입 필드를 두어 구분하는 식이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 토픽 개수는 적당히 단순하게 유지하면서 컨슈머가 필요한 이벤트만 골라 처리할 수 있을 거라 생각했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 표는 각 이벤트가 어떤 토픽을 거쳐 어떤 컨슈머에서 처리되는지 정리한 것이다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;발행 이벤트 (Producer)&lt;/td&gt;
&lt;td&gt;발행 토픽&lt;/td&gt;
&lt;td&gt;Consumer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OrderCreatedEvent&lt;/td&gt;
&lt;td&gt;order-events&lt;/td&gt;
&lt;td&gt;EventLogConsumer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PaymentSuccessEvent, PaymentFailEvent,PaymentCallbackFailEvent, PaymentRequestSuccessEvent&lt;/td&gt;
&lt;td&gt;payment-events&lt;/td&gt;
&lt;td&gt;EventLogConsumer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PointHistoryEvent&lt;/td&gt;
&lt;td&gt;user-events&lt;/td&gt;
&lt;td&gt;EventLogConsumer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CouponUseEvent&lt;/td&gt;
&lt;td&gt;user-events&lt;/td&gt;
&lt;td&gt;EventLogConsumer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ProductViewEvent&lt;/td&gt;
&lt;td&gt;product-events&lt;/td&gt;
&lt;td&gt;EventLogConsumer, CatalogEventConsumer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LikeAddEvent, LikeDeleteEvent&lt;/td&gt;
&lt;td&gt;catalog-events&lt;/td&gt;
&lt;td&gt;EventLogConsumer, CatalogEventConsumer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;StockIncreaseEvent, StockDecreaseEvent&lt;/td&gt;
&lt;td&gt;catalog-events&lt;/td&gt;
&lt;td&gt;EventLogConsumer, CatalogEventConsumer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BrandModifyEvent&lt;/td&gt;
&lt;td&gt;cache-events&lt;/td&gt;
&lt;td&gt;EventLogConsumer, CacheEventConsumer&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Consumer 설계&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨슈머 앱에서 담당해야 할 일은 크게 세 가지였다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;감사 로그&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;모든 이벤트를 &lt;b&gt;event_log&lt;/b&gt; 테이블에 저장&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;상품 데이터 집계&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;좋아요수, 판매량, 조회수 지표를 &lt;b&gt;product_metrics&lt;/b&gt; 테이블에 저장&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;캐시 무효화&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;캐시된 데이터에 변경 이벤트가 발생하면 Redis 캐시 제거&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;멱등성 보장&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;카프카는 기본적으로 &lt;b&gt;At-Least-Once&lt;/b&gt; 전달을 보장한다. 같은 이벤트가 여러 번 소비될 수 있기 때문에 &lt;b&gt;중복 처리 방지&lt;/b&gt;가 필요했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 위해 컨슈머 그룹별로 처리 여부를 기록하는 &lt;b&gt;event_handled&lt;/b&gt; 테이블을 뒀다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;event_id, consumer_group_id&lt;/b&gt; 기준으로 저장해서 같은 이벤트가 다시 와도 최종적으로는 한 번만 반영되도록 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔&amp;nbsp;&lt;b&gt;event_log&lt;/b&gt;&amp;nbsp;테이블과 합쳐도 되지 않을까 싶었는데 성격이 다르다고 생각해 분리하게 되었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;event_log:&lt;/b&gt; 원본 이벤트 보관용&lt;/li&gt;
&lt;li&gt;&lt;b&gt;event_handled:&lt;/b&gt; 컨슈머 그룹별 처리 여부 체크용&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 역할이 다르다 보니 테이블도 분리하는 게 맞다고 봤다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 분리한 김에 &lt;b&gt;Redis&lt;/b&gt;로 구현하는 게 나을지도 고민했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어차피 처리 여부 데이터는 오래 쌓아둘 필요가 없는 로그성이기 때문에 만료 정책이 있는 레디스가 더 적합하지 않을까 했고 성능상 조회 속도도 더 빠를 것 같았기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 TTL 관리나 장애 대응 같은 운영 복잡도가 따라오기 때문에 일단 DB 레벨에서 구현했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추후 트래픽이 커질 가능성을 고려해 보는 단계에서 전환을 생각해 볼 수 있을 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Ack 전략 분리&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 이벤트를 동일한 수준으로 정합성을 지킬 필요가 없다고 생각해 전략을 분리하게 되었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정합성이 중요한 이벤트와 상대적으로 느슨하게 처리해도 되는 이벤트를 나눠 전략을 다르게 가져갔다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;EventLogConsumer&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이벤트 로그는 모든 이벤트가 반드시 기록되어야함&lt;/li&gt;
&lt;li&gt;DB에 정상 반영된 경우에만 ack.acknowledge()&lt;/li&gt;
&lt;li&gt;중복은 &lt;b&gt;event_handled&lt;/b&gt; 테이블로 검증&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public void consume(List&amp;lt;ConsumerRecord&amp;lt;String, KafkaMessage&amp;lt;?&amp;gt;&amp;gt;&amp;gt; records, Acknowledgment ack) throws JsonProcessingException {
    for(ConsumerRecord&amp;lt;String, KafkaMessage&amp;lt;?&amp;gt;&amp;gt; record : records) {
        KafkaMessage&amp;lt;?&amp;gt; message = record.value();

        if (!eventHandledService.markHandled(message.eventId(), groupId)) {
            continue;
        }&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;CatalogEventConsumer / CacheEventConsumer&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;좋아요 수나 캐시 무효화는 유저가 인지하기 어려운 영역임&lt;/li&gt;
&lt;li&gt;약간의 중복이나 유실은 TTL, 재집계 로직으로 보정 가능함&lt;/li&gt;
&lt;li&gt;빠른 ack 처리로 성능 우선시&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이벤트별로 정합성과 성능의 트레이드오프를 다르게 가져가면서 전체 시스템의 효율을 높이는 방법을 고민해 보았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;실패 시 전략&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로 고민했던 건 실패 이벤트 처리였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재는 &lt;b&gt;DefaultErrorHandler + DeadLetterPublishingRecoverer&lt;/b&gt; 조합으로 3회 재시도 후에도 실패하면 &lt;b&gt;.DLT&lt;/b&gt; 토픽에 메시지를 넣도록 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 아직 DLQ 메시지를 어떻게 운영할진 좀 더 고민해봐야 할 지점인 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;마무리&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 주에는 카프카 기반 이벤트 파이프라인을 구현하면서 책임을 분리하고 서비스 경계를 넘어 이벤트를 전달하는 프로세스를 경험해 볼 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 여전히 풀리지 않은 과제도 남아 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;DLQ 운영 전략&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;원본 토픽별로 분리(catalog-events.DLT) vs 통합(dlq-events)&lt;/li&gt;
&lt;li&gt;실패 메시지를 다시 재처리 vs 단순 모니터링 용도&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Batch vs Single Listener&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;현재는 모든 컨슈머를 batch 모드로 작성했지만, EventLogConsumer처럼 정합성이 중요한 로직은 single listener가 더 적절할까 하는 생각도 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아무래도 이런 선택은 실제 운영 맥락에 따라 달라질 수 있어서 더 많은 레퍼런스를 찾아보고 정리한 뒤에야 확실한 결정을 내릴 수 있을 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 이번 주에 카프카를 붙일 때는 다들 카프카 카프카 하던데 나도..! 라는 기대 반, 걱정 반으로 시작했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;막상 부딪혀 보니 단순히 카프카 썼다로 끝나는 게 아니라 운영 관점에서 고민할 지점이 정말 많다는 걸 알게 됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;매번 설계 때마다 느끼는 건 모든 걸 다 가져가려 하기보다 무엇을 보장하고 무엇을 포기할지 명확히 하는 게 제일 중요하다는 것이다. 두 마리 토끼를 동시에 잡을 수는 없으니까 어떤 건 확실히 책임지고 어떤 건 과감히 놓아주는 선택이 필요할 때가 항상 오는 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내가 내린 결정들이 정답이 아니더라도 이제는 점점 감이 아니라 나름의 근거를 바탕으로 선택하고 있다는 점에서 조금은 성장했을까 하는 생각도 든다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞으로도 이런 고민과 선택들이 쌓이다 보면 언젠가는 나만의 운영 전략이 만들어지지 않을까!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Study/Architecture</category>
      <author>haylee</author>
      <guid isPermaLink="true">https://haylee.tistory.com/16</guid>
      <comments>https://haylee.tistory.com/16#entry16comment</comments>
      <pubDate>Fri, 5 Sep 2025 17:15:39 +0900</pubDate>
    </item>
    <item>
      <title>주문과 결제, 어디까지 한 트랜잭션으로 묶어야 할까?</title>
      <link>https://haylee.tistory.com/15</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;저번 주차에 장애 대응 시스템을 구축하면서 분리했던 주문과 결제 API를 다시 하나로 합쳤다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PG가 아직 시뮬레이터이기 때문에 별도의 인증/인가 로직이 존재하지 않았고, 실제 유저가 주문을 하는 플로우를 생각해 봤을 때 주문과 결제를 하나의 API로 묶는 게 자연스럽다는 판단이 들었기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 막상 합치고 나니 여러 문제가 발생했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;길어진 트랜잭션&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;합친 구조는 대략 이런 모습이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한눈에 봐도 로직이 길어졌고, 주문이 너무 많은 책임을 떠안게 됐다.&lt;/p&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;주문 {
	주문 생성
	쿠폰 조회 &amp;amp; 사용
	재고 조회 &amp;amp; 차감
	포인트 조회 &amp;amp; 차감
	결제 요청
	주문 정보 데이터 플랫폼 전송
}
결제 요청 {
	주문 검증
	결제 생성
	PG 결제 요청 API 호출
	결제 상태 변경
	주문 상태 변경
}
결제 콜백 {
	주문 검증
	결제 검증
	콜백 결과 따라 결제 상태 변경
	콜백 결과 따라 주문 상태 변경
	주문 정보 데이터 플랫폼 전송
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 실제 운영 상황을 가정하면 문제는 더 많아졌다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;PG API가 조금만 지연되더라도 주문 전체 응답이 길어짐&lt;/li&gt;
&lt;li&gt;주문 생성 중 락을 잡고 있는 시간이 길어지면서 동시 주문이 몰리면 다른 유저 주문까지 대기 상태로 밀림&lt;/li&gt;
&lt;li&gt;주문은 성공 상태로 저장됐는데 결제는 실패상태로 저장되는 애매한 상태가 발생해 상태를 구분하기 어려움&lt;/li&gt;
&lt;li&gt;실패 재시도를 하려 해도 어디까지 성공했는지 불명확함&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트랜잭션도 길어지고 주문 로직에 여러 도메인의 책임이 얽히면서 장애 대응이 어려워졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;@Transactional 통한 트랜잭션 경계 분리&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제일 먼저 시도한 건 트랜잭션 경계를 나누는 것이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 주문의 상태를 세분화했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에는 CREATED, SUCCESS, FAILED 뿐이었는데, 실제 프로세스를 반영해 좀 더 디테일하게 정의했다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public enum OrderStatus {
    CREATED,
    WAITING_PAYMENT,
    PAID,
    PAYMENT_FAILED,
    ORDER_FAILED,
    CANCELED,
    SHIPPING,
    DELIVERED,
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상태를 세분화해 &lt;b&gt;주문은 성공했지만 결제가 실패했다&lt;/b&gt; 같은 상황도 구분할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 결제 로직을 별도의 트랜잭션으로 분리해 결제 실패가 주문 전체 롤백으로 이어지지 않게 했다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Transactional
public void createOrder(OrderCommand.Create command) {
    Order order = orderService.create();

	try {
		couponUseService.use();
		stockDecreaseService.decrease();
		pointUseService.use();
	} catch(Exception e) {
        log.error(&quot;주문 처리 중 예외 발생: {}&quot;, e.getLocalizedMessage());
        order.markOrderFailed();
	}
		
	paymentFacade.requestPayment(); // REQUIRES_NEW
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;@Transactional(propagation = Propagation.REQUIRES_NEW)
public void requestPayment() {
	...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;한계&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 이 방법도 한계가 있었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;주문 로직이 여전히 결제를 직접 호출하기 때문에 의존성이 그대로 유지됨&lt;/li&gt;
&lt;li&gt;결제 실패 시 주문 쪽에서 보상 트랜잭션을 직접 작성해야 하므로 로직이 복잡해짐&lt;/li&gt;
&lt;li&gt;알림, 데이터 전송 같은 후속 작업이 늘어나면 모두 주문 로직에 추가됨&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;REQUIRES_NEW&lt;/b&gt;는 트랜잭션의 물리적 분리는 하게 했지만 도메인 간 책임까지는 분리해주지 못했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때부터 고민이 깊어졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문이 결제까지 책임지는 게 맞을까? 재시도나 복구 같은 건 어디까지 주문이 알아야 할까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;주문이 결제를 알아야 할까?&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금 상태에서는 너무 많은 책임이 주문에 몰려있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도메인 입장에서 생각해 보면 알 필요가 없는 정보가 너무 많았다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;주문은 주문 자체의 정합성만 책임지면 됨&lt;/li&gt;
&lt;li&gt;결제는 외부 PG와의 상호작용을 책임지면 됨&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서로 다른 유스케이스를 하나로 묶으니 장애 대응도 어렵고 결합도만 높아졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 주문은 &lt;b&gt;주문이 생성됐다는 사실만 알리고,&lt;/b&gt; 후속 단계는 필요한 도메인이 이어받아 처리하도록 끊어내야 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;이벤트 기반 분리&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트랜잭션의 경계만 나눠주는 것으로는 부족했기 때문에 이벤트를 도입하기로 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스프링의 &lt;b&gt;ApplicationEventPublisher&lt;/b&gt;를 사용해 이벤트를 발행하고 핸들러에서 후속 작업을 실행하도록 했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;커맨드(Command)&lt;/b&gt;: &lt;b&gt;무엇을 하라는 지시&lt;/b&gt;, 후속작업을 알고 있다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;이벤트(Event)&lt;/b&gt;: &lt;b&gt;무엇이 일어났다는 사실&lt;/b&gt;, 후속작업을 알지 못한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;이벤트 기반의 함정&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다고 모든걸 이벤트로 분리하는 게 과연 괜찮을까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문 정합성 검증 코드를 이벤트로 분리한다고 가정해 보자.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Transactional
public void createOrder(OrderCommand.Create command) {
    Order order = orderService.create();

    eventPublisher.publish(CouponUseEvent.of());
    eventPublisher.publish(StockDecreaseEvent.of());
    eventPublisher.publish(PointUseEvent.of());

    paymentFacade.requestPayment();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경우 정합성이 맞지 않아도 일단 주문은 완료된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자는 주문 완료 알림을 받았다가 곧바로 주문 취소 알림을 받아야 하는데, 그렇게 된다면 데이터 정합성뿐 아니라 사용자 경험까지 해칠 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 이벤트 실패 시에도 문제가 발생했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에는 하나의 트랜잭션 내에 모든 로직이 있었기 때문에 어디에선가 예외가 발생하면 전체가 롤백됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 이벤트가 실패한다면?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;복구 로직도 별도 트랜잭션에서 수행해야 하고, 그것도 실패하면 재시도가 모호해졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 모든 것을 이벤트로 분리한다는 접근은 오히려 더 큰 사이드 이펙트를 불러올 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;이벤트 분리 기준&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그만큼 분리 기준을 명확하게 세울 필요가 있었고, 다음과 같은 기준으로 분리해보고자 했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;정합성이 중요한 &lt;b&gt;핵심 로직&lt;/b&gt;은 메인 트랜잭션에 포함&lt;/li&gt;
&lt;li&gt;다른 도메인의 데이터는 &lt;b&gt;조회(Read)는 가능&lt;/b&gt;하지만, &lt;b&gt;수정(Create/Update/Delete)은 하지 않음&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;후속 작업&lt;/b&gt;(결제 요청, 알림, 데이터 적재 등)은 이벤트로 분리&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면 이렇게 나눌 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;분리하지 않을 로직 (핵심 정합성 로직)&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;주문 생성, 주문 검증, 결제 생성, 결제 검증&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;분리할 로직 (부가적인 후속 작업)&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;쿠폰 사용, 포인트 이력 저장, 결제 요청, 리소스 원복, 다른 도메인 상태 변경, 외부 API 호출&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;실제 적용&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;주문&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;주문 {
	주문 생성
	쿠폰 조회
	쿠폰 사용 이벤트 발행
	재고 조회 &amp;amp; 차감
	포인트 조회 &amp;amp; 차감
	주문 생성 이벤트 발행
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;쿠폰 사용 이벤트 핸들러&lt;/b&gt; &amp;rarr; 쿠폰 사용 커맨드&lt;/li&gt;
&lt;li&gt;&lt;b&gt;주문 성공 이벤트 핸들러&lt;/b&gt; &amp;rarr; 결제 요청 커맨드, 쿠폰 데이터 플랫폼 전송 이벤트&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;결제&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;결제 {
	주문 검증
	결제 생성
	PG 결제 요청 API 호출
	결제 상태 변경
	결제 요청 성공 이벤트 발행
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;결제 요청 성공 이벤트 핸들러&lt;/b&gt; &amp;rarr; 주문 상태 변경 커맨드&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;결제 콜백&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;결제 콜백 {
	주문 검증
	결제 검증
	결제 상태 변경
	결제 성공/실패 이벤트 발행
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;결제 성공 이벤트 핸들러&lt;/b&gt; &amp;rarr; 주문 상태 변경 커맨드, 데이터 플랫폼 전송 이벤트&lt;/li&gt;
&lt;li&gt;&lt;b&gt;결제 실패 이벤트 핸들러&lt;/b&gt; &amp;rarr; 주문 상태 변경 커맨드, 리소스 원복 커맨드, 데이터 플랫폼 전송 이벤트&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;전체 시퀀스&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;delphi&quot; data-ke-language=&quot;delphi&quot;&gt;&lt;code&gt;sequenceDiagram
    participant U as User
    participant O as OrderFacade
    participant E as EventPublisher
    participant H1 as OrderEventHandler
    participant P as PaymentFacade
    participant H2 as PaymentEventHandler
    participant C as Callback(PG)
    participant H3 as CallbackEventHandler

    U-&amp;gt;&amp;gt;O: 주문 요청
    O-&amp;gt;&amp;gt;O: 주문 생성 &amp;amp; 검증&amp;lt;br/&amp;gt;쿠폰/재고/포인트 처리
    O-&amp;gt;&amp;gt;E: 주문 생성 이벤트 발행
    O--&amp;gt;&amp;gt;U: 주문 결과 반환

    E--&amp;gt;&amp;gt;H1: 주문 성공 이벤트
    H1-&amp;gt;&amp;gt;P: 결제 요청 커맨드

    P-&amp;gt;&amp;gt;P: 결제 생성 &amp;amp; PG API 호출
    P-&amp;gt;&amp;gt;E: 결제 요청 성공 이벤트 발행

    E--&amp;gt;&amp;gt;H2: 결제 요청 성공 이벤트
    H2-&amp;gt;&amp;gt;O: 주문 상태 변경

    opt 결제 콜백
	    C-&amp;gt;&amp;gt;P: 결제 콜백 호출
	    P-&amp;gt;&amp;gt;E: 결제 성공/실패 이벤트 발행
	
	    E--&amp;gt;&amp;gt;H3: 결제 결과 이벤트
	    H3-&amp;gt;&amp;gt;O: 주문 상태 변경
	    H3-&amp;gt;&amp;gt;외부: 데이터 플랫폼 전송
	    opt 결제 콜백 결과가 실패인 경우
		    H3-&amp;gt;&amp;gt;O: 리소스 원복
	    end
    end&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;분리 결과&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이벤트를 통해 트랜잭션을 분리한 결과, 도메인 사이 결합도를 낮추고 경계를 선명히 할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 얻은 이점은 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;트랜잭션 경계 명확화&lt;/b&gt;: 핵심 정합성 로직만 메인 트랜잭션에 묶이고 나머지는 별도 트랜잭션으로 분리&lt;/li&gt;
&lt;li&gt;&lt;b&gt;도메인 간 결합도 감소&lt;/b&gt;: 주문은 주문만, 결제는 결제만 책임지도록 구조 단순화&lt;/li&gt;
&lt;li&gt;&lt;b&gt;보상 트랜잭션 단순화&lt;/b&gt;: 실패 처리 로직이 주문 코드에 얽히지 않고 이벤트 핸들러에서 독립적으로 수행&lt;/li&gt;
&lt;li&gt;&lt;b&gt;확장성&lt;/b&gt;: 이벤트 단위로 여러 전략을 선택할 수 있고 알림이나 데이터 전송 같은 후속 작업 확장이 쉬워짐&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;마무리&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에는 API를 합쳤다가 발생한 문제로 인해 트랜잭션 경계를 명확히 나누는 작업을 수행했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 과정 속에서 어떤 문제는 단순히 &lt;b&gt;@Transactional&lt;/b&gt;과&amp;nbsp;옵션으로는 해결되지 않는다는 것과 도메인 간 결합도가 너무 높으면 장애 대응도 복잡해진다는 것을 겪었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 이벤트 기반 구조로 변경하면서 &lt;b&gt;주문은 주문만, 결제는 결제만&lt;/b&gt; 책임지는 형태로 정리할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도메인의 경계와 책임을 나눈다는 것이 단순히 코드 레벨의 분리나 트랜잭션 분리 문제가 아니라 설계 차원의 중요한 문제라는 점을 배울 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 이벤트를 적용하면서 또 다른 고민들도 생겼다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이벤트가 발행되지 않는다면 어떻게 검증하고 대응할 것인가?&lt;/li&gt;
&lt;li&gt;동일 이벤트가 여러 번 발행된다면 멱등성을 어떻게 보장할 것인가?&lt;/li&gt;
&lt;li&gt;여러 이벤트가 동시에 처리될 때 순서를 어떻게 보장할 것인가?&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 안정성 문제는 또 다른 과제로 남았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스프링 기본 이벤트만으로는 이 부분을 다루기 어렵기 때문에 메시지 브로커(Kafka, RabbitMQ 등)나 별도의 이벤트 인프라를 고려해야겠다는 생각도 들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 다음 주에는 메시지 브로커 중 하나인 카프카를 사용해 안정성도 확보할 수 있도록 해보려 한다.&lt;/p&gt;</description>
      <category>Study/Architecture</category>
      <author>haylee</author>
      <guid isPermaLink="true">https://haylee.tistory.com/15</guid>
      <comments>https://haylee.tistory.com/15#entry15comment</comments>
      <pubDate>Fri, 29 Aug 2025 17:01:21 +0900</pubDate>
    </item>
    <item>
      <title>장애 대응 시스템 구축하기</title>
      <link>https://haylee.tistory.com/14</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;이번 주에는 시뮬레이터지만 &lt;b&gt;PG(Payment Gateway) 모듈&lt;/b&gt;을 결제 로직과 연동하는 작업을 진행했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 시뮬레이터 중 결제요청 API의 스펙은 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;요청 성공 확률:&lt;/b&gt; 60%&lt;/li&gt;
&lt;li&gt;&lt;b&gt;요청 지연:&lt;/b&gt; 100ms ~ 500ms&lt;/li&gt;
&lt;li&gt;&lt;b&gt;처리 지연:&lt;/b&gt; 1s ~ 5s&lt;/li&gt;
&lt;li&gt;&lt;b&gt;처리 결과:&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;성공 : 70%&lt;/li&gt;
&lt;li&gt;한도 초과 : 20%&lt;/li&gt;
&lt;li&gt;잘못된 카드 : 10%&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에는 사용자가 포인트를 충전하고 그 포인트로만 결제하는 구조였는데, 이제는 결제 시 포인트를 일부 사용하고 남은 금액은 PG를 통해 결제하는 방식으로 바꾸게 됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PG는 외부 시스템인만큼 통제할 수 없는 상황이 훨씬 많아져 고민할 지점이 너무 많아졌다. 단순히 호출만 성공하면 끝나는 게 아니라, 네트워크 지연, 응답 누락, 외부 장애 같은 수많은 변수를 생각해야 했다. 가장 중요한 건 PG가 실패한다고 해서 내 시스템까지 실패하도록 만들어서는 안된다는 점이었다. 그래서 이번 작업은&lt;b&gt; 외부 시스템은 언제든 실패할 수 있다&lt;/b&gt;는 것을 염두에 두고 내 시스템을 안정적으로 버틸 수 있게 설계해보는 작업이 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;주문과 결제 로직 분리&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 시스템을 설계했을 때는 단순하게 주문 요청 시 결제까지 한번에 처리하는 방식으로 구현했었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;주문 트랜잭션:&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;주문 생성 &amp;rarr; 쿠폰 적용 &amp;rarr; 포인트 사용 &amp;rarr; 재고 차감 &amp;rarr; 결제 생성 &amp;rarr; 결제상태 외부 전송 &amp;rarr; 주문 상태 변경&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문 트랜잭션 내에서 결제 로직을 같이 처리하고 주문 1건에 결제 1건이 매핑되는 구조였다. 당장 로컬에서 테스트 등이 문제없이 돌아가니까 괜찮아 보였지만 PG를 붙이는 등 시스템이 확장되면서 여러 문제가 발생했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;PG 호출 중 타임아웃 발생 시 주문까지 롤백
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;사용자가 주문 버튼을 눌렀는데 PG가 늦게 응답하거나 끊기면 주문 자체가 사라져버림&lt;/li&gt;
&lt;li&gt;사용자 입장에서는 주문 버튼을 눌러서 처리가 됐는데, 주문 목록에서는 노출되지 않게 될 수 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;재시도 시 중복 주문 발생 가능성
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;클라이언트나 서버에서 재시도를 걸면 같은 결제를 여러 번 요청하게 됨&lt;/li&gt;
&lt;li&gt;주문이 중복으로 생성되거나 주문 상태와 결제 상태가 일치하지 않을 수도 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;여러 가지 실패 케이스에 대한 로깅이 어려움
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;단순히 롤백되기 때문에 어느 단계에서 실패했는지 로깅이 어려움&lt;/li&gt;
&lt;li&gt;주문 조건이 맞지 않아 실패한 주문과 PG 단계에서 실패한 주문이 모두 같은 취소로 간주되고 있었음&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 다시 처음으로 돌아가 트랜잭션 단위를 다시 정의하고 주문과 결제를 아예 분리하기로 했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;주문 트랜잭션:&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;주문 생성 &amp;rarr; 주문 검증(쿠폰 적용, 포인트 사용, 재고차감)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;결제 트랜잭션:&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;주문상태 확인 (INIT) &amp;rarr; 주문상태 변경 (WAITING_PAYMENT) &amp;rarr; 결제 생성 &amp;rarr; PG 결제 요청&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 역할을 분리하자 구조나 책임이 더 명확해졌다. 주문 검증 단계에서 실패하면 주문은 FAILED 상태로 남아 왜 주문이 실패했는지 추적할 수 있게 됐다. 또한 결제 실패 시에도 주문은 그대로 남고 결제만 FAIL로 기록되어 원복 처리 하기 용이해졌다. 또한 한 주문에 여러 건의 결제가 연결될 수 있어 재시도 로직 처리도 더 안전해졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 그래도 여전히 남은 문제들이 있었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;응답이 늦을 경우 &lt;b&gt;얼마나 기다릴 것인가&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;실패했을 경우 &lt;b&gt;어디까지 재시도&lt;/b&gt;할 것인가&lt;/li&gt;
&lt;li&gt;계속해서 실패하는 경우 &lt;b&gt;어떻게 막을 것인가&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;콜백이 누락되거나 너무 늦을 경우 &lt;b&gt;어떻게 보완할 것인가&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 문제들을 해결하기 위해 하나씩 처리해 보기로 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Timeout&lt;/b&gt;&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;응답이 늦을 경우 얼마나 기다릴 것인가&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내부 서비스라면 보통은 짧은 응답시간이 보장되지만, PG처럼 외부 시스템은 네트워크 지연이나 상대 서버 부하로 인해 예상치 못하게 늘어날 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 아무 설정 없이 무한정 기다린다면 어떻게 될까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결제 API가 지연될 때마다 내 스레드도 무한정 지연될 것이다. 트래픽이 몰리면 스레드풀이 금방 고갈되고 다른 요청 처리도 계속 지연될 것이다. 결국 한 번의 지연이 전체 시스템으로 전파되어 시스템 장애로 이어지는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;타임아웃을 너무 짧게 잡으면 실패가 너무 잦아질 수 있고, 너무 길게 잡으면 지연은 줄어도 시스템 전체가 느려질 수 있어서 적정값을 정하는 게 중요했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시뮬레이터의 요청 지연 범위는 100ms ~ 500ms 이기 때문에 1초 이상이 걸린다면 장애로 판단하도록 했다. 처리 지연 범위는 1s ~ 5s 지만 너무 길게 잡게 되면 위에서 말한 문제가 발생하기 때문에 절충안으로 중간값인 3초를 선택했다. 3초를 넘으면 느린 응답으로 판단해 실패처리 후 별도로 처리해야겠다고 판단했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 실제로 프로젝트에서는 아래와 같이 설정을 추가했다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;spring:
  cloud:
    openfeign:
      client:
        config:
          default:
            connectTimeout: 1000     # TCP 연결 수립까지 최대 1초
            readTimeout: 3000        # 연결 후 응답을 기다리는 최대 3초
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Retry&lt;/b&gt;&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실패했을 경우 어디까지 재시도할 것인가&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Timeout 설정으로 응답이 너무 늦어지면 끊고 실패 처리까지는 할 수 있게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;nbsp;이 말은 응답이 늦으면 바로 실패로 처리하게 된다는 것이다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;PG는 외부 시스템이라 일시적인 네트워크 지연이 흔히 발생할 수 있다. 실제로는 정상처리가 가능한 상황인데도 한 번만에 바로 실패로 끝내게 된다면&amp;nbsp;이것 또한 사용자 경험에 썩 좋은 영향을 주지는 못할 것 같았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 다음으로 고려한 게 실패한 전략에 대해 재시도하는 &lt;b&gt;Retry 전략&lt;/b&gt;이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재시도 전략은 다음과 같이 설정했다. 구현에는 &lt;b&gt;Resilience4j&lt;/b&gt; 라이브러리를 활용해 간단하게 설정할 수 있었다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;resilience4j:
  retry:
    instances:
      pgRetry:
        max-attempts: 3               # 최대 재시도 횟수
        wait-duration: 1s             # 재시도 간 대기 시간
        retry-exceptions:             # 재시도 할 예외 클래스
          - feign.RetryableException
          - java.net.SocketTimeoutException
          - java.io.IOException
        fail-after-max-attempts: true # 최대 재시도 횟수 초과 시 명시적으로 예외 던짐
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;max-attempts: 3&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;단순 네트워크 지연이라면 보통 한두 번 안에 정상화될 것이라 생각해 3회로 설정함&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;wait-duration: 1s&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;바로 연속으로 재요청하면 PG 서버에 부하가 너무 커질 수 있기 때문에 1초 간격을 두고 요청하게 함&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;retry-exceptions&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;모든 실패에 대해 재시도하는 것이 아닌 네트워크 레벨 예외에 대해서만 재시도하도록 설정함&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;fail-after-max-attempts: true&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;설정된 횟수를 넘으면 진짜 실패하도록 예외를 던지도록 함&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 점은 회복 가능성이 있는 실패에 대해서만 재시도하도록 하는 것이었다. 따라서 비즈니스 로직을 제외한 네트워크 레벨의 예외에만 재시도를 설정해 주었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Circuit Breaker&lt;/b&gt;&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;계속해서 실패하는 경우 어떻게 막을 것인가&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Retry 설정을 통해 실패 요청에 대해 재시도할 수 있도록 설정해 주었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 만약 PG 시스템이 실제로 장애를 겪고 있는 상황이라면 어떻게 될까? Retry는 결국 같은 요청을 반복하니까 똑같은 실패를 3번 반복한 뒤에야 예외를 던진다. 이 경우 서버 리소스만 더 사용하고 PG 서버에는 불필요한 부하가 더 가해져 복구가 더뎌질 수 있다. 이렇듯 지속적인 장애에 대해 별도의 대책이 필요했고 &lt;b&gt;Circuit Breaker&lt;/b&gt;를 고려하게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서킷 브레이커는 주식을 해 본 사람이라면 익숙한 단어일 것이다. 주가가 급변할 때 매매를 중단시킬 때도 이 용어를 사용한다. 동일한 메커니즘으로 실패가 지속될 때 회로를 끊어버려서 요청을 막는 역할이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서킷 브레이커는 아래처럼 설정을 넣어주었다. 여기서 서킷 브레이커가 발동된 상황을 &lt;b&gt;open&lt;/b&gt;으로 표기하는데, 회로는 열려있는 상태가 끊긴 상태이기 때문에 그런 것 같았다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;resilience4j:
  circuitbreaker:
    instances:
      pgCircuit:
        sliding-window-size: 10          # 최근 n회 호출 결과 기준으로 실패율 계산
        failure-rate-threshold: 50       # 실패율이 50% 넘으면 Open
        wait-duration-in-open-state: 10s # Open 상태 유지 시간
        permitted-number-of-calls-in-half-open-state: 2
        slow-call-duration-threshold: 2s
        slow-call-rate-threshold: 50
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;sliding-window-size: 10&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;최근 요청 중 실패율을 계산하는 요청 개수 단위인데 10회 정도면 의미 있는 수치로 볼 수 있을 것 같았음&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;failure-rate-threshold: 50&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;절반 이상이 실패하면 정상이라고 보기 어렵기 때문에 50% 설정함&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;wait-duration-in-open-state: 10s&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;회로가 끊긴 상태를 유지하는 시간으로 10초 정도면 복구되지 않았을까 생각해 설정&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;permitted-number-of-calls-in-half-open-state: 3&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;10초 뒤 바로 정상으로 간주하지 않고 일부 요청만 보내보고 성공률을 보고 다시 회로를 닫음&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 코드는 아래처럼 구현했다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@CircuitBreaker(name = &quot;pgCircuit&quot;, fallbackMethod = &quot;fallback&quot;)
@Retry(name = &quot;pgRetry&quot;, fallbackMethod = &quot;fallback&quot;)
public PaymentResponse process(PaymentRequest request) {
    Payment payment = paymentService.getDetail(request.getPaymentId());

    // 결제 API 호출
    PaymentResponse response = paymentGateway.requestPayment(request, callbackUrl);
    if (response.getStatus().equals(&quot;SUCCESS&quot;)) {
        payment.setPaymentPending(response.getTransactionKey());
    }

    return response;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 Circuit Breaker를 설정하니 PG 서버가 다운된 상황에서도 불필요한 재시도를 막을 수 있었다. 우리 시스템 입장에서는 이미 장애인걸 알고 있으니까 괜히 시도하지 않고 빠르게 실패를 반환해 사용자 경험도 개선할 수 있을 것 같았다. 서킷 브레이커는 단순 PG 서버만 보호하는 게 아니라 결국은 우리 시스템도 보호하는 장치인 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Scheduler&lt;/b&gt;&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;콜백이 누락되거나 너무 늦을 경우 어떻게 보완할 것인가&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Circuit Breaker까지 적용하니까 PG 장애에 대해 비교적 단단한 서버가 되었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Timeout으로 지연 차단&lt;/li&gt;
&lt;li&gt;Retry로 일시적 실패에 대해 재시도&lt;/li&gt;
&lt;li&gt;Circuit Breaker로 장애 확산 방지&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래도 여전히 남은 구멍이 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PG 시스템은 콜백을 통해 결제 결과를 전달해 주는 비동기 결제 방식을 사용하기 때문에, 콜백이 정상적으로 오지 않는 상황도 가정해야 했다. 이런 상황을 방치한다면 우리 시스템과 PG 시스템 간 데이터 정합성 문제가 발생할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하기 위해 주기적으로 결제 상태를 점검하는 &lt;b&gt;Scheduler&lt;/b&gt;를 추가했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일정 주기마다 내 우리 시스템에서 아직 &lt;b&gt;WAITING_PAYMENT&lt;/b&gt; 상태인 결제를 조회하고, PG API를 통해 실제 결제 상태를 다시 확인한다. 만약 성공/실패이라면 SUCCESS/FAIL 갱신, 처리 중이라면 다음 주기에 다시 확인하는 식으로 작성했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식을 통해 외부 콜백에만 의존하지 않아 콜백 누락 건에 대해 보완할 수 있게 되었고, 서킷이 열려있을 때 들어와 실패했던 결제요청을 정상화 이후에 다시 확인할 수 있게 되었다. PG와 우리 시스템 간 데이터 정합성을 더 지킬 수 있게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 코드는 아래처럼 구현했다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Scheduled(fixedDelay = 60000) // 1분마다 실행
public void reconcilePayments() {
    List&amp;lt;Payment&amp;gt; waitingPayments = paymentRepository.findAllByStatus(PaymentStatus.WAITING);

    for (Payment payment : waitingPayments) {
        PgApiResponse response = pgClient.getTransactionsByOrder(payment.getOrderNo());
        payment.updateStatus(response.toPaymentStatus());
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;10분마다 &lt;b&gt;PENDING&lt;/b&gt; 상태인 결제를 점검하고, 생성 후 30분 이상된 지연 건은 강제로 실패처리한다. 실패처리 시 결제 데이터에 대한 원복도 함께 진행하도록 했다. 콜백 누락 시 계속해서 방치되는 것이 아니라 주기적으로 복구될 수 있게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;마무리&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 작업을 통해서 외부 API는 언제든 실패할 수 있기 때문에 실패 시에도 시스템이 안전하게 동작하도록 대비하는 과정을 배울 수 있었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;주문이 롤백돼서 기록이 사라지면? 원인 추적조차 못한다.&lt;/li&gt;
&lt;li&gt;타임아웃을 안 걸면? 우리 쪽 스레드 풀이 다 막혀서 서비스 전체가 멈출 수 있다.&lt;/li&gt;
&lt;li&gt;재시도가 없으면? 잠깐 끊긴 요청도 전부 실패 처리된다.&lt;/li&gt;
&lt;li&gt;서킷 브레이커가 없으면? 외부 장애가 그대로 내부 장애로 번진다.&lt;/li&gt;
&lt;li&gt;스케줄러가 없으면? 콜백 누락 하나로 주문이 대기상태에 머물러버린다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실패를 피할 수 없다면 그에 대한 보완책을 설정해야 한다는 고민을 하면서 &lt;b&gt;Timeout &amp;rarr; Retry &amp;rarr; Circuit Breaker &amp;rarr; Scheduler&lt;/b&gt; 로 이어지는 대책을 세울 수 있게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 PG 연동하기가 아니라 외부 연동을 어떻게 안정적으로 운영할지에 대한 감각을 조금은 익히게 된 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 이번에는 외부 API 호출 로직에 대해 트랜잭션까지 분리하지는 못했다. 앞으로 이 부분을 이벤트 처리를 통해 아예 트랜잭션 분리까지 가져갈 수 있게 해서 좀 더 안정적인 서비스를 만드는 과정을 고민해보고자 한다.&lt;/p&gt;</description>
      <category>Study/Architecture</category>
      <author>haylee</author>
      <guid isPermaLink="true">https://haylee.tistory.com/14</guid>
      <comments>https://haylee.tistory.com/14#entry14comment</comments>
      <pubDate>Fri, 22 Aug 2025 23:41:49 +0900</pubDate>
    </item>
    <item>
      <title>캐시 구조 개선</title>
      <link>https://haylee.tistory.com/13</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;지난번 진행한 읽기 성능 개선 작업에서는 캐시 구조를 다음과 같이 설계했었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;브랜드 목록:&lt;/b&gt; 상품 많은 순 상위 5% (50개 브랜드)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;상품 목록:&lt;/b&gt; 각 상위 브랜드 별 최신순 상품 상위 100개&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TTL은 브랜드 목록에 &lt;b&gt;1일&lt;/b&gt;, 상품 목록에 &lt;b&gt;1시간&lt;/b&gt;을 설정했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 브랜드나 상품이 수정/삭제될 때 별도의 캐시 갱신 로직이 존재하지 않아, 데이터 갱신 시 캐시를 어떻게 관리할 것인가 하는 문제가 남아있었다. 이 문제를 방치한다면 데이터 정합성이 맞지 않게 되니까 수정이 필요했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;기존 캐시 구조&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조회 로직은 다음과 같은 흐름으로 구성되어 있었다.&lt;/p&gt;
&lt;pre class=&quot;excel&quot;&gt;&lt;code&gt;검색 조건이 캐시 조건에 부합하는지
	&amp;rarr; 브랜드 목록 캐시가 존재하는지
		&amp;rarr; 상품 목록 캐시가 존재하는지
			&amp;rarr; 없다면 DB 조회
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구현 당시 각 캐시는 단순히 List 형태로 저장했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;브랜드 목록 &amp;rarr; &lt;b&gt;List&amp;lt;BrandInfo&amp;gt;&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;상품 목록 &amp;rarr; &lt;b&gt;List&amp;lt;ProductInfo&amp;gt;&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 &lt;b&gt;RedisTemplate&amp;lt;String, Object&amp;gt;&lt;/b&gt; 로 List를 직접 저장하다 보니 역직렬화 오류가 발생했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내부 제네릭 타입 정보를 잃어버려 단순 &lt;b&gt;List&amp;lt;Object&amp;gt;&lt;/b&gt; 로만 읽히는 것이 문제였던 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 해결하기 위해 별도의 &lt;b&gt;wrapper 클래스&lt;/b&gt;를 도입했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;브랜드 목록 &amp;rarr; List&amp;lt;BrandInfo&amp;gt; &amp;rarr;&lt;b&gt; BrandInfoList&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;상품목록 &amp;rarr; List&amp;lt;ProductInfo&amp;gt; &amp;rarr; &lt;b&gt;ProductInfoList&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캐시 저장 시 List를 감싸는 전용 DTO를 두어 역직렬화 문제를 회피한 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 이 방식에는 두가지 문제가 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 객체마다 래퍼 클래스를 만들어야해 관리 비용 증가&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rarr; 캐싱 대상이 늘어나면 래퍼 클래스도 계속 생성해주어야함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 조회 시 O(N) 탐색 비용 발생&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rarr; 브랜드 캐시에서 &lt;b&gt;stream(). filter()&lt;/b&gt;로 원하는 브랜드를 찾는 과정이 비효율적임&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;HSET 도입&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 Redis의 &lt;b&gt;HSET&lt;/b&gt; 구조를 알게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HSET 은 Map처럼 동작하기 때문에 brandId를 key로 BrandInfo를 저장할 수 있어 List 기반 캐시의 문제를 해결할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;적용 방식은 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// 단건 조회
public BrandInfo getCachedBrand(Long brandId) {
    BrandInfo cachedInfo = (BrandInfo) redisTemplate.opsForHash().get(BRANDS_CACHE_KEY, brandId.toString());
    if (cachedInfo != null) return cachedInfo;

    refreshBrandsCache();

    return (BrandInfo) redisTemplate.opsForHash().get(BRANDS_CACHE_KEY, brandId.toString());
}

// 브랜드 목록 HSET 저장
public void refreshBrandsCache() {
    List&amp;lt;BrandInfo&amp;gt; topBrands = brandService.getTopList().stream()
            .map(BrandInfo::from)
            .toList();

    Map&amp;lt;String, BrandInfo&amp;gt; brandInfoMap = topBrands.stream()
            .collect(Collectors.toMap(brandInfo -&amp;gt; brandInfo.getId().toString(), Function.identity()));

    redisTemplate.opsForHash().putAll(BRANDS_CACHE_KEY, brandInfoMap);
    redisTemplate.expire(BRANDS_CACHE_KEY, BRANDS_CACHE_TTL);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 바꾸니 래퍼 클래스 없이도 직렬화/역직렬화가 안정적으로 동작했고, 브랜드 조회 시 O(1)에 가까운 속도로 접근할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;마무리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기에는 단순 List 기반 캐시 구조로 구현했지만, 역직렬화와 탐색 비용 문제가 발생해 HSET 구조로 개선하게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개선 후 다음과 같은 효과를 얻을 수 있었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;별도 래퍼 클래스 없이도 직렬화/역직렬화 안정화&lt;/li&gt;
&lt;li&gt;특정 브랜드 단건 속도 개선 ( O(N) &amp;rarr; O(1) )&lt;/li&gt;
&lt;li&gt;캐시 관리 지점 단순화&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 아직 아쉬운 점도 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브랜드 별 상품 목록 캐시는 여전히 List&amp;lt;ProductInfo&amp;gt; 형태라 HSET처럼 개별 단위로 제어하기 어렵다고 판단해 개선하지 못했기 때문이다. 그래서 아직 상품 단건 수정/삭제 시에는 해당 브랜드의 전체 상품 목록 캐시를 갱신해야 하는 제약이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 상품 목록 캐시의 TTL을 브랜드보다 짧게 1시간으로 설정했었고, 추후 TTL을 좀 더 짧게 조정할 계획이라 당장은 별도로 개선을 진행하지 않고 마무리했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 개선을 통해 데이터 구조와 캐시 구조가 비즈니스 특성과 얼마나 밀접하게 맞물려야 하는지를 다시 느낄 수 있었다. 앞으로는 상품 캐시 역시 더 효율적으로 관리할 방법을 고민해보려고 한다.&lt;/p&gt;</description>
      <category>Study/Architecture</category>
      <author>haylee</author>
      <guid isPermaLink="true">https://haylee.tistory.com/13</guid>
      <comments>https://haylee.tistory.com/13#entry13comment</comments>
      <pubDate>Fri, 22 Aug 2025 16:01:06 +0900</pubDate>
    </item>
    <item>
      <title>[Loop:PAK] 5주차 회고</title>
      <link>https://haylee.tistory.com/12</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;이번 주는 상품 목록 조회 API의 읽기 성능 개선 작업을 진행했다. 단순히 애플리케이션 로직만 손보는 것만이 아니라 DB 인덱스와 캐시까지 적용하며 병목 지점을 단계별로 확인하고 개선하는 데 집중했다.&lt;/p&gt;
&lt;p data-end=&quot;471&quot; data-start=&quot;199&quot; data-ke-size=&quot;size16&quot;&gt;먼저 불필요하다고 생각한 로직을 제거해봤지만 응답 속도나 TPS 개선 폭은 미미해서 놀랐었다. 구조 개선도 어느정도 영향을 줄 수 있을줄 알았는데 거의 없다니.. 그래도 이 작업을 통해 병목의 주된 원인이 애플리케이션 로직이 아니라 DB 쿼리 처리 과정에 있음을 확인할 수 있었다. 이후 검색 조건과 정렬 조건을 조합한 복합 인덱스를 적용하자 filesort가 제거되고 쿼리 실행 시간이 약 500ms에서 3ms까지 단축되었다. TPS도 약 2배 상승하고 에러율도 70% 이상 줄었지만, 부하 테스트에서 응답 시간이 여전히 초 단위라는 한계는 남아 있었다.&lt;/p&gt;
&lt;p data-end=&quot;616&quot; data-start=&quot;473&quot; data-ke-size=&quot;size16&quot;&gt;마지막으로 Redis 캐시를 도입하면서 큰 변화가 있었다. 평균 응답 시간이 7초대에서 20ms대로 줄었고, TPS도 수천 단위까지 증가하며 안정성이 확보됐다. 특히 캐시워밍까지 적용했을 때 초기 요청부터 안정화된 점이 인상적이었다.&lt;/p&gt;
&lt;p data-end=&quot;785&quot; data-start=&quot;618&quot; data-ke-size=&quot;size16&quot;&gt;이번 과정을 통해 단순 로직 개선만으로는 병목을 해소하기 어렵고 DB 최적화와 캐시가 중요한 해결책이라는 걸 다시금 느꼈다. 또 캐시는 단순히 넣고 끝나는 것이 아니라 TTL, 쓰기 전략, 무효화 정책까지 운영 전략이 뒤따라야 정합성과 성능을 함께 챙길 수 있다는 점도 배웠다.&lt;/p&gt;
&lt;p data-end=&quot;891&quot; data-start=&quot;787&quot; data-ke-size=&quot;size16&quot;&gt;앞으로는 쓰기 전략을 보완해 조회 성능뿐만 아니라 데이터 정합성까지 만족시키는 구조로 발전시켜 볼 계획이다. 추가로 캐시 스탬피드 현상에 대한 대책도 고민해 적용해보고자 한다.&lt;/p&gt;</description>
      <category>Project/회고</category>
      <author>haylee</author>
      <guid isPermaLink="true">https://haylee.tistory.com/12</guid>
      <comments>https://haylee.tistory.com/12#entry12comment</comments>
      <pubDate>Sun, 17 Aug 2025 23:57:40 +0900</pubDate>
    </item>
    <item>
      <title>읽기 성능 개선 보고서</title>
      <link>https://haylee.tistory.com/11</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;테스트 개요&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;테스트 시나리오&lt;/b&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;대상 기능&lt;/b&gt;: 읽기 병목 가능성이 높은 &lt;b&gt;상품 목록 조회 API&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;목표&lt;/b&gt;: 현 상태 기준 &lt;b&gt;p95 응답 시간&lt;/b&gt;과 &lt;b&gt;처리량&lt;/b&gt; 측정&lt;/li&gt;
&lt;li&gt;&lt;b&gt;조건&lt;/b&gt;:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;brandId &amp;isin; {1..5}&lt;/li&gt;
&lt;li&gt;sort &amp;isin; {LATEST, PRICE_ASC, LIKES_DESC}&lt;/li&gt;
&lt;li&gt;page &amp;isin; [0, 49], size=20&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;데이터 준비:&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;유저: 100,000 건&lt;/li&gt;
&lt;li&gt;브랜드: 1,000 건&lt;/li&gt;
&lt;li&gt;상품: 1,000,000 건&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터 기준은 아래 참고&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;트랜잭션 계산&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;상품 목록 조회&lt;/b&gt; 기준 (행사 &lt;b&gt;미실시&lt;/b&gt; 월 가정, 숫자 라운딩 적용)&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 값은 테스트 설계용 가정치이며 실제 트래픽과 다를 수 있음.&lt;br /&gt;MAU 출처: &lt;a href=&quot;https://v.daum.net/v/20241212165321851&quot;&gt;https://v.daum.net/v/20241212165321851&lt;/a&gt; &lt;br /&gt;DAU 계산 근거: &lt;a href=&quot;https://sendbird.com/ko/blog/monthly-active-users-mau&quot;&gt;https://sendbird.com/ko/blog/monthly-active-users-mau&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;1) 트랜잭션 정의&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;MAU(비행사월 추정):&lt;/b&gt; 1,190,000 &amp;rarr; &lt;b&gt;1,200,000&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;근거: 행사 월 기준 MAU 170만 -&amp;gt; 비 행사월 대비 30% 증가했을 것으로 가정&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;DAU(= MAU의 20%):&lt;/b&gt; &amp;rarr; &lt;b&gt;240,000&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;근거: 서비스 점착도 가정 (보수 10%~공격 30% 중 &lt;b&gt;20%&lt;/b&gt; 채택)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;피크 동시 접속자 N(= DAU의 5%):&lt;/b&gt; &amp;rarr; &lt;b&gt;12,000&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;근거: 피크 시간대 집중 + 세션길이 고려 시 동접/DAU 1~5% 범위가 실무적으로 자주 쓰임. 여기선 &lt;b&gt;5%&lt;/b&gt; 채택&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;2) 트래픽 볼륨 가정&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;응답시간 R:&lt;/b&gt; &lt;b&gt;0.25초(=250ms)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;근거: 단일 조회 API의 실용 SLO(사용자 체감+프론트 여유)로 p95 &lt;b&gt;200~300ms&lt;/b&gt; 범위 중 &lt;b&gt;중간값&lt;/b&gt; 채택&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;생각시간 Z:&lt;/b&gt; &lt;b&gt;5초&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;근거: 목록 스크롤/판독/다음 페이지 전환까지 &lt;b&gt;수초 단위 체류&lt;/b&gt; 가정(팀 로그로 보정 권장)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;목록 호출 비중 p_list:&lt;/b&gt; &lt;b&gt;80% (=0.7)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;근거: 브라우징 세션에서 목록 호출 비중이 큼(서비스별 60~90% 내 조정)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;3) 처리량 계산&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;공식:&lt;/b&gt; TPS = N / (R + Z)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;전체 TPS:&lt;/b&gt; 12,000 / (0.25 + 5) = 12,000 / 5.25 &amp;asymp; 2,286 req/s &amp;rarr; &lt;b&gt;&amp;asymp; 2,300 RPS&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;상품 목록 TPS:&lt;/b&gt; 2,300 &amp;times; 0.7 = 1,610 RPS &amp;asymp; 1,500 RPS&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;4) 테스트 목표 TPS&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Baseline(스테이징/로컬 축소):&lt;/b&gt; &lt;b&gt;375 TPS&lt;/b&gt; (피크의 &lt;b&gt;~25%&lt;/b&gt; 수준)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Peak(계산 근사):&lt;/b&gt; &lt;b&gt;1,500 TPS&lt;/b&gt; (이론상 피크)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Stress(여유 검증):&lt;/b&gt; &lt;b&gt;1,800 TPS&lt;/b&gt; (피크 &lt;b&gt;+20%&lt;/b&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;테스트 환경&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬 단일 노드 환경에서 진행&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;macOS / Apple M1 Pro / 32GB RAM / 512GB SSD&lt;/li&gt;
&lt;li&gt;DB: MySQL 8.0.x (InnoDB) - Docker 컨테이너 실행&lt;/li&gt;
&lt;li&gt;Redis: Docker 컨테이너 실행&lt;/li&gt;
&lt;li&gt;k6: 로컬 환경 실행&lt;/li&gt;
&lt;li&gt;네트워크: 로컬 환경&lt;/li&gt;
&lt;li&gt;대상: 상품 목록 조회 API&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;테스트 스크립트&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;import http from &quot;k6/http&quot;;

export const options = {
    stages: [
        { duration: &quot;1m&quot;, target: 100 }
    ],
};

export default function () {
    const brandId = [1, 2, 3, 4, 5][Math.floor(Math.random() * 5)]; // 가장 상품이 많은 상위 5개
    const sort = &quot;LIKES_DESC&quot;; // 좋아요순 정렬
    const page = Math.floor(Math.random() * 10);
    const size = 20;

    http.get(
        `http://localhost:8080/api/v1/products?brandId=${brandId}&amp;amp;sort=${sort}&amp;amp;page=${page}&amp;amp;size=${size}`,
        { tags: { name: &quot;GET /api/v1/products&quot; }, responseType: &quot;none&quot; }
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;일정한 동시 사용자 수를 유지한 상태에서 API 응답 시간, 에러율, 처리량을 측정해 시스템이 안정적으로 처리 가능한 부하 수준과 병목 가능성을 파악&lt;/li&gt;
&lt;li&gt;정렬 조건은 캐시 테스트 제외하고&amp;nbsp;&lt;b&gt;LIKES_DESC&lt;/b&gt; 고정&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. 개선 전&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;개선 전 구조&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;BRAND {  
    BIGINT ID PK &quot;ID&quot;  
    VARCHAR NAME &quot;브랜드명&quot;  
    VARCHAR DESCRIPTION &quot;브랜드설명&quot;  
}  

PRODUCT {  
    BIGINT ID PK &quot;ID&quot;
    BIGINT BRAND_ID FK &quot;브랜드 ID&quot;  
    VARCHAR NAME &quot;상품명&quot;  
    INT PRICE &quot;가격&quot;
    INT LIKE_COUNT &quot;좋아요개수&quot;
}

STOCK {
    BIGINT ID PK &quot;ID&quot;
    BIGINT PRODUCT_ID FK &quot;상품 ID&quot;
    INT QUANTITY &quot;수량&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public ProductInfo.Summary getList(ProductCommand.Search command) {
    Page&amp;lt;Product&amp;gt; products = productService.getList(command.getBrandId(), command.toPageable(), command.getSort());
    if (products.isEmpty()) {
        return ProductInfo.Summary.empty();
    }

    List&amp;lt;Long&amp;gt; productIds = products.stream().map(BaseEntity::getId).toList();

    List&amp;lt;Long&amp;gt; brandIds = products.stream().map(product -&amp;gt; product.getBrand().getId()).distinct().toList();
    List&amp;lt;Brand&amp;gt; brands = brandService.getListByIds(brandIds);

    List&amp;lt;Stock&amp;gt; stocks = productService.getStocksByProductIds(productIds);

    return ProductInfo.Summary.from(products, brands, stocks);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;페이징 및 정렬 처리된 상품 목록 조회한 다음, 브랜드 정보와 재고 정보를 각각 조회한 후 매핑하는 구조&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;SELECT
    b.id as brand_id, b.name as brand_name, b.description as brand_desc,
    p.id as product_id, p.name as product_name, p.price, p.like_count,
    s.quantity as stock
FROM product p
LEFT OUTER JOIN brand b ON p.brand_id = b.id
LEFT OUTER JOIN stock s ON p.id = s.product_id
WHERE p.brand_id = #{brandId}
AND p.deleted_at IS NULL
ORDER BY like_count DESC // 정렬조건
LIMIT 0, 20;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세 테이블을 한번에 조회하는 쿼리를 가정해 &lt;b&gt;EXPLAIN&lt;/b&gt; 통해 지표를 확인하면 다음과 같음&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 131.163%; height: 245px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 35px;&quot;&gt;
&lt;td style=&quot;height: 35px; width: 11.4362%;&quot;&gt;&lt;b&gt;select_type&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 35px; width: 5.93972%;&quot;&gt;&lt;b&gt;table&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 35px; width: 6.82624%;&quot;&gt;&lt;b&gt;type&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 35px; width: 26.3298%;&quot;&gt;&lt;b&gt;key&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 35px; width: 7.97872%;&quot;&gt;&lt;b&gt;key_len&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 35px; width: 10.727%;&quot;&gt;&lt;b&gt;ref&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 35px; width: 6.82624%;&quot;&gt;&lt;b&gt;rows&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 35px; width: 7.71277%;&quot;&gt;&lt;b&gt;filtered&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 35px; width: 12.6773%;&quot;&gt;&lt;b&gt;Extra&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 70px;&quot;&gt;
&lt;td style=&quot;height: 70px; width: 11.4362%;&quot;&gt;SIMPLE&lt;/td&gt;
&lt;td style=&quot;height: 70px; width: 5.93972%;&quot;&gt;p&lt;/td&gt;
&lt;td style=&quot;height: 70px; width: 6.82624%;&quot;&gt;ref&lt;/td&gt;
&lt;td style=&quot;height: 70px; width: 26.3298%;&quot;&gt;&lt;b&gt;fk_product_brand&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 70px; width: 7.97872%;&quot;&gt;8&lt;/td&gt;
&lt;td style=&quot;height: 70px; width: 10.727%;&quot;&gt;const&lt;/td&gt;
&lt;td style=&quot;height: 70px; width: 6.82624%;&quot;&gt;87952&lt;/td&gt;
&lt;td style=&quot;height: 70px; width: 7.71277%;&quot;&gt;10&lt;/td&gt;
&lt;td style=&quot;height: 70px; width: 12.6773%;&quot;&gt;&lt;b&gt;Using where&lt;br /&gt;Using filesort&lt;br /&gt;&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 35px;&quot;&gt;
&lt;td style=&quot;height: 35px; width: 11.4362%;&quot;&gt;SIMPLE&lt;/td&gt;
&lt;td style=&quot;height: 35px; width: 5.93972%;&quot;&gt;b&lt;/td&gt;
&lt;td style=&quot;height: 35px; width: 6.82624%;&quot;&gt;const&lt;/td&gt;
&lt;td style=&quot;height: 35px; width: 26.3298%;&quot;&gt;PRIMARY&lt;/td&gt;
&lt;td style=&quot;height: 35px; width: 7.97872%;&quot;&gt;8&lt;/td&gt;
&lt;td style=&quot;height: 35px; width: 10.727%;&quot;&gt;const&lt;/td&gt;
&lt;td style=&quot;height: 35px; width: 6.82624%;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;height: 35px; width: 7.71277%;&quot;&gt;100&lt;/td&gt;
&lt;td style=&quot;height: 35px; width: 12.6773%;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 35px;&quot;&gt;
&lt;td style=&quot;height: 35px; width: 11.4362%;&quot;&gt;SIMPLE&lt;/td&gt;
&lt;td style=&quot;height: 35px; width: 5.93972%;&quot;&gt;s&lt;/td&gt;
&lt;td style=&quot;height: 35px; width: 6.82624%;&quot;&gt;eq_ref&lt;/td&gt;
&lt;td style=&quot;height: 35px; width: 26.3298%;&quot;&gt;UKkhabtqwr86p7x9mt2krib98tx&lt;/td&gt;
&lt;td style=&quot;height: 35px; width: 7.97872%;&quot;&gt;8&lt;/td&gt;
&lt;td style=&quot;height: 35px; width: 10.727%;&quot;&gt;loopers.p.id&lt;/td&gt;
&lt;td style=&quot;height: 35px; width: 6.82624%;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;height: 35px; width: 7.71277%;&quot;&gt;100&lt;/td&gt;
&lt;td style=&quot;height: 35px; width: 12.6773%;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;-&amp;gt; Limit: 20 row(s)  (cost=30857 rows=20) (actual time=558..559 rows=20 loops=1)
    -&amp;gt; Nested loop left join  (cost=30857 rows=87952) (actual time=558..559 rows=20 loops=1)
        -&amp;gt; Nested loop left join  (cost=13305 rows=87952) (actual time=558..558 rows=20 loops=1)
            -&amp;gt; Sort: p.like_count DESC, limit input to 20 row(s) per chunk  (cost=4509 rows=87952) (actual time=558..558 rows=20 loops=1)
                -&amp;gt; Filter: (p.deleted_at is null)  (cost=4509 rows=87952) (actual time=2.51..518 rows=43292 loops=1)
                    -&amp;gt; Index lookup on p using fk_product_brand (brand_id=1)  (cost=4509 rows=87952) (actual time=2.51..501 rows=43292 loops=1)
            -&amp;gt; Constant row from b  (cost=96.6e-6..96.6e-6 rows=1) (actual time=0.00571..0.00589 rows=1 loops=20)
        -&amp;gt; Single-row index lookup on s using UKkhabtqwr86p7x9mt2krib98tx (product_id=p.id)  (cost=0.996 rows=1) (actual time=0.0415..0.0418 rows=1 loops=20)
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Extra&lt;/b&gt; 의 &lt;b&gt;Using where + Using filesort:&lt;/b&gt; 정렬 인덱스가 없어 대량 결과를 filesort&lt;/li&gt;
&lt;li&gt;&lt;b&gt;실측 ANALYZE:&lt;/b&gt; 최초 스캔 후 인덱스가 없어 결과에서 filesore 진행 (~ 558 ms)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;성능테스트 &lt;span data-token-index=&quot;1&quot;&gt;결과 ( VUSER:100 )&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;443&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/n54V0/btsPTmNiqK6/vZZOF6sPZicDod0GuwGKpK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/n54V0/btsPTmNiqK6/vZZOF6sPZicDod0GuwGKpK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/n54V0/btsPTmNiqK6/vZZOF6sPZicDod0GuwGKpK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fn54V0%2FbtsPTmNiqK6%2FvZZOF6sPZicDod0GuwGKpK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;443&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;443&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;성능 지표&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;총 요청 수&lt;/b&gt;: 471 건&lt;/li&gt;
&lt;li&gt;&lt;b&gt;평균 응답 시간&lt;/b&gt;: 7.56 s&lt;/li&gt;
&lt;li&gt;&lt;b&gt;P95 응답 시간&lt;/b&gt;: 19.2 s&lt;/li&gt;
&lt;li&gt;&lt;b&gt;TPS&lt;/b&gt;: 6.7 req/s&lt;/li&gt;
&lt;li&gt;&lt;b&gt;에러율&lt;/b&gt;: 11.25 %&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;분석&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;평균 응답 시간은 7.56초, p95 응답 시간은 19.2초로 대부분의 요청이 매우 느린 상태였음&lt;/li&gt;
&lt;li&gt;TPS는 6.7로 낮고, 에러율이 11.25%로 안정성이 떨어짐&lt;/li&gt;
&lt;li&gt;지연의 주 원인이 네트워크가 아닌 서버 내부 처리이며, DB 커넥션 풀 고갈로 인한 &lt;b&gt;Unable to acquire JDBC Connection&lt;/b&gt; 예외가 다수 발생&lt;/li&gt;
&lt;li&gt;DB 부하와 긴 커넥션 점유 시간이 병목의 핵심 원인으로 판단됨&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rarr; DB 인덱스, 캐시, 쿼리 튜닝 등 서버 쪽 최적화 진행&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;발생 예외 예시&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Caused by: org.hibernate.exception.JDBCConnectionException: Unable to acquire JDBC Connection 
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;개선 방안&lt;/b&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;조회 로직 개선&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;재고 정보 컬럼 추가 및 조회 로직 개선&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;DB 인덱스 추가&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;정렬, 필터 조합에 맞춘 복합 인덱스 추가&lt;/li&gt;
&lt;li&gt;EXPLAIN으로 실행 계획 검증&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Redis 캐시 도입&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;TTL을 짧게 설정하여 자주 조회되는 초기 페이지 캐싱&lt;/li&gt;
&lt;li&gt;DB 부하 완화 및 응답 속도 개선&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. 조회 로직 개선&lt;/b&gt;&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;목표&lt;/b&gt;: 불필요한 로직을 제거하여 응답 속도 개선 및 TPS 향상&lt;/li&gt;
&lt;li&gt;&lt;b&gt;부가 목적&lt;/b&gt;: 병목이 애플리케이션 로직인지 DB 접근 구조인지 검증&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;개선 내용&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;scala&quot;&gt;&lt;code&gt;public class Product extends BaseEntity {
    @Enumerated(EnumType.STRING)
    @Column(name = &quot;status&quot;,nullable = false)
    private SalesStatus status; // ACTIVE, INACTIVE, SOLD_OUT
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;public ProductInfo.Summary getList(ProductCommand.Search command) {
    Page&amp;lt;Product&amp;gt; products = productService.getList(command.getBrandId(), command.toPageable(), command.getSort());
    if (products.isEmpty()) {
        return ProductInfo.Summary.empty();
    }

    List&amp;lt;Long&amp;gt; brandIds = products.stream().map(product -&amp;gt; product.getBrand().getId()).distinct().toList();
    List&amp;lt;Brand&amp;gt; brands = brandService.getListByIds(brandIds);

    return ProductInfo.Summary.from(products, brands);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Product&lt;/b&gt; 에 판매상태와 품절여부를 알 수 있는 &lt;b&gt;status&lt;/b&gt; 필드 추가 후 조회 로직에서 재고 조회 로직 제거&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;성능테스트 결과 ( VUSER:100 )&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;419&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cy4InB/btsPUeOgKaD/T4qN9n0ieQotgWHKClcTBK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cy4InB/btsPUeOgKaD/T4qN9n0ieQotgWHKClcTBK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cy4InB/btsPUeOgKaD/T4qN9n0ieQotgWHKClcTBK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcy4InB%2FbtsPUeOgKaD%2FT4qN9n0ieQotgWHKClcTBK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;419&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;419&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;성능 지표&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;총 요청 수&lt;/b&gt;: 507 건&lt;/li&gt;
&lt;li&gt;&lt;b&gt;평균 응답 시간&lt;/b&gt;: 6.91 s&lt;/li&gt;
&lt;li&gt;&lt;b&gt;P95 응답 시간&lt;/b&gt;: 19.01 s&lt;/li&gt;
&lt;li&gt;&lt;b&gt;TPS&lt;/b&gt;: 7.27 req/s&lt;/li&gt;
&lt;li&gt;&lt;b&gt;에러율&lt;/b&gt;: 12.62 %&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;결과 분석&lt;/b&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;평균 응답 시간은 6.91초로 소폭 개선되었으나, TPS는 7.27로 큰 변화가 없음&lt;/li&gt;
&lt;li&gt;에러율은 12.62%로 오히려 소폭 증가&lt;/li&gt;
&lt;li&gt;조회 쿼리 일부를 제거했음에도 성능 개선 폭이 제한적이었으며 병목 원인이 애플리케이션 로직이 아님을 확인&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;3. DB 인덱스 추가&lt;/b&gt;&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;목표&lt;/b&gt;: 정렬&amp;middot;필터 조건에 맞춘 복합 인덱스 적용으로 쿼리 실행 계획 최적화 및 DB 부하 완화&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;개선 내용&lt;/b&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;상품 목록 조회에 사용되는 &lt;b&gt;brand_id + 정렬조건&lt;/b&gt; 으로 &lt;b&gt;복합 인덱스&lt;/b&gt; 생성&lt;/li&gt;
&lt;li&gt;개선 결과를 &lt;b&gt;EXPLAIN&lt;/b&gt;, &lt;b&gt;ANALYZE&lt;/b&gt; 로 확인
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;select_type: 쿼리의 형태. PRIMARY(바깥), DEPENDENT SUBQUERY(상관 서브쿼리) 등.&lt;/li&gt;
&lt;li&gt;type: 접근 방법. 좋은 순서 대략 const &amp;gt; eq_ref &amp;gt; ref &amp;gt; range &amp;gt; index &amp;gt; ALL.&lt;/li&gt;
&lt;li&gt;ALL이면 풀스캔.&lt;/li&gt;
&lt;li&gt;key / possible_keys: 실제 사용 인덱스 / 사용 가능 후보 인덱스. 비어있으면 인덱스 미사용.&lt;/li&gt;
&lt;li&gt;rows: 옵티마이저의 예상 스캔 건수(정확치 않지만 크기 판단 지표).&lt;/li&gt;
&lt;li&gt;filtered: where로 추가로 걸러질 비율(%). 낮을수록 불필요 스캔이 많다는 뜻.&lt;/li&gt;
&lt;li&gt;Extra: Using filesort(인덱스로 정렬 불가 &amp;rarr; 임시 정렬), Using where(where로 필터) 등.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;인덱스 도입 시 고려한 점&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;단일 인덱스를 사용할 것인지, 복합 인덱스를 사용할 것인지&lt;/li&gt;
&lt;li&gt;어떤 컬럼을 인덱스로 선택할 것인지&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;단일 인덱스 vs 복합 인덱스&lt;/b&gt;&lt;/h4&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;특성&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;단일 인덱스&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;복합 인덱스&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;적용 대상&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;하나의 컬럼&lt;/td&gt;
&lt;td&gt;여러 컬럼 함께 사용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;성능 최적화&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;단일 컬럼 대한 검색 최적화&lt;/td&gt;
&lt;td&gt;여러 컬럼 결합한 조건에 최적화&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;공간 사용&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;적은 공간 사용&lt;/td&gt;
&lt;td&gt;더 많은 공간 사용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;업데이트 성능&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;인덱스 업데이트가 빠름&lt;/td&gt;
&lt;td&gt;인덱스 업데이트가 느릴 수 있음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;사용 예시&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;단일 컬럼 검색, 범위 검색&lt;/td&gt;
&lt;td&gt;여러 컬럼에 대한 검색 조건이 포함된 쿼리&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;상품 목록 조회 API의 경우 여러 조건이 결합된 쿼리 사용&lt;/li&gt;
&lt;li&gt;옵티마이저는 보통 테이블 당 하나의 인덱스를 사용&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rarr; 검색 조건을 한번에 만족시킬 &lt;b&gt;복합 인덱스&lt;/b&gt; 선택&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;복합 인덱스 순서&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;복합 인덱스의 경우 작성된 순서대로 정렬을 진행하기 때문에 순서가 매우 중요함&lt;/li&gt;
&lt;li&gt;아래와 같은 규칙에 따라 순서 결정
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;공통적으로 사용하는 &lt;b&gt;필수 조건절 컬럼&lt;/b&gt; 우선&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&amp;lsquo;=&amp;rsquo; 조건 컬럼&lt;/b&gt; 우선&lt;/li&gt;
&lt;li&gt;&lt;b&gt;대분류 &amp;rarr; 중분류 &amp;rarr; 소분류 컬럼 순&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;WHERE &amp;rarr; ORDER BY 컬럼 순&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;어떤 필드를 선택할 지&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다고 검색에 사용되는 모든 필드와 경우의 수를 전부 인덱스에 추가한다면, 오히려 쓰기 성능이 떨어지고 메모리를 차지해 성능저하가 일어날 수 있음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;카디널리티를 비교해 복합인덱스를 생성할 컬럼을 결정함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 정렬 조건은 다음과 같음&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;좋아요순, 생성일순, 가격낮은순&lt;/li&gt;
&lt;li&gt;셋 다 중복도가 낮아 카디널리티가 높은 필드들이라고 판단해 복합 인덱스를 생성하기로 결정&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;복합 인덱스 생성&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;-- 최신순
CREATE INDEX idx_product_brand_created_desc ON product (brand_id, created_at DESC, id DESC);
-- 좋아요순
CREATE INDEX idx_product_brand_likes_desc ON product (brand_id, like_count DESC, id DESC);
-- 가격 낮은 순
CREATE INDEX idx_product_brand_price_asc ON product (brand_id, price ASC, id ASC);
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;EXPLAIN 결과&lt;/b&gt;&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 123.14%; height: 192px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 9.4186%;&quot;&gt;&lt;b&gt;select_type&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 5.34884%;&quot;&gt;&lt;b&gt;table&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 5.93023%;&quot;&gt;&lt;b&gt;type&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 20.6977%;&quot;&gt;&lt;b&gt;key&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 6.74419%;&quot;&gt;&lt;b&gt;key_len&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 8.95349%;&quot;&gt;&lt;b&gt;ref&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 6.04651%;&quot;&gt;&lt;b&gt;rows&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 6.62791%;&quot;&gt;&lt;b&gt;filtered&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 9.18605%;&quot;&gt;&lt;b&gt;Extra&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 9.4186%;&quot;&gt;SIMPLE&lt;/td&gt;
&lt;td style=&quot;width: 5.34884%;&quot;&gt;p&lt;/td&gt;
&lt;td style=&quot;width: 5.93023%;&quot;&gt;ref&lt;/td&gt;
&lt;td style=&quot;width: 20.6977%;&quot;&gt;&lt;b&gt;idx_product_brand_likes_desc&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 6.74419%;&quot;&gt;8&lt;/td&gt;
&lt;td style=&quot;width: 8.95349%;&quot;&gt;const&lt;/td&gt;
&lt;td style=&quot;width: 6.04651%;&quot;&gt;90720&lt;/td&gt;
&lt;td style=&quot;width: 6.62791%;&quot;&gt;10&lt;/td&gt;
&lt;td style=&quot;width: 9.18605%;&quot;&gt;Using where&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 9.4186%;&quot;&gt;SIMPLE&lt;/td&gt;
&lt;td style=&quot;width: 5.34884%;&quot;&gt;b&lt;/td&gt;
&lt;td style=&quot;width: 5.93023%;&quot;&gt;const&lt;/td&gt;
&lt;td style=&quot;width: 20.6977%;&quot;&gt;PRIMARY&lt;/td&gt;
&lt;td style=&quot;width: 6.74419%;&quot;&gt;8&lt;/td&gt;
&lt;td style=&quot;width: 8.95349%;&quot;&gt;const&lt;/td&gt;
&lt;td style=&quot;width: 6.04651%;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 6.62791%;&quot;&gt;100&lt;/td&gt;
&lt;td style=&quot;width: 9.18605%;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 9.4186%;&quot;&gt;SIMPLE&lt;/td&gt;
&lt;td style=&quot;width: 5.34884%;&quot;&gt;s&lt;/td&gt;
&lt;td style=&quot;width: 5.93023%;&quot;&gt;eq_ref&lt;/td&gt;
&lt;td style=&quot;width: 20.6977%;&quot;&gt;UKkhabtqwr86p7x9mt2krib98tx&lt;/td&gt;
&lt;td style=&quot;width: 6.74419%;&quot;&gt;8&lt;/td&gt;
&lt;td style=&quot;width: 8.95349%;&quot;&gt;loopers.p.id&lt;/td&gt;
&lt;td style=&quot;width: 6.04651%;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 6.62791%;&quot;&gt;100&lt;/td&gt;
&lt;td style=&quot;width: 9.18605%;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;-&amp;gt; Limit: 20 row(s)  (cost=16910 rows=20) (actual time=3.13..4.16 rows=20 loops=1)
    -&amp;gt; Nested loop left join  (cost=16910 rows=9072) (actual time=3.13..4.14 rows=20 loops=1)
        -&amp;gt; Nested loop left join  (cost=6970 rows=9072) (actual time=3.05..3.19 rows=20 loops=1)
            -&amp;gt; Filter: (p.deleted_at is null)  (cost=6062 rows=9072) (actual time=2.99..3.07 rows=20 loops=1)
                -&amp;gt; Index lookup on p using idx_product_brand_likes_desc (brand_id=1)  (cost=6062 rows=90720) (actual time=2.99..3.04 rows=20 loops=1)
            -&amp;gt; Constant row from b  (cost=93.7e-6..93.7e-6 rows=1) (actual time=0.00367..0.00392 rows=1 loops=20)
        -&amp;gt; Single-row index lookup on s using UKkhabtqwr86p7x9mt2krib98tx (product_id=p.id)  (cost=0.996 rows=1) (actual time=0.0451..0.0454 rows=1 loops=20)
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;분석 및 비교&lt;/b&gt;&lt;/h4&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;항목&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;인덱스 전&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;인덱스 후&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;정렬 처리&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;Using filesort 발생&lt;/td&gt;
&lt;td&gt;filesort 제거&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;사용 인덱스&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;fk_product_brand&lt;/td&gt;
&lt;td&gt;idx_product_brand_likes_desc&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;조회 row 수&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;rows=87952 &amp;rarr; 필터 후 43292&lt;/td&gt;
&lt;td&gt;rows=90720 &amp;rarr; 바로 20개&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;실행 시간 (actual time)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;전체 558~559ms&lt;/td&gt;
&lt;td&gt;전체 3.13~4.16ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Extra&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;Using where; Using filesort&lt;/td&gt;
&lt;td&gt;Using where만&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;성능테스트 결과 ( VUSER:100 )&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;431&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bMtxrm/btsPSL7lU78/eugf5VvHnvB7YnRWXlKvqK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bMtxrm/btsPSL7lU78/eugf5VvHnvB7YnRWXlKvqK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bMtxrm/btsPSL7lU78/eugf5VvHnvB7YnRWXlKvqK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbMtxrm%2FbtsPSL7lU78%2Feugf5VvHnvB7YnRWXlKvqK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;431&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;431&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;성능 지표&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;총 요청 수&lt;/b&gt;: 890 건&lt;/li&gt;
&lt;li&gt;&lt;b&gt;평균 응답 시간&lt;/b&gt;: 3.67 s&lt;/li&gt;
&lt;li&gt;&lt;b&gt;P95 응답 시간&lt;/b&gt;: 10.37 s&lt;/li&gt;
&lt;li&gt;&lt;b&gt;TPS&lt;/b&gt;: 13.39 req/s&lt;/li&gt;
&lt;li&gt;&lt;b&gt;에러율&lt;/b&gt;: 3.03 %&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;결과 분석&lt;/b&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;TPS가 6.7 &amp;rarr; 13.39로 약 2배 증가하고, 총 요청 수도 2배 가까이 확대됨&lt;/li&gt;
&lt;li&gt;평균 응답 시간은 7.56초 &amp;rarr; 3.67초로 절반 가까이 단축&lt;/li&gt;
&lt;li&gt;에러율이 11.25% &amp;rarr; 3.03%로 감소하며 안정성이 크게 향상됨&lt;/li&gt;
&lt;li&gt;정렬 및 필터 조건에 맞춘 복합 인덱스 적용으로 filesort가 제거되고 쿼리 실행 시간이 밀리초 수준으로 단축됨&lt;/li&gt;
&lt;li&gt;다만 초 단위 응답 속도 자체는 여전히 느려 추가적으로 캐시를 활용한 구조 개선 필요&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;4. Redis 캐시 적용&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;목표&lt;/b&gt;: 자주 조회되는 데이터의 DB 접근을 캐시로 대체하여 지연 제거 및 처리량 극대화&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;개선 내용&lt;/b&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;상품 개수 상위 50개&lt;/b&gt;인 브랜드 대상으로 기본 정렬인 최신 순으로 최초 조회한다고 가정해 최신순 LATEST 정렬에서 상위 100개 상품에 대해 캐싱&lt;/li&gt;
&lt;li&gt;나머지는 기존 DB 조회로 폴백&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;캐시 선택시 고려한 점&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;로컬 캐시를 사용할 것인지, 글로벌 캐시를 사용할 것인지&lt;/li&gt;
&lt;li&gt;어떤 캐시 전략을 사용할 것인지&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;로컬 캐시 vs 글로벌 캐시&lt;/b&gt;&lt;/h4&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;구분&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;로컬 캐시&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;글로벌 캐시&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;장점&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;매우 빠른 속도&lt;/b&gt; (네트워크 통신 불필요)&lt;br /&gt;캐시 서버 장애로부터 자유로움&lt;/td&gt;
&lt;td&gt;&lt;b&gt;서버 간 데이터 공유 용이&lt;/b&gt;&lt;br /&gt;데이터 분산 저장 가능&lt;br /&gt;수평 확장성 높음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;단점&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;서버 재시작 시 데이터 유실(휘발성)&lt;br /&gt;서버 간 데이터 불일치&lt;br /&gt;애플리케이션 메모리 사용량 증가&lt;/td&gt;
&lt;td&gt;로컬캐시에 비해 느린 속도&lt;br /&gt;캐시 서버 장애 가능성&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;여러 서버 인스턴스에서 동일한 데이터를 조회해야 함&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;rarr; &lt;b&gt;글로벌 캐시&lt;/b&gt; 선택&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;캐시 시스템 선택&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;rarr;&lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;b&gt;Redis&lt;/b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;선택&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;768&quot; data-start=&quot;739&quot;&gt;메모리 기반 저장소로 읽기 속도 매우 빠름&lt;/li&gt;
&lt;li data-end=&quot;812&quot; data-start=&quot;771&quot;&gt;데이터 영속성 옵션 제공해&amp;nbsp;장애 시 복구 가능&lt;/li&gt;
&lt;li data-end=&quot;868&quot; data-start=&quot;815&quot;&gt;Pub/Sub, Keyspace Notifications 등 캐시 무효화 처리에 유용&lt;/li&gt;
&lt;li data-end=&quot;901&quot; data-start=&quot;871&quot;&gt;클러스터링 및 샤딩을 통한 수평 확장성 확보&lt;/li&gt;
&lt;li data-end=&quot;930&quot; data-start=&quot;904&quot;&gt;많이 사용하는 캐시 시스템 중 하나로 운영 사례가 풍부해 유지보수 용이&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;캐시 전략 선택&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-end=&quot;976&quot; data-start=&quot;947&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;1. 쓰기 전략&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1160&quot; data-start=&quot;977&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1074&quot; data-start=&quot;977&quot;&gt;&lt;b&gt;Look Aside 패턴&lt;/b&gt; 적용
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1074&quot; data-start=&quot;1004&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1039&quot; data-start=&quot;1004&quot;&gt;상품 목록 API는 변경 빈도가 낮고 조회 요청이 대부분임&lt;/li&gt;
&lt;li data-end=&quot;1039&quot; data-start=&quot;1004&quot;&gt;조회 요청이 동일 조건으로 반복 조회되는 경우가 많기 때문에 선택&lt;/li&gt;
&lt;li data-end=&quot;1039&quot; data-start=&quot;1004&quot;&gt;요청 시 캐시 확인 후 없으면 DB 조회 후 캐시에 저장&lt;/li&gt;
&lt;li data-end=&quot;1074&quot; data-start=&quot;1042&quot;&gt;불필요한 캐시 쓰기 방지, 초기 캐시 부하 완화&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;1160&quot; data-start=&quot;1075&quot;&gt;&lt;b&gt;캐시 워밍(Cache Warming)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1160&quot; data-start=&quot;1106&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1143&quot; data-start=&quot;1106&quot;&gt;캐시 미스로 인한 성능 저하를 최소화하고자 선택&lt;/li&gt;
&lt;li data-end=&quot;1143&quot; data-start=&quot;1106&quot;&gt;배포 직후 또는 트래픽 피크 전 주요 데이터를 캐시에 선반영&lt;/li&gt;
&lt;li data-end=&quot;1160&quot; data-start=&quot;1146&quot;&gt;초기 요청 지연 최소화&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1190&quot; data-start=&quot;1162&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;2. 읽기 전략&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1320&quot; data-start=&quot;1191&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1320&quot; data-start=&quot;1191&quot;&gt;&lt;b&gt;Write Around 패턴&lt;/b&gt; 적용
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1320&quot; data-start=&quot;1220&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1247&quot; data-start=&quot;1220&quot;&gt;상품 정보 변경 시마다 캐시에 저장하지 않아 캐시 서버에 불필요한 부하를 주지 않으려 선택&lt;/li&gt;
&lt;li data-end=&quot;1247&quot; data-start=&quot;1220&quot;&gt;DB에 직접 쓰고, 읽기 시에만 캐시 갱신&lt;/li&gt;
&lt;li data-end=&quot;1280&quot; data-start=&quot;1250&quot;&gt;쓰기 부하가 많은 경우 캐시 오염 최소화&lt;/li&gt;
&lt;li data-end=&quot;1320&quot; data-start=&quot;1283&quot;&gt;최초 조회 시 캐시 미스 발생 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;성능테스트 결과 ( VUSER:100 )&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;캐시워밍 X&lt;/b&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;421&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/FV320/btsPUSjNUSS/wb1E7sQ828VMTnowKvIZc1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/FV320/btsPUSjNUSS/wb1E7sQ828VMTnowKvIZc1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/FV320/btsPUSjNUSS/wb1E7sQ828VMTnowKvIZc1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FFV320%2FbtsPUSjNUSS%2Fwb1E7sQ828VMTnowKvIZc1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;421&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;421&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;성능 지표&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;총 요청 수&lt;/b&gt;: 133,853 건&lt;/li&gt;
&lt;li&gt;&lt;b&gt;평균 응답 시간&lt;/b&gt;: 22.34 ms&lt;/li&gt;
&lt;li&gt;&lt;b&gt;P95 응답 시간&lt;/b&gt;: 43.75 ms&lt;/li&gt;
&lt;li&gt;&lt;b&gt;TPS&lt;/b&gt;: 2,229 req/s&lt;/li&gt;
&lt;li&gt;&lt;b&gt;에러율&lt;/b&gt;: 0 %&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;캐시워밍 O&lt;/b&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;418&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/o8KUT/btsPSQHD1Bv/OdTWwzR84zBLhKYQNFMpa1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/o8KUT/btsPSQHD1Bv/OdTWwzR84zBLhKYQNFMpa1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/o8KUT/btsPSQHD1Bv/OdTWwzR84zBLhKYQNFMpa1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fo8KUT%2FbtsPSQHD1Bv%2FOdTWwzR84zBLhKYQNFMpa1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;418&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;600&quot; data-origin-height=&quot;418&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;성능 지표&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;총 요청 수&lt;/b&gt;: 148,216 건&lt;/li&gt;
&lt;li&gt;&lt;b&gt;평균 응답 시간&lt;/b&gt;: 20.16 ms&lt;/li&gt;
&lt;li&gt;&lt;b&gt;P95 응답 시간&lt;/b&gt;: 39.82 ms&lt;/li&gt;
&lt;li&gt;&lt;b&gt;TPS&lt;/b&gt;: 2,469 req/s&lt;/li&gt;
&lt;li&gt;&lt;b&gt;에러율&lt;/b&gt;: 0 %&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;결과 분석&lt;/b&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;인덱스 적용 대비 TPS가 13.39 &amp;rarr; 2,229로 약 166배 증가하며 처리량이 비약적으로 향상됨&lt;/li&gt;
&lt;li&gt;평균 응답 속도는 3.67초 &amp;rarr; 22.34ms로 전환되어 지연이 사실상 해소됨&lt;/li&gt;
&lt;li&gt;에러율이 3.03%에서 0%로 감소해 안정성이 완전히 확보됨&lt;/li&gt;
&lt;li&gt;캐시워밍 적용 시 초기 요청에서도 캐시 미스가 제거되어 TPS가 2,469로 추가 향상됨&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;종합 결론&lt;/b&gt;&lt;/h2&gt;
&lt;table style=&quot;border-collapse: collapse; width: 99.8837%; height: 218px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;단계&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;총 요청 수 (건)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;평균 응답 시간&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;P95 응답 시간&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;TPS (req/s)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;에러율&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;개선 전&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;471&lt;/td&gt;
&lt;td&gt;&lt;b&gt;7.56 s&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;19.20 s&lt;/td&gt;
&lt;td&gt;6.7&lt;/td&gt;
&lt;td&gt;11.25%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;조회 로직 개선&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;507&lt;/td&gt;
&lt;td&gt;&lt;b&gt;6.91 s&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;19.01 s&lt;/td&gt;
&lt;td&gt;7.27&lt;/td&gt;
&lt;td&gt;12.62%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;DB 인덱스 추가&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;890&lt;/td&gt;
&lt;td&gt;&lt;b&gt;3.67 s&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;10.37 s&lt;/td&gt;
&lt;td&gt;13.39&lt;/td&gt;
&lt;td&gt;3.03%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Redis 캐시 적용 (워밍 X)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;133,853&lt;/td&gt;
&lt;td&gt;&lt;b&gt;22.34 ms&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;43.75 ms&lt;/td&gt;
&lt;td&gt;2,229&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Redis 캐시 적용 (워밍 O)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;148,216&lt;/td&gt;
&lt;td&gt;&lt;b&gt;20.16 ms&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;39.82 ms&lt;/td&gt;
&lt;td&gt;2,469&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;349&quot; data-start=&quot;93&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;136&quot; data-start=&quot;93&quot;&gt;최종적으로 총 요청 수가 &lt;b&gt;471건 &amp;rarr; 148,216건&lt;/b&gt;으로 약 314배 증가&lt;/li&gt;
&lt;li data-end=&quot;172&quot; data-start=&quot;137&quot;&gt;TPS가 &lt;b&gt;6.7 &amp;rarr; 2,469&lt;/b&gt;로 약 368배 향상&lt;/li&gt;
&lt;li data-end=&quot;262&quot; data-start=&quot;173&quot;&gt;평균 응답 시간은 &lt;b&gt;7.56초 &amp;rarr; 20.16ms&lt;/b&gt;, p95 응답 시간은 &lt;b&gt;19.20초 &amp;rarr; 39.82ms&lt;/b&gt;로 단축되어 응답 속도가 99% 이상 개선&lt;/li&gt;
&lt;li data-end=&quot;306&quot; data-start=&quot;263&quot;&gt;에러율이 &lt;b&gt;11.25% &amp;rarr; 0%&lt;/b&gt;로 감소하여 안정성이 완전히 확보됨&lt;/li&gt;
&lt;li data-end=&quot;349&quot; data-start=&quot;307&quot;&gt;캐시워밍 적용으로 초기 요청에서도 캐시 미스 없이 안정적인 성능 유지&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 성능 개선 과정은 &lt;b&gt;상품 목록 조회 API&lt;/b&gt;의 읽기 병목을 제거하고 처리량과 안정성을 극대화하는 것을 목표로 진행하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 단계를 요약하면 다음과 같다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;개선 전&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;DB 부하와 커넥션 풀 고갈로 인한 지연과 높은 에러율(11.25%)이 발생&lt;/li&gt;
&lt;li&gt;평균 응답 시간은 7.56초, TPS는 6.7로 실서비스 대응이 어려운 수준&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;조회 로직 개선&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;불필요한 재고 조회를 제거했으나, 응답 시간과 TPS 개선 폭은 미미&lt;/li&gt;
&lt;li&gt;병목 원인이 애플리케이션 로직이 아님을 확인&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;DB 인덱스 추가&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;정렬&amp;middot;필터 조건에 맞춘 복합 인덱스 적용으로 쿼리 실행 시간이 크게 단축&lt;/li&gt;
&lt;li&gt;TPS가 약 2배 증가하고, 에러율이 70% 이상 감소&lt;/li&gt;
&lt;li&gt;그러나 여전히 초 단위 응답 속도가 유지되어 구조적 한계 존재&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Redis 캐시 적용&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;DB 조회를 캐시로 대체하여 평균 응답 속도가 밀리초 단위로 전환&lt;/li&gt;
&lt;li&gt;TPS가 약 166배 증가하고(13.39 &amp;rarr; 2,229) 에러율 0%로 안정성 완전 확보&lt;/li&gt;
&lt;li&gt;캐시워밍으로 초기 요청 성능까지 안정화&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h3 data-end=&quot;606&quot; data-start=&quot;589&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;앞으로의 개선 계획&lt;/b&gt;&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;1142&quot; data-start=&quot;607&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;756&quot; data-start=&quot;607&quot;&gt;&lt;b&gt;TTL 재설계&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;756&quot; data-start=&quot;625&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;675&quot; data-start=&quot;625&quot;&gt;현재 TTL이 다소 길어 변경 데이터가 오래 반영되지 않는 문제 가능성 있음&lt;/li&gt;
&lt;li data-end=&quot;756&quot; data-start=&quot;679&quot;&gt;정보 변경 주기를 고려해 TTL을 단축하고 다른 쓰기 전략을 고민해 볼 필요가 있음&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;961&quot; data-start=&quot;758&quot;&gt;&lt;b&gt;쓰기 전략 개선&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;961&quot; data-start=&quot;777&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;838&quot; data-start=&quot;777&quot;&gt;현재 Write Around 방식은 변경 시 캐시를 바로 갱신하지 않아 정합성 지연이 발생 가능&lt;/li&gt;
&lt;li data-end=&quot;906&quot; data-start=&quot;842&quot;&gt;데이터 수정 시 &lt;b&gt;Write Through 전략&lt;/b&gt;&amp;nbsp;또는 무효화로 캐시와 DB를 동시에 갱신하는 방식 고려&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-end=&quot;1401&quot; data-start=&quot;1160&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;1401&quot; data-start=&quot;1160&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;마무리&lt;/b&gt;&lt;/h3&gt;
&lt;p data-end=&quot;263&quot; data-start=&quot;83&quot; data-ke-size=&quot;size16&quot;&gt;이번 성능 개선 작업은 상품 목록 조회 API에 DB 인덱스 적용과 Redis 캐시 도입으로 성능 안정화 및 응답속도 향상을 통한 읽기 병목 제거를 목표로 작업했다. 그 결과 응답 속도와 에러율이 크게 줄어들고, TPS는 늘어나는 성과를 달성했다. 단일 조회 API 여도 구조 개선과 캐시 전략 설계 만으로 서비스 속도와 처리량을 크게 증가시킬 수 있음을 확인했다.&lt;/p&gt;
&lt;p data-end=&quot;263&quot; data-start=&quot;83&quot; data-ke-size=&quot;size16&quot;&gt;다만 의외였던 점은 애플리케이션 로직 개선이 읽기 성능 향상에 미치는 영향이 거의 없었다는 것이다. 인덱스 최적화는 DB 부하 완화와 안정성 향상에 뚜렷한 효과가 있었지만 완전한 병목 해소에는 한계가 있어서 결국 캐시로 보완해야 했다.&lt;/p&gt;
&lt;p data-end=&quot;263&quot; data-start=&quot;83&quot; data-ke-size=&quot;size16&quot;&gt;캐시 전략에 대해 더 깊게 고민해 보아야할 필요성도 느꼈다. 현재 TTL이 다소 길어 데이터 변경이 제때 반영되지 않을 가능성이 있고 데이터 수정 시 데이터 불일치가 발생할 수 있다. 앞으로 TTL 재설계와 쓰기 전략 보완을 통해 정합성도 만족시킬 수 있는 구조로 개선해봐야겠다. 캐시는 한 번 적용하고 끝나는 것이 아니라 트래픽 패턴과 데이터 변경 주기에 맞춰 끊임없이 조정하는 운영 전략이 필요하다는 점을 다시금 깨달았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;참고&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://khdscor.tistory.com/51&quot;&gt;https://khdscor.tistory.com/51&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://bcp0109.tistory.com/384&quot;&gt;https://bcp0109.tistory.com/384&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://kk-programming.tistory.com/83&quot;&gt;https://kk-programming.tistory.com/83&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://toss.tech/article/cache-traffic-tip&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://toss.tech/article/cache-traffic-tip&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://inpa.tistory.com/entry/REDIS-%F0%9F%93%9A-%EC%BA%90%EC%8B%9CCache-%EC%84%A4%EA%B3%84-%EC%A0%84%EB%9E%B5-%EC%A7%80%EC%B9%A8-%EC%B4%9D%EC%A0%95%EB%A6%AC&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://inpa.tistory.com/entry/REDIS-%F0%9F%93%9A-%EC%BA%90%EC%8B%9CCache-%EC%84%A4%EA%B3%84-%EC%A0%84%EB%9E%B5-%EC%A7%80%EC%B9%A8-%EC%B4%9D%EC%A0%95%EB%A6%AC&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://medium.com/garimoo/%EA%B0%9C%EB%B0%9C%EC%9E%90%EB%A5%BC-%EC%9C%84%ED%95%9C-%EB%A0%88%EB%94%94%EC%8A%A4-%ED%8A%9C%ED%86%A0%EB%A6%AC%EC%96%BC-01-92aaa24ca8cc&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://medium.com/garimoo/%EA%B0%9C%EB%B0%9C%EC%9E%90%EB%A5%BC-%EC%9C%84%ED%95%9C-%EB%A0%88%EB%94%94%EC%8A%A4-%ED%8A%9C%ED%86%A0%EB%A6%AC%EC%96%BC-01-92aaa24ca8cc&lt;/a&gt;&lt;/p&gt;</description>
      <category>Study/Architecture</category>
      <author>haylee</author>
      <guid isPermaLink="true">https://haylee.tistory.com/11</guid>
      <comments>https://haylee.tistory.com/11#entry11comment</comments>
      <pubDate>Fri, 15 Aug 2025 08:59:42 +0900</pubDate>
    </item>
    <item>
      <title>[Loop:PAK] 4주차 회고</title>
      <link>https://haylee.tistory.com/10</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;이번 주는 DB 수준에서의 동시성 제어를 주문 로직에 적용하는 작업을 진행했다. 단순히 @Transactional을 거는 것 외에, 락 전략을 어떻게 선택할지와 그 이유를 명확히 하기 위해 DB의 트랜잭션과 격리 수준, MVCC 구조, Undo Log 동작 방식 등을 먼저 학습해 보았다. 이 과정을 거치니 락이 실제로 어떤 상황에서 동작하고 성능에 어떤 영향을 주는지 더 이해가 잘 됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주문 로직에서 락이 필요한 지점은 쿠폰 사용, 포인트 사용 그리고 재고 차감 부분이었다. 각 자원의 특성과 경합 가능성을 고려해서 쿠폰과 포인트는 낙관적 락, 재고는 비관적 락을 적용했다. 특히 포인트는 두 방식 다 사용해도 될 것 같아 어떤 락을 선택할지 고민을 많이 했었다. 1000건 정도의 요청이 들어올 것을 가정해 테스트를 진행하여 요청시간을 보고 낙관적 락으로 최종 선택했다. 그런데 아직 API를 작성하지 않아 부하테스트를 진행한 것이 아니라 제대로 된 비교결과가 되지 못한 것 같아 조금 아쉬웠다. 여기는 나중에 API 작성하게 되면 추가로 테스트를 해봐야겠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트는 MySQL InnoDB 환경에서 단일 인스턴스를 가정하고, CountDownLatch와 ExecutorService를 사용해 멀티 스레드 상황을 시뮬레이션했다. 결과로는 성공 요청 수, 실패 요청 수, DB 에 반영된 최종 상태를 검증했다. 테스트 결과 일단 단일 인스턴스에서는 DB 락 전략과 트랜잭션 범위 설정만 잘한다면 안정적으로 굴릴 수 있지 않을까 라는 생각도 들었다. 하지만 보통 운영은 멀티 인스턴스를 사용하니 추가로 그런 환경을 고려해야 할 필요성도 느꼈다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 주는 읽기, 쓰기 상황에서 정합성을 유지하는 것에 집중했다. DB 수준의 락 전략을 적용해 데이터를 처리하도록 수정하고 테스트를 통해 검증했다. &lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;다음주는 한걸음 더 나아가서 읽기 성능 최적화에 집중해보려 한다. DB 인덱스를 설정하고, 필요한 경우 캐시를 적용해 읽기 부하를 줄이는 전략을 시도해 볼 예정이다.&lt;/span&gt;&lt;/p&gt;</description>
      <category>Project/회고</category>
      <author>haylee</author>
      <guid isPermaLink="true">https://haylee.tistory.com/10</guid>
      <comments>https://haylee.tistory.com/10#entry10comment</comments>
      <pubDate>Sat, 9 Aug 2025 19:11:16 +0900</pubDate>
    </item>
  </channel>
</rss>