<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>개발 여정</title>
    <link>https://devstep.tistory.com/</link>
    <description>웹 백엔드 개발자</description>
    <language>ko</language>
    <pubDate>Tue, 26 May 2026 13:52:59 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>devstep</managingEditor>
    <item>
      <title>[WIL] 루퍼스_부트캠프 9주차</title>
      <link>https://devstep.tistory.com/158</link>
      <description>&lt;p style=&quot;color: #000000; text-align: start;&quot; data-end=&quot;113&quot; data-start=&quot;82&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번주는 랭킹 시스템을 개발하였고 레디스의 ZSET 자료구조를 사용했다. 그러면서 공부했던 것들을 정리해본다.&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;1. Redis 자료구조 학습&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;랭킹 시스템 구현의 핵심은 Redis &lt;b&gt;ZSET(정렬된 집합)&lt;/b&gt; 이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ZSET은 &lt;code&gt;(member, score)&lt;/code&gt; 쌍을 저장하고, 점수 기준으로 정렬된 상태를 유지한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1757847988162&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;ZADD rank:product:day:20250914 100 productId:123 
ZINCRBY rank:product:day:20250914 1 productId:123&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;랭킹 조회 (Top-N):&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1757848049311&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;ZREVRANGE rank:product:day:20250914 0 9 WITHSCORES&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;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1757848070116&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;ZSCORE rank:product:day:20250914 productId:123 
ZREVRANK rank:product:day:20250914 productId:123&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;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1757848093700&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;ZSCAN rank:product:day:20250914 0 COUNT 1000&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;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1757848103485&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;ZUNIONSTORE rank:product:day:20250915 1 rank:product:day:20250914 WEIGHTS 0.8&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 일일 랭킹 Key 전략&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(1) 고정 윈도우 (Fixed Window)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&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;code&gt;  rank:product:day:YYYYMMDD
&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;캐리오버&lt;/b&gt;: 이전날 점수를 &amp;alpha;(0~1 사이 값)만큼 곱해서 다음날로 이관&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;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;  ZUNIONSTORE rank:product:day:20250915 1 rank:product:day:20250914 WEIGHTS 0.8
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;사전 준비&lt;/b&gt;: 자정 10분 전(&lt;code&gt;23:50&lt;/code&gt;) 다음날 키를 생성하고 캐리오버를 실행. &lt;br /&gt;이렇게 하면 0시 직후에도 데이터가 비어 있지 않다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;(2) 슬라이딩 윈도우 (Sliding Window)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;일일 랭킹일 때 더 작은 시간 단위(예: 1시간)로 나누어 &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;code&gt;  rank:product:hour:YYYYMMDDHH
&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;최근 24시간(rolling)을 합산해 &lt;b&gt;일간 랭킹처럼 조회&lt;/b&gt;한다.
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;  ZUNIONSTORE rank:product:day:20250914 24 rank:product:hour:2025091400 ... rank:product:hour:2025091423
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;장점: 시간 흐름에 따라 자연스럽게 갱신되므로 콜드 스타트 완화.&lt;/li&gt;
&lt;li&gt;단점: 조회/집계 시 여러 키를 합산해야 하므로 비용 증가.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 콜드 스타트 대비 전략&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;콜드 스타트란 새로운 윈도우가 시작될 때 랭킹 데이터가 비어 있는 상황을 말한다.&lt;br /&gt;예를 들어 윈도우 크기가 1일이고 0시에 새로 시작된다면, 다음날 0시 조회 시 데이터가 없어 빈 화면이 노출될 수 있다.&lt;br /&gt;이 경우 사용자가 해당 시점에 랭킹을 조회하면 결과가 비어 있거나 신뢰하기 어려운 결과 화면이 노출되어 서비스 신뢰성이 떨어진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 완화하기 위해 고정 윈도우일 경우는 이전 윈도우의 값을 scale down하여 다음 윈도우를 생성할 수 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.1 내 구현 방식&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;: 하루 단위로 키를 두고, 자정 10분 전 미리 다음날 키를 생성.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;처음엔 모든 상품을 옮기려고 했으나&lt;/b&gt;, 실제로는 &lt;b&gt;TOP100만 보장하면 충분&lt;/b&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;한 번에 대규모 연산(O(N))을 하면 블로킹이 발생할 수 있음.&lt;/li&gt;
&lt;li&gt;따라서 &lt;code&gt;ZSCAN&lt;/code&gt;을 이용해 &lt;b&gt;일정 단위(batch)&lt;/b&gt;로 데이터를 가져와 &lt;code&gt;ZADD&lt;/code&gt; 하는 방식으로 구현.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;파이프라이닝&lt;/b&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;: 반복되는 &lt;/span&gt;&lt;code style=&quot;letter-spacing: 0px;&quot;&gt;ZADD&lt;/code&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;를 파이프라인으로 묶어 왕복 비용 최소화할 수 있다고 함.&amp;nbsp;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1757849385815&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;ZSCAN rank:product:day:20250914 0 COUNT 1000 
ZADD rank:product:day:20250915 &amp;lt;score*&amp;alpha;&amp;gt; member&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.2 TOP100만 보장&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ZUNIONSTORE&lt;/code&gt;로 한 번에 처리. N이 클 경우 블로킹 가능성 존재. 그러나 100건은 1초 이내로 빠르게 처리 가능.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. Key의 TTL 관리&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1) TTL 부여 시점&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;키 생성 시점에만 TTL을 설정&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;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1757856494094&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;ZADD rank:product:day:20250914 ...
EXPIRE rank:product:day:20250914 172800   # 2일. 초 단위&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2) TTL을 2일로 설정한 이유&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;li&gt;어제 키: 오늘까지 유지 &amp;rarr; 내일 새벽에 자동 소멸&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-start=&quot;82&quot; data-end=&quot;113&quot; data-ke-size=&quot;size16&quot;&gt; &lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a style=&quot;color: #0070d1;&quot; href=&quot;https://www.loopers.im&quot;&gt;&lt;b&gt;루퍼스 Loop:Pak L2 Backend&lt;/b&gt;&lt;/a&gt;&lt;br /&gt;백엔드 경력 3년 이상 대상, 10주 실무 중심 부트캠프.&lt;br /&gt;Java/Kotlin 중 하나를 선택해&lt;span&gt;&amp;nbsp;&lt;/span&gt;실전 문제 해결&amp;middot;코드 리뷰&amp;middot;운영 관점 설계&lt;span&gt;&amp;nbsp;&lt;/span&gt;등 실무 기술을 집중적으로 다룹니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-start=&quot;187&quot; data-end=&quot;217&quot; data-ke-size=&quot;size16&quot;&gt; &lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;20만원 할인 코드:&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;N17NC&lt;/b&gt;&lt;/p&gt;</description>
      <category>Essay/WIL</category>
      <author>devstep</author>
      <guid isPermaLink="true">https://devstep.tistory.com/158</guid>
      <comments>https://devstep.tistory.com/158#entry158comment</comments>
      <pubDate>Sun, 14 Sep 2025 20:06:05 +0900</pubDate>
    </item>
    <item>
      <title>Redis ZSET 을 이용한 랭킹 시스템 개발</title>
      <link>https://devstep.tistory.com/157</link>
      <description>&lt;p style=&quot;color: #000000; text-align: start;&quot; data-end=&quot;157&quot; data-start=&quot;120&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-end=&quot;157&quot; data-start=&quot;120&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;1 사용자 행동 수와 랭킹 점수&lt;/span&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1.1 랭킹은 사용자 행동을 점수로 환산한 결과&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 남긴 행동은 좋아요, 조회, 구매처럼 다양하며 먼저 이러한 행동이 몇 번 일어났는지를 저장한다.&lt;br /&gt;하지만 랭킹에 반영할 때는 어떤 행동이 순위에 더 중요한지를 표현하기 위해 가중치를 적용해 점수로 계산한다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-end=&quot;327&quot; data-start=&quot;231&quot; data-ke-size=&quot;size16&quot;&gt;따라서 랭킹을 만들려면 두 가지가 필요하다.&lt;br /&gt;하나는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;사용자 행동 횟수를 저장하는 것&lt;/b&gt;, 다른 하나는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;그 데이터를 바탕으로 점수를 계산해 저장하는 것&lt;/b&gt;이다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;행동 집계 저장소&lt;/b&gt;:&lt;span&gt;&amp;nbsp;&lt;/span&gt;product_metrics&lt;span&gt;&amp;nbsp;&lt;/span&gt;테이블&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;581&quot; data-start=&quot;479&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;534&quot; data-start=&quot;479&quot;&gt;날짜별, 상품별로 좋아요 수, 조회 수, 주문 수 같은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;count 데이터&lt;/b&gt;를 저장한다.&lt;/li&gt;
&lt;li data-end=&quot;581&quot; data-start=&quot;537&quot;&gt;이곳은 원천 데이터이자 점수를 계산하기 위한&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;재료&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;역할을 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;랭킹 계산 저장소&lt;/b&gt;: Redis Sorted Set&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;750&quot; data-start=&quot;627&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;687&quot; data-start=&quot;627&quot;&gt;product_metrics에 쌓인 데이터를 기반으로 계산된 점수(score)를 저장한다.&lt;/li&gt;
&lt;li data-end=&quot;750&quot; data-start=&quot;690&quot;&gt;자동 정렬된 상태로 유지되기 때문에 Top-N 상품이나 특정 상품의 순위를 빠르게 조회할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-end=&quot;1188&quot; data-start=&quot;1000&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-end=&quot;1188&quot; data-start=&quot;1000&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1.2 &amp;nbsp;일 단위 랭킹&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-end=&quot;1188&quot; data-start=&quot;1000&quot; data-ke-size=&quot;size16&quot;&gt;그래서&lt;span&gt; 행동 집계 저장소인 &lt;/span&gt;product_metrics에는 날짜별&lt;span&gt;&amp;nbsp;&lt;/span&gt;(productId, yyyymmdd)&lt;span&gt;&amp;nbsp;&lt;/span&gt;단위로 카운트가 저장된다.&lt;br /&gt;그 데이터를 집계하여 Redis의&lt;span&gt;&amp;nbsp;&lt;/span&gt;rank:all:product:{yyyyMMdd}&lt;span&gt;&amp;nbsp;&lt;/span&gt;같은 키로 반영하면, 하루 단위의 인기 순위를 손쉽게 조회할 수 있다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-end=&quot;1400&quot; data-start=&quot;1190&quot; data-ke-size=&quot;size16&quot;&gt;만약 서비스에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;시간 단위 랭킹&lt;/b&gt;을 보여주고 싶다면 원리는 동일하다.&lt;br /&gt;일 단위가 아닌 시간 단위별 카운트를 별도의 저장소(DB, Redis Hash, Redis Sorted Set 등)에 쌓고,&lt;br /&gt;이에 대한 점수를 계산&amp;middot;집계하여 Redis에 반영하면 된다.&lt;br /&gt;즉, 윈도우 크기(일, 시간, 분)를 어떻게 정의하느냐에 따라 랭킹의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;시간 양자화&lt;/b&gt;가 달라진다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-end=&quot;1494&quot; data-start=&quot;1402&quot; data-ke-size=&quot;size16&quot;&gt;다만 윈도우 크기를 너무 잘게 나누면 카운트 저장 자체가 부담이 될 수 있다.&lt;br /&gt;이 부분은 어떻게 최적화&amp;middot;운영 할 수 있는지 아직도 고민되는 지점이다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-end=&quot;1576&quot; data-start=&quot;1501&quot; data-ke-size=&quot;size16&quot;&gt;여기까지는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;랭킹을 어떻게 설계할 수 있는지&lt;/b&gt;에 대한 이야기다.&lt;br /&gt;이제부터는 실제 구현 과정에서 마주쳤던 문제들을 정리해본다.&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-end=&quot;1251&quot; data-start=&quot;1056&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;2. 대량 데이터 처리: Count 저장과 랭킹 계산을 어떻게 나눌 것인가?&lt;/span&gt;&lt;/b&gt;&lt;/h2&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-end=&quot;1251&quot; data-start=&quot;1056&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;이번에 특히 고민했던 부분은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;&amp;ldquo;재료가 되는 count를 저장하는 곳&amp;rdquo;과 &amp;ldquo;실제로 랭킹을 계산하는 곳&amp;rdquo;을 어떻게 구분할 것인가&lt;/b&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;였다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2.1 기존 count 저장 로직: 분리된 Handler&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이전 주에 개발했던 사용자 행동 이벤트는&amp;nbsp;&lt;b&gt;컨슈머 그룹과 Kafka Listener는 동일하게 두고, KafkaHandler를 이용해 분리해서 처리&lt;/b&gt;했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;좋아요 이벤트 &amp;rarr; LikeHandler&lt;/li&gt;
&lt;li&gt;주문 이벤트 &amp;rarr; OrderHandler&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 구조에서는&amp;nbsp;product_metrics&amp;nbsp;테이블에 데이터를 저장하는 것까지는 문제 없었다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;하지만&amp;nbsp;&lt;b&gt;저장 로직에서 랭킹 점수 저장을 위해 Redis까지 갱신하려고 하니 각 핸들러마다 코드를 추가해야 했고 기존 로직을 수정해야 하는 부담이 컸다.&lt;br /&gt;&lt;/b&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;결국 핸들러마다 Redis 호출 로직이 흩어지면서 관리가 복잡해질 수밖에 없었다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2.2 첫번째 시도 : 별도 컨슈머 그룹에서 Redis 반영&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그래서 처음에는 &amp;ldquo;같은 토픽을 다른 Consumer 그룹에서 동시에 수신해서&lt;br /&gt;한쪽은 product_metrics에 사용자 행동 count를 저장하고 &lt;span style=&quot;letter-spacing: 0px;&quot;&gt;다른 한쪽은 Redis에 랭킹용 score를 저장하는 방식을 고려했다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;하지만 이 방식은&amp;nbsp;&lt;b&gt;DB와 Redis가 서로 다른 경로로 데이터를 반영하기 때문에 이중 집계/누락 문제가 생길 수 있었다.&lt;br /&gt;&lt;/b&gt;왜냐하면, DB 조회 + 해당 이벤트 카운트를 더해서 Redis에 반영하므로 DB 저장이 이미 완료된 상황에서는 이중 집계가 되기 때문이다. &lt;br /&gt;그렇다고 DB조회분만 점수를 계산해 반영하면 아직 처리하지 못한 사용자 행동 카운트가 반영되지 않는다. &amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2.3 &amp;nbsp;최종 구조: product_metrics 테이블 변경 이벤트 발행 &amp;rarr; Redis 반영&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하기 위해 구조를 아래와 같이 하였다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;모든 사용자 행동은&lt;b&gt;&amp;nbsp;product_metrics&amp;nbsp;테이블에만 반영&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;그 후, 테이블이 변경될 때&amp;nbsp;새로운&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;이벤트를 발행&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;이 이벤트를 수신해서 Redis에 랭킹 점수를 반영&lt;/li&gt;
&lt;/ol&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 하면 Redis는 항상&amp;nbsp;&lt;b&gt;DB 반영이 끝난 후&lt;/b&gt;에만 업데이트되므로 DB와 Redis 간 불일치나 이중 카운팅 문제를 막을 수 있었다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그렇다면 어떻게 최적화 해서 랭킹 반영을 할 수 있을까&lt;br /&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;랭킹 처리를 위한 이벤트를 분리한 뒤 새로운 이벤트를 수신할 때 Redis 부하를 최소화하기 위해 아래와 같은 방법을 적용하였다.&lt;/span&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;3. 사용자 행동의 랭킹 반영 최적화&lt;/b&gt;&lt;/h2&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-end=&quot;179&quot; data-start=&quot;153&quot; data-ke-size=&quot;size23&quot;&gt;3.1 &amp;nbsp;Batch Listener 활용&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;color: #000000;&quot;&gt;&lt;b&gt;단건 Listener&lt;/b&gt; 일 때, &amp;nbsp;이벤트 1개마다 Redis에 ZINCRBY 호출 &amp;rarr; Redis 부하 &amp;uarr;&lt;/li&gt;
&lt;li style=&quot;color: #000000;&quot;&gt;&lt;b&gt;Batch Listener&lt;/b&gt; 일 때, 일정 개수의 메시지를 모아 한 번에 처리&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-end=&quot;401&quot; data-start=&quot;323&quot; data-ke-size=&quot;size16&quot;&gt;특히 같은&lt;span&gt;&amp;nbsp;&lt;/span&gt;(productId, date)&lt;span&gt;&amp;nbsp;&lt;/span&gt;조합은 동일 파티션으로 들어오게 하여, 중복으로 들어왔을 경우&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;한 번만 집계&lt;/b&gt;하도록 했다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-end=&quot;426&quot; data-start=&quot;403&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3.2 ZADD를 통한 일괄 반영&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 Redis에서는 각 멤버를 ZINCRBY로 건건히 증가시키는 대신, &amp;nbsp;&lt;b&gt;ZADD의 덮어쓰기 기능을 활용해 여러 멤버를 한 번의 명령어로 처리&lt;/b&gt;했다. 이렇게 하면 Redis와의 네트워크 통신 횟수를 크게 줄일 수 있다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;  해당 내용을 Redis 명령어로 비교해 보면 아래와 같다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;#### ZINCRBY (건건히 증가)
# 상품 101번 좋아요 이벤트 발생 (점수 +3)
ZINCRBY rank:product:day:20250912 3 product:101

# 상품 202번 주문 이벤트 발생 (점수 +6)
ZINCRBY rank:product:day:20250912 6 product:202

# 상품 101번 다시 조회 이벤트 발생 (점수 +1)
ZINCRBY rank:product:day:20250912 1 product:101

#### ZADD (덮어쓰기, 일괄 처리)
ZADD rank:product:day:20250912
    4   product:101
    6   product:202
    14  product:303
    9   product:404
    5   product:505

&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-end=&quot;1251&quot; data-start=&quot;1056&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot; data-end=&quot;113&quot; data-start=&quot;82&quot;&gt; &lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a title=&quot;루퍼스 Loop:Pak L2 Backend&quot; href=&quot;https://www.loopers.im&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;b&gt;루퍼스 Loop:Pak L2 Backend&lt;/b&gt;&lt;/a&gt;&lt;br /&gt;백엔드 경력 3년 이상 대상, 10주 실무 중심 부트캠프.&lt;br /&gt;Java/Kotlin 중 하나를 선택해&lt;span&gt;&amp;nbsp;&lt;/span&gt;실전 문제 해결&amp;middot;코드 리뷰&amp;middot;운영 관점 설계&lt;span&gt;&amp;nbsp;&lt;/span&gt;등 실무 기술을 집중적으로 다룹니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-end=&quot;217&quot; data-start=&quot;187&quot; data-ke-size=&quot;size16&quot;&gt; &lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;20만원 할인 코드:&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;N17NC&lt;/b&gt;&lt;/p&gt;</description>
      <category>Database/Redis</category>
      <author>devstep</author>
      <guid isPermaLink="true">https://devstep.tistory.com/157</guid>
      <comments>https://devstep.tistory.com/157#entry157comment</comments>
      <pubDate>Fri, 12 Sep 2025 17:30:48 +0900</pubDate>
    </item>
    <item>
      <title>[WIL] 부트캠프 8주차</title>
      <link>https://devstep.tistory.com/156</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;이번 주에는 카프카를 직접 써봤다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;글로벌 이벤트와 로컬 이벤트&amp;hellip; &amp;ldquo;이벤트에도 글로벌이 있구나&amp;rdquo; 싶은 순간도 있었다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;어디에 쓰이는지&lt;/b&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;가 먼저 궁금&lt;/span&gt;&lt;/h2&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;K.A.F.K.A라는 이름 정도만 아는 상태에서, 이 도구가&amp;nbsp;&lt;b&gt;무엇으로 구성되는지&lt;/b&gt;보다&amp;nbsp;&lt;b&gt;어디에 쓰이는지&lt;/b&gt;가 먼저 궁금했다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그 부분은 월요일 멘토링에서 전체 흐름을 들으며 큰 도움을 받았다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이벤트 분리를 처음부터 미세하게 하려니 막막했는데, &amp;ldquo;&lt;b&gt;쉬운 것부터 해보자&lt;/b&gt;&amp;rdquo;는 말이 전환점이었다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;즉, 주문등록(재고 차감, 포인트 차감, 쿠폰 사용) 같은 핵심 트랜잭션부터 쪼개기보다, 우리 시스템 관점에서 바깥이라고 볼 수 있는&amp;nbsp;&lt;b&gt;데이터 플랫폼 전송&lt;/b&gt;&amp;nbsp;같은 곳부터 분리해보기로 했다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그리고 나서 주문등록 이벤트를 떼어내 보니, 예컨대 주문 이벤트 발행 후 포인트 차감이 실패하면&amp;nbsp;&lt;b&gt;보상 트랜잭션&lt;/b&gt;이 필요하다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;다만 모든 걸 보상 트랜잭션으로만 해결하려 들면 작업 순서를 정교하게 이해해야 하고, 잘못하면 뫼비우스의 띠처럼 맴돌 수 있다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 지점에서 예전엔 &amp;ldquo;하나의 트랜잭션&amp;rdquo;으로만 여겼던 작업들이&amp;nbsp;&lt;b&gt;사가 패턴&lt;/b&gt;으로 연결된다고 하셨다. 아, 그렇게 이어지는 거였구나.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;전체 그림을 보고나니 화요일부터는 다른 질문도 할 수 있었다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;토픽을 이벤트 단위로 나눌 것인가, 도메인 단위로 묶을 것인가?&lt;/b&gt;&lt;/h2&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이벤트 단위라면 &amp;ldquo;주문완료&amp;rdquo;, &amp;ldquo;주문취소&amp;rdquo;처럼 쪼갤 수 있고, 도메인 단위라면 &amp;ldquo;주문&amp;rdquo;으로 뭉칠 수 있다. 둘 중 하나를 고를 수 있고 &amp;nbsp;트레이드오프가 따른다. 이번 과제에서는 도메인 안의 세부 이벤트들이 하는 일이 거의 같아서 굳이 나눌 이유가 없었다. 반대로 세부 이벤트별로 해야 할 일이 달라지면 분리가 필요하겠지.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;처음 배울 때는 용어도 벽이었다.&lt;/h2&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;카프카는 용어를 텍스트로만 보면 그냥 개념일 뿐이다.&amp;nbsp;용어가 유기적으로 연결되다 보니&amp;nbsp;&lt;b&gt;그림으로 그려져야&lt;/b&gt;&amp;nbsp;이해가 되었다. 과제를 수행하다보니 토픽과 컨슈머 그룹은 머릿속에 그림이 그려졌다. 하지만&amp;nbsp;&lt;b&gt;파티션과 컨슈머&lt;/b&gt;는 아직 유기적으로 이어지지 않는다. 순서 보장, 파티션 선택 같은 이슈까지 함께 고려하며 이해하려면, 설정값을 바꿔가며 직접 확인하는 과정이 필요하겠다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;마지막으로&amp;nbsp;&lt;b&gt;예외 케이스&lt;/b&gt;를 더 들여다봐야 한다.&lt;/h2&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;메시지 커밋은 예외 상황에서 어떻게 처리되는지, 재시도/실패 시나리오는 어떤 경로를 밟는지 등. 그리고 &amp;nbsp;나중에 다시 실행해 볼 수 있도록&amp;nbsp;&lt;b&gt;테스트 코드&lt;/b&gt;로 남겨 두어야한다.&lt;/p&gt;</description>
      <category>Essay/WIL</category>
      <category>카프카</category>
      <author>devstep</author>
      <guid isPermaLink="true">https://devstep.tistory.com/156</guid>
      <comments>https://devstep.tistory.com/156#entry156comment</comments>
      <pubDate>Sun, 7 Sep 2025 23:58:00 +0900</pubDate>
    </item>
    <item>
      <title>[WIL] 부트캠프 7주차 : Spring Application Event 기본 용어와 튜토리얼 코드</title>
      <link>https://devstep.tistory.com/155</link>
      <description>&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: #000000; text-align: start;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;Spring Application Event를 사용해보긴 했지만, 용어와 코드가 어떻게 매칭되는지는 늘 헷갈렸다.&lt;span&gt;&amp;nbsp;&lt;br /&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;이번 글에서는 이벤트 관련 용어와 Spring을 이용해 내부 이벤트를 사용할 때 참고할 수 있는 예시 코드를 소개하려 한다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;br /&gt;&lt;br /&gt;&lt;/span&gt;&lt;/span&gt;&lt;br /&gt;&lt;/span&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;1. 이벤트 용어&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;멘토링 시간에 들었던 예시는 이벤트 관련 용어를 이해하는 데 가장 도움이 되었다.&lt;/span&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;마라탕집 3000평 가게에 식사하러 갔다.&lt;br /&gt;&lt;br /&gt;테이블A에서 &quot;계산해&quot; 달라고 한다 &lt;br /&gt;&amp;rarr; 지나가던 알바가 무전기로 &quot;A테이블 계산이요&quot;라고 전달한다.&amp;nbsp;&lt;br /&gt;&amp;rarr; 계산 서버가 달려와서 &quot;계산해드리겠습니다&quot;&lt;/blockquote&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;이벤트 프로듀서 (Publisher)&lt;/b&gt;&amp;nbsp;=&amp;nbsp;테이블A&lt;/li&gt;
&lt;li&gt;&lt;b&gt;이벤트&lt;/b&gt;&amp;nbsp;= &quot;계산 해줘&quot; &amp;rarr; 커맨드가 아니니깐, &amp;ldquo;계산해줘&amp;rdquo; 보다는 &quot;여기 식사 다 했어요!&quot; 일 듯.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;이벤트 브로커&lt;/b&gt;&amp;nbsp;=&amp;nbsp;알바 + 무전기&lt;/li&gt;
&lt;li&gt;&lt;b&gt;이벤트 컨슈머 (Listener)&lt;/b&gt; = 계산 서버 &amp;nbsp;: 이벤트를 구독하고 있다가 해당 이벤트 발생 시 미리 정의된 로직 실행&lt;/li&gt;
&lt;li&gt;&lt;b&gt;이벤트 핸들러&lt;/b&gt;&amp;nbsp;= 실제 실행되는 구체적 처리 로직 &amp;rarr; &quot;계산 서버&quot;가 계산 처리&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;2. 코드로 보는 이벤트&lt;/h2&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;소개하는 코드는 &lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;Spring에서 &lt;/span&gt;이벤트를 언제, 어떻게 발행하고 수신 측에서 어떻게 받아 처리하는지 쉽게 이해할 수 있는 좋은 튜토리얼이 될 것이다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;cs&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;*// === 이벤트 클래스 ===*
public class OrderCompletedEvent {
    private String orderId;
    private String userId;
    private List&amp;lt;String&amp;gt; items;
    private int amount;
    private LocalDateTime timestamp;
    
    *// 생성자, getter/setter...*
}

*// === 주문 서비스 (이벤트 발행자) ===*
@Service
public class OrderService {
    
    @Autowired
    private ApplicationEventPublisher eventPublisher;
    
    public void createOrder(String userId, List&amp;lt;String&amp;gt; items, int amount) {
        *// 주문 생성 로직*
        String orderId = UUID.randomUUID().toString();
        
        *// 주문 저장...*
        saveOrder(orderId, userId, items, amount);
        
        *// 이벤트 발행 (기존 코드는 수정 불필요!)*
        OrderCompletedEvent event = new OrderCompletedEvent(orderId, userId, items, amount);
        eventPublisher.publishEvent(event);
        
        System.out.println(&quot;  주문 생성 완료: &quot; + orderId);
    }
}

*// === 기존 서비스들 ===// 결제 서비스*
@Service
public class PaymentService {
    
    @EventListener
    public void handleOrderCompleted(OrderCompletedEvent event) {
        System.out.println(&quot;  결제 처리 시작: &quot; + event.getOrderId());
        processPayment(event.getAmount());
        sendPaymentConfirmation(event.getUserId());
    }
}

*// 재고 서비스*  
@Service
public class InventoryService {
    
    @EventListener
    public void handleOrderCompleted(OrderCompletedEvent event) {
        System.out.println(&quot;  재고 차감 처리: &quot; + event.getOrderId());
        updateStock(event.getItems());
        checkLowStock();
    }
}

*// === 새로운 요구사항 1: 포인트 적립 기능 추가 ===*

@Service  *// 새로운 서비스 추가!*
public class PointService {
    
    @EventListener  *// 기존 코드 수정 없이 리스너만 추가!*
    public void handleOrderCompleted(OrderCompletedEvent event) {
        System.out.println(&quot;⭐ 포인트 적립 처리: &quot; + event.getOrderId());
        int points = (int)(event.getAmount() * 0.01); *// 1% 적립*
        addPoints(event.getUserId(), points);
        sendPointNotification(event.getUserId());
    }
}

*// === 새로운 요구사항 2: 이메일 알림 추가 ===*

@Service  *// 또 다른 새로운 서비스!*
public class NotificationService {
    
    @EventListener  *// 또다시 기존 코드 수정 없이!*
    public void handleOrderCompleted(OrderCompletedEvent event) {
        System.out.println(&quot;  이메일 알림 발송: &quot; + event.getOrderId());
        sendOrderConfirmationEmail(event.getUserId(), event);
        sendSMSNotification(event.getUserId());
    }
}

*// === 새로운 요구사항 3: 배송 준비 추가 ===*

@Service  *// 계속해서 새로운 서비스 추가 가능!*
public class ShippingService {
    
    @EventListener
    public void handleOrderCompleted(OrderCompletedEvent event) {
        System.out.println(&quot;  배송 준비 시작: &quot; + event.getOrderId());
        createShippingLabel(event);
        scheduleDelivery(event.getOrderId());
    }
}

*// === 컨트롤러 (실행부) ===*

@RestController
public class OrderController {
    
    @Autowired
    private OrderService orderService;
    
    @PostMapping(&quot;/orders&quot;)
    public ResponseEntity&amp;lt;String&amp;gt; createOrder(@RequestBody OrderRequest request) {
        orderService.createOrder(
            request.getUserId(), 
            request.getItems(), 
            request.getAmount()
        );
        
        return ResponseEntity.ok(&quot;주문이 접수되었습니다.&quot;);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실행 시 로그:&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;  주문 생성 완료: abc-123-def&lt;/b&gt; &lt;br /&gt;  결제 처리 시작: abc-123-def &lt;br /&gt;  재고 차감 처리: abc-123-def &lt;br /&gt;⭐ 포인트 적립 처리: abc-123-def &lt;br /&gt;  이메일 알림 발송: abc-123-def &lt;br /&gt;  배송 준비 시작: abc-123-def&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;3. 이벤트 사용 장점&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;기존 코드 수정 없음&lt;/b&gt;:&amp;nbsp;OrderService,&amp;nbsp;PaymentService,&amp;nbsp;InventoryService는 전혀 건드리지 않음&lt;/li&gt;
&lt;li&gt;&lt;b&gt;새 서비스 추가&lt;/b&gt;: &lt;u&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;새로운 비즈니스 로직이 필요하다면 서비스 클래스를 하나 추가하고, 메서드에&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;@EventListener&lt;/u&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;u&gt;만 붙이면 된다&lt;/u&gt;. &lt;br /&gt;어떤 이벤트를 리슨할지는 발행된 이벤트 객체와 수신 측에서 정의한 이벤트 객체가 동일한 타입으로 매칭되어 결정된다.&lt;/span&gt;&lt;u&gt;&lt;/u&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Spring이 자동 처리&lt;/b&gt;: Spring이&amp;nbsp;&lt;u&gt;OrderCompletedEvent가 발행되면 모든&amp;nbsp;@EventListener를 자동으로 호출&lt;/u&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;독립적 실행&lt;/b&gt;: &lt;u&gt;각 리스너는 독립적으로 실행되며, 하나가 실패해도 다른 리스너에 영향 없음&lt;/u&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;확장 용이&lt;/b&gt;: 쿠폰 서비스, 리뷰 요청 서비스 등 무한히 추가 가능&lt;/li&gt;
&lt;/ol&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 Spring Application Event를 사용하면&amp;nbsp;&lt;u&gt;&lt;b&gt;기존 코드 변경 없이&lt;/b&gt; 새로운 기능을 계속 추가할 수 있습니다!&lt;/u&gt;&lt;/p&gt;</description>
      <category>Essay/WIL</category>
      <author>devstep</author>
      <guid isPermaLink="true">https://devstep.tistory.com/155</guid>
      <comments>https://devstep.tistory.com/155#entry155comment</comments>
      <pubDate>Sun, 7 Sep 2025 23:36:59 +0900</pubDate>
    </item>
    <item>
      <title>Kafka 하나의 토픽, 작업 특성에 맞춘 컨슈머 그룹 분리</title>
      <link>https://devstep.tistory.com/154</link>
      <description>&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이번 주는 카프카를 활용해 외부 이벤트를 처리하며 다양한 고민을 해보는 시간이었습니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;모든 이벤트를 정확히 한 번만 처리해야 할까? 때로는 처리되지 않아도 되는 경우가 있지 않을까? 빠른 처리가 중요한 작업과 정확성이 중요한 작업을 어떻게 처리할까?&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이러한 질문들을 바탕으로 카프카의 특성을 활용해 각 작업의 요구사항에 맞는 처리 전략을 설계했습니다. 이 글에서는 그 과정에서 얻은 인사이트와 실제 적용한 설계 방법을 공유하고자 합니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; 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;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;카프카를 쓰며 배운 것들: 작업 특성에 맞춘 컨슈머 그룹 분리&lt;/h2&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;카프카의 기본 구조&lt;/h3&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;카프카는 물리적인 파티션을 논리적으로 묶은 '토픽'이라는 개념을 사용합니다. 토픽은 일정 기간 영속성을 가진 데이터로, 소비된 후에도 그 자리에 계속 남아있습니다. 마치 칠판에 적힌 글씨를 여러 학생이 각자의 속도로 서로 다른 부분을 읽어가는 것과 유사합니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;컨슈머 그룹의 동작 방식&lt;/h3&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;토픽에 관심 있는 컨슈머 그룹들은 각자의 처리 속도에 맞춰 데이터를 읽고, 어디까지 읽었는지 오프셋을 기록합니다. 이전 주에 다뤘던 어플리케이션 이벤트 리스너 방식과 비슷하게, 토픽이 발행되면 구독 중인 컨슈머 그룹들이 해당 메시지를 읽어 처리합니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;동일한 작업을 수행하는 컨슈머들은 하나의 그룹으로 묶어 효율적으로 관리할 수 있습니다. 예를 들어, 사용자의 좋아요 클릭, 상품 조회, 구매 이벤트를 하나의 컨슈머 그룹이 집계하도록 구성할 수 있습니다. 또한 같은 이벤트를 여러 컨슈머 그룹이 구독하여 각자 다른 작업을 수행할 수도 있습니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;어플리케이션 이벤트와 외부 이벤트의 차이&lt;/h3&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;어플리케이션 이벤트는 특정 도메인 내부의 처리가 중심입니다. 반면 외부 이벤트(카프카)는 여러 도메인을 아우르는 작업 처리가 가능했습니다. 외부 이벤트 시스템을 통해 주문, 상품, 좋아요 등 서로 다른 도메인들이 자연스럽게 연결됩니다. 각 도메인은 단순히 이벤트를 외부로 발행하기만 하면, 관심 있는 &quot;작업 처리기&quot;가 이를 구독하여 필요한 처리를 수행합니다. 이런 구조는 여러 도메인 간 오케스트레이션이 필요할 때도 이용할 수 있다고 생각합니다.&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;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;카프카를 활용한 3가지 작업 처리 전략&lt;/h2&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;구현한 작업들&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;감사 로그&lt;/b&gt;&amp;nbsp;- 모든 이벤트 수집&lt;/li&gt;
&lt;li&gt;&lt;b&gt;상품 집계&lt;/b&gt;&amp;nbsp;- 주문, 상품, 좋아요 이벤트 처리&lt;/li&gt;
&lt;li&gt;&lt;b&gt;캐시 삭제&lt;/b&gt;&amp;nbsp;- 주문, 상품, 좋아요 이벤트 처리&lt;/li&gt;
&lt;/ol&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;세 작업 모두 비슷한 도메인의 이벤트를 구독하고 있어 하나의 컨슈머 그룹에서 처리할 수도 있지만 각 작업의 특성을 고려하면 컨슈머 그룹 분리가 필요합니다. 아래는 각각의 작업 특성이 어떤 차이가 있기 때문에 분리하는 것이 좋은지 적어보았습니다.&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;각 작업의 특성과 처리 전략&lt;/h3&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;  감사 로그 (Audit Log)&lt;/h4&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;특징: 지연 허용, 데이터 무손실이 핵심&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;모든 이벤트를 빠짐없이 저장&lt;/li&gt;
&lt;li&gt;예외 상황에서 충분한 재시도 보장&lt;/li&gt;
&lt;li&gt;재시도 실패 시 DLQ(Dead Letter Queue)에 저장 후 별도 처리&lt;/li&gt;
&lt;li&gt;DLQ 메시지는 반드시 재처리&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;latest가 권고사항이지만, 무손실이 핵심이라면 earliest로 설정하여 빠짐없이 데이터가 처리되면서 멱등성을 보장해 중복 처리 되지 않게 개발해야 합니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;  메트릭 집계 (Metrics)&lt;/h4&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;특징: 높은 처리량(throughput)이 우선&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&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;DLQ 메시지는 비율 모니터링만 수행, 통계적으로 미미하면 무시&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;파티션 전략 고민&lt;/b&gt;: &amp;nbsp;구현은 상품ID로 파티션을 분리했지만 재고려가 필요하다고 봅니다. &amp;nbsp;8:2 법칙처럼 인기는 특정 상품에 집중되므로 인기 상품의 집계가 지연될 수 있기 때문입니다. 또한 프로젝트의 현재 구현이 개별 카운트 증가 방식이라 순서가 크게 중요하지 않습니다. &amp;nbsp;라운드 로빈이나 스티키 파티셔닝이 더 적합할 수 있다고 생각합니다. 즉, 먼저 온 순서대로 빠르게 처리되는 것이 중요하지 상품ID별로 분리되어 순서가 중요한 작업이 아니라 생각합니다.&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;  캐시 무효화 (Cache Evict)&lt;/h4&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;특징: 실시간성이 최우선&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;가볍고 빠른 처리 필수&lt;/li&gt;
&lt;li&gt;과거 데이터는 무시. 최신 데이터만 처리&lt;/li&gt;
&lt;li&gt;장애 시 latest 설정으로 최신 데이터부터 처리&lt;/li&gt;
&lt;li&gt;DLQ는 장애 모니터링 용도로만 활용&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;컨슈머 그룹 분리의 이점&lt;/h3&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;각 작업을 별도 컨슈머 그룹으로 분리하면 &lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;작업별 특성에 맞는 처리 속도와 오프셋 관리 전략을 적용할 수 있습니다. 그리하여 단일 컨슈머 그룹보다 훨씬 효과적인 운영이 가능합니다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&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;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;저장소 선택 전략&lt;/h2&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;멱등성 요구사항에 따른 저장소 전략을 생각해보았습니다.&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;감사 로그&lt;/b&gt;: RDB (정확한 멱등성 보장)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;집계&lt;/b&gt;: Redis 검토 (빠른 처리, 멱등성 검증 필요)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;캐시 무효화&lt;/b&gt;: 별도 멱등성 관리 불필요&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;집계 데이터 처리 아키텍처 설계&lt;/h3&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;Redis 기반 실시간 집계 + 주기적 RDB 영속화&lt;/h4&gt;
&lt;ol style=&quot;list-style-type: decimal; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;실시간 집계&lt;/b&gt;: 원본 key에서 고속 카운팅&lt;/li&gt;
&lt;li&gt;&lt;b&gt;데이터 백업 및 초기화&lt;/b&gt;&amp;nbsp;(원자적 처리)
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;별도 Redis key에 원본 Key값 백업&lt;/li&gt;
&lt;li&gt;원본 key 초기화&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;집계 재개&lt;/b&gt;: 초기화한 Key에서 새로운 집계 즉시 시작&lt;/li&gt;
&lt;li&gt;&lt;b&gt;영속화&lt;/b&gt;: 백업용 Key의 값을 RDB에 누적 반영&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;구현 시 고려사항&lt;/h4&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Redis &lt;span&gt;기반&lt;/span&gt; &lt;span&gt;실시간&lt;/span&gt; &lt;span&gt;집계&lt;/span&gt; + &lt;span&gt;주기적&lt;/span&gt; RDB &lt;span&gt;영속화 작업을 하려면 &lt;/span&gt;2번 &quot;데이터 백업 및 초기화&quot; 작업 할 때, Redis 분산 락으로 원자성을 보장해야 합니다. 구현 복잡성과 시간 제약으로 현재는 RDB로 구현했지만 이런 아키텍처 설계를 고민해보는 시간이었습니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>카프카</category>
      <author>devstep</author>
      <guid isPermaLink="true">https://devstep.tistory.com/154</guid>
      <comments>https://devstep.tistory.com/154#entry154comment</comments>
      <pubDate>Fri, 5 Sep 2025 17:50:33 +0900</pubDate>
    </item>
  </channel>
</rss>