<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>후니-devStory</title>
    <link>https://dev-th.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Sun, 24 May 2026 22:40:55 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>그zi운아이</managingEditor>
    <item>
      <title>달리기를 말할 때 내가 하고 싶은 이야기를 읽고</title>
      <link>https://dev-th.tistory.com/77</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;Pain is inevitable, Suffering is optional&quot;&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock floatLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1050&quot; data-origin-height=&quot;1400&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/4d0nh/dJMcagrGhZg/C0AZ3OWSO3sB7ZLS94DVVk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/4d0nh/dJMcagrGhZg/C0AZ3OWSO3sB7ZLS94DVVk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/4d0nh/dJMcagrGhZg/C0AZ3OWSO3sB7ZLS94DVVk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F4d0nh%2FdJMcagrGhZg%2FC0AZ3OWSO3sB7ZLS94DVVk%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;322&quot; height=&quot;429&quot; data-origin-width=&quot;1050&quot; data-origin-height=&quot;1400&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;책을 처음 펼쳤을 때 솔직히 낯설었다. 이런 에세이를 읽은 게 몇 년 만인지도 모르겠다. 학부생 시절에는 전공서적이나 개발 관련 책은 종종 읽었지만, 에세이는 어떻게 읽어야 하는지도 잘 몰랐다. 그래서 초반에는 계속 이런 생각이 들었다. &quot;그래서 나보고 뭘 느끼라는 거지?&quot; &quot;이 사람 인생 이야기인데, 나랑 무슨 상관이지?&quot; 그냥 페이지를 넘기면서도 쉽게 와닿지 않았다.&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;취업 이후 한동안 목표를 잃고 약 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;&quot;나는 정말 꾸준한 걸까?&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;그냥 책상에 앉아 있는 시간을 꾸준함이라고 착각하고 있는 건 아닐까?&quot;&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;/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;나는 항상 &quot;남들보다 더 잘해야 한다&quot;, &quot;져서는 안 된다&quot;는 생각으로 살아왔다. 그게 나를 움직이게 하는 동기부여였고, 지금까지 버틸 수 있었던 이유이기도 하다. 현실에서는 이직해서 더 높은 연봉을 받는 사람들, 앞서 나가는 사람들을 보면서 비교하지 않는다는 것이 과연 가능한 일인지 의문이 들었다.&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;/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;하루키에게 달리기는 단순한 취미가 아니었다. 글을 쓰기 위한 체력을 유지하기 위해, 자신의 삶을 지탱하기 위한 수단으로 달리기를 선택한 것이다. 그 지점을 보면서 이런 생각이 들었다. &quot;나는 무엇을 위해 이걸 하고 있는 걸까?&quot; 나는 지금 스터디를 하고, 공부를 하고, 이직을 준비하고 있다. 그런데 이 모든 것의 중심이 무엇인지 명확하게 설명하기가 어려웠다. 원래의 목표는 분명했다. 더 성장해서 인정받고, 더 나은 환경으로 이직해서 가족과 내 미래를 위해 더 좋은 삶을 만들고 싶었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 지금은 그 목표가 예전처럼 선명하게 느껴지지 않는다. &quot;이직을 하면 그 다음에는?&quot; &quot;그 이후의 나는 무엇을 위해 움직일까?&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;취업을 했을 때처럼 다시 방향을 잃어버리는 것은 아닐까 하는 생각도 들었다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;10년 넘게 만난 연인과 한 달 전에 이별을 했고, 그 이후로는 일상이 많이 무너졌다. 무기력하게 하루를 보내는 날이 많아졌고, 회사에 가는 것조차 힘들게 느껴졌다. 그럼에도 불구하고 &quot;스터디만큼은 포기하지 말자&quot;는 생각으로 계속 이어가고 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 그것조차도 스스로에게 질문하게 된다. &quot;나는 지금 무엇을 하고 있는 걸까?&quot; &quot;무엇을 위해 나아가고 있는 걸까?&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이직 준비 역시 쉽지 않다. 수십 개의 지원 중 일부만 면접까지 이어졌고, 그 과정에서 여러 번의 탈락을 경험했다. 최종 합격을 받은 곳도 있었지만, 연봉 조건이 맞지 않아 선택하지 않았다. 그 선택 이후로는 오히려 더 불안해졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;나는 과연 이직할 수 있을까?&quot; &quot;이 방향이 맞는 걸까?&quot; 동기부여도 예전 같지 않다. 'Just do it'처럼 그냥 하는 것이 중요하다고 하지만, 막상 그것을 계속 해내는 것은 쉽지 않다. 동기부여가 없는 상태에서도 꾸준함이 가능한 것인지 스스로에게 묻게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 생각이 들기도 한다. &quot;지금은 쉬어야 하는 시기일까?&quot; &quot;아니면 계속 나아가야 하는 걸까?&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 한편으로는 여기서 멈추면 이 상태가 너무 길어질 것 같다는 막연한 두려움도 있다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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;Pain is inevitable, Suffering is optional. 고통은 피할 수 없지만, 괴로움은 선택이라고.&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;</description>
      <author>그zi운아이</author>
      <guid isPermaLink="true">https://dev-th.tistory.com/77</guid>
      <comments>https://dev-th.tistory.com/77#entry77comment</comments>
      <pubDate>Mon, 20 Apr 2026 20:46:52 +0900</pubDate>
    </item>
    <item>
      <title>루프팩 L2 10주 회고 &amp;mdash; &amp;ldquo;왜&amp;rdquo;를 다시 배우다</title>
      <link>https://dev-th.tistory.com/76</link>
      <description>&lt;div data-message-model-slug=&quot;gpt-5-thinking&quot; data-message-id=&quot;4a574a32-d88f-4b95-a967-356e485a7736&quot; data-message-author-role=&quot;assistant&quot;&gt;
&lt;p data-end=&quot;215&quot; data-start=&quot;132&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;TL;DR:&lt;/b&gt; 기능보다 근거. 실패&amp;middot;복구까지 설계하고, 지표로 말하는 습관을 얻었다. 이제부터는 그 습관을 매일 쌓아 팀이 믿는 개발자로 간다.&lt;/p&gt;
&lt;h2 data-end=&quot;42&quot; data-start=&quot;31&quot; data-ke-size=&quot;size26&quot;&gt;시작한 이유&lt;/h2&gt;
&lt;p data-end=&quot;251&quot; data-start=&quot;43&quot; data-ke-size=&quot;size16&quot;&gt;입사 1년. 회사 일이 바쁘다는 이유로 공부를 미뤘다. 학부 시절엔 설계와 아키텍처 얘기를 좋아했는데, 어느새 출퇴근 루틴에 익숙해진 내 모습이 보였다. 변화가 필요했다. 내 돈과 시간을 들여 선택한 과정이라 솔직히 무서웠다. 후회할까 봐. 결론은 후회 없음이다. 팀원들과 부딪히며 매일 &amp;ldquo;왜 이렇게 설계해야 하는가&amp;rdquo;를 다시 묻게 됐다. 의지가 돌아왔고, 재미도 돌아왔다.&lt;/p&gt;
&lt;h2 data-end=&quot;269&quot; data-start=&quot;253&quot; data-ke-size=&quot;size26&quot;&gt;10주 동안 얻은 것&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;468&quot; data-start=&quot;270&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;309&quot; data-start=&quot;270&quot;&gt;기능보다 근거. 책임 경계와 실패&amp;middot;복구 시나리오를 먼저 적는다.&lt;/li&gt;
&lt;li data-end=&quot;371&quot; data-start=&quot;310&quot;&gt;운영을 버티는 기본기. 멱등성, 락 전략, 읽기&amp;middot;쓰기 분리, 재시도&amp;middot;서킷 브레이커를 실제 코드로 체득.&lt;/li&gt;
&lt;li data-end=&quot;421&quot; data-start=&quot;372&quot;&gt;체감이 아닌 지표. p95/p99, 실패 후 재처리 성공률을 설계 단계부터 정의.&lt;/li&gt;
&lt;li data-end=&quot;468&quot; data-start=&quot;422&quot;&gt;사람과 리듬. 같은 고민을 가진 동료와 피드백 주고받으며 끝까지 밀어붙이는 힘.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-end=&quot;479&quot; data-start=&quot;470&quot; data-ke-size=&quot;size26&quot;&gt;주차별 요약&lt;/h2&gt;
&lt;h3 data-end=&quot;501&quot; data-start=&quot;480&quot; data-ke-size=&quot;size23&quot;&gt;1&amp;ndash;3주차: 코드 전에 그림&lt;/h3&gt;
&lt;p data-end=&quot;659&quot; data-start=&quot;502&quot; data-ke-size=&quot;size16&quot;&gt;요구사항을 시퀀스&amp;middot;클래스&amp;middot;ERD로 먼저 모델링하고 TDD로 뼈대를 세웠다. 작은 API도 Facade&amp;rarr;Service&amp;rarr;Repository 경계를 지키니 이후 이벤트&amp;middot;락&amp;middot;캐시를 붙일 때 표면 변경으로 흡수됐다.&lt;br /&gt;&lt;b&gt;핵심 교훈:&lt;/b&gt; 나중에 붙을 요소를 상상하고 책임 경계부터 잡아라.&lt;/p&gt;
&lt;h3 data-end=&quot;689&quot; data-start=&quot;661&quot; data-ke-size=&quot;size23&quot;&gt;4&amp;ndash;6주차: 정합성과 성능을 숫자로 고정&lt;/h3&gt;
&lt;p data-end=&quot;823&quot; data-start=&quot;690&quot; data-ke-size=&quot;size16&quot;&gt;재고 경합과 중복 업데이트를 비관&amp;middot;낙관 락으로 비교했고, 동시성 테스트를 만들었다. 느린 조회는 인덱스 재배치와 캐시(TTL&amp;middot;버전 키)로 개선하고 전후 p95를 비교했다.&lt;br /&gt;&lt;b&gt;핵심 교훈:&lt;/b&gt; 체감이 아니라 측정 가능한 기준으로 말해라.&lt;/p&gt;
&lt;h3 data-end=&quot;853&quot; data-start=&quot;825&quot; data-ke-size=&quot;size23&quot;&gt;7&amp;ndash;8주차: 한 트랜잭션에 다 넣지 않기&lt;/h3&gt;
&lt;p data-end=&quot;990&quot; data-start=&quot;854&quot; data-ke-size=&quot;size16&quot;&gt;도메인 이벤트와 Kafka로 후처리(랭킹&amp;middot;알림)를 분리하고 Consumer 모듈을 따로 뺐다. 메시지 리플레이와 멱등 소비를 설계해 장애 시에도 복구 가능성을 확보했다.&lt;br /&gt;&lt;b&gt;핵심 교훈:&lt;/b&gt; 실패해도 다시 할 수 있는 구조가 가용성을 만든다.&lt;/p&gt;
&lt;h3 data-end=&quot;1015&quot; data-start=&quot;992&quot; data-ke-size=&quot;size23&quot;&gt;9&amp;ndash;10주차: 배치와 읽기 모델&lt;/h3&gt;
&lt;p data-end=&quot;1210&quot; data-start=&quot;1016&quot; data-ke-size=&quot;size16&quot;&gt;랭킹&amp;middot;집계처럼 계산이 무겁고 읽기 빈도가 높은 작업을 실시간 트랜잭션에서 빼서 배치로 안전하게 처리하고, 결과는 머티리얼라이즈드 뷰(MV)나 Redis에 올려 조회 속도를 일정하게 유지하는 쪽으로 진행했다.&lt;br /&gt;&lt;b&gt;핵심 교훈:&lt;/b&gt; 목적은 대용량 집계를 안전하게 돌리고, 결과를 읽기 전용 모델로 제공해 조회 속도를 &lt;b&gt;항상 일정하게&lt;/b&gt; 만드는 것.&lt;/p&gt;
&lt;h2 data-end=&quot;1231&quot; data-start=&quot;1212&quot; data-ke-size=&quot;size26&quot;&gt;현업에서 적용하고 싶은 포인트&lt;/h2&gt;
&lt;h3 data-end=&quot;1247&quot; data-start=&quot;1232&quot; data-ke-size=&quot;size23&quot;&gt;1) 요청 멱등성&lt;/h3&gt;
&lt;p data-end=&quot;1402&quot; data-start=&quot;1248&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;목적:&lt;/b&gt; 재시도&amp;middot;중복 호출&amp;middot;동시 클릭이 와도 결과가 흔들리지 않게&lt;br /&gt;&lt;b&gt;방법:&lt;/b&gt; Idempotency-Key 강제, 키별 요청 이력 저장, 중복 요청은 동일 응답 반환 또는 업서트&lt;br /&gt;&lt;b&gt;효과:&lt;/b&gt; 이중 결제&amp;middot;이중 알림톡 차단, 장애 시 재시도 전략을 마음 놓고 적용&lt;/p&gt;
&lt;h3 data-end=&quot;1427&quot; data-start=&quot;1404&quot; data-ke-size=&quot;size23&quot;&gt;2) 재시도 표준화와 실패 격리&lt;/h3&gt;
&lt;p data-end=&quot;1620&quot; data-start=&quot;1428&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;목적:&lt;/b&gt; 호출마다 제각각인 재시도를 없애고 디버깅 비용을 줄이기&lt;br /&gt;&lt;b&gt;방법:&lt;/b&gt; 지수 백오프 + 지터, 최대 시도 수, 재시도 안전/불가 태그를 공통 정책으로 고정. 실패는 DLT로 분리하고 재처리 런북 마련&lt;br /&gt;&lt;b&gt;효과:&lt;/b&gt; 알림톡 전송 실패, 충전 종료/시도 실패 같은 흐름을 안전하게 재처리. 로그가 한군데 모여 원인 파악이 빨라짐&lt;/p&gt;
&lt;h3 data-end=&quot;1639&quot; data-start=&quot;1622&quot; data-ke-size=&quot;size23&quot;&gt;3) 읽기 모델 분리&lt;/h3&gt;
&lt;p data-end=&quot;1818&quot; data-start=&quot;1640&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;목적:&lt;/b&gt; 대시보드&amp;middot;통계 같은 비싼 조회를 항상 빠르게&lt;br /&gt;&lt;b&gt;방법:&lt;/b&gt; 서비스 합성 쿼리를 집계 테이블과 조회 전용 뷰로 분리. 배치는 선계산, API는 MV&amp;middot;Projection&amp;middot;Redis에서 단건 조회&lt;br /&gt;&lt;b&gt;효과:&lt;/b&gt; 운영 시간대 p95 안정화, 트래픽 피크에서도 속도 유지. 원장 테이블 잠금&amp;middot;IO 압력 감소&lt;/p&gt;
&lt;h3 data-end=&quot;1840&quot; data-start=&quot;1820&quot; data-ke-size=&quot;size23&quot;&gt;4) 관측 지표 기본 카드&lt;/h3&gt;
&lt;p data-end=&quot;1982&quot; data-start=&quot;1841&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;목적:&lt;/b&gt; &amp;ldquo;느낌&amp;rdquo;이 아닌 숫자로 상태를 말하기&lt;br /&gt;&lt;b&gt;방법:&lt;/b&gt; p95, p99, 실패율, 재처리 성공률, 큐 적체량을 대시보드 기본 카드로 고정. 배포 전후 스냅샷 비교&lt;br /&gt;&lt;b&gt;효과:&lt;/b&gt; 회귀 조기 탐지, 성능 논쟁 단축, 개선의 전후가 명확해짐&lt;/p&gt;
&lt;h3 data-end=&quot;2005&quot; data-start=&quot;1984&quot; data-ke-size=&quot;size23&quot;&gt;5) 이벤트 경계 얇게 유지&lt;/h3&gt;
&lt;p data-end=&quot;2176&quot; data-start=&quot;2006&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;목적:&lt;/b&gt; 한 트랜잭션에 모든 후처리를 몰아 넣지 않기&lt;br /&gt;&lt;b&gt;방법:&lt;/b&gt; 실시간 경로는 최소 책임만 처리. 과금&amp;middot;랭킹&amp;middot;알림은 이벤트 발행 후 소비로 분리. 소비 측은 멱등 키와 실패 기록으로 리플레이 가능하게&lt;br /&gt;&lt;b&gt;효과:&lt;/b&gt; 성능&amp;middot;가용성&amp;middot;복구성이 동시에 올라감. 실시간 장애가 후처리로 전파되지 않음&lt;/p&gt;
&lt;h2 data-end=&quot;2191&quot; data-start=&quot;2178&quot; data-ke-size=&quot;size26&quot;&gt;내가 달라진 점&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2759&quot; data-start=&quot;2192&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2319&quot; data-start=&quot;2192&quot;&gt;&lt;b&gt;&amp;ldquo;돌아가면 끝&amp;rdquo; &amp;rarr; &amp;ldquo;복구 가능해야 끝&amp;rdquo;.&lt;/b&gt; 성공 응답으로 안심하지 않는다. 실패 시 어디까지 처리됐는지, 무엇을 되돌릴지, 어떻게 다시 시도할지를 먼저 설계하고, 성공/실패 시나리오를 같은 무게로 테스트에 남긴다.&lt;/li&gt;
&lt;li data-end=&quot;2450&quot; data-start=&quot;2320&quot;&gt;&lt;b&gt;코드보다 경계와 지표.&lt;/b&gt; 도메인&amp;middot;애플리케이션&amp;middot;인프라 경계를 먼저 긋고, 예외는 사용자 메시지/시스템 사유/재시도 가능 여부로 분리한다. 완료 기준에는 p95/p99, 재처리 성공률, 큐 적체량 같은 관측 지표가 포함된다.&lt;/li&gt;
&lt;li data-end=&quot;2544&quot; data-start=&quot;2451&quot;&gt;&lt;b&gt;코드 전에 &amp;lsquo;왜&amp;rsquo;를 묻는 루틴.&lt;/b&gt; 1순위 품질속성, 복구 위치/주체, 멱등 보장 방식, 전후 비교 지표, 나중에 갈아끼울 확장 포인트를 메모하고 시작한다.&lt;/li&gt;
&lt;li data-end=&quot;2603&quot; data-start=&quot;2545&quot;&gt;&lt;b&gt;요령 아닌 방법.&lt;/b&gt; 재현 스크립트&amp;middot;회귀 테스트&amp;middot;런북을 남겨 반복 가능한 해결법으로 고정한다.&lt;/li&gt;
&lt;li data-end=&quot;2705&quot; data-start=&quot;2604&quot;&gt;&lt;b&gt;동시성과 장애를 테스트로 증명.&lt;/b&gt; CountDownLatch 기반 동시성, 타임아웃/부분 성공/중복 메시지 테스트를 표준 세트로 두고 현상을 드러내는 이름으로 고정한다.&lt;/li&gt;
&lt;li data-end=&quot;2759&quot; data-start=&quot;2706&quot;&gt;&lt;b&gt;결정의 근거 기록.&lt;/b&gt; PR/문서에 선택지&amp;rarr;기준&amp;rarr;채택 사유&amp;rarr;버린 대안을 짧게라도 남긴다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-end=&quot;2776&quot; data-start=&quot;2761&quot; data-ke-size=&quot;size26&quot;&gt;받은 피드백과 해석&lt;/h2&gt;
&lt;p data-end=&quot;2871&quot; data-start=&quot;2777&quot; data-ke-size=&quot;size16&quot;&gt;끈기와 태도는 좋다. 다만 좋은 코드를 더 많이 보라는 피드백을 받았다. 동의한다. 내 코드만 고치는 데서 멈추지 않고, &amp;ldquo;왜 좋은지 설명 가능한 기준&amp;rdquo;을 몸에 넣겠다.&lt;/p&gt;
&lt;h2 data-end=&quot;2883&quot; data-start=&quot;2873&quot; data-ke-size=&quot;size26&quot;&gt;앞으로의 행보&lt;/h2&gt;
&lt;p data-end=&quot;2904&quot; data-start=&quot;2884&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;&quot; data-end=&quot;3094&quot; data-start=&quot;2905&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2940&quot; data-start=&quot;2905&quot;&gt;목표: 프레임워크 수준에서 &amp;ldquo;왜 이렇게 설계됐는가&amp;rdquo; 이해&lt;/li&gt;
&lt;li data-end=&quot;3034&quot; data-start=&quot;2941&quot;&gt;토픽: 빈 생명주기/후처리기, AOP 프록시, 트랜잭션 전파&amp;middot;격리/롤백 규칙, MVC&amp;middot;WebFlux 스레딩 모델, Spring Batch 청크&amp;middot;재시도&amp;middot;스킵 동작&lt;/li&gt;
&lt;li data-end=&quot;3094&quot; data-start=&quot;3035&quot;&gt;방법: 샘플 프로젝트로 기능 분리, 디버거로 호출 스택 추적, 핵심 코드 스냅을 짧은 주석과 함께 정리&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;3116&quot; data-start=&quot;3096&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;&quot; data-end=&quot;3237&quot; data-start=&quot;3117&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;3141&quot; data-start=&quot;3117&quot;&gt;목표: 감이 아니라 패턴 언어로 설명&lt;/li&gt;
&lt;li data-end=&quot;3184&quot; data-start=&quot;3142&quot;&gt;기준: 경계 설계, 실패 흐름, 테스트 용이성, 확장 포인트, 관측성&lt;/li&gt;
&lt;li data-end=&quot;3237&quot; data-start=&quot;3185&quot;&gt;활동: 유명 OSS의 서비스 레이어&amp;middot;테스트 코드 주 2회 리딩, 내 언어로 요약 노트 작성&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;3253&quot; data-start=&quot;3239&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;&quot; data-end=&quot;3364&quot; data-start=&quot;3254&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;3303&quot; data-start=&quot;3254&quot;&gt;목표: 매주 한 모듈을 선정해 이해 가능성&amp;uarr; 결합도&amp;darr; 복구 가능성&amp;uarr; 기준으로 재단&lt;/li&gt;
&lt;li data-end=&quot;3364&quot; data-start=&quot;3304&quot;&gt;체크: 명령/조회 분리, 책임 섞인 클래스 쪼개기, 예외&amp;middot;오류 규칙 통일, 동시성&amp;middot;실패 회귀 테스트 추가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;3386&quot; data-start=&quot;3366&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;알고리즘&amp;middot;코딩 테스트 준비&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;3502&quot; data-start=&quot;3387&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;3418&quot; data-start=&quot;3387&quot;&gt;목표: 문제 분해와 복잡도 관리, 구현 속도 유지&lt;/li&gt;
&lt;li data-end=&quot;3467&quot; data-start=&quot;3419&quot;&gt;루틴: 주 5문제(그리디&amp;middot;투포인터&amp;middot;그래프&amp;middot;DP&amp;middot;자료구조), 주 1회 실전 타이머&lt;/li&gt;
&lt;li data-end=&quot;3502&quot; data-start=&quot;3468&quot;&gt;관점: 코테는 코딩 속도보다 경계 조건과 복잡도 제어 훈련&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;3516&quot; data-start=&quot;3504&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;&quot; data-end=&quot;3598&quot; data-start=&quot;3517&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;3555&quot; data-start=&quot;3517&quot;&gt;형태: 블로그 짧은 글, 요약 10줄과 코드 스냅 20줄 이내&lt;/li&gt;
&lt;li data-end=&quot;3598&quot; data-start=&quot;3556&quot;&gt;주제: 스프링 내부 한 포인트, 좋은 코드 한 조각, 리팩토링 전후 비교&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-end=&quot;3385&quot; data-start=&quot;3382&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;3393&quot; data-start=&quot;3387&quot; data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-end=&quot;126&quot; data-start=&quot;81&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;&quot; data-end=&quot;656&quot; data-start=&quot;128&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;214&quot; data-start=&quot;128&quot;&gt;&lt;b&gt;습관을 되찾았다.&lt;/b&gt; &amp;ldquo;일단 만들자&amp;rdquo;에서 벗어나, 코딩 전에 &lt;b&gt;왜/복구/지표&lt;/b&gt;를 적는 루틴이 생겼다. 이건 혼자선 못 만들었을 확률이 높다.&lt;/li&gt;
&lt;li data-end=&quot;295&quot; data-start=&quot;215&quot;&gt;&lt;b&gt;설명할 수 있게 됐다.&lt;/b&gt; &amp;ldquo;그냥 이렇게요&amp;rdquo;가 아니라, &lt;b&gt;왜 이 설계를 택했는지&lt;/b&gt;를 근거로 말한다. 다음 선택의 속도가 달라진다.&lt;/li&gt;
&lt;li data-end=&quot;405&quot; data-start=&quot;296&quot;&gt;&lt;b&gt;운영을 버티는 감각을 얻었다.&lt;/b&gt; 멱등성&amp;middot;재시도&amp;middot;DLT&amp;middot;락&amp;middot;MV/배치 같은 것들이 머릿속 지식이 아니라 &lt;b&gt;손에 남는 방법&lt;/b&gt;이 됐다. 장애가 나도 다시 세울 수 있다는 확신이 생겼다.&lt;/li&gt;
&lt;li data-end=&quot;486&quot; data-start=&quot;406&quot;&gt;&lt;b&gt;측정으로 대화한다.&lt;/b&gt; p95/p99, 재처리 성공률, 큐 적체량 같은 지표로 의사결정을 한다. 감(느낌)으로 다투던 시간을 줄였다.&lt;/li&gt;
&lt;li data-end=&quot;563&quot; data-start=&quot;487&quot;&gt;&lt;b&gt;혼자가 아니다.&lt;/b&gt; 같은 고민을 하는 동료들과의 피드백 루프가 생겼다. 이 리듬이 없었으면 다시 예전 패턴으로 돌아갔을 거다.&lt;/li&gt;
&lt;li data-end=&quot;656&quot; data-start=&quot;564&quot;&gt;&lt;b&gt;다음 단계의 기준이 생겼다.&lt;/b&gt; &amp;ldquo;좋은 코드 많이 보라&amp;rdquo;는 피드백을 &lt;b&gt;행동 계획&lt;/b&gt;으로 번역했다(스프링 뜯어보기, 코드 리딩, 주간 리팩토링, 기록 루틴).&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-is-only-node=&quot;&quot; data-is-last-node=&quot;&quot; data-end=&quot;836&quot; data-start=&quot;658&quot; data-ke-size=&quot;size16&quot;&gt;그러니까 &lt;b&gt;이제부터가 시작&lt;/b&gt;이다. 10주 동안 손에 쥔 걸 &lt;b&gt;몸에 붙이고&lt;/b&gt;, 매일 조금씩이라도 &lt;b&gt;학습을 놓지 않는 습관&lt;/b&gt;으로 굳히겠다. 내가 정한 계획&amp;mdash;&lt;b&gt;스프링 뜯어보기, 좋은 코드 읽기, 주간 리팩토링, 관측 지표 고정, 기록 루틴&lt;/b&gt;&amp;mdash;을 지키면서 &lt;b&gt;팀이 믿을 수 있는 개발자&lt;/b&gt;로 자라나는 게 목표다.&lt;/p&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Loopers</category>
      <author>그zi운아이</author>
      <guid isPermaLink="true">https://dev-th.tistory.com/76</guid>
      <comments>https://dev-th.tistory.com/76#entry76comment</comments>
      <pubDate>Fri, 19 Sep 2025 02:00:55 +0900</pubDate>
    </item>
    <item>
      <title>WIL Redis 자료구조</title>
      <link>https://dev-th.tistory.com/75</link>
      <description>&lt;h1&gt;&lt;span&gt;WIL (2025-09-08 ~ 2025-09-14)&lt;/span&gt;&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;Redis 자료구조 집중 요약 (Data Structures Only)&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이번 주는 &lt;/span&gt;&lt;span&gt;&lt;b&gt;자료구조 자체만&lt;/b&gt;&lt;/span&gt;&lt;span&gt; 정리했다. 각 구조의 한 줄 설명, 언제 쓰는지, 핵심 명령과 복잡도, 주의점만 남겼다.&lt;/span&gt;&lt;/p&gt;
&lt;div contenteditable=&quot;false&quot;&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;0) TL;DR&lt;/span&gt;&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;String / List / Set / Sorted Set(ZSET) / Hash / Stream / Bitmap&amp;middot;Bitfield / HyperLogLog / Geo&lt;/b&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;기본 패턴만 알면 80%는 끝: 캐시(String), 큐(List), 중복 제거(Set), 랭킹(ZSET), 작은 객체(Hash), 이벤트 로그(Stream), 출석&amp;middot;플래그(Bitmap), 고유 수 추정(HLL), 반경 검색(Geo).&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;div contenteditable=&quot;false&quot;&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;1) 한눈 비교표&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&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;/td&gt;
&lt;td&gt;특징&lt;/td&gt;
&lt;td&gt;대표 시나리오&lt;/td&gt;
&lt;td&gt;핵심 명령&lt;/td&gt;
&lt;td&gt;삽입&lt;/td&gt;
&lt;td&gt;갱신/조회/범위&lt;/td&gt;
&lt;td&gt;주의/비고&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 9.76744%;&quot;&gt;&lt;span&gt;&lt;b&gt;String&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 18.2558%;&quot;&gt;&lt;span&gt;값 1개(바이너리 안전)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 12.093%;&quot;&gt;&lt;span&gt;캐시, 카운터, 토큰&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 20.2326%;&quot;&gt;&lt;span&gt;SET/GET&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;SETEX&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;GETEX&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;INCRBY&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 7.67442%;&quot;&gt;&lt;span&gt;O(1)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 13.1395%;&quot;&gt;&lt;span&gt;O(1)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 18.6047%;&quot;&gt;&lt;span&gt;TTL 옵션(NX/XX/EX/PX/KEEPTTL)&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 9.76744%;&quot;&gt;&lt;span&gt;&lt;b&gt;List&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 18.2558%;&quot;&gt;&lt;span&gt;양끝 입출(큐/스택)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 12.093%;&quot;&gt;&lt;span&gt;작업 큐, 최근 N개&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 20.2326%;&quot;&gt;&lt;span&gt;LPUSH/RPUSH&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;BLPOP/BRPOP&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;LRANGE&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;LTRIM&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 7.67442%;&quot;&gt;&lt;span&gt;O(1) (양끝)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 13.1395%;&quot;&gt;&lt;span&gt;LRANGE&lt;/span&gt;&lt;span&gt; O(N)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 18.6047%;&quot;&gt;&lt;span&gt;중간 삭제/접근 O(N)&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 9.76744%;&quot;&gt;&lt;span&gt;&lt;b&gt;Set&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 18.2558%;&quot;&gt;&lt;span&gt;중복 없음, 무순서&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 12.093%;&quot;&gt;&lt;span&gt;태그, 팔로우, 교집합&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 20.2326%;&quot;&gt;&lt;span&gt;SADD/SREM&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;SISMEMBER&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;SINTER&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 7.67442%;&quot;&gt;&lt;span&gt;평균 O(1)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 13.1395%;&quot;&gt;&lt;span&gt;집합연산 O(N)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 18.6047%;&quot;&gt;&lt;span&gt;큰 집합은 &lt;/span&gt;&lt;span&gt;SSCAN&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 9.76744%;&quot;&gt;&lt;span&gt;&lt;b&gt;ZSET&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 18.2558%;&quot;&gt;&lt;span&gt;점수 기반 정렬&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 12.093%;&quot;&gt;&lt;span&gt;랭킹, 점수 집계&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 20.2326%;&quot;&gt;&lt;span&gt;ZADD&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;ZINCRBY&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;ZRANGE*&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;ZREMRANGEBYRANK&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 7.67442%;&quot;&gt;&lt;span&gt;O(log N)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 13.1395%;&quot;&gt;&lt;span&gt;범위 O(log N+M)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 18.6047%;&quot;&gt;&lt;span&gt;상위 N만 유지로 pruning&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 9.76744%;&quot;&gt;&lt;span&gt;&lt;b&gt;Hash&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 18.2558%;&quot;&gt;&lt;span&gt;작은 JSON 느낌&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 12.093%;&quot;&gt;&lt;span&gt;사용자/상품 속성&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 20.2326%;&quot;&gt;&lt;span&gt;HSET/HGET/HMGET&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;HGETALL&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;HINCRBY&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 7.67442%;&quot;&gt;&lt;span&gt;평균 O(1)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 13.1395%;&quot;&gt;&lt;span&gt;HGETALL&lt;/span&gt;&lt;span&gt; O(N)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 18.6047%;&quot;&gt;&lt;span&gt;필요한 필드만 조회&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 9.76744%;&quot;&gt;&lt;span&gt;&lt;b&gt;Stream&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 18.2558%;&quot;&gt;&lt;span&gt;로그 + 컨슈머그룹&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 12.093%;&quot;&gt;&lt;span&gt;이벤트 처리, 재시도&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 20.2326%;&quot;&gt;&lt;span&gt;XADD&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;XGROUP&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;XREADGROUP&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;XACK&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 7.67442%;&quot;&gt;&lt;span&gt;O(1)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 13.1395%;&quot;&gt;&lt;span&gt;O(1~)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 18.6047%;&quot;&gt;&lt;span&gt;MAXLEN&lt;/span&gt;&lt;span&gt; 유지, PENDING 관리&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 9.76744%;&quot;&gt;&lt;span&gt;&lt;b&gt;Bitmap&lt;/b&gt;&lt;/span&gt;&lt;span&gt;/&lt;/span&gt;&lt;span&gt;&lt;b&gt;Bitfield&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 18.2558%;&quot;&gt;&lt;span&gt;비트 플래그&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 12.093%;&quot;&gt;&lt;span&gt;출석, 활성여부&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 20.2326%;&quot;&gt;&lt;span&gt;SETBIT/GETBIT&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;BITCOUNT&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;BITFIELD&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 7.67442%;&quot;&gt;&lt;span&gt;O(1)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 13.1395%;&quot;&gt;&lt;span&gt;BITCOUNT&lt;/span&gt;&lt;span&gt; O(N/word)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 18.6047%;&quot;&gt;&lt;span&gt;오프셋 설계 고정&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 9.76744%;&quot;&gt;&lt;span&gt;&lt;b&gt;HyperLogLog&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 18.2558%;&quot;&gt;&lt;span&gt;근사 유니크 카운트&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 12.093%;&quot;&gt;&lt;span&gt;일간 UV&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 20.2326%;&quot;&gt;&lt;span&gt;PFADD&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;PFCOUNT&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;PFMERGE&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 7.67442%;&quot;&gt;&lt;span&gt;O(1)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 13.1395%;&quot;&gt;&lt;span&gt;O(1)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 18.6047%;&quot;&gt;&lt;span&gt;목록 조회 불가, 오차 허용&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 9.76744%;&quot;&gt;&lt;span&gt;&lt;b&gt;Geo&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 18.2558%;&quot;&gt;&lt;span&gt;좌표 + 반경 검색&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 12.093%;&quot;&gt;&lt;span&gt;근처 매장/충전소&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 20.2326%;&quot;&gt;&lt;span&gt;GEOADD&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;GEOSEARCH&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;GEODIST&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 7.67442%;&quot;&gt;&lt;span&gt;O(log N)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 13.1395%;&quot;&gt;&lt;span&gt;O(log N+M)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 18.6047%;&quot;&gt;&lt;span&gt;내부 ZSET, 단위 주의&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;복잡도 주의: &lt;/span&gt;&lt;span&gt;*MEMBERS&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;*RANGE&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;*GETALL&lt;/span&gt;&lt;span&gt; 류는 요소 수에 비례. 대량 데이터엔 &lt;/span&gt;&lt;span&gt;SCAN&lt;/span&gt;&lt;span&gt; 계열 사용.&lt;/span&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;div contenteditable=&quot;false&quot;&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;2) 자료구조별 핵심 정리&lt;/span&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;2.1 String &amp;mdash; &quot;포스트잇&quot;&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;언제&lt;/b&gt;&lt;/span&gt;&lt;span&gt;: 캐시 값, 토큰, 분산 락, 카운터.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;명령&lt;/b&gt;&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;캐시: &lt;/span&gt;&lt;span&gt;SET key value EX 60&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;조건부 set(없을 때만): &lt;/span&gt;&lt;span&gt;SET k v NX EX 60&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;카운터: &lt;/span&gt;&lt;span&gt;INCRBY k 1&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;TTL만 갱신: &lt;/span&gt;&lt;span&gt;GETEX k EX 30&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;주의&lt;/b&gt;&lt;/span&gt;&lt;span&gt;: 큰 값은 네트워크&amp;middot;메모리 비용&amp;uarr;. 만료 설계 없으면 캐시 폭주 위험.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;2.2 List &amp;mdash; &quot;줄 서기&quot;&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;언제&lt;/b&gt;&lt;/span&gt;&lt;span&gt;: 작업 큐(생산자/소비자), 최근 N개 보관.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;명령&lt;/b&gt;&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;넣기/빼기: &lt;/span&gt;&lt;span&gt;RPUSH q 1 2&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;BLPOP q 5&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;슬라이스: &lt;/span&gt;&lt;span&gt;LRANGE q 0 9&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;상한 유지: &lt;/span&gt;&lt;span&gt;LTRIM q 0 999&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;주의&lt;/b&gt;&lt;/span&gt;&lt;span&gt;: 중간 접근/삭제는 비쌈(O(N)).&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;2.3 Set &amp;mdash; &quot;중복 없는 모음&quot;&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;언제&lt;/b&gt;&lt;/span&gt;&lt;span&gt;: 유저 태그, 팔로우, 추천 교집합.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;명령&lt;/b&gt;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;SADD&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;SISMEMBER&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;SINTER&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;SPOP&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;SCARD&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;주의&lt;/b&gt;&lt;/span&gt;&lt;span&gt;: 전체 나열 &lt;/span&gt;&lt;span&gt;SMEMBERS&lt;/span&gt;&lt;span&gt; 대신 대규모는 &lt;/span&gt;&lt;span&gt;SSCAN&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;2.4 Sorted Set(ZSET) &amp;mdash; &quot;점수 달린 순위표&quot;&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;언제&lt;/b&gt;&lt;/span&gt;&lt;span&gt;: 랭킹, 점수 기반 정렬, 시간/점수 범위 조회.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;명령&lt;/b&gt;&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;추가/증가: &lt;/span&gt;&lt;span&gt;ZADD rank 1 user:1&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;ZINCRBY rank 1 user:1&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;조회: &lt;/span&gt;&lt;span&gt;ZREVRANGE rank 0 9 WITHSCORES&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;ZRANGEBYSCORE rank 0 100&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;정리: &lt;/span&gt;&lt;span&gt;ZREMRANGEBYRANK rank 0 -1001&lt;/span&gt;&lt;span&gt; (Top 1000 유지)&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;주의&lt;/b&gt;&lt;/span&gt;&lt;span&gt;: 대량 멤버 관리 시 정리(pruning) 필요.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;2.5 Hash &amp;mdash; &quot;작은 JSON&quot;&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;언제&lt;/b&gt;&lt;/span&gt;&lt;span&gt;: 객체를 필드 단위로 저장(이름, 포인트 등).&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;명령&lt;/b&gt;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;HSET user:1 name Taehoon point 100&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;HMGET user:1 name point&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;HINCRBY user:1 point 10&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;주의&lt;/b&gt;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;HGETALL&lt;/span&gt;&lt;span&gt; 남용 금지(필드 많을수록 비용&amp;uarr;).&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;2.6 Stream &amp;mdash; &quot;이벤트 로그&quot;&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;언제&lt;/b&gt;&lt;/span&gt;&lt;span&gt;: 소비자 그룹으로 작업 분배/재시도 필요한 큐.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;명령&lt;/b&gt;&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;추가: &lt;/span&gt;&lt;span&gt;XADD orders * id 100 price 3000&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;그룹 생성: &lt;/span&gt;&lt;span&gt;XGROUP CREATE orders g1 $ MKSTREAM&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;읽기: &lt;/span&gt;&lt;span&gt;XREADGROUP GROUP g1 c1 COUNT 100 BLOCK 5000 STREAMS orders &amp;gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;확인: &lt;/span&gt;&lt;span&gt;XACK orders g1 &amp;lt;id&amp;gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;주의&lt;/b&gt;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;MAXLEN&lt;/span&gt;&lt;span&gt;으로 길이 제한, PENDING(미확인) 주기적 정리.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;2.7 Bitmap/Bitfield &amp;mdash; &quot;출석판/플래그&quot;&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;언제&lt;/b&gt;&lt;/span&gt;&lt;span&gt;: 사용자 i의 상태(0/1), 빠른 합계.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;명령&lt;/b&gt;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;SETBIT attend:20250910 123 1&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;GETBIT attend:20250910 123&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;BITCOUNT attend:20250910&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;주의&lt;/b&gt;&lt;/span&gt;&lt;span&gt;: 오프셋에 어떤 의미를 줄지(사용자ID/날짜 등) &lt;/span&gt;&lt;span&gt;&lt;b&gt;처음에 고정&lt;/b&gt;&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;2.8 HyperLogLog &amp;mdash; &quot;UV 근사치&quot;&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;언제&lt;/b&gt;&lt;/span&gt;&lt;span&gt;: 고유 수를 메모리 작게, 빠르게 추정.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;명령&lt;/b&gt;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;PFADD uv:20250910 u1 u2&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;PFCOUNT uv:20250910&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;주의&lt;/b&gt;&lt;/span&gt;&lt;span&gt;: 개별 목록은 못 얻는다(근사치 전용).&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span&gt;2.9 Geo &amp;mdash; &quot;근처 찾기&quot;&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;언제&lt;/b&gt;&lt;/span&gt;&lt;span&gt;: 반경/거리 기반 검색.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;명령&lt;/b&gt;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;GEOADD shop 126.9 37.5 s1&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;GEOSEARCH shop FROMLONLAT 126.9 37.5 BYRADIUS 3 km WITHDIST&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;주의&lt;/b&gt;&lt;/span&gt;&lt;span&gt;: 내부적으로 ZSET, 단위(km/m) 명시.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;div contenteditable=&quot;false&quot;&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span&gt;3) 사용처 기반 선택 가이드&lt;/span&gt;&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;캐시&amp;middot;토큰&amp;middot;카운터&lt;/b&gt;&lt;/span&gt;&lt;span&gt; &amp;rarr; String&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;큐/백로그&lt;/b&gt;&lt;/span&gt;&lt;span&gt; &amp;rarr; List(간단), Stream(여러 소비자&amp;middot;재시도)&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;중복 없는 집합/교집합&lt;/b&gt;&lt;/span&gt;&lt;span&gt; &amp;rarr; Set&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;순위/정렬&amp;middot;점수&lt;/b&gt;&lt;/span&gt;&lt;span&gt; &amp;rarr; ZSET&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;작은 객체&lt;/b&gt;&lt;/span&gt;&lt;span&gt; &amp;rarr; Hash&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;출석/활성 플래그&lt;/b&gt;&lt;/span&gt;&lt;span&gt; &amp;rarr; Bitmap/Bitfield&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;대략 UV&lt;/b&gt;&lt;/span&gt;&lt;span&gt; &amp;rarr; HyperLogLog&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;반경&amp;middot;거리&lt;/b&gt;&lt;/span&gt;&lt;span&gt; &amp;rarr; Geo&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;div contenteditable=&quot;false&quot;&gt;&lt;hr data-ke-style=&quot;style1&quot; /&gt;&lt;/div&gt;</description>
      <category>Loopers</category>
      <author>그zi운아이</author>
      <guid isPermaLink="true">https://dev-th.tistory.com/75</guid>
      <comments>https://dev-th.tistory.com/75#entry75comment</comments>
      <pubDate>Sun, 14 Sep 2025 12:04:59 +0900</pubDate>
    </item>
    <item>
      <title>  &amp;quot;랭킹 좀 만들어달라는데 Redis 메모리가...&amp;quot; - ZSET 랭킹 시스템 구축기</title>
      <link>https://dev-th.tistory.com/74</link>
      <description>&lt;h1&gt;  &quot;랭킹 좀 만들어달라는데 Redis 메모리가...&quot; - ZSET 랭킹 시스템 구축기&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;TL;DR&lt;/b&gt;: &quot;간단한 랭킹 좀 만들어주세요&quot;라는 요청이 어떻게 다중 ZSET vs 단일 ZSET 설계 고민 &amp;rarr; 가중치 실시간 변경 지옥 &amp;rarr; 콜드 스타트 딜레마까지 이어졌는지. 실제 커밋 로그와 함께 보는 7일간의 현실.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  Day 1: 기존 구조에 한 줄만 추가하면 되겠네&lt;/h2&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;: &quot;오늘의 인기상품 랭킹 보여주실 수 있나요?&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;나&lt;/b&gt;: &quot;MetricsHandler에 랭킹 서비스 한 줄만 추가하면 될 것 같은데요?&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트를 살펴보니 이미 완성된 이벤트 처리 구조가 있었다:&lt;/p&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;// apps/commerce-streamer/src/main/java/com/loopers/handler/impl/MetricsHandler.java
@Component
public class MetricsHandler implements EventHandler {
    private final MetricsService metricsService;
    private final RankingService rankingService; // 이 한 줄이 핵심

    @Override
    public void handle(GeneralEnvelopeEvent envelope) {
        metricsService.recordMetric(envelope);
        rankingService.updateRanking(envelope); // 딱 한 줄 추가
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;첫 번째 설계 결정&lt;/b&gt;: 새로운 Consumer를 만들지 말고 기존 MetricsHandler에 한 줄만 추가하자.&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;이미 METRIC_EVENTS 필터링이 되어 있음&lt;/li&gt;
&lt;li&gt;배치 처리도 handleBatch() 메서드로 구현되어 있음&lt;/li&gt;
&lt;li&gt;중복 Consumer 방지&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  설계 결정의 딜레마&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  첫 번째 고민: 다중 ZSET vs 단일 ZSET&lt;/h3&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;//   처음 생각한 구조 (복잡함)
ranking:view:20250911  &amp;rarr; 조회 점수만
ranking:like:20250911  &amp;rarr; 좋아요 점수만  
ranking:order:20250911 &amp;rarr; 주문 점수만

// 조회할 때마다 ZUNIONSTORE로 합산
ZUNIONSTORE ranking:final:20250911 3 
    ranking:view:20250911 ranking:like:20250911 ranking:order:20250911 
    WEIGHTS 0.1 0.2 0.7

// ✅ 결국 선택한 구조 (단순함)
ranking:all:20250911 &amp;rarr; 가중치 적용된 최종 점수
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Untitled diagram _ Mermaid Chart-2025-09-11-122412.png&quot; data-origin-width=&quot;3840&quot; data-origin-height=&quot;1208&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bLw8Qy/btsQwTaSzeo/PC3JFYB9SUmEpHcxSL4FMk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bLw8Qy/btsQwTaSzeo/PC3JFYB9SUmEpHcxSL4FMk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bLw8Qy/btsQwTaSzeo/PC3JFYB9SUmEpHcxSL4FMk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbLw8Qy%2FbtsQwTaSzeo%2FPC3JFYB9SUmEpHcxSL4FMk%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;3840&quot; height=&quot;1208&quot; data-filename=&quot;Untitled diagram _ Mermaid Chart-2025-09-11-122412.png&quot; data-origin-width=&quot;3840&quot; data-origin-height=&quot;1208&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&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;ZUNIONSTORE 연산 제거 &amp;rarr; 성능 향상&lt;/li&gt;
&lt;li&gt;키 관리 복잡도 감소 (3개 &amp;rarr; 1개)&lt;/li&gt;
&lt;li&gt;실시간 조회 최적화&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  두 번째 고민: 단건 처리 vs 배치 처리&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;//   처음 구현 (단건 처리)
@Override
public void handle(GeneralEnvelopeEvent envelope) {
    metricsService.recordMetric(envelope);
    rankingService.updateRanking(envelope); // 하나씩 Redis 호출
}

//   개선 후 (배치 처리)
public void handleBatch(List&amp;lt;GeneralEnvelopeEvent&amp;gt; envelopes) {
    for (GeneralEnvelopeEvent envelope : envelopes) {
        metricsService.recordMetric(envelope);
    }
    rankingService.updateRankingBatch(envelopes); // Pipeline으로 일괄 처리
}
&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;단건: 100개 이벤트 = 100번 Redis 호출&lt;/li&gt;
&lt;li&gt;배치: 100개 이벤트 = 1번 Pipeline 호출 (네트워크 비용 대폭 절약!)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  세 번째 고민: 하드코딩 vs WeightManager&lt;/h3&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;: &quot;가중치를 실시간으로 바꿀 수 있어야 해요!&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;//   처음엔 하드코딩
private Double calculateScore(String eventType, JsonNode payload) {
    return switch (eventType) {
        case EventTypes.PRODUCT_VIEWED -&amp;gt; 0.1;
        case EventTypes.PRODUCT_LIKED -&amp;gt; 0.2;
        case EventTypes.ORDER_CREATED -&amp;gt; 0.7;
        default -&amp;gt; null;
    };
}

//   WeightManager 도입
private Double calculateScore(String eventType, JsonNode payload) {
    return switch (eventType) {
        case EventTypes.PRODUCT_VIEWED -&amp;gt; weightManager.getWeight(RankingEventType.PRODUCT_VIEWED);
        case EventTypes.PRODUCT_LIKED -&amp;gt; weightManager.getWeight(RankingEventType.PRODUCT_LIKED);
        case EventTypes.ORDER_CREATED -&amp;gt; {
            double orderAmount = extractOrderAmount(payload);
            double weight = weightManager.getWeight(RankingEventType.ORDER_CREATED);
            yield weight * Math.log(1 + orderAmount / 1000.0); // 로그 스케일링!
        }
        default -&amp;gt; null;
    };
}
&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;1만원 주문 vs 100만원 주문이 100배 차이는 너무 극단적&lt;/li&gt;
&lt;li&gt;Math.log(1 + amount/1000.0)로 완만한 증가 곡선 적용&lt;/li&gt;
&lt;li&gt;공정한 점수 분배 달성&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;⚡ Day 4-5: 실시간 가중치 변경의 지옥&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  기획팀과의 폭탄 발언&lt;/h3&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;: &quot;내일 블랙프라이데이인데, 주문 가중치를 2배로 올려주세요! 바로 반영되어야 해요!&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;나&lt;/b&gt;: &quot;기존 점수들은 어떻게 하죠? 어제는 옛날 가중치고 오늘은 새 가중치인데...&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;기획팀&lt;/b&gt;: &quot;그냥 다 새로운 가중치로 맞춰주시면 안 되나요?&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;나&lt;/b&gt;: &quot;...&quot;  &lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Untitled diagram _ Mermaid Chart-2025-09-11-120543.png&quot; data-origin-width=&quot;3840&quot; data-origin-height=&quot;1396&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/q9XaR/btsQtasdJRf/GWzWGfh5wvt3mx5i6JLUn1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/q9XaR/btsQtasdJRf/GWzWGfh5wvt3mx5i6JLUn1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/q9XaR/btsQtasdJRf/GWzWGfh5wvt3mx5i6JLUn1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fq9XaR%2FbtsQtasdJRf%2FGWzWGfh5wvt3mx5i6JLUn1%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;3840&quot; height=&quot;1396&quot; data-filename=&quot;Untitled diagram _ Mermaid Chart-2025-09-11-120543.png&quot; data-origin-width=&quot;3840&quot; data-origin-height=&quot;1396&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  절충안: 비율 계산으로 재계산&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;public void updateWeight(RankingEventType eventType, double newWeight) {
    double oldWeight = getWeight(eventType);
    
    // Redis 업데이트
    redisTemplate.opsForHash().put(WEIGHTS_KEY, eventType.name(), String.valueOf(newWeight));
    
    //   핵심: 기존 랭킹 데이터 재계산
    recalculateExistingRankings(eventType, oldWeight, newWeight);
}

private void recalculateExistingRankings(RankingEventType eventType, double oldWeight, double newWeight) {
    if (oldWeight == 0.0) {
        log.warn(&quot;기존 가중치가 0이므로 재계산 건너뜀: {}&quot;, eventType);
        return;
    }
    
    double ratio = newWeight / oldWeight; //   비율로 해결!
    
    // 오늘과 어제만 재계산 (성능 vs 정확성 트레이드오프)
    for (int i = 0; i &amp;lt; 2; i++) {
        LocalDate targetDate = LocalDate.now().minusDays(i);
        recalculateRankingForDate(targetDate, ratio, eventType);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;⚖️ 트레이드오프 테이블:&lt;/h3&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;/td&gt;
&lt;td&gt;장점&lt;/td&gt;
&lt;td&gt;&amp;nbsp; 단점&lt;/td&gt;
&lt;td&gt;&amp;nbsp;선택&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;전체 재계산&lt;/td&gt;
&lt;td&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;/td&gt;
&lt;td&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;/td&gt;
&lt;td&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;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  Day 5-6: 콜드 스타트 딜레마&amp;nbsp;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  자정이 되면서 랭킹이 텅 비었다...&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;timeline
    title 하루의 랭킹 변화
    section 23:59
        랭킹 풍성 : ranking:all:20250911
                   : product:1 &amp;rarr; 1000점
                   : product:2 &amp;rarr; 800점
                   : product:3 &amp;rarr; 600점
    section 00:00
        키 변경됨 : ranking:all:20250912
                  : (텅 비어있음)
                  : 신상품과 기존상품 모두 0점
    section 00:01
        사용자 접속 : &quot;인기상품이 없네요?&quot;
                    : 이탈률 증가  
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  Carry-over 스케줄러 도입&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Scheduled(cron = &quot;0 50 23 * * *&quot;, zone = &quot;Asia/Seoul&quot;) // 매일 23:50
public void performCarryOver() {
    // 상위 100개의 10%를 다음날 시드로 복사
    Set&amp;lt;TypedTuple&amp;lt;String&amp;gt;&amp;gt; top100 = redisTemplate.opsForZSet()
        .reverseRangeWithScores(todayKey, 0, 99);
    
    for (TypedTuple&amp;lt;String&amp;gt; tuple : top100) {
        Double score = tuple.getScore();
        if (score != null) {
            redisTemplate.opsForZSet().add(tomorrowKey, 
                tuple.getValue(), score * 0.1); // 10% 가중치
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  하지만 새로운 고민이...&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  Carry-over 시뮬레이션&lt;b&gt;:&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;어제 1위 상품: 1000점 &amp;rarr; 오늘 시작 시 100점 (10%)
신상품: 0점 &amp;rarr; 오늘 시작 시 0점

결과: 신상품이 1위 되려면 100점 이상 필요  
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  딜레마:&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Carry-over 없음: 새벽에 빈 랭킹 &amp;rarr; 사용자 경험 최악  &lt;/li&gt;
&lt;li&gt;Carry-over 있음: 신상품 차별 &amp;rarr; 공정성 문제  &lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  Day 6-7: TTL 없이 무한 증가하는 메모리&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  &quot;어? 메모리 사용량이 계속 늘어나네?&quot;&lt;/h3&gt;
&lt;pre class=&quot;vala&quot;&gt;&lt;code&gt;# 실제 메모리 사용량 측정
$ redis-cli MEMORY USAGE ranking:all:20250911
(integer) 15728640  # 랭킹 하나당 15MB

#   계산해보니...
# 365일 &amp;times; 15MB = 5.4GB (랭킹만!)
# TTL 없으면 계속 누적...
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;⚡ TTL 관리와 Pipeline 최적화&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;//   TTL로 메모리 관리
private void setTtlIfNeeded(String key) {
    Long ttl = redisTemplate.getExpire(key);
    if (ttl == null || ttl == -1) {
        redisTemplate.expire(key, Duration.ofDays(2)); // 2일 후 자동 삭제
    }
}

//   Pipeline으로 네트워크 비용 절약
public void updateRankingBatch(List&amp;lt;GeneralEnvelopeEvent&amp;gt; envelopes) {
    redisTemplate.executePipelined((RedisCallback&amp;lt;Object&amp;gt;) connection -&amp;gt; {
        for (GeneralEnvelopeEvent envelope : envelopes) {
            Double score = calculateScore(envelope.type(), envelope.payload());
            Long productId = extractProductId(envelope.payload());
            
            if (score != null &amp;amp;&amp;amp; productId != null) {
                String member = &quot;product:&quot; + productId;
                redisTemplate.opsForZSet().incrementScore(todayKey, member, score);
            }
        }
        return null;
    });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;  최적화 효과:&lt;/b&gt;&lt;/h3&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;/td&gt;
&lt;td&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;&lt;/td&gt;
&lt;td&gt;개선 후&lt;/td&gt;
&lt;td&gt;&amp;nbsp;효과&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;네트워크 호출&lt;/td&gt;
&lt;td&gt;100번 개별 호출&lt;/td&gt;
&lt;td&gt;1번 Pipeline&lt;/td&gt;
&lt;td&gt;네트워크 지연 대폭 감소&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;메모리 관리&lt;/td&gt;
&lt;td&gt;TTL 없음 (무한 증가)&lt;/td&gt;
&lt;td&gt;TTL 2일&lt;/td&gt;
&lt;td&gt;메모리 안정화&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;처리 속도&lt;/td&gt;
&lt;td&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;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  Day 7: Fallback 전략과 상품 정보 연동&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt; ️ 3단계 Fallback 전략&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Untitled diagram _ Mermaid Chart-2025-09-11-120622.png&quot; data-origin-width=&quot;2399&quot; data-origin-height=&quot;3840&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/VHaIi/btsQwzRghAE/KYU2St3RoSQmjVD4OEdy20/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/VHaIi/btsQwzRghAE/KYU2St3RoSQmjVD4OEdy20/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/VHaIi/btsQwzRghAE/KYU2St3RoSQmjVD4OEdy20/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FVHaIi%2FbtsQwzRghAE%2FKYU2St3RoSQmjVD4OEdy20%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;618&quot; height=&quot;989&quot; data-filename=&quot;Untitled diagram _ Mermaid Chart-2025-09-11-120622.png&quot; data-origin-width=&quot;2399&quot; data-origin-height=&quot;3840&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;private RankingResult getRankingWithFallback(LocalDate targetDate, int start, int end) {
    for (int i = 0; i &amp;lt; 3; i++) {
        LocalDate fallbackDate = targetDate.minusDays(i);
        String key = generateRankingKey(fallbackDate);
        
        Set&amp;lt;TypedTuple&amp;lt;String&amp;gt;&amp;gt; results = redisTemplate.opsForZSet()
            .reverseRangeWithScores(key, start, end);
            
        if (results != null &amp;amp;&amp;amp; !results.isEmpty()) {
            String source = i == 0 ? &quot;today&quot; : 
                           i == 1 ? &quot;yesterday&quot; : &quot;day-before-yesterday&quot;;
            return RankingResult.of(fallbackDate, parseEntries(results), source);
        }
    }
    
    return RankingResult.empty(targetDate); // 최종 안전장치
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  상품 정보 N+1 문제 해결&lt;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// ❌ 처음 시도 (N+1 문제)
entries.stream()
    .map(entry -&amp;gt; {
        ProductInfo info = productService.getProduct(entry.productId()); // N번 호출!
        return new RankingEntry(entry.productId(), entry.score(), info);
    })

// ✅ 배치 조회로 해결
private List&amp;lt;RankingEntry&amp;gt; enrichWithProductInfo(List&amp;lt;RankingEntry&amp;gt; entries) {
    List&amp;lt;Long&amp;gt; productIds = entries.stream()
        .map(RankingEntry::productId)
        .collect(Collectors.toList());
    
    // 1번의 배치 호출로 모든 상품 정보 조회
    Map&amp;lt;Long, ProductInfo&amp;gt; productInfoMap = productFacade.getProductInfoMap(productIds);
    
    return entries.stream()
        .map(entry -&amp;gt; new RankingEntry(entry.productId(), entry.score(), 
                                     productInfoMap.get(entry.productId())))
        .filter(entry -&amp;gt; entry.productInfo() != null)
        .collect(Collectors.toList());
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  최종 성과와 아직 남은 과제&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  달성한 것들&lt;/h3&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;span style=&quot;color: #333333; text-align: start;&quot;&gt;기능&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;상태&lt;/td&gt;
&lt;td&gt;&amp;nbsp;성과&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redis ZSET 랭킹&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;/td&gt;
&lt;td&gt;✅ 완료&lt;/td&gt;
&lt;td&gt;기획팀 요구사항 충족&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&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;Fallback 전략&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;/td&gt;
&lt;td&gt;✅ 완료&lt;/td&gt;
&lt;td&gt;Pipeline + TTL 적용&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  아직 남은 과제들&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. Hot Key 문제&lt;/b&gt;  &lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;# 인기 상품에 이벤트 집중 시
ZINCRBY ranking:all:20250911 0.7 product:12345
# 특정 상품 &amp;rarr; 특정 파티션 과부하
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 가중치 변경의 원자성&lt;/b&gt; ⚛️&lt;/p&gt;
&lt;pre class=&quot;haxe&quot;&gt;&lt;code&gt;// Thread 1: 재계산 중
for (TypedTuple&amp;lt;String&amp;gt; entry : allEntries) {
    double newScore = entry.getScore() * ratio;
    redis.add(key, entry.getValue(), newScore);
    // &amp;larr; 이 시점에 Thread 2에서 ZINCRBY 발생?
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. 메모리 사용량 모니터링&lt;/b&gt;  &lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;# 1년 누적 예상치
15MB &amp;times; 365일 = 5.4GB (랭킹만)
+ 가중치 캐시 + 메트릭 = 8GB+
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  현실 체크: 과연 이 모든 복잡성이 필요했을까?&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&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;  : &quot;실시간 가중치 변경 최고예요!&quot;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;사용자&lt;/b&gt;  : &quot;인기상품 랭킹 보기 편해졌어요!&quot;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;운영팀&lt;/b&gt;  : &quot;메모리 사용량도 안정적이고...&quot;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;개발자&lt;/b&gt;  : &quot;어우 복잡성 높고 유지보수 하기 힘들꺼 같은데'&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  냉정한 비용 계산&lt;/h3&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;개발 시간: 약 40시간 (1주)&lt;/li&gt;
&lt;li&gt;Redis 메모리 증가: 5.4GB/년&lt;/li&gt;
&lt;li&gt;운영 복잡도: 가중치 관리, 콜드 스타트 해결, 원자성 문제...&lt;/li&gt;
&lt;li&gt;유지보수 비용: Hot Key 모니터링, TTL 관리, 성능 튜닝...&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;??? (사실 측정하기 어려움)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  실무에서 진짜 선택한다면?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;랭킹이 핵심 도메인이 아니라면:&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;color: #383a42; text-align: left;&quot;&gt;&lt;code&gt;// 솔직히 이렇게 했을 것 같다
@Service
public class SimpleRankingService {
    
    // 그냥 조회수 기반 단순 정렬
    public List&amp;lt;Product&amp;gt; getPopularProducts() {
        return productRepository.findTop20ByOrderByViewCountDesc();
    }
    
    // 또는 파트너사 추천 상품 먼저 노출
    public List&amp;lt;Product&amp;gt; getRecommendedProducts() {
        List&amp;lt;Product&amp;gt; partnerProducts = getPartnerRecommendations(); // 비즈니스 우선
        List&amp;lt;Product&amp;gt; popularProducts = getPopularByViews();
        
        return Stream.concat(partnerProducts.stream(), popularProducts.stream())
                    .limit(20)
                    .collect(Collectors.toList());
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&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;개발 시간: 2시간&lt;/li&gt;
&lt;li&gt;운영 복잡도: 거의 없음&lt;/li&gt;
&lt;li&gt;Redis 메모리: 0원&lt;/li&gt;
&lt;li&gt;유지보수: DB 인덱스 관리만&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  복잡성 vs 가치 매트릭스&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&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;/td&gt;
&lt;td&gt;개발비용&lt;/td&gt;
&lt;td&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;Redis ZSET 풀스택&lt;/td&gt;
&lt;td&gt;높음  &lt;/td&gt;
&lt;td&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;단순 DB 정렬&lt;/td&gt;
&lt;td&gt;낮음 ✨&lt;/td&gt;
&lt;td&gt;낮음  &lt;/td&gt;
&lt;td&gt;80% 달성  &lt;/td&gt;
&lt;td&gt;실무용  &lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;파트너사 큐레이션&lt;/td&gt;
&lt;td&gt;낮음 ✨&lt;/td&gt;
&lt;td&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;h3 data-ke-size=&quot;size23&quot;&gt;  결론&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;기술적으로 할 수 있다&quot;와 &quot;해야 한다&quot;는 다르다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 이번 경험으로 Redis 고급 활용법을 익혔고, 실시간 시스템 설계 역량도 늘었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실무에서는:&lt;/b&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;/li&gt;
&lt;li&gt;&lt;b&gt;단순한 해결책&lt;/b&gt;으로 80% 달성&lt;/li&gt;
&lt;li&gt;&lt;b&gt;복잡성 추가&lt;/b&gt;는 명확한 ROI 검증 후&lt;/li&gt;
&lt;/ol&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;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;최종 교훈&lt;/b&gt;: 기술은 수단이지 목적이 아니다. 하지만 이런 경험을 통해 &quot;언제 복잡하게, 언제 단순하게&quot; 할지 판단하는 눈을 기를 수 있었다.&lt;/p&gt;</description>
      <category>Loopers</category>
      <author>그zi운아이</author>
      <guid isPermaLink="true">https://dev-th.tistory.com/74</guid>
      <comments>https://dev-th.tistory.com/74#entry74comment</comments>
      <pubDate>Thu, 11 Sep 2025 21:29:48 +0900</pubDate>
    </item>
    <item>
      <title>&amp;quot;같은 주문이 두 번 결제됐습니다&amp;quot;   - Kafka로 배운 분산 시스템의 잔혹한 현실</title>
      <link>https://dev-th.tistory.com/73</link>
      <description>&lt;blockquote data-end=&quot;5880&quot; data-start=&quot;5533&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;TL;DR &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;설정(acks=all, idempotence=true)만으로는 애플리케이션 레벨 경합을 막을 수 없었다.&lt;br /&gt;나는 Outbox + DB 원자적 선점 + 동기 전송(.get())으로 &quot;처음부터 안전&quot;을 선택했다.&lt;br /&gt;토픽은 max.in.flight=5, Consumer는 별도 테이블 기반 멱등성, DLT는 Table 우선으로 운영했다.&lt;br /&gt;결과적으로 유실/중복/순서 문제를 &quot;설계로 산&quot; 뒤, 성능 최적화를 진행할 수 있었다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-end=&quot;5880&quot; data-start=&quot;5535&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;그날, 같은 주문이 두 번 결제되었다  &lt;/h2&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;[ERROR] Duplicate payment detected
paymentId: 92134, orderId: 55120, amount: 39000
Original: 2024-03-08 14:23:11
Duplicate: 2024-03-08 14:23:12
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;금요일 오후, 모니터링 알람이 울렸다  . 같은 주문이 1초 차이로 두 번 결제됐다는 것이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;뭔가 잘못됐다.&quot;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Kafka가 뭔데 이렇게 복잡해?  &lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;과제는 단순해 보였다: &lt;b&gt;이벤트를 Kafka에도 기록해서 외부 시스템이 볼 수 있게 하기&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;lasso&quot;&gt;&lt;code&gt;// 기존: 내부에서만 처리
@Transactional
public Order process(OrderCommand command) {
    // 비즈니스 로직
    Order order = orderService.createOrder(command.userId(), items, OrderAmount.of(finalAmount), command.couponId());
    
    // 내부 이벤트만 발행
    // eventBridge.publishEvent(EventType.ORDER_CREATED, OrderCreatedEvent.of(...));
    
    return order;
}

// 추가 목표: 동일한 이벤트를 Kafka에도 기록
@Transactional  
public Order process(OrderCommand command) {
    Order order = orderService.createOrder(...);
    
    // 내부 + 외부 이벤트 동시 발행
    eventBridge.publishEvent(EventType.ORDER_CREATED, 
        OrderCreatedEvent.of(order.getId(), command.userId(), items));
    
    return order;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 내부 처리는 그대로 두고, 추가로 Kafka에 기록해서 외부 시스템(commerce-streamer)이 감사로그, 메트릭 집계, 별도 캐시 관리를 할 수 있게 하는 것이 목표였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 Kafka가 생각보다 훨씬 복잡한 녀석이었다는 것이다  .&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Kafka의 잔혹한 진실들  &lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. At-least-once가 기본이다&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Producer는 &quot;최소 한 번&quot;은 보낸다 (더 보낼 수도 있음)&lt;/li&gt;
&lt;li&gt;Consumer는 &quot;최소 한 번&quot;은 받는다 (더 받을 수도 있음)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Exactly-once는 환상이다&lt;/b&gt; ✨&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 순서는 파티션 내에서만 보장된다&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;Product 123 이벤트:
Partition 0: 재고 100 &amp;rarr; 50 
Partition 1: 재고 0     &amp;larr; 이게 먼저 처리될 수 있음!  
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 설정 하나 잘못하면 지옥이다&lt;/h3&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;# 이 한 줄이 순서를 망가뜨린다
max.in.flight.requests.per.connection: 5  # 기본값
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;첫 번째 삽질: 설정만으로 해결될 줄 알았다  &amp;zwj;♂️&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 &quot;Kafka 설정만 잘하면 되겠지&quot;라고 생각했다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;# commerce-api application.yml
spring:
  kafka:
    producer:
      bootstrap-servers: ${KAFKA_BROKERS:localhost:9092}
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
      acks: all                    # 모든 replica 확인
      enable-idempotence: true     # 중복 전송 방지
      retries: 2147483647          # 거의 무한 재시도
      properties:
        max.in.flight.requests.per.connection: 5  # 성능 우선
        compression.type: snappy   # 성능 최적화
        linger.ms: 10
        batch.size: 16384
        spring.json.add.type.headers: false
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 설정들은 분명 중요하다 ✅. acks=all + enable.idempotence=true로 Producer 레벨에서는 중복을 막을 수 있다. 하지만 더 큰 문제가 있었다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;진짜 문제: 트랜잭션 경계가 애매했다  &amp;zwj; &lt;/h2&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// ❌ 처음 시도한 직접 발행 방식
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderCreated(OrderCreatedEvent event) {
    // 기존 내부 처리
    processInternalNotification(event);
    
    // Kafka 직접 발행 시도
    kafkaTemplate.send(&quot;order-events.v1&quot;, event); // 문제!
}
&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Kafka 브로커가 다운되면? &amp;rarr; 이벤트 유실&lt;/li&gt;
&lt;li&gt;네트워크 타임아웃 발생하면? &amp;rarr; 재시도 불가&lt;/li&gt;
&lt;li&gt;여러 인스턴스가 동시에 실행되면? &amp;rarr; 중복 발행 가능&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;acks=all + idempotence=true로도 애플리케이션 레벨 중복은 막을 수 없다는 걸 깨달았다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;Server A: onOrderCreated() 실행 &amp;rarr; kafkaTemplate.send()
Server B: onOrderCreated() 실행 &amp;rarr; 같은 이벤트 또 send()  
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Producer idempotence=true는 &lt;b&gt;같은 Producer 인스턴스 내에서의 중복&lt;/b&gt;만 막는다. 서로 다른 애플리케이션 인스턴스면 소용없다  &amp;zwj;♂️.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;해결책 1: Outbox 패턴으로 이벤트 유실 방지  ️&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;이벤트 발행과 비즈니스 로직이 같이 성공하거나 같이 실패해야 한다&quot;&lt;/b&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Outbox 패턴이 뭔가?  &lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;기존 문제&lt;/b&gt;:&lt;/p&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;// ❌ 이렇게 하면 트랜잭션 경계가 애매해진다
@Transactional
public void completePayment(Long paymentId) {
    payment.complete();
    paymentRepository.save(payment);  // DB 트랜잭션
    
    kafkaTemplate.send(&quot;payment-events&quot;, event);  // 네트워크 호출
    // 만약 Kafka 전송이 실패하면? 결제는 완료되었는데 이벤트는 안 감...  
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Outbox 해결책&lt;/b&gt; ✅:&lt;/p&gt;
&lt;pre class=&quot;lasso&quot;&gt;&lt;code&gt;// ✅ 비즈니스 로직과 이벤트를 같은 트랜잭션에서 처리
@Transactional
public Order process(OrderCommand command) {
    // 1. 비즈니스 로직
    Order order = orderService.createOrder(...);
    
    // 2. 이벤트를 내부 + 외부 동시 발행 (같은 트랜잭션!)
    eventBridge.publishEvent(EventType.ORDER_CREATED, 
        OrderCreatedEvent.of(order.getId(), command.userId(), items));
    
    // 둘 다 성공하거나 둘 다 실패  
    return order;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실제 DomainEventBridge 구조  &lt;/h3&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;@Component
@RequiredArgsConstructor
public class DomainEventBridge {
    private final ApplicationEventPublisher publisher;
    private final EnvelopeFactory envelopeFactory;
    private final OutboxEventService outboxEventService;

    @Transactional(propagation = Propagation.MANDATORY)
    public void publishEvent(EventType eventType, Object payload) {
        Envelope&amp;lt;Object&amp;gt; envelope = envelopeFactory.create(eventType, payload);

        publisher.publishEvent(envelope);    // 내부 이벤트 발행
        outboxEventService.save(envelope);   // 외부 이벤트 DB 저장
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;해결책 2: 원자적 선점 패턴  &lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Outbox로 이벤트 유실은 막았지만, 새로운 문제가 생겼다: &lt;b&gt;여러 서버가 같은 이벤트를 중복 처리하는 것&lt;/b&gt;.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 아이디어&lt;/b&gt;: &quot;하나씩 선점해서 처리하자&quot;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;일괄 처리의 함정  &lt;/h3&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// ❌ 이렇게 하면 동시성 문제 발생
@Scheduled(fixedDelay = 10000)
public void relayEvents() {
    List&amp;lt;OutboxEvent&amp;gt; events = repository.findByStatus(NEW);
    
    // 여러 서버가 같은 리스트를 가져올 수 있음!  
    events.forEach(event -&amp;gt; {
        event.markAsSending();
        sendToKafka(event);
    });
}
&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;pre class=&quot;routeros&quot;&gt;&lt;code&gt;14:23:11.000 Server A: findByStatus(NEW) &amp;rarr; [Event_92134]
14:23:11.001 Server B: findByStatus(NEW) &amp;rarr; [Event_92134] (동일!)
14:23:11.100 Server A: markAsSending() + send()
14:23:11.101 Server B: markAsSending() + send() &amp;larr; 중복!  
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;단건 선점의 안전성 ✅&lt;/h3&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;// ✅ 하나씩 원자적으로 선점해서 처리
@Scheduled(fixedDelay = 10000)
@Transactional
public void relayEvents() {
    List&amp;lt;OutboxEvent&amp;gt; ready = outboxEventRepository.findTop100ReadyToSend(ZonedDateTime.now());
    if (ready.isEmpty()) return;

    // 원자적 클레임: UPDATE WHERE로 먼저 선점한 서버만 처리  
    int claimed = outboxEventRepository.claimEventsForSending(
        ready.stream().map(OutboxEvent::getId).toList(),
        ZonedDateTime.now() 
    );
    
    if (claimed == 0) return; // 다른 서버가 이미 처리 중

    // 안전하게 하나씩 처리 ✅
    for (OutboxEvent event : ready) {
        try {
            sendOnce(event);
        } catch (Exception ex) {
            log.error(&quot;Unexpected error while sending event: {}&quot;, event.getMessageId(), ex);
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Repository의 원자적 클레임&lt;/b&gt;  :&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Modifying
@Query(&quot;UPDATE OutboxEvent o SET o.status = 'SENDING', o.lastModified = :now &quot; +
       &quot;WHERE o.id IN :ids AND o.status IN ('NEW', 'FAILED')&quot;)
int claimEventsForSending(@Param(&quot;ids&quot;) List&amp;lt;Long&amp;gt; ids, @Param(&quot;now&quot;) ZonedDateTime now);
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 먼저 클레임한 서버만 이벤트를 처리할 수 있다  .&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Untitled diagram _ Mermaid Chart-2025-09-05-060618.png&quot; data-origin-width=&quot;3840&quot; data-origin-height=&quot;2980&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cRvff1/btsQm1U9b6V/f7fxaSLHzHC3y3nhL9xz30/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cRvff1/btsQm1U9b6V/f7fxaSLHzHC3y3nhL9xz30/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cRvff1/btsQm1U9b6V/f7fxaSLHzHC3y3nhL9xz30/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcRvff1%2FbtsQm1U9b6V%2Ff7fxaSLHzHC3y3nhL9xz30%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;3840&quot; height=&quot;2980&quot; data-filename=&quot;Untitled diagram _ Mermaid Chart-2025-09-05-060618.png&quot; data-origin-width=&quot;3840&quot; data-origin-height=&quot;2980&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;토픽 설계 고민: 언제 나누고 언제 합칠까?  &amp;zwj;♂️&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실제 적용한 이벤트 타입별 분리 전략  &lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 도메인별로 토픽을 나누려고 했지만, 실제 운영하면서 &lt;b&gt;이벤트 타입별 분리&lt;/b&gt;가 더 현실적이었습니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;yaml&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;ldif&quot; style=&quot;color: #383a42; text-align: left;&quot;&gt;&lt;code&gt;# 실제 적용한 토픽 구조
order-events.v1: 주문 생성/수정/취소 통합
catalog-events.v1: 상품 재고/좋아요 변경 통합  
notification-events.v1: 알림 발송 요청 통합&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 이벤트 타입별 통합이 현실적인가?  &lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;선택적 구독의 유연성&lt;/b&gt;: Consumer가 관심있는 이벤트만 필터링해서 처리&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;color: #383a42; text-align: left;&quot;&gt;&lt;code&gt;// 감사로그: 모든 이벤트 구독하되 타입별로 다르게 처리
@KafkaListener(topics = {&quot;order-events.v1&quot;, &quot;catalog-events.v1&quot;}, groupId = &quot;audit-consumer&quot;)
public void handleAllEvents(GeneralEnvelopeEvent envelope) {
    switch (envelope.type()) {
        case &quot;ORDER_CREATED&quot; -&amp;gt; auditService.logOrderCreation(envelope);
        case &quot;STOCK_ADJUSTED&quot; -&amp;gt; auditService.logStockChange(envelope);
        case &quot;PRODUCT_LIKED&quot; -&amp;gt; auditService.logUserActivity(envelope);
    }
}

// 캐시 무효화: 특정 이벤트만 처리
@KafkaListener(topics = &quot;catalog-events.v1&quot;, groupId = &quot;cache-consumer&quot;)
public void handleCacheEviction(GeneralEnvelopeEvent envelope) {
    if (CACHE_EVICTION_EVENTS.contains(envelope.type())) {
        cacheService.evict(envelope);
    }
    // 다른 타입은 무시
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&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;order-events.v1: 10개 파티션 (주문량이 많음)&lt;/li&gt;
&lt;li&gt;catalog-events.v1: 20개 파티션 (재고 변경이 빈번함)&lt;/li&gt;
&lt;li&gt;notification-events.v1: 5개 파티션 (상대적으로 적음)&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;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;less&quot; style=&quot;color: #383a42; text-align: left;&quot;&gt;&lt;code&gt;// 주문 Consumer 장애가 상품 Consumer에 영향 없음
@KafkaListener(topics = &quot;order-events.v1&quot;, groupId = &quot;order-consumer&quot;)
public void handleOrderEvents(GeneralEnvelopeEvent envelope) {
    // 주문 처리 로직이 실패해도...
}

@KafkaListener(topics = &quot;catalog-events.v1&quot;, groupId = &quot;catalog-consumer&quot;)  
public void handleCatalogEvents(GeneralEnvelopeEvent envelope) {
    // 상품 처리는 계속 정상 동작
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;보존 정책&lt;/b&gt;: 토픽별로 다른 TTL/보관 정책&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;주문 이벤트: 7년 보관 (법적 요구사항)&lt;/li&gt;
&lt;li&gt;상품 이벤트: 1년 보관 (분석 목적)&lt;/li&gt;
&lt;li&gt;알림 이벤트: 30일 보관 (임시 추적)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;안티패턴: 이벤트별 토픽 남발 ❌&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;yaml&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;css&quot; style=&quot;color: #383a42; text-align: left;&quot;&gt;&lt;code&gt;❌ order-created-events.v1
❌ order-updated-events.v1  
❌ order-cancelled-events.v1
❌ stock-increased-events.v1
❌ stock-decreased-events.v1&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&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;토픽 관리 복잡성 증가 (5개 &amp;rarr; 50개)&lt;/li&gt;
&lt;li&gt;Consumer 코드 중복 (비슷한 처리 로직)&lt;/li&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;: 도메인별 통합 + eventType 필드로 구분 ✅&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;color: #383a42; text-align: left;&quot;&gt;&lt;code&gt;// 하나의 토픽에서 여러 이벤트 타입 처리
@KafkaListener(topics = &quot;order-events.v1&quot;, groupId = &quot;order-consumer&quot;)
public void handleOrderEvents(GeneralEnvelopeEvent envelope) {
    switch (envelope.type()) {
        case &quot;ORDER_CREATED&quot; -&amp;gt; handleOrderCreated(envelope);
        case &quot;ORDER_UPDATED&quot; -&amp;gt; handleOrderUpdated(envelope);
        case &quot;ORDER_CANCELLED&quot; -&amp;gt; handleOrderCancelled(envelope);
        default -&amp;gt; log.warn(&quot;Unknown event type: {}&quot;, envelope.type());
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Consumer별 처리 전략  &lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 감사로그: 모든 이벤트 저장  &lt;/h3&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;@Component
public class AuditLogHandler implements EventHandler {
    private final AuditLogService auditLogService;

    @Override
    public boolean canHandle(String eventType) {
        return true; // 모든 이벤트 로깅  
    }
    
    @Override
    public void handle(GeneralEnvelopeEvent envelope) {
        auditLogService.saveAuditLog(envelope);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 메트릭 집계: 특정 이벤트만  &lt;/h3&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;@Component
public class MetricsHandler implements EventHandler {
    private final MetricsService metricsService;

    @Override
    public boolean canHandle(String eventType) {
        return EventTypes.METRIC_EVENTS.contains(eventType);
    }
    
    @Override
    public void handle(GeneralEnvelopeEvent envelope) {
        metricsService.recordMetric(envelope);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;세 번째 삽질: 비동기의 함정  &lt;/h2&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// ❌ 이것도 함정이었다
@Transactional
protected void sendOnce(OutboxEvent event) {
    event.markAsSending();
    
    kafkaTemplate.send(event.getTopic(), event.getEventKey(), event.toGeneralEnvelopeEvent())
        .whenComplete((result, ex) -&amp;gt; {
            if (ex == null) {
                event.markAsPublished(); // 다른 스레드에서 실행!
                repository.save(event);  // @Transactional 전파 안됨!
            }
        });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제&lt;/b&gt;: .whenComplete() 콜백은 다른 스레드 풀에서 실행되어 트랜잭션 컨텍스트가 없다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해결: 동기 전송으로 변경 ✅&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// ✅ 안전한 동기 처리 (실제 구현)
@SuppressWarnings(&quot;unchecked&quot;)
@Transactional
protected void sendOnce(OutboxEvent event) {
    event.markAsSending();

    try {
        // .get()으로 동기 대기 - 트랜잭션 안에서 상태 관리
        SendResult&amp;lt;String, Object&amp;gt; result = (SendResult&amp;lt;String, Object&amp;gt;) kafkaTemplate
            .send(event.getTopic(), event.getEventKey(), event.toGeneralEnvelopeEvent()).get();

        event.markAsPublished();
        outboxEventRepository.save(event);

    } catch (InterruptedException ie) {
        Thread.currentThread().interrupt(); // 인터럽트 복구
        event.markAsFailed(ie.getMessage(), retryPolicy);
        outboxEventRepository.save(event);

    } catch (ExecutionException ee) {
        log.error(&quot;Failed to send event: {}&quot;, event.getMessageId(), ee);
        event.markAsFailed(ee.getMessage(), retryPolicy);
        outboxEventRepository.save(event);
    }
}
&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;방식 장점 단점 선택&lt;/p&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;/td&gt;
&lt;td&gt;TPS 높음&lt;/td&gt;
&lt;td&gt;트랜잭션 경계 불명확&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;동기 .get()&lt;/td&gt;
&lt;td&gt;상태관리 명확&lt;/td&gt;
&lt;td&gt;TPS 손실&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결정 근거&lt;/b&gt;: 초기에는 안전성이 성능보다 우선&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Consumer: 멱등성이 생명  &lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Producer에서 중복을 막아도, Consumer는 또 다른 차원의 중복을 고려해야 한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;멱등성 테이블 설계&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Entity
@Table(name = &quot;processed_events&quot;)
public class ProcessedEvent extends BaseEntity {
    
    @Column(name = &quot;message_id&quot;, length = 128, unique = true, nullable = false)
    private String messageId; // 멱등성 키
    
    @Enumerated(EnumType.STRING)
    @Column(name = &quot;status&quot;, nullable = false)
    private Status status = Status.PROCESSING; // 기본은 선점 중
    
    @Column(name = &quot;event_type&quot;, length = 100, nullable = false)
    private String eventType;
    
    @Column(name = &quot;correlation_id&quot;, length = 128)
    private String correlationId;
    
    @Column(name = &quot;started_at&quot;, nullable = false)
    private ZonedDateTime startedAt = ZonedDateTime.now();
    
    @Column(name = &quot;processed_at&quot;)
    private ZonedDateTime processedAt;
    
    public enum Status { PROCESSING, PROCESSED, FAILED }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Untitled diagram _ Mermaid Chart-2025-09-05-060736.png&quot; data-origin-width=&quot;3403&quot; data-origin-height=&quot;3840&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ujGir/btsQnr7aMZA/ze1yTCZNIe3L01SIh6qeSK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ujGir/btsQnr7aMZA/ze1yTCZNIe3L01SIh6qeSK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ujGir/btsQnr7aMZA/ze1yTCZNIe3L01SIh6qeSK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FujGir%2FbtsQnr7aMZA%2Fze1yTCZNIe3L01SIh6qeSK%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;3403&quot; height=&quot;3840&quot; data-filename=&quot;Untitled diagram _ Mermaid Chart-2025-09-05-060736.png&quot; data-origin-width=&quot;3403&quot; data-origin-height=&quot;3840&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Consumer 멱등성 처리 실제 구현&lt;/h3&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@KafkaListener(topics = &quot;order-events.v1&quot;, groupId = &quot;order-consumer&quot;)
public void handleOrderEvents(GeneralEnvelopeEvent envelope, 
                             Acknowledgment ack,
                             @Header(KafkaHeaders.RECEIVED_TOPIC) String topic,
                             @Header(KafkaHeaders.RECEIVED_PARTITION) int partition,
                             @Header(KafkaHeaders.OFFSET) long offset) {
    
    log.info(&quot;Order event received - messageId: {}, type: {}, offset: {}:{}&quot;, 
            envelope.messageId(), envelope.type(), partition, offset);
    
    try {
        // 주문 이벤트는 중요하므로 적극적인 재시도 정책 적용
        retryableEventProcessor.processWithRetry(
            envelope, topic, partition, offset, &quot;order-consumer&quot;, RetryPolicy.AGGRESSIVE);
        
        ack.acknowledge();
        log.debug(&quot;Order event acknowledged - messageId: {}&quot;, envelope.messageId());
        
    } catch (Exception e) {
        log.error(&quot;Critical: Failed to process order event after all retries - messageId: {}. &quot; +
                 &quot;Event has been sent to Dead Letter Table.&quot;, envelope.messageId(), e);
        
        // 재시도 로직에서 이미 DLT로 보냈으므로 ACK 처리
        ack.acknowledge();
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;핵심 설정들과 이유&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Producer 필수 설정&lt;/h3&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;spring:
  kafka:
    producer:
      bootstrap-servers: ${KAFKA_BROKERS:localhost:9092}
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
      acks: all                              # 모든 ISR 확인
      enable-idempotence: true               # Producer 레벨 중복 방지
      retries: 2147483647                    # 거의 무한 재시도
      properties:
        max.in.flight.requests.per.connection: 5  # 성능 우선
        compression.type: snappy             # 네트워크 대역폭 절약
        linger.ms: 10                        # 배치 최적화
        batch.size: 16384                    # 적당한 배치 크기
        spring.json.add.type.headers: false  # 타입 헤더 생략
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;생각해볼 점&lt;/b&gt;: &quot;max.in.flight=5가 너무 성능 우선적이지 않나?&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;답&lt;/b&gt;: 순서가 중요한 토픽만 1로 설정. 순서 무관하면 5 (기본값) 사용해서 성능 확보.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Consumer 필수 설정&lt;/h3&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;spring:
  kafka:
    consumer:
      group-id: loopers-default-consumer
      key-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer
      value-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer
      properties:
        spring.deserializer.value.delegate.class: org.springframework.kafka.support.serializer.JsonDeserializer
        spring.json.trusted.packages: &quot;*&quot;   # 역직렬화 허용 패키지
        spring.json.value.default.type: com.fasterxml.jackson.databind.JsonNode
        enable.auto.commit: false            # Manual ACK
        auto.offset.reset: latest            # 새 메시지부터
    listener:
      ack-mode: manual                       # 처리 완료 후 수동 ACK
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Dead Letter Table: 실패의 완전한 추적&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;고민&lt;/b&gt;: &quot;DLQ는 Topic으로 해야 하나요, Table로 해야 하나요?&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;답&lt;/b&gt;: 초기엔 Table이 관리하기 쉽다. 이유:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;SQL로 쉽게 조회/분석 가능&lt;/li&gt;
&lt;li&gt;상태 관리 (DEAD &amp;rarr; INVESTIGATING &amp;rarr; RESOLVED)&lt;/li&gt;
&lt;li&gt;운영팀이 익숙한 도구 (DB Admin)&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;scala&quot;&gt;&lt;code&gt;@Entity
public class DeadLetterEvent extends BaseEntity {
    
    private String messageId;
    private String originalTopic;
    private Integer partitionId;
    private Long offsetId;
    private String consumerGroup;
    
    @Column(columnDefinition = &quot;JSON&quot;)
    private String payload;
    
    @Enumerated(EnumType.STRING)
    private FailureReason failureReason;
    
    private String errorMessage;
    private String stackTrace;
    private Integer retryAttempts;
    
    public enum FailureReason {
        DESERIALIZATION_ERROR,    // JSON 파싱 실패
        HANDLER_EXCEPTION,        // 비즈니스 로직 오류
        TIMEOUT,                  // 처리 시간 초과
        RESOURCE_UNAVAILABLE      // DB/API 접근 불가
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;아직 남은 과제들&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 배치 처리 도입&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;현재 한계&lt;/b&gt;: 메시지 1개씩 처리로 DB 커넥션 낭비&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// 지금: 100개 메시지 = 100번 DB 호출
for (GeneralEnvelopeEvent event : events) {
    auditLogService.saveAuditLog(event); // 각각 DB 호출
}

// 목표: 100개 메시지 = 1번 배치 처리
auditLogService.saveBatch(events); // 한 번에 처리
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;고려사항&lt;/b&gt;: 일부 실패 시 전체 롤백 vs 부분 처리&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. Hot Key 대응&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;잠재적 문제&lt;/b&gt;: 인기 상품에 이벤트 몰릴 경우&lt;/p&gt;
&lt;pre class=&quot;makefile&quot;&gt;&lt;code&gt;productId=12345 (인기상품)에 이벤트 몰림
&amp;rarr; 특정 파티션에만 트래픽 집중
&amp;rarr; 해당 Consumer만 과부하
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;대응 방안&lt;/b&gt;: 샤딩 키 전략 재설계 or 파티션 증설&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 스키마 진화 대응&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;현재&lt;/b&gt;: JSON 직렬화로 스키마 변화에 취약&lt;br /&gt;&lt;b&gt;향후&lt;/b&gt;: Avro + Schema Registry 고려&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 모니터링 개선&lt;/h3&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;li&gt;DLT 누적 패턴 분석&lt;/li&gt;
&lt;li&gt;Consumer Group별 성능 비교&lt;/li&gt;
&lt;li&gt;파티션간 처리량 불균형 모니터링&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1주간 Kafka 파이프라인 구현 여정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Week 1 Day 1-2&lt;/b&gt;: &quot;Kafka 설정만 하면 되겠지&quot; &amp;rarr; 직접 발행 시도 &amp;rarr; 트랜잭션 경계 문제 발견&lt;br /&gt;&lt;b&gt;Week 1 Day 3-4&lt;/b&gt;: Outbox 패턴 도입 &amp;rarr; 동시성 지옥 경험 &amp;rarr; 원자적 선점으로 해결&lt;br /&gt;&lt;b&gt;Week 1 Day 5-7&lt;/b&gt;: Consumer 멱등성 + DLT 구현 &amp;rarr; 모니터링까지 완성&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;At-least-once가 현실이다&lt;/b&gt;: Producer든 Consumer든 중복을 전제로 설계&lt;/li&gt;
&lt;li&gt;&lt;b&gt;동시성은 항상 고려하라&lt;/b&gt;: 여러 인스턴스 환경에서는 모든 게 경합 상황&lt;/li&gt;
&lt;li&gt;&lt;b&gt;실패를 설계하라&lt;/b&gt;: DLT, 재시도, 모니터링까지 포함해야 완성&lt;/li&gt;
&lt;li&gt;&lt;b&gt;안전성 확보 후 성능 최적화&lt;/b&gt;: 빠르게 만들어서 버그 투성이가 되느니 느리더라도 안전하게&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Kafka는 도구일 뿐이다&lt;/b&gt;. 중요한 건 안전하고 추적 가능한 이벤트 전파 체계를 만드는 것이다. 이번 1주 경험으로 분산 시스템 설계의 기본기를 탄탄히 다질 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;같은 주문이 두 번 결제되는 일은 이제 없다. 하지만 언제나 새로운 문제가 기다리고 있다는 것도 알고 있다.&quot;&lt;/b&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;다이어그램 모음  &lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;전체 아키텍처 플로우  ️&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Untitled diagram _ Mermaid Chart-2025-09-05-060450.png&quot; data-origin-width=&quot;3343&quot; data-origin-height=&quot;3840&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/CETpQ/btsQmuXAsCq/UOIxB4y0nsxHWo5mHlHI8K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/CETpQ/btsQmuXAsCq/UOIxB4y0nsxHWo5mHlHI8K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/CETpQ/btsQmuXAsCq/UOIxB4y0nsxHWo5mHlHI8K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FCETpQ%2FbtsQmuXAsCq%2FUOIxB4y0nsxHWo5mHlHI8K%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;3343&quot; height=&quot;3840&quot; data-filename=&quot;Untitled diagram _ Mermaid Chart-2025-09-05-060450.png&quot; data-origin-width=&quot;3343&quot; data-origin-height=&quot;3840&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;</description>
      <category>Loopers</category>
      <author>그zi운아이</author>
      <guid isPermaLink="true">https://dev-th.tistory.com/73</guid>
      <comments>https://dev-th.tistory.com/73#entry73comment</comments>
      <pubDate>Fri, 5 Sep 2025 15:18:35 +0900</pubDate>
    </item>
    <item>
      <title>  &amp;ldquo;그냥 @EventListener면 끝?&amp;rdquo; &amp;mdash; 이벤트, 언제&amp;middot;왜&amp;middot;어떻게 사용할 것인가 ⚙️</title>
      <link>https://dev-th.tistory.com/72</link>
      <description>&lt;p data-end=&quot;393&quot; data-start=&quot;74&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;TL;DR&lt;/b&gt;&lt;br /&gt;&amp;ldquo;관심사 분리&amp;rdquo;용으로 가볍게 시작했다가, &lt;b&gt;도메인 사실을 안전하게 전파&lt;/b&gt;하고 &lt;b&gt;추적 가능한 메타데이터&lt;/b&gt;를 더해 &lt;b&gt;운영/관측성&lt;/b&gt;까지 챙기는 구조로 진화.&lt;br /&gt;핵심 선택은 &lt;b&gt;AFTER_COMMIT + @Async + Envelope + Bridge + Policy/Sender 분리&lt;/b&gt;.&lt;br /&gt;이번 과제에서 &lt;b&gt;데이터 플랫폼 싱크 = 알림톡&lt;/b&gt;이었고, 도메인 이벤트(PaymentCaptured)가 **봉투(Envelope)**로 표준화되어 &lt;b&gt;알림 정책 &amp;rarr; 전송자&lt;/b&gt;를 통해 **단일 싱크(알림톡)**로 흘러가게 설계했다.&lt;/p&gt;
&lt;hr data-end=&quot;398&quot; data-start=&quot;395&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-end=&quot;663&quot; data-start=&quot;615&quot; data-ke-size=&quot;size26&quot;&gt;1) 처음 생각: &amp;ldquo;@EventListener 붙이면 관심사 분리 완료&amp;rdquo;&lt;/h2&gt;
&lt;p data-end=&quot;734&quot; data-start=&quot;664&quot; data-ke-size=&quot;size16&quot;&gt;처음엔 정말 이렇게 시작했다. &lt;b&gt;도메인 로직(주문/결제)&lt;/b&gt; 뒤에 &lt;b&gt;알림/집계/로그&lt;/b&gt;를 리스너로 뽑아내면 끝이라 믿었음.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1756447839393&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;[Controller/Service]  &amp;rarr;  (도메인 상태 변화)
                         ↳ @EventListener들(알림/통계/캐시 등)&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-end=&quot;857&quot; data-start=&quot;837&quot; data-ke-size=&quot;size23&quot;&gt;그런데 바로 맞은 세 가지 벽&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;1014&quot; data-start=&quot;858&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;902&quot; data-start=&quot;858&quot;&gt;&lt;b&gt;트랜잭션 영향&lt;/b&gt;: 리스너에서 예외 터지면 &lt;b&gt;원 트랜잭션 롤백&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;954&quot; data-start=&quot;903&quot;&gt;&lt;b&gt;이벤트 순서/시점&lt;/b&gt;: 비동기/스레드 풀에 따라 &lt;b&gt;발생 순서 &amp;ne; 처리 순서&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;1014&quot; data-start=&quot;955&quot;&gt;&lt;b&gt;디버깅 불가&lt;/b&gt;: &amp;ldquo;이벤트 발행됐니?&amp;rdquo;, &amp;ldquo;어디서 누락?&amp;rdquo;을 &lt;b&gt;추적할 표준 메타데이터가 없음&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote data-end=&quot;1097&quot; data-start=&quot;1016&quot; data-ke-style=&quot;style2&quot;&gt;
&lt;p data-end=&quot;1097&quot; data-start=&quot;1018&quot; data-ke-size=&quot;size16&quot;&gt;여기서 관점이 바뀜:&lt;br /&gt;이벤트 = 콜백 수단이 아니라 **&amp;ldquo;과거에 일어난 사실(Fact)을 안전하게 전파하는 매체&amp;rdquo;여야 한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-end=&quot;1102&quot; data-start=&quot;1099&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;1149&quot; data-start=&quot;1104&quot; data-ke-size=&quot;size26&quot;&gt;2) 이벤트 재정의 &amp;mdash; &amp;ldquo;사실(Fact)이며, 시간(Time)을 가진다&amp;rdquo;&lt;/h2&gt;
&lt;p data-end=&quot;1177&quot; data-start=&quot;1150&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;&quot; data-end=&quot;1362&quot; data-start=&quot;1179&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1228&quot; data-start=&quot;1179&quot;&gt;&lt;b&gt;과거형 이름&lt;/b&gt;: PaymentCaptured, OrderCreated&lt;/li&gt;
&lt;li data-end=&quot;1250&quot; data-start=&quot;1229&quot;&gt;&lt;b&gt;불변&lt;/b&gt;: 이미 일어난 사실&lt;/li&gt;
&lt;li data-end=&quot;1297&quot; data-start=&quot;1251&quot;&gt;&lt;b&gt;시간&lt;/b&gt;: occurredAt이 명확해야 &lt;b&gt;순서&lt;/b&gt;를 논할 수 있음&lt;/li&gt;
&lt;li data-end=&quot;1362&quot; data-start=&quot;1298&quot;&gt;&lt;b&gt;추적&lt;/b&gt;: messageId, correlationId, source 같은 &lt;b&gt;표준 메타데이터&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-end=&quot;1634&quot; data-start=&quot;1364&quot; data-ke-style=&quot;style2&quot;&gt;
&lt;h3 data-end=&quot;1395&quot; data-start=&quot;1366&quot; data-ke-size=&quot;size23&quot;&gt;  빠르게 참조하는 정의 박스&amp;nbsp;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Domain Event: 비즈니스 의미 있는 상태 변화(PaymentCaptured)&lt;/li&gt;
&lt;li&gt;Integration Event: 시스템 간 통신용(MessageSendRequested)&lt;/li&gt;
&lt;li&gt;System Event: 기술적 상태 변화(CacheInvalidated)&lt;/li&gt;
&lt;li&gt;원칙: 도메인 이벤트는 도메인 언어로, 메타데이터는 봉투(Envelope)가 책임&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;hr data-end=&quot;1639&quot; data-start=&quot;1636&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;1678&quot; data-start=&quot;1641&quot; data-ke-size=&quot;size26&quot;&gt;3) 언제 이벤트를 쓰고, 언제 동기로 부른다? (결정 트리)&lt;/h2&gt;
&lt;h3 data-end=&quot;1704&quot; data-start=&quot;1680&quot; data-ke-size=&quot;size23&quot;&gt;✅ 결정 트리 (내가 실제로 쓴 것)&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;1736&quot; data-start=&quot;1705&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;1736&quot; data-start=&quot;1705&quot;&gt;&lt;b&gt;명령(Do) vs 사실(Happened)&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1796&quot; data-start=&quot;1737&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1765&quot; data-start=&quot;1737&quot;&gt;&amp;ldquo;무언가 &lt;b&gt;해라&lt;/b&gt;&amp;rdquo; &amp;rarr; &lt;b&gt;동기 호출&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;1796&quot; data-start=&quot;1766&quot;&gt;&amp;ldquo;무언가가 &lt;b&gt;일어났다&lt;/b&gt;&amp;rdquo; &amp;rarr; &lt;b&gt;이벤트 후보&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;1814&quot; data-start=&quot;1798&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;1814&quot; data-start=&quot;1798&quot;&gt;&lt;b&gt;즉시성 필요?&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1879&quot; data-start=&quot;1815&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1851&quot; data-start=&quot;1815&quot;&gt;호출자가 지금 결과를 &lt;b&gt;바로 써야&lt;/b&gt; 함 &amp;rarr; &lt;b&gt;동기&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;1879&quot; data-start=&quot;1852&quot;&gt;&lt;b&gt;지연 가능&lt;/b&gt;/나중 일관 &amp;rarr; &lt;b&gt;이벤트&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;1902&quot; data-start=&quot;1881&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;1902&quot; data-start=&quot;1881&quot;&gt;&lt;b&gt;실패 전파/롤백 필요?&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1983&quot; data-start=&quot;1903&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1945&quot; data-start=&quot;1903&quot;&gt;실패를 &lt;b&gt;바로 알려야&lt;/b&gt; 하거나 &lt;b&gt;원자성&lt;/b&gt; 필요 &amp;rarr; &lt;b&gt;동기&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;1983&quot; data-start=&quot;1946&quot;&gt;실패가 원 트랜잭션에 &lt;b&gt;영향 주면 안 됨&lt;/b&gt; &amp;rarr; &lt;b&gt;이벤트&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-end=&quot;2002&quot; data-start=&quot;1985&quot; data-ke-size=&quot;size23&quot;&gt;  사례로 딱 감 잡기&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2176&quot; data-start=&quot;2003&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2057&quot; data-start=&quot;2003&quot;&gt;&lt;b&gt;PG 세션키 발급(결제 요청)&lt;/b&gt;: 사용자 리다이렉트 &lt;b&gt;즉시 필요&lt;/b&gt; &amp;rarr; &lt;b&gt;동기&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;2116&quot; data-start=&quot;2058&quot;&gt;&lt;b&gt;결제 완료 후 후속 작업&lt;/b&gt;(예: 영수증/알림): 실패해도 결제 자체는 유지 &amp;rarr; &lt;b&gt;이벤트&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;2176&quot; data-start=&quot;2117&quot;&gt;&lt;b&gt;좋아요 &amp;rarr; 집계 업데이트&lt;/b&gt;: 사용자 성공이 우선, 집계는 &lt;b&gt;eventual&lt;/b&gt; &amp;rarr; &lt;b&gt;이벤트&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1756448542279&quot; class=&quot;java&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;[요청] 
  ├─ 즉시 결과/원자성 필요? ── Yes &amp;rarr; 동기 호출(명령)
  │                                 (예: PG 세션키)
  └─ No ── 사실 전파? ── Yes &amp;rarr; 이벤트 발행(사실)
                                (예: PaymentCaptured &amp;rarr; 알림)
                  └─ No &amp;rarr; 그냥 함수 호출&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-end=&quot;2181&quot; data-start=&quot;2178&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;2221&quot; data-start=&quot;2183&quot; data-ke-size=&quot;size26&quot;&gt;4) 트랜잭션 타이밍 비교 &amp;mdash; 왜 AFTER_COMMIT인가&lt;/h2&gt;
&lt;div&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-end=&quot;2501&quot; data-start=&quot;2223&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;옵션&lt;/td&gt;
&lt;td&gt;언제 &amp;nbsp;실행&lt;/td&gt;
&lt;td&gt;장점&lt;/td&gt;
&lt;td&gt;리스크/언제 쓰지 말까&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;2343&quot; data-start=&quot;2276&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;2294&quot; data-start=&quot;2276&quot;&gt;BEFORE_COMMIT&lt;/td&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;2302&quot; data-start=&quot;2294&quot;&gt;커밋 직전&lt;/td&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;2318&quot; data-start=&quot;2302&quot;&gt;커밋 전 검증/보조 로직&lt;/td&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;2343&quot; data-start=&quot;2318&quot;&gt;커밋 실패 시 &lt;b&gt;유령 이벤트&lt;/b&gt; 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;2416&quot; data-start=&quot;2344&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;2363&quot; data-start=&quot;2344&quot;&gt;AFTER_COMMIT ✅&lt;/td&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;2374&quot; data-start=&quot;2363&quot;&gt;커밋 성공 직후&lt;/td&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;2395&quot; data-start=&quot;2374&quot;&gt;&lt;b&gt;DB에 확정된 사실&lt;/b&gt;만 전파&lt;/td&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;2416&quot; data-start=&quot;2395&quot;&gt;처리 실패 시 재시도/보상 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;2501&quot; data-start=&quot;2417&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;2438&quot; data-start=&quot;2417&quot;&gt;AFTER_COMPLETION&lt;/td&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;2456&quot; data-start=&quot;2438&quot;&gt;성공/실패 &lt;b&gt;모두&lt;/b&gt; 이후&lt;/td&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;2467&quot; data-start=&quot;2456&quot;&gt;양쪽 공통 정리&lt;/td&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;2501&quot; data-start=&quot;2467&quot;&gt;&lt;b&gt;롤백된 사실&lt;/b&gt;도 흘러감 (도메인 이벤트엔 부적합)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;blockquote data-end=&quot;2610&quot; data-start=&quot;2503&quot; data-ke-style=&quot;style2&quot;&gt;
&lt;p data-end=&quot;2542&quot; data-start=&quot;2505&quot; data-ke-size=&quot;size16&quot;&gt;내 선택: AFTER_COMMIT + @Async&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2610&quot; data-start=&quot;2545&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2579&quot; data-start=&quot;2545&quot;&gt;이벤트는 &lt;b&gt;커밋된 사실&lt;/b&gt;만 흘러야 멘탈 모델이 깔끔&lt;/li&gt;
&lt;li data-end=&quot;2610&quot; data-start=&quot;2582&quot;&gt;&lt;b&gt;비동기&lt;/b&gt;로 원 트랜잭션과 &lt;b&gt;장애 격리&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;hr data-end=&quot;2615&quot; data-start=&quot;2612&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;2634&quot; data-start=&quot;2617&quot; data-ke-size=&quot;size26&quot;&gt;5) 진화 타임라인&lt;/h2&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1756447929069&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;  (1) @EventListener(동기) &amp;rarr; 트랜잭션 롤백 유발 
  (2) @Async 추가 &amp;rarr; 커밋 전 조회/순서 꼬임
  (3) @TransactionalEventListener(AFTER_COMMIT) &amp;rarr; 시점 안정 
  (4) DomainEventBridge &amp;rarr; 스프링 의존 격리 + 타입 안전 발행
  (5) Envelope(봉투) &amp;rarr; 표준 메타데이터 + 추적성 확보 
  (6) Policy / Sender 분리 &amp;rarr; 단일 책임, 테스트/확장성 확보 
  (7) EventLogger/샘플링 &amp;rarr; 운영 관측성 강화  &lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;hr data-end=&quot;2946&quot; data-start=&quot;2943&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;2999&quot; data-start=&quot;2948&quot; data-ke-size=&quot;size26&quot;&gt;6) Envelope(봉투) &amp;mdash; &amp;ldquo;비즈니스 이벤트는 그대로, 운영 메타데이터는 봉투로&amp;rdquo;&lt;/h2&gt;
&lt;h3 data-end=&quot;3010&quot; data-start=&quot;3001&quot; data-ke-size=&quot;size23&quot;&gt;왜 봉투?&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;3139&quot; data-start=&quot;3011&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;3090&quot; data-start=&quot;3011&quot;&gt;이벤트 클래스에 type/occurredAt/correlationId... 섞기 시작하면 &lt;b&gt;침입적&lt;/b&gt;이고 &lt;b&gt;변경 파급&lt;/b&gt;이 커짐&lt;/li&gt;
&lt;li data-end=&quot;3139&quot; data-start=&quot;3091&quot;&gt;&lt;b&gt;봉투&lt;/b&gt;가 메타데이터를 &lt;b&gt;표준화&lt;/b&gt;하고, &lt;b&gt;도메인 페이로드는 그대로&lt;/b&gt; 둔다&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-end=&quot;3157&quot; data-start=&quot;3141&quot; data-ke-size=&quot;size23&quot;&gt;봉투 필드(우리 기준)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;3282&quot; data-start=&quot;3158&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;3217&quot; data-start=&quot;3158&quot;&gt;messageId(ULID) / type(enum 값 문자열) / payload(그대로)&lt;/li&gt;
&lt;li data-end=&quot;3282&quot; data-start=&quot;3218&quot;&gt;occurredAt(UTC) / source(서비스명) / correlationId(요청 단위 추적)&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1756448434161&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{
  &quot;messageId&quot;: &quot;01J8X2&amp;hellip;&quot;,
  &quot;type&quot;: &quot;PAYMENT_CAPTURED&quot;,
  &quot;occurredAt&quot;: &quot;2025-03-08T12:34:56Z&quot;,
  &quot;source&quot;: &quot;payment-service&quot;,
  &quot;correlationId&quot;: &quot;req_abc123&quot;,
  &quot;payload&quot;: { &quot;paymentId&quot;: 92134, &quot;orderId&quot;: 55120, &quot;amount&quot;: 39000 }
}&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-end=&quot;3378&quot; data-start=&quot;3284&quot; data-ke-style=&quot;style2&quot;&gt;
&lt;p data-end=&quot;3378&quot; data-start=&quot;3286&quot; data-ke-size=&quot;size16&quot;&gt;correlationId 추출 규칙:&lt;br /&gt;HTTP 헤더 X-Correlation-ID가 최우선 &amp;rarr; 없으면 세션ID &amp;rarr; 백그라운드면 bg_&amp;lt;ULID&amp;gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-end=&quot;3383&quot; data-start=&quot;3380&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;3415&quot; data-start=&quot;3385&quot; data-ke-size=&quot;size26&quot;&gt;7) Bridge &amp;mdash; 스프링에 직접 의존하지 말자&lt;/h2&gt;
&lt;pre id=&quot;code_1756448116735&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Slf4j
@Component
@RequiredArgsConstructor
public class DomainEventBridge {

    private final ApplicationEventPublisher publisher;
    private final EnvelopeFactory envelopeFactory;

    // Payment Events
    public void publish(PaymentCompletedEvent event) {
        Envelope&amp;lt;PaymentCompletedEvent&amp;gt; envelope = envelopeFactory.create(EventType.PAYMENT_COMPLETED, event);

        log.debug(&quot;Publishing PaymentCompleted event - messageId: {}, paymentId: {}&quot;,
            envelope.messageId(), event.paymentId());

        publisher.publishEvent(envelope);
    }&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;3582&quot; data-start=&quot;3416&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;3462&quot; data-start=&quot;3416&quot;&gt;도메인 모듈은 ApplicationEventPublisher를 몰라야 함&lt;/li&gt;
&lt;li data-end=&quot;3550&quot; data-start=&quot;3463&quot;&gt;DomainEventBridge.publish(PaymentCaptured) 같은 &lt;b&gt;전용 메서드&lt;/b&gt;들로 &lt;b&gt;타입 안전&lt;/b&gt; + &lt;b&gt;로깅 표준화&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;3582&quot; data-start=&quot;3551&quot;&gt;나중에 Kafka로 바꾸면 &lt;b&gt;Bridge만 교체&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-end=&quot;3587&quot; data-start=&quot;3584&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;3640&quot; data-start=&quot;3589&quot; data-ke-size=&quot;size26&quot;&gt;8) Policy &amp;harr; Sender &amp;mdash; 알림을 &amp;ldquo;비즈니스 결정&amp;rdquo;과 &amp;ldquo;전송 I/O&amp;rdquo;로 분리&lt;/h2&gt;
&lt;pre id=&quot;code_1756448164114&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;[Payment Service]
  PaymentCaptured (도메인 이벤트)
        │
        ▼
[Event Layer]
  Envelope + Bridge ──(AFTER_COMMIT, @Async)──► NotificationPolicy(순수 결정)
        │                                        └─ template/locale/variables 선택
        ▼
 MessageSendRequested (Integration Event)
        │
        ▼
 NotificationSender (I/O) ──► Kakao Alimtalk API&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;4229&quot; data-start=&quot;4104&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;4229&quot; data-start=&quot;4205&quot;&gt;&lt;b&gt;왜 분리?&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;4229&quot; data-start=&quot;4104&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;4155&quot; data-start=&quot;4104&quot;&gt;Policy는 &lt;b&gt;언제/무엇을/어떻게&lt;/b&gt; 보낼지 &lt;b&gt;순수 로직&lt;/b&gt; &amp;rarr; 단위테스트 쉬움&lt;/li&gt;
&lt;li data-end=&quot;4204&quot; data-start=&quot;4156&quot;&gt;Sender는 &lt;b&gt;외부 연동&lt;/b&gt;만 &amp;rarr; 장애가 &lt;b&gt;도메인/정책&lt;/b&gt;을 침식하지 않음&lt;/li&gt;
&lt;li data-end=&quot;4229&quot; data-start=&quot;4205&quot;&gt;새 채널 추가/교체도 Sender에 국한&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 data-end=&quot;4387&quot; data-start=&quot;4384&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;4421&quot; data-start=&quot;4389&quot; data-ke-size=&quot;size26&quot;&gt;9) 운영 관측성 &amp;mdash; 발행/처리 이중 로그 + 샘플링&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;4629&quot; data-start=&quot;4422&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;4499&quot; data-start=&quot;4422&quot;&gt;&lt;b&gt;발행 로그&lt;/b&gt;:   type, messageId, correlationId, source, payload(sanitize)&lt;/li&gt;
&lt;li data-end=&quot;4549&quot; data-start=&quot;4500&quot;&gt;&lt;b&gt;처리 로그&lt;/b&gt;: ✅ type, messageId, correlationId&lt;/li&gt;
&lt;li data-end=&quot;4584&quot; data-start=&quot;4550&quot;&gt;&lt;b&gt;샘플링&lt;/b&gt;: 고빈도(예: Viewed) 10%만&lt;/li&gt;
&lt;li data-end=&quot;4629&quot; data-start=&quot;4585&quot;&gt;효용: 어디서 끊겼나?를 correlationId 한 번으로 추적&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-end=&quot;4634&quot; data-start=&quot;4631&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;4678&quot; data-start=&quot;4636&quot; data-ke-size=&quot;size26&quot;&gt;10)   이번 주차의 &amp;ldquo;데이터 플랫폼&amp;rdquo; 해석 = &lt;b&gt;알림톡 싱크&lt;/b&gt;&lt;/h2&gt;
&lt;blockquote data-end=&quot;4719&quot; data-start=&quot;4679&quot; data-ke-style=&quot;style2&quot;&gt;
&lt;p data-end=&quot;4719&quot; data-start=&quot;4681&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;데이터 플랫폼으로 흘린다&amp;rdquo; = 알림톡으로 내보낸다(이번 주)&lt;/p&gt;
&lt;pre id=&quot;code_1756448256038&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;[Payment Service]            [Event Layer]                        [Sink = 데이터 플랫폼(알림톡)]
 PaymentCaptured  ──►  Envelope + Bridge ──(AFTER_COMMIT)──► NotificationPolicy
                                                             └─► MessageSendRequested
                                                                  └─► NotificationSender &amp;rarr; Kakao API&lt;/code&gt;&lt;/pre&gt;
&lt;/blockquote&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;5177&quot; data-start=&quot;5085&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;5120&quot; data-start=&quot;5085&quot;&gt;&lt;b&gt;한 갈래 팬아웃&lt;/b&gt;: 알림이 곧 &lt;b&gt;데이터 수신처&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;5177&quot; data-start=&quot;5121&quot;&gt;향후에 &lt;b&gt;진짜 분석 싱크(Kafka/S3/DB)&lt;/b&gt; 를 붙이면 이때 &lt;b&gt;두 갈래&lt;/b&gt;로 확장 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-end=&quot;5182&quot; data-start=&quot;5179&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;5221&quot; data-start=&quot;5184&quot; data-ke-size=&quot;size26&quot;&gt;11) 스레드 풀/성능/멱등성 &amp;mdash; 지금 챙긴 것 &amp;amp; 다음 단계&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;5476&quot; data-start=&quot;5222&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;5297&quot; data-start=&quot;5222&quot;&gt;&lt;b&gt;스레드 풀 분리&lt;/b&gt;: notificationsExec, likeAggregationExec 등 &lt;b&gt;도메인별&lt;/b&gt;로 격리&lt;/li&gt;
&lt;li data-end=&quot;5340&quot; data-start=&quot;5298&quot;&gt;&lt;b&gt;큐 용량/거부 정책&lt;/b&gt;: 운영 중 back-pressure에 대비&lt;/li&gt;
&lt;li data-end=&quot;5421&quot; data-start=&quot;5341&quot;&gt;&lt;b&gt;멱등성&lt;/b&gt;: messageId 기반 &lt;b&gt;중복 가드(임시)&lt;/b&gt; &amp;rarr; 다음 주에 &lt;b&gt;Redis/Inbox/Outbox&lt;/b&gt;로 영속화 예정&lt;/li&gt;
&lt;li data-end=&quot;5476&quot; data-start=&quot;5422&quot;&gt;&lt;b&gt;순서&lt;/b&gt;: 사용자/결제 단위 순서 중요성이 커지면 &lt;b&gt;Kafka 파티션 키&lt;/b&gt;로 이관 고려&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-end=&quot;5481&quot; data-start=&quot;5478&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;5521&quot; data-start=&quot;5483&quot; data-ke-size=&quot;size26&quot;&gt;12) &amp;ldquo;이벤트를 언제/왜 쓰는가&amp;rdquo; &amp;mdash; 남는 레퍼런스 1장 요약&lt;/h2&gt;
&lt;h4 data-end=&quot;781&quot; data-start=&quot;763&quot; 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-end=&quot;889&quot; data-start=&quot;782&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;809&quot; data-start=&quot;782&quot;&gt;사실(이미 일어난 것)을 전파할 때&lt;/li&gt;
&lt;li data-end=&quot;834&quot; data-start=&quot;810&quot;&gt;원 트랜잭션 실패와 격리되어야 할 때&lt;/li&gt;
&lt;li data-end=&quot;857&quot; data-start=&quot;835&quot;&gt;즉시성/강결합이 필요하지 않을 때&lt;/li&gt;
&lt;li data-end=&quot;889&quot; data-start=&quot;858&quot;&gt;여러 후속 작업(알림/집계/지표)으로 팬아웃할 때&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-end=&quot;914&quot; data-start=&quot;891&quot; 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-end=&quot;1031&quot; data-start=&quot;915&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;985&quot; data-start=&quot;915&quot;&gt;호출자가 즉시 결과가 필요할 때 (예: PG 세션키)&lt;/li&gt;
&lt;li data-end=&quot;1031&quot; data-start=&quot;986&quot;&gt;실패가 원자성을 꼭 깨야 할 때 (예: 쿠폰 사용과 주문 동시 성공/실패)&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-end=&quot;1049&quot; data-start=&quot;1033&quot; 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-end=&quot;1145&quot; data-start=&quot;1050&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1090&quot; data-start=&quot;1050&quot;&gt;도메인 사실 전파: AFTER_COMMIT + @Async&lt;/li&gt;
&lt;li data-end=&quot;1145&quot; data-start=&quot;1091&quot;&gt;&lt;b&gt;커밋 실패해도 필요한 정리:&lt;/b&gt; AFTER_COMPLETION (도메인 이벤트 X)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-end=&quot;5904&quot; data-start=&quot;5901&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;5943&quot; data-start=&quot;5906&quot; data-ke-size=&quot;size26&quot;&gt;13) 자주 물을 것 같은 Q&amp;amp;A (스스로&amp;nbsp; 물었던 것들)&lt;/h2&gt;
&lt;p data-end=&quot;6116&quot; data-start=&quot;5945&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Q. @EventListener면 충분한데 왜 Bridge/Envelope까지?&lt;/b&gt;&lt;br /&gt;A. 이벤트는 &lt;b&gt;운영 대상&lt;/b&gt;이다. 타입 안전한 발행, 표준 메타데이터, 로깅/추적을 &lt;b&gt;프레임&lt;/b&gt;으로 고정해 두면 팀 전체 품질이 일정해진다. 나중에 Kafka 붙일 때도 &lt;b&gt;Bridge만 교체&lt;/b&gt;하면 된다.&lt;/p&gt;
&lt;p data-end=&quot;6116&quot; data-start=&quot;5945&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;6225&quot; data-start=&quot;6118&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Q. 왜 꼭 AFTER_COMMIT?&lt;/b&gt;&lt;br /&gt;A. &amp;ldquo;DB에 &lt;b&gt;반영되지 않은 사실&lt;/b&gt;&amp;rdquo;이 흘러나가는 삽질을 두 번 하고 깨달았다. &lt;b&gt;커밋 확정&lt;/b&gt;만 전파해야 디버깅/멘탈 모델이 산다.&lt;/p&gt;
&lt;p data-end=&quot;6225&quot; data-start=&quot;6118&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;6376&quot; data-start=&quot;6227&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Q. 알림 정책을 코드에 하드코딩하면 안 돼?&lt;/b&gt;&lt;br /&gt;A. 처음엔 쉬운데, 밤 10시 제한/템플릿 다국어/채널 분기/AB테스트가 붙으면 &lt;b&gt;정책과 전송이 섞여&lt;/b&gt; 복잡도가 폭발한다. &lt;b&gt;Policy(결정) &amp;harr; Sender(전송)&lt;/b&gt; 분리가 장기적으로 싸게 먹힌다.&lt;/p&gt;
&lt;hr data-end=&quot;6381&quot; data-start=&quot;6378&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;6404&quot; data-start=&quot;6383&quot; data-ke-size=&quot;size26&quot;&gt;14) 마무리 &amp;mdash; 내 관점의 변화&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;6662&quot; data-start=&quot;6405&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;6442&quot; data-start=&quot;6405&quot;&gt;&lt;b&gt;Before&lt;/b&gt;: 이벤트 = 옵저버 패턴으로 관심사 분리&lt;/li&gt;
&lt;li data-end=&quot;6516&quot; data-start=&quot;6443&quot;&gt;&lt;b&gt;After&lt;/b&gt;: 이벤트 = &lt;b&gt;커밋된 도메인 사실&lt;/b&gt;을 &lt;b&gt;표준 메타데이터&lt;/b&gt;와 함께 &lt;b&gt;안전하게 전파&lt;/b&gt;하는 운영 대상&lt;/li&gt;
&lt;li data-end=&quot;6563&quot; data-start=&quot;6517&quot;&gt;도구 선택보다 중요한 건 &amp;ldquo;왜 지금 이벤트인가&amp;rdquo;에 대한 명확한 이유.&lt;/li&gt;
&lt;li data-end=&quot;6662&quot; data-start=&quot;6564&quot;&gt;이번 주차에서 특히, &lt;b&gt;데이터 플랫폼 싱크 = 알림톡&lt;/b&gt;이라는 컨텍스트를 &lt;b&gt;도메인 이벤트 &amp;rarr; 봉투 &amp;rarr; Policy/Sender&lt;/b&gt;로 자연스럽게 연결한 게 가장 큰 수확.&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Loopers</category>
      <author>그zi운아이</author>
      <guid isPermaLink="true">https://dev-th.tistory.com/72</guid>
      <comments>https://dev-th.tistory.com/72#entry72comment</comments>
      <pubDate>Fri, 29 Aug 2025 15:30:47 +0900</pubDate>
    </item>
    <item>
      <title>Resilience와 보상 트랜잭션: 장애에 대응하는 방법</title>
      <link>https://dev-th.tistory.com/71</link>
      <description>&lt;p data-end=&quot;279&quot; data-start=&quot;245&quot; data-ke-size=&quot;size16&quot;&gt;이번 주는 &lt;b&gt;외부 결제(PG) 연동 안정성&lt;/b&gt;을 주제로,&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;412&quot; data-start=&quot;280&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;322&quot; data-start=&quot;280&quot;&gt;&lt;b&gt;Resilience4j&lt;/b&gt;로 장애를 제어 가능한 범위로 줄이고,&lt;/li&gt;
&lt;li data-end=&quot;365&quot; data-start=&quot;323&quot;&gt;&lt;b&gt;보상 트랜잭션&lt;/b&gt;으로 외부 자원과 로컬 DB의 정합성을 맞추며,&lt;/li&gt;
&lt;li data-end=&quot;412&quot; data-start=&quot;366&quot;&gt;&lt;b&gt;Spring Events&lt;/b&gt;로 도메인 사건을 분리하는 방식을 학습했다.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-end=&quot;417&quot; data-start=&quot;414&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;458&quot; data-start=&quot;419&quot; data-ke-size=&quot;size26&quot;&gt;1. Resilience4j &amp;mdash; 회복탄력성을 위한 첫 번째 생명선&lt;/h2&gt;
&lt;h3 data-end=&quot;483&quot; data-start=&quot;460&quot; data-ke-size=&quot;size23&quot;&gt;Resilience4j란 무엇인가?&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;580&quot; data-start=&quot;484&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;521&quot; data-start=&quot;484&quot;&gt;&lt;b&gt;자바 기반 회복탄력성(Resilience) 라이브러리&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;580&quot; data-start=&quot;522&quot;&gt;외부 시스템 호출 실패나 지연을 &lt;b&gt;서비스 전반으로 확산되지 않도록 제어&lt;/b&gt;하는 다양한 패턴 제공&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-end=&quot;593&quot; data-start=&quot;582&quot; data-ke-size=&quot;size23&quot;&gt;왜 필요한가?&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;740&quot; data-start=&quot;594&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;628&quot; data-start=&quot;594&quot;&gt;네트워크&amp;middot;외부 API는 언제든 장애가 발생할 수 있다.&lt;/li&gt;
&lt;li data-end=&quot;688&quot; data-start=&quot;629&quot;&gt;단순 try-catch는 개별 호출만 잡아줄 뿐, &lt;b&gt;서비스 전체 안정성&lt;/b&gt;은 지켜주지 못한다.&lt;/li&gt;
&lt;li data-end=&quot;740&quot; data-start=&quot;689&quot;&gt;핵심은 &lt;b&gt;&amp;ldquo;장애를 없애는 게 아니라, 장애를 제어 가능한 형태로 축소하는 것&amp;rdquo;&lt;/b&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-end=&quot;754&quot; data-start=&quot;742&quot; data-ke-size=&quot;size23&quot;&gt;주요 동작 패턴&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;993&quot; data-start=&quot;755&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;809&quot; data-start=&quot;755&quot;&gt;&lt;b&gt;TimeLimiter&lt;/b&gt;: 일정 시간 이상 응답 없으면 조기 종료 &amp;rarr; 무한 대기 차단&lt;/li&gt;
&lt;li data-end=&quot;850&quot; data-start=&quot;810&quot;&gt;&lt;b&gt;Retry&lt;/b&gt;: 일시적 장애라면 제한된 횟수/간격으로 재시도&lt;/li&gt;
&lt;li data-end=&quot;930&quot; data-start=&quot;851&quot;&gt;&lt;b&gt;Circuit Breaker&lt;/b&gt;: 연속 실패율이 일정 기준을 넘으면 회로를 열어 추가 호출 차단 &amp;rarr; Half-Open을 거쳐 복구&lt;/li&gt;
&lt;li data-end=&quot;993&quot; data-start=&quot;931&quot;&gt;&lt;b&gt;Bulkhead&lt;/b&gt;: 호출 자원을 격리(스레드풀 분리)해, 특정 자원 고갈이 전체로 전파되지 않도록&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-end=&quot;1014&quot; data-start=&quot;995&quot; data-ke-size=&quot;size23&quot;&gt;내부적으로 어떻게 동작하나?&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1211&quot; data-start=&quot;1015&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1054&quot; data-start=&quot;1015&quot;&gt;Resilience4j는 &lt;b&gt;함수형 데코레이터&lt;/b&gt;처럼 동작한다.&lt;/li&gt;
&lt;li data-end=&quot;1148&quot; data-start=&quot;1055&quot;&gt;실제 호출을 감싼 래퍼(wrapper)에서 호출 횟수&amp;middot;실패율&amp;middot;지연 시간 등을 집계하고, 상태 머신(CLOSED &amp;harr; OPEN &amp;harr; HALF_OPEN)으로 제어한다.&lt;/li&gt;
&lt;li data-end=&quot;1211&quot; data-start=&quot;1149&quot;&gt;따라서 서비스 로직을 크게 바꾸지 않고, &lt;b&gt;어댑터 레벨&lt;/b&gt;에서 장애 제어 로직을 쉽게 추가할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-end=&quot;1216&quot; data-start=&quot;1213&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;1258&quot; data-start=&quot;1218&quot; data-ke-size=&quot;size26&quot;&gt;2. 보상 트랜잭션 (Compensating Transaction)&lt;/h2&gt;
&lt;h3 data-end=&quot;1274&quot; data-start=&quot;1260&quot; data-ke-size=&quot;size23&quot;&gt;보상 트랜잭션이란?&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1424&quot; data-start=&quot;1275&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1368&quot; data-start=&quot;1275&quot;&gt;DB 트랜잭션은 ACID 보장으로 Commit/Rollback이 가능하지만,&lt;br /&gt;외부 자원(PG, 포인트, 쿠폰 등)은 같은 트랜잭션 경계에 묶을 수 없다.&lt;/li&gt;
&lt;li data-end=&quot;1424&quot; data-start=&quot;1369&quot;&gt;따라서 실패 시 **&amp;ldquo;반대 방향으로 되돌리는 트랜잭션&amp;rdquo;**을 별도로 실행해 정합성을 맞춘다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-end=&quot;1437&quot; data-start=&quot;1426&quot; data-ke-size=&quot;size23&quot;&gt;왜 필요한가?&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1525&quot; data-start=&quot;1438&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1478&quot; data-start=&quot;1438&quot;&gt;예: PG 결제 실패 &amp;rarr; 이미 차감된 포인트/쿠폰을 돌려줘야 함.&lt;/li&gt;
&lt;li data-end=&quot;1525&quot; data-start=&quot;1479&quot;&gt;하나의 DB 트랜잭션으로 묶을 수 없으므로, &lt;b&gt;후속 보상 작업&lt;/b&gt;이 필수.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-end=&quot;1536&quot; data-start=&quot;1527&quot; data-ke-size=&quot;size23&quot;&gt;동작 원리&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;1622&quot; data-start=&quot;1537&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;1557&quot; data-start=&quot;1537&quot;&gt;메인 트랜잭션에서 결제 요청&lt;/li&gt;
&lt;li data-end=&quot;1579&quot; data-start=&quot;1558&quot;&gt;실패 시 &amp;ldquo;보상 이벤트&amp;rdquo; 발행&lt;/li&gt;
&lt;li data-end=&quot;1622&quot; data-start=&quot;1580&quot;&gt;이벤트 핸들러에서 REQUIRES_NEW 트랜잭션으로 보상 실행&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-end=&quot;1634&quot; data-start=&quot;1624&quot; data-ke-size=&quot;size23&quot;&gt;중요한 원칙&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1754&quot; data-start=&quot;1635&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1692&quot; data-start=&quot;1635&quot;&gt;&lt;b&gt;멱등성(Idempotency)&lt;/b&gt;: 보상 로직은 여러 번 실행돼도 결과가 변하지 않아야 함.&lt;/li&gt;
&lt;li data-end=&quot;1754&quot; data-start=&quot;1693&quot;&gt;&lt;b&gt;독립성&lt;/b&gt;: 보상 트랜잭션은 메인 트랜잭션과 분리 &amp;rarr; 메인 롤백이 보상까지 함께 롤백시키면 안 됨.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-end=&quot;1759&quot; data-start=&quot;1756&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;1798&quot; data-start=&quot;1761&quot; data-ke-size=&quot;size26&quot;&gt;3. Spring Events &amp;mdash; 도메인 사건을 분리하는 도구&lt;/h2&gt;
&lt;h3 data-end=&quot;1824&quot; data-start=&quot;1800&quot; data-ke-size=&quot;size23&quot;&gt;Spring Events란 무엇인가?&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2048&quot; data-start=&quot;1825&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1869&quot; data-start=&quot;1825&quot;&gt;Spring이 제공하는 &lt;b&gt;애플리케이션 내부 이벤트 발행/구독 시스템&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;1907&quot; data-start=&quot;1870&quot;&gt;내부적으로는 &lt;b&gt;Observer 패턴&lt;/b&gt;으로 구현되어 있다.&lt;/li&gt;
&lt;li data-end=&quot;2048&quot; data-start=&quot;1908&quot;&gt;발행자(Publisher)는 ApplicationEventPublisher로 이벤트를 발행하고,&lt;br /&gt;리스너(Subscriber)는 @EventListener나 @TransactionalEventListener로 이벤트를 구독한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-end=&quot;2061&quot; data-start=&quot;2050&quot; data-ke-size=&quot;size23&quot;&gt;왜 필요한가?&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2255&quot; data-start=&quot;2062&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2138&quot; data-start=&quot;2062&quot;&gt;&lt;b&gt;관심사 분리&lt;/b&gt;:&lt;br /&gt;&amp;ldquo;결제 실패&amp;rdquo;라는 사건만 발행 &amp;rarr; 주문 상태 변경, 쿠폰 복원, 포인트 환불은 각각 리스너에서 처리.&lt;/li&gt;
&lt;li data-end=&quot;2221&quot; data-start=&quot;2139&quot;&gt;&lt;b&gt;트랜잭션 안전성&lt;/b&gt;:&lt;br /&gt;AFTER_COMMIT 단계에서 실행하도록 지정하면, 메인 트랜잭션이 성공적으로 커밋된 뒤에만 후처리 실행.&lt;/li&gt;
&lt;li data-end=&quot;2255&quot; data-start=&quot;2222&quot;&gt;&lt;b&gt;유연성&lt;/b&gt;:&lt;br /&gt;동기/비동기 실행 선택 가능.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-end=&quot;2269&quot; data-start=&quot;2257&quot; data-ke-size=&quot;size23&quot;&gt;내부 동작 원리&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2496&quot; data-start=&quot;2270&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2356&quot; data-start=&quot;2270&quot;&gt;발행자가 이벤트 객체를 Publisher에 전달 &amp;rarr; Spring ApplicationEventMulticaster가 리스너 목록을 탐색해 호출.&lt;/li&gt;
&lt;li data-end=&quot;2404&quot; data-start=&quot;2357&quot;&gt;리스너는 Observer처럼 등록돼 있으며, 이벤트 타입 매칭 시 콜백 실행.&lt;/li&gt;
&lt;li data-end=&quot;2496&quot; data-start=&quot;2405&quot;&gt;@TransactionalEventListener는 트랜잭션의 특정 Phase(AFTER_COMMIT, AFTER_ROLLBACK 등)에 맞춰 실행된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-end=&quot;2504&quot; data-start=&quot;2498&quot; data-ke-size=&quot;size23&quot;&gt;장점&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2606&quot; data-start=&quot;2505&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2546&quot; data-start=&quot;2505&quot;&gt;결합도 낮춤 (Publisher는 Subscriber를 몰라도 됨)&lt;/li&gt;
&lt;li data-end=&quot;2585&quot; data-start=&quot;2547&quot;&gt;도메인 사건 중심 코드 작성 가능 &amp;rarr; 가독성과 유지보수성 향상&lt;/li&gt;
&lt;li data-end=&quot;2606&quot; data-start=&quot;2586&quot;&gt;트랜잭션 경계 맞춤 제어 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-end=&quot;2614&quot; data-start=&quot;2608&quot; data-ke-size=&quot;size23&quot;&gt;단점&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;2877&quot; data-start=&quot;2615&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;2670&quot; data-start=&quot;2615&quot;&gt;&lt;b&gt;JVM 내부 범위 한정&lt;/b&gt; &amp;rarr; 분산 환경에선 브로커(Kafka, RabbitMQ) 필요&lt;/li&gt;
&lt;li data-end=&quot;2719&quot; data-start=&quot;2671&quot;&gt;&lt;b&gt;기본은 동기 호출&lt;/b&gt; &amp;rarr; 리스너 처리 지연이 발행자까지 영향을 줄 수 있음&lt;/li&gt;
&lt;li data-end=&quot;2762&quot; data-start=&quot;2720&quot;&gt;&lt;b&gt;에러 전파 불투명&lt;/b&gt; &amp;rarr; 리스너에서 발생한 예외 관리가 까다로움&lt;/li&gt;
&lt;li data-end=&quot;2821&quot; data-start=&quot;2763&quot;&gt;&lt;b&gt;실행 순서 보장 없음&lt;/b&gt; &amp;rarr; 여러 리스너 순서 의존성이 있으면 직접 제어 필요(@Order)&lt;/li&gt;
&lt;li data-end=&quot;2877&quot; data-start=&quot;2822&quot;&gt;&lt;b&gt;운영 모니터링 취약&lt;/b&gt; &amp;rarr; DLQ, 재처리 메커니즘 없음. 이벤트 사라지면 추적 어려움&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-end=&quot;2882&quot; data-start=&quot;2879&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;2900&quot; data-start=&quot;2884&quot; data-ke-size=&quot;size26&quot;&gt;4. 이번 주 핵심 배움&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;3114&quot; data-start=&quot;2901&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2964&quot; data-start=&quot;2901&quot;&gt;&lt;b&gt;Resilience4j&lt;/b&gt;: 장애를 완전히 없앨 수 없으므로, &lt;b&gt;제어 가능한 장애&lt;/b&gt;로 바꿔야 한다.&lt;/li&gt;
&lt;li data-end=&quot;3029&quot; data-start=&quot;2965&quot;&gt;&lt;b&gt;보상 트랜잭션&lt;/b&gt;: 외부 자원과 로컬 DB의 정합성을 맞추는 핵심 전략 &amp;rarr; 멱등성과 독립성 보장이 필수.&lt;/li&gt;
&lt;li data-end=&quot;3114&quot; data-start=&quot;3030&quot;&gt;&lt;b&gt;Spring Events&lt;/b&gt;: 도메인 사건을 드러내고 관심사를 분리하는 강력한 도구지만, JVM 내부 한계와 운영성 부족이라는 단점도 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-end=&quot;3119&quot; data-start=&quot;3116&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;3143&quot; data-start=&quot;3121&quot; data-ke-size=&quot;size26&quot;&gt;5. To-Study &amp;mdash; 다음 단계&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;3378&quot; data-start=&quot;3144&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;3195&quot; data-start=&quot;3144&quot;&gt;&lt;b&gt;Outbox / Inbox 패턴&lt;/b&gt;: DB Commit과 이벤트 발행 원자성 보장&lt;/li&gt;
&lt;li data-end=&quot;3251&quot; data-start=&quot;3196&quot;&gt;&lt;b&gt;DLQ (Dead Letter Queue)&lt;/b&gt;: 이벤트 소비 실패 메시지 격리 및 재처리&lt;/li&gt;
&lt;li data-end=&quot;3326&quot; data-start=&quot;3252&quot;&gt;&lt;b&gt;메시지 브로커 기반 이벤트(Kafka/RabbitMQ)&lt;/b&gt;: Spring Events의 JVM 한계를 넘어 분산 환경 대응&lt;/li&gt;
&lt;li data-end=&quot;3378&quot; data-start=&quot;3327&quot;&gt;&lt;b&gt;이벤트 멱등성 / Exactly-Once 보장&lt;/b&gt;: 메시지 중복 소비/재처리 대응&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Loopers</category>
      <author>그zi운아이</author>
      <guid isPermaLink="true">https://dev-th.tistory.com/71</guid>
      <comments>https://dev-th.tistory.com/71#entry71comment</comments>
      <pubDate>Sun, 24 Aug 2025 11:48:20 +0900</pubDate>
    </item>
    <item>
      <title>PG가 터져도 우리 서비스는 멀쩡해야 한다  </title>
      <link>https://dev-th.tistory.com/70</link>
      <description>&lt;h1&gt;PG가 터져도 우리 서비스는 멀쩡해야 한다  &lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Resilience4j로 결제 시스템 장애 방어선 구축한 썰&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;TL;DR&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PG 시뮬레이터 연동하다가 외부 시스템 장애로 전체 서비스 마비 경험. Resilience4j의 Circuit Breaker, Retry, Bulkhead, Timeout 패턴으로 방어선 구축하고, 이벤트 기반 보상 트랜잭션과 스케줄러 복구로 완전체 만든 실전기. &lt;b&gt;&quot;PG 하나 죽어도 주문은 계속 받아야 한다&quot;&lt;/b&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  사건의 발단: PG 하나가 터지니까 전체가 다운됐다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 6주차 과제에서 PG 시뮬레이터를 붙이면서 처음으로 &quot;외부 의존성의 무서움&quot;을 체감했습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;PG 시뮬레이터 스펙 (현실적으로 잔인함)&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;  PG 시뮬레이터 장애 시나리오
- 요청 성공 확률: 60% (40%는 그냥 실패)
- 요청 지연: 100ms ~ 500ms
- 처리 지연: 1s ~ 5s  
- 처리 결과: 성공 70%, 한도초과 20%, 카드오류 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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 &quot;그냥 RestTemplate으로 호출하면 되는 거 아니야?&quot; 싶었는데...&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;초기 구현 (무방비 상태)&lt;/h3&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;//   이렇게 했다가 서비스 전체가 터짐
@Service
public class PaymentService {
    
    public void processPayment(PaymentRequest request) {
        // PG 호출 (타임아웃 설정 없음)
        PgResponse response = restTemplate.postForObject(pgUrl, request, PgResponse.class);
        
        if (&quot;SUCCESS&quot;.equals(response.getStatus())) {
            // 성공 처리
        } else {
            // 실패 처리 
        }
        // PG가 죽으면 여기서 전체 주문 프로세스 멈춤  
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;예상 참사들&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;PG 응답 5초 지연&lt;/b&gt; &amp;rarr; 사용자가 결제 버튼 연타 &amp;rarr; 중복 결제 발생&lt;/li&gt;
&lt;li&gt;&lt;b&gt;PG 서버 500 에러&lt;/b&gt; &amp;rarr; 우리 주문 시스템도 500 에러로 전파&lt;/li&gt;
&lt;li&gt;&lt;b&gt;콜백 누락&lt;/b&gt; &amp;rarr; 결제는 성공했는데 주문은 PENDING 상태로 방치&lt;/li&gt;
&lt;li&gt;&lt;b&gt;재고 차감 후 PG 실패&lt;/b&gt; &amp;rarr; 재고는 없어졌는데 주문은 실패&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 깨달았습니다: &lt;b&gt;&quot;외부 시스템은 언제든 죽을 수 있다. 그걸 전제로 설계해야 한다.&quot;&lt;/b&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  1차 방어선: Resilience4j 패턴 적용&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;타임아웃부터 잡자 (무한 대기는 곧 죽음)&lt;/h3&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;# resilience4j.yml - 첫 번째 생명선
resilience4j:
  timelimiter:
    instances:
      pg-client:
        timeout-duration: 5s                # 5초 이상 기다리지 않음
        cancel-running-future: true
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;// FeignClient 레벨에서도 이중 보험
@Bean
public Request.Options pgRequestOptions() {
    return new Request.Options(
        1000, TimeUnit.MILLISECONDS,  // 연결 타임아웃 1초
        3000, TimeUnit.MILLISECONDS,  // 읽기 타임아웃 3초  
        true
    );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;5초로 정한 이유:&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;PG 시뮬레이터 최대 응답시간이 5초&lt;/li&gt;
&lt;li&gt;사용자 경험상 5초 이상은 너무 길음&lt;/li&gt;
&lt;li&gt;재시도까지 고려하면 적절한 기준점&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;재시도 전략 (하지만 무작정 재시도하면 안 됨)&lt;/h3&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;retry:
  instances:
    pg-client:
      max-attempts: 3                     # 최대 3회 (원본 1회 + 재시도 2회)
      wait-duration: 1s                   # 기본 1초 간격
      exponential-backoff-multiplier: 2   # 지수 백오프 (1s &amp;rarr; 2s &amp;rarr; 4s)
      randomized-delay-factor: 0.2        # &amp;plusmn;20% 지터로 요청 분산
      retry-exceptions:
        - java.net.SocketTimeoutException
        - java.net.ConnectException  
        - feign.FeignException.InternalServerError
        - feign.FeignException.ServiceUnavailable
      ignore-exceptions:
        - feign.FeignException.BadRequest   # 4xx는 재시도 의미 없음
        - feign.FeignException.Unauthorized
&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;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;단순 재시도는 장애난 PG 서버를 더 괴롭힘&lt;/li&gt;
&lt;li&gt;지수 백오프로 PG 서버 복구 시간 확보&lt;/li&gt;
&lt;li&gt;지터로 여러 서버의 재시도가 동시에 몰리지 않게 분산&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Circuit Breaker (언제 포기할지 정하기)&lt;/h3&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;circuitbreaker:
  instances:
    pg-client:
      failure-rate-threshold: 50          # 실패율 50% 초과시 Circuit Open
      slow-call-rate-threshold: 70        # 느린 호출 70% 초과도 비정상으로 판단
      slow-call-duration-threshold: 3s    # 3초 이상을 느린 호출로 간주
      minimum-number-of-calls: 5          # 최소 5회 호출 후 상태 판단
      sliding-window-size: 10             # 최근 10개 호출 기준으로 판단
      wait-duration-in-open-state: 30s    # Circuit Open 후 30초 대기
      permitted-number-of-calls-in-half-open-state: 3  # Half-Open에서 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;&lt;b&gt;50% 실패율을 기준으로 정한 이유:&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;PG 시뮬레이터 성공률이 60%라서 정상 상황에서도 40% 실패&lt;/li&gt;
&lt;li&gt;너무 낮게 설정하면 정상 상황에서도 Circuit이 열림&lt;/li&gt;
&lt;li&gt;50%를 넘으면 &quot;정말 비정상&quot;이라고 판단할 수 있는 기준점&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Bulkhead (격리로 전체 시스템 보호)&lt;/h3&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;bulkhead:
  instances:
    pg-client:
      max-concurrent-calls: 20            # PG 호출 전용 스레드 풀 20개
      max-wait-duration: 1s               # 1초 이상 대기하지 않음
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 모든 Resilience 패턴을 조합한 최종 형태
@Component
@RequiredArgsConstructor  
public class PgPaymentGateway {

    private final PgClient pgClient;

    @Retry(name = &quot;pg-client&quot;)
    @Bulkhead(name = &quot;pg-client&quot;)
    @CircuitBreaker(name = &quot;pg-client&quot;, fallbackMethod = &quot;requestPaymentFallback&quot;)
    public PgPaymentResponse requestPayment(String userId, PgPaymentRequest req) {
        PgApiResponse&amp;lt;PgPaymentResponse&amp;gt; res = pgClient.requestPayment(userId, req);
        
        if (!&quot;success&quot;.equals(res.meta().result())) {
            throw new CoreException(ErrorType.INTERNAL_ERROR, &quot;PG 호출 실패&quot;);
        }
        
        return res.data();
    }

    //   여기가 핵심! Fallback에서 어떻게 처리할 것인가?
    private PgPaymentResponse requestPaymentFallback(String userId, PgPaymentRequest req, Throwable ex) {
        log.error(&quot;PG 결제 요청 완전 실패 - userId: {}, orderId: {}, error: {}&quot;, 
            userId, req.orderId(), ex.getMessage());
        throw new CoreException(ErrorType.INTERNAL_ERROR, &quot;결제 시스템 일시 장애&quot;);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  2차 방어선: 이벤트 기반 보상 트랜잭션&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Resilience 패턴으로 장애를 막았지만, &lt;b&gt;결제 실패시 이미 차감된 재고와 쿠폰을 어떻게 되돌릴 것인가?&lt;/b&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;초기 접근법 (강결합 지옥)&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// ❌ 이렇게 했다가 유지보수 지옥 경험
@Service
public class PaymentService {
    
    private final StockService stockService;
    private final CouponService couponService;
    
    public void processPayment(PaymentCommand cmd) {
        try {
            // 1. 재고 차감
            stockService.reserve(cmd.getProductId(), cmd.getQuantity());
            
            // 2. 쿠폰 사용
            couponService.use(cmd.getCouponId(), cmd.getUserId());
            
            // 3. PG 호출
            pgGateway.requestPayment(cmd);
            
        } catch (Exception e) {
            //   여기서 수동 롤백... 강결합의 시작
            try {
                stockService.restore(cmd.getProductId(), cmd.getQuantity());
                couponService.release(cmd.getCouponId(), cmd.getUserId());
            } catch (Exception rollbackEx) {
                // 롤백도 실패하면?  
                log.error(&quot;롤백도 실패했다... 어떡하지?&quot;, rollbackEx);
            }
        }
    }
}
&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;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;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;이벤트 기반 보상으로 해결&lt;/h3&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;// ✅ 이벤트 발행으로 결합도 제거
@Component
public class CardPaymentStrategy implements PaymentStrategy {
    
    private final ApplicationEventPublisher eventPublisher;
    
    @Override
    public void pay(PaymentCommand cmd) {
        Long paymentId = paymentService.createInitiatedPayment(cmd);
        
        try {
            PgPaymentResponse resp = pgGateway.requestPayment(cmd.userId().value(), request);
            paymentService.updateToProcessing(paymentId, resp.transactionKey());
            
        } catch (Exception e) {
            // 실패시 이벤트만 발행하고 끝
            publishFailedEvent(cmd, paymentId, &quot;PG 요청 실패&quot;, e.getMessage());
        }
    }
    
    private void publishFailedEvent(PaymentCommand cmd, Long paymentId, String reason, String detail) {
        paymentService.updateToFailed(paymentId, reason);
        
        // 이벤트 발행 (강결합 제거!)
        eventPublisher.publishEvent(new PaymentFailedEvent(
            paymentId, cmd.orderId(), cmd.userId(), reason, detail
        ));
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// ✅ 별도 핸들러에서 보상 처리 (관심사 분리)
@Component
@RequiredArgsConstructor
public class OrderEventHandler {

    private final OrderTransactionService orderTransactionService;

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
    public void onPaymentFailed(PaymentFailedEvent event) {
        log.info(&quot;결제 실패 이벤트 수신 - orderId: {}, reason: {}&quot;, event.orderId(), event.reason());
        orderTransactionService.handlePaymentFailed(event);
    }
}

@Service
@RequiredArgsConstructor
public class OrderTransactionService {

    private final CompensationService compensationService;
    private final OrderService orderService;

    @Transactional(propagation = Propagation.REQUIRES_NEW)  //   이게 핵심!
    public void handlePaymentFailed(PaymentFailedEvent event) {
        Order order = orderService.getOrder(event.orderId());
        
        if (order == null || order.getStatus().isFinal()) return;  // 이미 처리됨
        if (!order.getStatus().canPayFail()) return;              // 상태 불일치
        
        // 주문 상태 변경
        orderService.markPaymentFailed(order, event.reason());
        
        // 보상 트랜잭션 실행
        compensationService.reverseFor(order);
        
        log.info(&quot;결제 실패 보상 처리 완료 - orderId: {}&quot;, event.orderId());
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// ✅ 실제 보상 로직 (확장 가능한 구조)
@Service
@RequiredArgsConstructor
public class CompensationService {

    private final ProductService productService;
    private final CouponService couponService;
    private final OrderService orderService;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void reverseFor(Order order) {
        
        // 1. 재고 복원
        restoreStock(order.getId());
        
        // 2. 쿠폰 복원 (사용된 쿠폰이 있을 때만)
        if (order.getUsedCouponId() != null) {
            releaseCoupons(order.getUserId(), order.getUsedCouponId());
        }
        
        log.info(&quot;보상 트랜잭션 완료 - orderId: {}&quot;, order.getId());
    }

    private void restoreStock(Long orderId) {
        List&amp;lt;OrderItem&amp;gt; orderItems = orderService.getOrder(orderId).getItems();
        for (OrderItem item : orderItems) {
            productService.restoreStock(item.getProductId(), item.getQuantity());
        }
    }

    private void releaseCoupons(UserId userId, Long usedCouponId) {
        couponService.releaseSpecificCoupon(usedCouponId, userId.value());
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;REQUIRES_NEW 트랜잭션을 선택한 핵심 이유:&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;li&gt;보상 처리 실패가 결제 실패 기록을 롤백시키지 않음&lt;/li&gt;
&lt;li&gt;각각 독립적으로 재시도 가능&lt;/li&gt;
&lt;li&gt;트랜잭션 경계 명확히 분리로 데드락 위험 감소&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;⏰ 3차 방어선: 스케줄러 기반 상태 복구&lt;/h2&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;PG 시스템 특성상 결제 요청 &amp;rarr; 즉시 응답(PENDING) &amp;rarr; 나중에 콜백으로 최종 결과 전달하는 구조입니다. 그런데 &lt;b&gt;네트워크 이슈로 콜백이 안 오면?&lt;/b&gt; 결제는 성공했는데 우리 시스템은 모르는 상황 발생.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;주기적 상태 동기화로 해결&lt;/h3&gt;
&lt;pre class=&quot;processing&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor
public class PaymentScheduler {

    private final PaymentStatePort paymentStatePort;
    private final PgPaymentGateway pgGateway;
    private final ApplicationEventPublisher eventPublisher;

    @Scheduled(cron = &quot;${payment.scheduler.sweep.cron:0 */5 * * * *}&quot;)  // 5분마다
    @Transactional
    public void sweepPending() {
        log.info(&quot;  PENDING 결제 상태 복구 시작&quot;);

        List&amp;lt;Payment&amp;gt; pendingPayments = paymentStatePort.loadPending();
        if (pendingPayments.isEmpty()) {
            log.debug(&quot;복구할 PENDING 결제 없음&quot;);
            return;
        }

        log.info(&quot;복구 대상 PENDING 결제: {}건&quot;, pendingPayments.size());

        for (Payment payment : pendingPayments) {
            try {
                // PG API로 실제 상태 확인
                PgPaymentStatusResponse statusResponse = pgGateway.getPaymentByOrderId(
                    payment.getUserId().value(),
                    &quot;ORDER_&quot; + payment.getOrderId()
                );

                String status = statusResponse.status() == null ? &quot;&quot; : statusResponse.status().toUpperCase();
                String txKey = safeTransactionKey(statusResponse.transactionKey(), payment.getTransactionKey());

                switch (status) {
                    case &quot;SUCCESS&quot; -&amp;gt; {
                        paymentStatePort.updateToCompleted(payment.getId(), txKey);
                        eventPublisher.publishEvent(PaymentCompletedEvent.of(
                            payment.getId(), payment.getOrderId(), payment.getUserId(), txKey));
                        log.info(&quot;✅ PENDING &amp;rarr; SUCCESS 복구 - paymentId={}, txKey={}&quot;, payment.getId(), txKey);
                    }
                    case &quot;FAILED&quot; -&amp;gt; {
                        String reason = safeReason(statusResponse.reason(), &quot;PG 상태조회: 실패&quot;);
                        paymentStatePort.updateToFailed(payment.getId(), reason);
                        eventPublisher.publishEvent(PaymentFailedEvent.of(
                            payment.getId(), payment.getOrderId(), payment.getUserId(), reason, txKey));
                        log.info(&quot;❌ PENDING &amp;rarr; FAILED 복구 - paymentId={}, reason={}&quot;, payment.getId(), reason);
                    }
                    case &quot;PENDING&quot; -&amp;gt; {
                        log.debug(&quot;⏳ 여전히 PENDING - paymentId={}&quot;, payment.getId());
                        // 타임아웃 체크 로직 추가 가능
                    }
                    default -&amp;gt; {
                        log.warn(&quot;⚠️ 알 수 없는 PG 상태 - paymentId={}, status={}&quot;, payment.getId(), status);
                    }
                }

            } catch (CoreException ex) {
                // PG 조회도 실패하면 해당 결제를 실패 처리
                String reason = &quot;PG 상태 조회 실패: &quot; + ex.getMessage();
                paymentStatePort.updateToFailed(payment.getId(), reason);
                eventPublisher.publishEvent(PaymentFailedEvent.of(
                    payment.getId(), payment.getOrderId(), payment.getUserId(), reason, null));
                log.error(&quot;  PG 조회 실패로 PENDING &amp;rarr; FAILED 처리 - paymentId={}&quot;, payment.getId(), ex);
            }
        }

        log.info(&quot;✅ PENDING 결제 상태 복구 완료&quot;);
    }

    private static String safeTransactionKey(String pgTxKey, String currentTxKey) {
        return (pgTxKey != null &amp;amp;&amp;amp; !pgTxKey.isBlank()) ? pgTxKey : currentTxKey;
    }

    private static String safeReason(String pgReason, String fallback) {
        return (pgReason == null || pgReason.isBlank()) ? fallback : pgReason;
    }
}
&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;b&gt;5분 주기로 정한 근거:&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;사용자 경험상 결제 후 5분이면 충분히 기다릴 수 있는 시간&lt;/li&gt;
&lt;li&gt;PG 시스템 부하를 고려한 적절한 간격 (너무 자주 호출하면 PG에서 차단 가능)&lt;/li&gt;
&lt;li&gt;실시간성보다는 최종 일관성 확보가 목표&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  실전 테스트: 정말 방어가 되나?&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Circuit Breaker 동작 확인&lt;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Test
@DisplayName(&quot;PG 서버 장애 시 Circuit Breaker 동작 검증&quot;)
void circuit_breaker_opens_on_pg_failure() {
    // given: PG가 계속 500 에러를 뱉는 상황
    when(pgClient.requestPayment(any(), any()))
        .thenThrow(new FeignException.InternalServerError(&quot;PG 서버 장애&quot;, 
            Request.create(Request.HttpMethod.POST, &quot;&quot;, Map.of(), null, Charset.defaultCharset(), null), 
            null, null));

    // when: 연속으로 여러 번 결제 시도 
    for (int i = 0; i &amp;lt; 10; i++) {
        assertThrows(CoreException.class, () -&amp;gt; 
            cardPaymentStrategy.pay(createPaymentCommand()));
    }

    // then: Circuit이 열리고 Fallback으로 빠르게 실패
    // 더 이상 PG 호출 없이 즉시 실패 응답
}

@Test  
@DisplayName(&quot;PG 타임아웃 시 재시도 후 최종 실패&quot;)
void pg_timeout_triggers_retry_then_fails() {
    // given: PG가 타임아웃을 발생시키는 상황
    when(pgClient.requestPayment(any(), any()))
        .thenThrow(new SocketTimeoutException(&quot;PG 타임아웃&quot;));

    // when: 결제 시도
    assertThrows(CoreException.class, () -&amp;gt; 
        cardPaymentStrategy.pay(createPaymentCommand()));

    // then: 재시도가 3회 발생했는지 확인
    verify(pgClient, times(3)).requestPayment(any(), any());
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;보상 트랜잭션 동작 확인&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Test
@DisplayName(&quot;결제 실패 시 재고와 쿠폰이 정상 복원되는가&quot;)
void payment_failure_triggers_compensation() {
    // given: 재고와 쿠폰을 사용한 주문
    Order order = createOrderWithStockAndCoupon();
    PaymentFailedEvent event = new PaymentFailedEvent(1L, order.getId(), order.getUserId(), &quot;PG 실패&quot;, null);

    // when: 결제 실패 이벤트 발생
    orderEventHandler.onPaymentFailed(event);

    // then: 보상 트랜잭션이 실행되었는지 확인
    verify(productService).restoreStock(order.getItems().get(0).getProductId(), 
        order.getItems().get(0).getQuantity());
    verify(couponService).releaseSpecificCoupon(order.getUsedCouponId(), order.getUserId().value());
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;스케줄러 복구 동작 확인&lt;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Test
@DisplayName(&quot;콜백 누락된 SUCCESS 결제가 스케줄러로 복구되는가&quot;)
void scheduler_recovers_missing_callback() {
    // given: PENDING 상태인 결제 (콜백이 안온 상황)
    Payment pendingPayment = createPendingPayment();
    when(paymentStatePort.loadPending()).thenReturn(List.of(pendingPayment));
    
    // PG 조회하면 실제로는 SUCCESS 상태
    when(pgGateway.getPaymentByOrderId(any(), any()))
        .thenReturn(new PgPaymentStatusResponse(&quot;SUCCESS&quot;, &quot;tx_123&quot;, null));

    // when: 스케줄러 실행
    paymentScheduler.sweepPending();

    // then: SUCCESS로 상태 변경되고 완료 이벤트 발행
    verify(paymentStatePort).updateToCompleted(pendingPayment.getId(), &quot;tx_123&quot;);
    verify(eventPublisher).publishEvent(any(PaymentCompletedEvent.class));
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  실무 관점에서 배운 것들&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. Fallback 전략은 단순 실패가 아니다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재는 Fallback에서 그냥 예외만 던지고 있는데, 실무에서는 더 정교한 전략이 필요합니다:&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;//   현재: 단순 실패 처리
private PgPaymentResponse requestPaymentFallback(String userId, PgPaymentRequest req, Throwable ex) {
    throw new CoreException(ErrorType.INTERNAL_ERROR, &quot;결제 시스템 일시 장애&quot;);
}

//   실무에서 고려할 수 있는 전략들:
// 1. 다른 PG사로 자동 라우팅
// 2. 대체 결제 수단 제안 (포인트 결제 등)
// 3. 오프라인 결제 안내
// 4. 나중에 처리 예약 (결제 대기 큐)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 보상 트랜잭션의 실패는 어떻게 처리할 것인가?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재는 보상 트랜잭션이 실패하면 로그만 남기는데, 실무에서는 더 견고한 처리가 필요:&lt;/p&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;// 고려해볼 점들:
// 1. Dead Letter Queue로 실패한 보상 작업 저장
// 2. 수동 보상 처리를 위한 관리자 도구
// 3. 보상 작업의 멱등성 보장 (여러 번 실행해도 안전)
// 4. 부분 보상 실패 시 처리 방안 (재고는 복원됐는데 쿠폰은 실패)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 모니터링과 알림이 생명선&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아무리 Resilience 패턴을 잘 적용해도 장애 상황을 빠르게 감지하고 대응해야 합니다:&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;# 실무에서 모니터링해야 할 지표들
circuit_breaker_state: CLOSED/OPEN/HALF_OPEN 상태 변화
retry_attempts: 재시도 횟수 및 성공률 추이  
pending_payment_count: PENDING 상태 결제 건수
compensation_failure_rate: 보상 트랜잭션 실패율
pg_response_time_p95: PG 응답시간 95퍼센타일
callback_missing_rate: 콜백 누락률
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4. 설정값에 정답은 없다 (상황에 맞게)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Resilience4j 설정은 시스템 특성과 비즈니스 요구사항에 따라 달라져야 합니다:&lt;/p&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;// 내가 고려한 기준들:
// - 타임아웃: 사용자 경험 vs PG 응답시간 특성
// - 재시도 횟수: 네트워크 복구 가능성 vs 전체 응답시간
// - Circuit Breaker 임계값: 일시적 장애 vs 시스템 장애 구분점
// - 벌크헤드 크기: 동시 결제 처리량 vs 시스템 리소스
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  마무리: 장애는 언제나 온다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 PG 연동 과제를 통해 가장 크게 깨달은 점은 &quot;외부 시스템 장애는 선택이 아니라 필수&quot;라는 것입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&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;Circuit Breaker&lt;/b&gt;: PG 장애가 전체 시스템을 마비시키지 않음&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Retry + Exponential Backoff&lt;/b&gt;: 일시적 네트워크 이슈 극복&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Bulkhead&lt;/b&gt;: PG 호출이 다른 기능에 영향 주지 않음&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;</description>
      <category>Loopers</category>
      <author>그zi운아이</author>
      <guid isPermaLink="true">https://dev-th.tistory.com/70</guid>
      <comments>https://dev-th.tistory.com/70#entry70comment</comments>
      <pubDate>Fri, 22 Aug 2025 15:28:07 +0900</pubDate>
    </item>
    <item>
      <title>WIL - 인덱스 -&amp;gt; 캐시 -&amp;gt; Redis</title>
      <link>https://dev-th.tistory.com/69</link>
      <description>&lt;h1 data-end=&quot;126&quot; data-start=&quot;90&quot;&gt;인덱스 &amp;rarr; 캐시 &amp;rarr; Redis&amp;nbsp;&lt;/h1&gt;
&lt;h2 data-end=&quot;137&quot; data-start=&quot;128&quot; data-ke-size=&quot;size26&quot;&gt;한눈에 보기&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;273&quot; data-start=&quot;138&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;178&quot; data-start=&quot;138&quot;&gt;&lt;b&gt;인덱스&lt;/b&gt;: 쿼리 자체를 빠르게 만드는 구조.&lt;/li&gt;
&lt;li data-end=&quot;219&quot; data-start=&quot;179&quot;&gt;&lt;b&gt;캐시&lt;/b&gt;: 반복 결과를 메모리에 두고 재사용해 부하/지연을 줄임.&lt;/li&gt;
&lt;li data-end=&quot;273&quot; data-start=&quot;220&quot;&gt;&lt;b&gt;Redis&lt;/b&gt;: 캐시로도 쓰지만, 자료구조/원자연산/TTL까지 제공하는 인메모리 스토어.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-end=&quot;278&quot; data-start=&quot;275&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;298&quot; data-start=&quot;280&quot; data-ke-size=&quot;size26&quot;&gt;인덱스 &amp;mdash; 무엇이고 왜 쓰나&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;531&quot; data-start=&quot;299&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;352&quot; data-start=&quot;299&quot;&gt;&lt;b&gt;정의&lt;/b&gt;: RDB가 &lt;b&gt;찾기/정렬/필터&lt;/b&gt;를 빠르게 하려는 자료구조(B+Tree 중심).&lt;/li&gt;
&lt;li data-end=&quot;531&quot; data-start=&quot;353&quot;&gt;&lt;b&gt;핵심 포인트&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;531&quot; data-start=&quot;368&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;413&quot; data-start=&quot;368&quot;&gt;&lt;b&gt;선택&lt;/b&gt;: 선두 컬럼 분포가 좋아야 스캔이 짧아짐.&lt;/li&gt;
&lt;li data-end=&quot;478&quot; data-start=&quot;416&quot;&gt;&lt;b&gt;정렬 흡수&lt;/b&gt;: 인덱스 순서와 ORDER BY를 맞추면 filesort 회피.&lt;/li&gt;
&lt;li data-end=&quot;531&quot; data-start=&quot;481&quot;&gt;&lt;b&gt;커버링&lt;/b&gt;: 필요한 컬럼이 인덱스 안에 있으면 테이블 접근 생략.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-end=&quot;551&quot; data-start=&quot;533&quot; data-ke-size=&quot;size23&quot;&gt;인덱스 설계 요령&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;826&quot; data-start=&quot;552&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;645&quot; data-start=&quot;552&quot;&gt;&lt;b&gt;복합 인덱스 순서 = 쿼리 의도&lt;/b&gt;&lt;br /&gt;WHERE a=? &amp;hellip; ORDER BY b DESC, id DESC &amp;rarr; &lt;b&gt;(a, b DESC, id DESC)&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;699&quot; data-start=&quot;646&quot;&gt;&lt;b&gt;카디널리티&lt;/b&gt;: BOOLEAN/NULL 등 &lt;b&gt;저카디널리티 선두 금지&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;772&quot; data-start=&quot;700&quot;&gt;&lt;b&gt;범위 조건 주의&lt;/b&gt;: 복합 인덱스에서 &lt;b&gt;첫 범위(&amp;gt;, &amp;lt;, BETWEEN, LIKE 'p%')&lt;/b&gt; 이후 컬럼 활용 약화.&lt;/li&gt;
&lt;li data-end=&quot;826&quot; data-start=&quot;773&quot;&gt;&lt;b&gt;Keyset(Seek) 페이징&lt;/b&gt;: 깊은 OFFSET 대신 다음 페이지 시작점으로 탐색.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;976&quot; data-start=&quot;959&quot; data-ke-size=&quot;size23&quot;&gt;인덱스로 안 끝나는 지점&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1113&quot; data-start=&quot;977&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;999&quot; data-start=&quot;977&quot;&gt;깊은 페이지(OFFSET 수백~수천)&lt;/li&gt;
&lt;li data-end=&quot;1031&quot; data-start=&quot;1000&quot;&gt;일부 &lt;b&gt;필터+정렬&lt;/b&gt; 조합에서만 비싸게 튀는 케이스&lt;/li&gt;
&lt;li data-end=&quot;1066&quot; data-start=&quot;1032&quot;&gt;&lt;b&gt;JOIN 비용 폭증&lt;/b&gt;(드라이빙 테이블 선택 실패 등)&lt;/li&gt;
&lt;li data-end=&quot;1113&quot; data-start=&quot;1067&quot;&gt;순간 &lt;b&gt;경합&lt;/b&gt;(스토리지/락/네트워크)&lt;br /&gt;&amp;rarr; 여기서 &lt;b&gt;캐시&lt;/b&gt;의 가치가 생김.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-end=&quot;1118&quot; data-start=&quot;1115&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;1137&quot; data-start=&quot;1120&quot; data-ke-size=&quot;size26&quot;&gt;캐시 &amp;mdash; 무엇이고 왜 쓰나&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1334&quot; data-start=&quot;1138&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1191&quot; data-start=&quot;1138&quot;&gt;&lt;b&gt;정의&lt;/b&gt;: &lt;b&gt;반복 접근&lt;/b&gt; 결과를 &lt;b&gt;더 빠른 계층&lt;/b&gt;(대개 메모리)에 저장해 재사용.&lt;/li&gt;
&lt;li data-end=&quot;1283&quot; data-start=&quot;1192&quot;&gt;&lt;b&gt;언제 고려?&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1283&quot; data-start=&quot;1207&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1225&quot; data-start=&quot;1207&quot;&gt;읽기 편향&amp;middot;중복 접근이 뚜렷함&lt;/li&gt;
&lt;li data-end=&quot;1264&quot; data-start=&quot;1228&quot;&gt;&lt;b&gt;강한 순간 일관성&lt;/b&gt;이 꼭 필요하진 않음(짧은 지연 허용)&lt;/li&gt;
&lt;li data-end=&quot;1283&quot; data-start=&quot;1267&quot;&gt;정렬/집계/조인 비용이 큼&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;1334&quot; data-start=&quot;1284&quot;&gt;&lt;b&gt;효과&lt;/b&gt;: 원본(DB&amp;middot;외부 API) 부하 절감, 평균/꼬리 지연 개선, 비용 절감.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-end=&quot;107&quot; data-start=&quot;76&quot; data-ke-size=&quot;size23&quot;&gt;캐시 패턴 (Cache-aside 포함, 핵심만)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;638&quot; data-start=&quot;109&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;231&quot; data-start=&quot;109&quot;&gt;&lt;b&gt;Cache-aside (앱 주도 미스 처리)&lt;/b&gt;&lt;br /&gt;GET cache &amp;rarr; Miss &amp;rarr; GET DB &amp;rarr; SET cache &amp;rarr; 응답&lt;br /&gt;단순&amp;middot;통제 쉬움, 프레임워크 종속 낮음. 대부분의 조회 캐시 기본값.&lt;/li&gt;
&lt;li data-end=&quot;337&quot; data-start=&quot;233&quot;&gt;&lt;b&gt;Read-through (캐시가 로더)&lt;/b&gt;&lt;br /&gt;앱은 캐시만 읽고, &lt;b&gt;미스는 캐시가 직접 원본 로드&lt;/b&gt; 후 저장/반환.&lt;br /&gt;앱 단순하지만 캐시 컴포넌트 의존&amp;middot;운영 복잡도&amp;uarr;.&lt;/li&gt;
&lt;li data-end=&quot;434&quot; data-start=&quot;339&quot;&gt;&lt;b&gt;Write-through (동기 쓰기)&lt;/b&gt;&lt;br /&gt;&lt;b&gt;캐시에 먼저 기록&lt;/b&gt;, 캐시가 &lt;b&gt;즉시 DB 반영&lt;/b&gt;.&lt;br /&gt;캐시/DB 동기화 쉬움, 대신 쓰기 레이턴시&amp;uarr;.&lt;/li&gt;
&lt;li data-end=&quot;549&quot; data-start=&quot;436&quot;&gt;&lt;b&gt;Write-back / Write-behind (지연 쓰기)&lt;/b&gt;&lt;br /&gt;&lt;b&gt;캐시에만 먼저 기록&lt;/b&gt;, DB는 &lt;b&gt;비동기/배치 반영&lt;/b&gt;.&lt;br /&gt;쓰기 성능&amp;uarr;, 장애 시 유실&amp;middot;불일치 리스크 관리 필요.&lt;/li&gt;
&lt;li data-end=&quot;638&quot; data-start=&quot;551&quot;&gt;&lt;b&gt;Refresh-ahead (선제 갱신)&lt;/b&gt;&lt;br /&gt;&lt;b&gt;만료 전에&lt;/b&gt; 백그라운드로 재로딩.&lt;br /&gt;만료 순간 버스트 완화, 예측 빗나가면 불필요 비용.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-is-only-node=&quot;&quot; data-is-last-node=&quot;&quot; data-end=&quot;722&quot; data-start=&quot;640&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-is-last-node=&quot;&quot; data-end=&quot;722&quot; data-start=&quot;642&quot; data-ke-size=&quot;size16&quot;&gt;실전 기본 조합: &lt;b&gt;Cache-aside(읽기) + Write-around(쓰기 시 DB만 갱신 후 캐시 무효화)&lt;/b&gt; &amp;mdash; 단순하고 예측 가능.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-end=&quot;1863&quot; data-start=&quot;1860&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;1889&quot; data-start=&quot;1865&quot; data-ke-size=&quot;size26&quot;&gt;Redis &amp;mdash; 무엇인가(캐시 그 이상)&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2134&quot; data-start=&quot;1890&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1987&quot; data-start=&quot;1890&quot;&gt;&lt;b&gt;정의&lt;/b&gt;: 인메모리 데이터 스토어. &lt;b&gt;String/Hash/Set/ZSet/Bitmap/HLL/Geo/Stream&lt;/b&gt; 등 자료구조 + &lt;b&gt;원자 연산/TTL&lt;/b&gt; 제공.&lt;/li&gt;
&lt;li data-end=&quot;2134&quot; data-start=&quot;1988&quot;&gt;&lt;b&gt;자주 쓰는 용도&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2134&quot; data-start=&quot;2005&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2027&quot; data-start=&quot;2005&quot;&gt;&lt;b&gt;캐시&lt;/b&gt;(페이지/목록/상세 결과)&lt;/li&gt;
&lt;li data-end=&quot;2049&quot; data-start=&quot;2030&quot;&gt;&lt;b&gt;세션/토큰&lt;/b&gt;(만료&amp;middot;원자성)&lt;/li&gt;
&lt;li data-end=&quot;2083&quot; data-start=&quot;2052&quot;&gt;&lt;b&gt;카운팅/레이트리밋&lt;/b&gt;(INCR, 슬라이딩 윈도우)&lt;/li&gt;
&lt;li data-end=&quot;2134&quot; data-start=&quot;2086&quot;&gt;&lt;b&gt;랭킹/피드&lt;/b&gt;(ZSet), &lt;b&gt;가벼운 메시징&lt;/b&gt;(Pub/Sub, Streams)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-end=&quot;2139&quot; data-start=&quot;2136&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-end=&quot;2155&quot; data-start=&quot;2141&quot; data-ke-size=&quot;size26&quot;&gt;간단 의사결정 가이드&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2291&quot; data-start=&quot;2156&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2205&quot; data-start=&quot;2156&quot;&gt;쿼리 자체가 느리다 &amp;rarr; &lt;b&gt;인덱스&lt;/b&gt;부터(정렬 포함, 선두 선택도, Seek 페이징)&lt;/li&gt;
&lt;li data-end=&quot;2246&quot; data-start=&quot;2206&quot;&gt;반복 조회/부하가 문제다 &amp;rarr; &lt;b&gt;캐시&lt;/b&gt;(패턴 선택 + 무효화 전략)&lt;/li&gt;
&lt;li data-end=&quot;2291&quot; data-start=&quot;2247&quot;&gt;세션&amp;middot;카운트&amp;middot;랭킹 등 기능적 요구가 있다 &amp;rarr; &lt;b&gt;Redis 자료구조&lt;/b&gt; 매칭&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <author>그zi운아이</author>
      <guid isPermaLink="true">https://dev-th.tistory.com/69</guid>
      <comments>https://dev-th.tistory.com/69#entry69comment</comments>
      <pubDate>Sun, 17 Aug 2025 22:35:08 +0900</pubDate>
    </item>
    <item>
      <title>인덱스 걸었는데 왜 또 느려져요? &amp;rarr; Redis로 해결한 썰</title>
      <link>https://dev-th.tistory.com/68</link>
      <description>&lt;h1&gt;  인덱스를 걸었는데 또 느려졌다? &amp;rarr; Redis 캐시로 완전체 만든 썰 Part 2&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  인사말&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안뇽 여러분~ 지난번에 인덱스 최적화로 p95 개선한 썰 풀었는데 기억하시나요?&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;WHERE만 보고 인덱스 걸었다가 filesort 지옥 맛봄  &lt;/li&gt;
&lt;li&gt;정렬 컬럼 + 타이브레이커(id) 포함한 전용 인덱스로 해결&lt;/li&gt;
&lt;li&gt;&lt;b&gt;p95 기준 3~10배 성능 개선&lt;/b&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;p data-ke-size=&quot;size16&quot;&gt;그래서 이번엔 &lt;b&gt;Redis 캐시까지 도입해서 완전체&lt;/b&gt;를 만들어봤습니다!&lt;br /&gt;K6 테스트로 정량 측정한 결과가 &lt;b&gt;레전드급&lt;/b&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 data-ke-size=&quot;size26&quot;&gt;  인덱스 최적화했는데 왜 또 느려졌을까?&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;지난번 인덱스 개선 요약&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지난번에 이런 삽질을 했었죠:&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;-- ❌ WHERE만 보고 만든 나쁜 인덱스
CREATE INDEX idx_bad_product_brand ON product(brand_id);
CREATE INDEX idx_bad_deleted_only ON product(deleted_at);

-- ✅ 정렬까지 고려한 좋은 인덱스  
CREATE INDEX idx_live_created ON product (deleted_at, created_at, id);
CREATE INDEX idx_live_price ON product (deleted_at, price, id);
CREATE INDEX idx_live_brand_created ON product (deleted_at, brand_id, created_at, id);
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결과&lt;/b&gt;: p95 기준 3~10배 성능 개선 달성!&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;인덱스 최적화로 단일 쿼리는 빨라졌지만:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&amp;nbsp;&quot;아 동접자 늘어나니까 또 느려져요 ㅠㅠ&quot;&lt;/li&gt;
&lt;li&gt;&quot;인덱스 다 최적화했는데??&quot;&lt;/li&gt;
&lt;li&gt;&quot;DB CPU가 100% 찍혀요&quot;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&quot;&lt;/b&gt;아...&quot;&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;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;: 인기상품들을 계속 DB에서 가져오니까 부하 심함&lt;/li&gt;
&lt;li&gt;&lt;b&gt;복잡한 조인&lt;/b&gt;: 상품-브랜드-좋아요 3개 테이블 조인은 여전히 무거움&lt;/li&gt;
&lt;li&gt;&lt;b&gt;동접자 증가&lt;/b&gt;: 커넥션 풀 터지면서 DB 큐잉 현상 발생&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;-- 이 쿼리가 계속 날아옴 (1초에 수십번)
SELECT 
    p.id, p.name, p.price,
    b.name AS brand_name,
    COALESCE(pl.like_count, 0) AS like_count
FROM product AS p
LEFT JOIN brand AS b ON p.brand_id = b.id  
LEFT JOIN product_likes AS pl ON pl.product_id = p.id
WHERE p.deleted_at IS NULL
ORDER BY p.created_at DESC
LIMIT 20;
&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt; ️ 캐시 도입 삽질 스토리&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1차 시도: @Cacheable 써봤는데...&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 Spring 기본 캐시 어노테이션으로 시작했어요:&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// ❌ 이렇게 했다가 멘붕옴
@Cacheable(value = &quot;products&quot;, key = &quot;#searchCommand&quot;)
public Page&amp;lt;ProductInfo&amp;gt; searchProducts(ProductSearchCommand searchCommand) {
    return productRepository.searchByCondition(searchCommand);
}
&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;&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;Page 객체 직렬화 안됨&lt;/li&gt;
&lt;li&gt;캐시 무효화 타이밍 조절 불가&lt;/li&gt;
&lt;li&gt;복잡한 검색 조건 처리 어려움&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2차 시도: RedisTemplate 직접 사용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 RedisTemplate로 직접 구현해봤어요:&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// ❌ 이것도 노답
@Service  
public class ProductService {
    
    public ProductInfo getProduct(Long id) {
        String key = &quot;product:&quot; + id;
        String cached = redisTemplate.opsForValue().get(key);
        
        if (cached != null) {
            return objectMapper.readValue(cached, ProductInfo.class);
        }
        
        ProductInfo product = productRepository.findById(id);
        redisTemplate.opsForValue().set(key, objectMapper.writeValueAsString(product));
        return product;
    }
}&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;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;try-catch 떡칠&lt;/li&gt;
&lt;li&gt;타입 안전성 제로&lt;/li&gt;
&lt;li&gt;캐시 무효화 로직 복잡&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3차 시도: 아키텍처 다시 설계&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;삽질 끝에 깨달은 것&lt;/b&gt;: 기존 코드는 건드리지 말고 캐시 레이어만 추가하자!&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt; ️ 최종 캐시 아키텍처 (이제야 제대로)&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;데코레이터 패턴으로 깔끔하게 분리&lt;/h3&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;@Service
@Primary  // 이게 포인트! 기존 서비스 대체
@RequiredArgsConstructor
public class CachedProductService implements ProductService {
    
    private final ProductRepository repo;         // 원래 레포
    private final CacheService cache;           // 캐시 추상화
    private final CachePolicy policy;          // 캐시 정책
    private final ProductCacheKeyGenerator keyGenerator; // 키 생성
    
    private static final TypeRef&amp;lt;ProductInfo&amp;gt; PRODUCT = new TypeRef&amp;lt;&amp;gt;() {};
    private static final TypeRef&amp;lt;PageView&amp;lt;ProductInfo&amp;gt;&amp;gt; PAGE_OF_PRODUCT = new TypeRef&amp;lt;&amp;gt;() {};
    
    @Override
    public ProductInfo getProduct(Long id) {
        return cache.getOrLoad(
            keyGenerator.createDetailKey(id),        // 키 생성
            PRODUCT,                                 // 타입 정보
            () -&amp;gt; repo.findProductInfoById(id)       // 로딩 로직
                .orElseThrow(() -&amp;gt; new CoreException(ErrorType.NOT_FOUND)),
            policy                                   // TTL 정책
        );
    }
    
    @Override
    public Page&amp;lt;ProductInfo&amp;gt; getProducts(ProductSearchCommand c) {
        return cache.getOrLoad(
            keyGenerator.createListKey(c),           // 키 생성
            PAGE_OF_PRODUCT,                         // 타입 정보
            () -&amp;gt; {
                Page&amp;lt;ProductInfo&amp;gt; result = repo.searchByCondition(c);
                return PageView.from(result);        // 직렬화 가능한 형태로 변환
            },
            policy                                   // TTL 정책
        ).toPage();  // 다시 Page로 변환
    }
}
&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;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;@Primary로 자동 DI 교체&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;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;타입 안전한 캐시 서비스 구현&lt;/h3&gt;
&lt;pre class=&quot;processing&quot;&gt;&lt;code&gt;@Component
@RequiredArgsConstructor
public class RedisCacheService implements CacheService {
    
    private final StringRedisTemplate redis;
    private final JsonCodec codec;
    private final SingleFlightRegistry singleFlight = new SingleFlightRegistry();
    private final VersionClock versionClock;
    
    @Override
    public &amp;lt;T&amp;gt; T getOrLoad(CacheKey key, TypeRef&amp;lt;T&amp;gt; typeRef, 
                          Loader&amp;lt;T&amp;gt; loader, CachePolicy policy) {
        
        final String redisKey = key.asString();
        
        // 1. 캐시에서 먼저 찾기
        String cached = redis.opsForValue().get(redisKey);
        if (cached != null) {
            T decoded = decodeOrEvict(cached, typeRef, redisKey);
            if (decoded != null) return decoded;  // 타입 안전!
        }
        
        // 2. 없으면 로더로 가져오기 (SingleFlight 적용!)
        CompletableFuture&amp;lt;String&amp;gt; flight = singleFlight.computeIfAbsent(redisKey, () -&amp;gt;
            CompletableFuture.supplyAsync(() -&amp;gt; {
                // 혹시 다른 스레드가 이미 캐시했나 더블체크
                String doubleCheck = redis.opsForValue().get(redisKey);
                if (doubleCheck != null) {
                    T decoded = decodeOrEvict(doubleCheck, typeRef, redisKey);
                    if (decoded != null) return doubleCheck;
                }
                
                // 진짜 DB 접근은 딱 한 번만!
                try {
                    T value = loader.load();
                    writeCache(redisKey, value, policy);
                    return codec.encode(value);
                } catch (Exception e) {
                    throw new CoreException(ErrorType.INTERNAL_ERROR);
                }
            })
        );
        
        try {
            String json = flight.join();
            return codec.decode(json, typeRef);
        } catch (Exception e) {
            // 캐시 실패해도 원본 데이터는 리턴
            try {
                return loader.load();
            } catch (Exception ex) {
                throw new CoreException(ErrorType.INTERNAL_ERROR);
            }
        }
    }
    
    private &amp;lt;T&amp;gt; T decodeOrEvict(String json, TypeRef&amp;lt;T&amp;gt; typeRef, String redisKey) {
        try {
            return codec.decode(json, typeRef);  // 타입 안전한 디코딩
        } catch (Exception e) {
            redis.delete(redisKey);  // 깨진 캐시 삭제
            return null;
        }
    }
    
    private &amp;lt;T&amp;gt; void writeCache(String redisKey, T value, CachePolicy policy) {
        Duration ttl = jitter(policy.ttlDetail(), 0.1);  // 지터 적용
        String json = codec.encode(value);
        redis.opsForValue().set(redisKey, json, ttl);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Page 직렬화 문제 해결&lt;/h3&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;// ✅ 직렬화 가능한 PageView 클래스
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PageView&amp;lt;T&amp;gt; {
    private List&amp;lt;T&amp;gt; content;
    private long totalElements;
    private boolean hasNext;
    private int page;
    private int size;
    
    public static &amp;lt;T&amp;gt; PageView&amp;lt;T&amp;gt; from(Page&amp;lt;T&amp;gt; page) {
        return new PageView&amp;lt;&amp;gt;(
            page.getContent(), 
            page.getTotalElements(), 
            page.hasNext(),
            page.getNumber(),
            page.getSize()
        );
    }
    
    public Page&amp;lt;T&amp;gt; toPage() {
        Pageable pageable = PageRequest.of(page, size);
        return new PageImpl&amp;lt;&amp;gt;(content, pageable, totalElements);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  캐시 처리 흐름&amp;nbsp;&lt;/h2&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;pre class=&quot;routeros&quot;&gt;&lt;code&gt;1. 사용자 요청: GET /api/products?page=0&amp;amp;sort=LATEST
   &amp;darr;
2. CachedProductService.getProducts() 호출
   &amp;darr;  
3. 캐시 키 생성: &quot;shop:cache:product:v1:list:page=0&amp;amp;sort=LATEST&quot;
   &amp;darr;
4. RedisCacheService.getOrLoad() 실행
   &amp;darr;
5. Redis에서 키 찾기: GET shop:cache:product:v1:list:page=0&amp;amp;sort=LATEST
   &amp;darr;
6-A. 캐시 HIT인 경우:
     JSON 디코딩 &amp;rarr; PageView 객체 리턴 (응답시간: ~7ms)
   &amp;darr;
6-B. 캐시 MISS인 경우:
     SingleFlight로 중복 요청 방지
     &amp;rarr; 원본 서비스 호출 (DB 접근)
     &amp;rarr; PageView로 변환 후 Redis 저장  
     &amp;rarr; 결과 리턴 (응답시간: ~25ms)
   &amp;darr;
7. PageView &amp;rarr; Page 변환 후 클라이언트에 응답
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;SingleFlight가 중요한 이유&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;동시에 같은 상품 100명이 조회해도 &lt;b&gt;DB는 1번만 접근&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;캐시 스탬피드 현상 완전 방지&lt;/b&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 data-ke-size=&quot;size26&quot;&gt;⚡ 핵심 최적화 테크닉들&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. SingleFlight로 똑같은 요청 합치기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제상황&lt;/b&gt;: 인기상품 캐시 만료시 동시에 100개 요청이 DB로 몰림  &lt;/p&gt;
&lt;pre class=&quot;processing&quot;&gt;&lt;code&gt;//   이게 핵심! 똑같은 키면 한 번만 DB 접근
@Component
public class SingleFlightRegistry {
    
    private final ConcurrentMap&amp;lt;String, CompletableFuture&amp;lt;String&amp;gt;&amp;gt; inflight = new ConcurrentHashMap&amp;lt;&amp;gt;();
    
    public CompletableFuture&amp;lt;String&amp;gt; computeIfAbsent(String key, Supplier&amp;lt;CompletableFuture&amp;lt;String&amp;gt;&amp;gt; supplier) {
        return inflight.computeIfAbsent(key, k -&amp;gt; supplier.get())
                      .whenComplete((result, throwable) -&amp;gt; inflight.remove(key));  // 완료 후 제거
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결과&lt;/b&gt;: 동시 요청 100개 &amp;rarr; DB 접근 1개로 감소!  &lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 네임스페이스 버전으로 스마트 무효화&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개별 캐시 키 찾아서 지우는 거 너무 빡셈. 그래서 &lt;b&gt;버전 시스템&lt;/b&gt; 도입!&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;@Component
public class ProductCacheKeyGenerator extends CacheKeyGenerator {
    
    public CacheKey createListKey(ProductSearchCommand cmd) {
        Map&amp;lt;String, Object&amp;gt; params = new HashMap&amp;lt;&amp;gt;();
        params.put(&quot;type&quot;, &quot;list&quot;);
        if (cmd.brandId() != null) params.put(&quot;brand&quot;, cmd.brandId());
        if (cmd.sortType() != null) params.put(&quot;sort&quot;, cmd.sortType());
        if (cmd.pageable() != null) params.put(&quot;page&quot;, cmd.pageable().getPageNumber());
        
        return buildKey(params);  // 내부적으로 버전 포함
    }
}

// 캐시 키에 네임스페이스 버전 포함
private CacheKey buildKey(Map&amp;lt;String, Object&amp;gt; params) {
    String version = versionClock.current(&quot;product&quot;);  // 현재 버전
    return new CacheKey(String.format(&quot;shop:cache:product:v%s:%s&quot;, version, 
        params.entrySet().stream()
            .map(e -&amp;gt; e.getKey() + &quot;=&quot; + e.getValue())
            .collect(Collectors.joining(&quot;&amp;amp;&quot;))));
}

// 상품 정보 변경시 버전만 올리면 끝!
@EventListener
public void onProductChanged(ProductEvent event) {
    switch (event.type()) {
        case STOCK_CHANGED, LIKE_CHANGED -&amp;gt; {
            versionClock.bump(&quot;product&quot;);  // 버전 증가 &amp;rarr; 모든 리스트 캐시 무효화
        }
    }
}

@Component
public class RedisVersionClock implements VersionClock {
    
    private final RedisTemplate&amp;lt;String, String&amp;gt; redis;
    private static final String NS_KEY_PREFIX = &quot;shop:cache:ns:&quot;;
    
    @Override
    public String current(String ns) {
        String key = NS_KEY_PREFIX + ns;
        String v = redis.opsForValue().get(key);
        return v == null ? &quot;0&quot; : v;
    }
    
    @Override
    public void bump(String ns) {
        String key = NS_KEY_PREFIX + ns;
        redis.opsForValue().increment(key);
    }
}
&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;h3 data-ke-size=&quot;size23&quot;&gt;3. 캐시 워밍업으로 UX 레벨업&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 첫 페이지 열었을 때도 빠르게 보이게 하려고 워밍업 추가:&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;@Component
@RequiredArgsConstructor
public class ProductWarmup implements CacheWarmer {
    
    private final CacheService cache;
    private final ProductRepository repo;
    private final CachePolicy policy;
    private final ProductCacheKeyGenerator keyGenerator;
    
    @Async
    @EventListener(ApplicationReadyEvent.class)  // 앱 시작시 실행
    public void warmOnBoot() {
        log.info(&quot;  캐시 워밍업 시작!&quot;);
        
        // 최신상품 리스트 미리 캐싱
        ProductSearchCommand latest = new ProductSearchCommand(
            PageRequest.of(0, 20), ProductSortType.LATEST, null
        );
        cache.preload(keyGenerator.createListKey(latest), PAGE_OF_PRODUCT,
            () -&amp;gt; PageView.from(repo.searchByCondition(latest)), policy);
        
        // 인기상품 리스트 미리 캐싱  
        ProductSearchCommand popular = new ProductSearchCommand(
            PageRequest.of(0, 20), ProductSortType.LIKES_DESC, null
        );
        cache.preload(keyGenerator.createListKey(popular), PAGE_OF_PRODUCT,
            () -&amp;gt; PageView.from(repo.searchByCondition(popular)), policy);
        
        // 인기상품 상세 정보 미리 캐싱
        repo.searchByCondition(popular).getContent().stream()
            .map(ProductInfo::productId)
            .forEach(id -&amp;gt;
                cache.preload(keyGenerator.createDetailKey(id), PRODUCT,
                    () -&amp;gt; repo.findProductInfoById(id).orElse(null), policy)
            );
        
        log.info(&quot;✅ 캐시 워밍업 완료!&quot;);
    }
}
&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;h3 data-ke-size=&quot;size23&quot;&gt;4. TTL Jitter로 캐시 만료 시간 분산&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제상황&lt;/b&gt;: 모든 인기상품 캐시가 동시에 만료되면서 DB 폭격&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 랜덤 지터 추가로 만료시간 분산
private Duration jitter(Duration base, double ratio) {
    if (base == null) return null;
    long ms = base.toMillis();
    long delta = (long) (ms * ratio);
    long j = ThreadLocalRandom.current().nextLong(-delta, delta + 1);
    return Duration.ofMillis(Math.max(1000, ms + j));
}

// 30분 &amp;plusmn; 10% = 27~33분 사이 랜덤 만료
Duration ttl = jitter(Duration.ofMinutes(30), 0.1);
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  K6 성능 테스트&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  테스트 시나리오&lt;/h3&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// 3단계 캐시 테스트: 워밍업 &amp;rarr; 인덱스 테스트 &amp;rarr; 캐시 테스트
export const options = {
  scenarios: {
    cache_warmup: {
      executor: 'constant-vus',
      vus: 2,
      duration: '15s',
      exec: 'warmupCache',
    },
    index_performance: {
      executor: 'constant-vus', 
      vus: 3,
      duration: '30s',
      startTime: '16s',
      exec: 'testPageDepthPerformance',
    },
    cache_performance: {
      executor: 'constant-vus',
      vus: 4, 
      duration: '45s',
      startTime: '16s',
      exec: 'testCachePerformance',
    },
  }
};

// 캐시 Hit/Miss 성능 테스트
function testCacheHitMiss(productId) {
  // 1차 요청 (Cache Miss)
  const start1 = Date.now();
  const response1 = http.get(`${BASE_URL}/api/products/${productId}`);
  const duration1 = Date.now() - start1;
  
  firstRequestTime.add(duration1);
  
  sleep(0.1); // 캐시 저장 시간
  
  // 2차 요청 (Cache Hit)
  const start2 = Date.now();
  const response2 = http.get(`${BASE_URL}/api/products/${productId}`);
  const duration2 = Date.now() - start2;
  
  secondRequestTime.add(duration2);
  
  // Cache Hit 여부 판단
  const isCacheHit = duration2 &amp;lt; duration1 * 0.6;
  cacheHitRate.add(isCacheHit ? 1 : 0);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  실제 테스트 결과&lt;/h3&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;로컬 MySQL 8.x + Redis 7.x&lt;/li&gt;
&lt;li&gt;상품 데이터: 약 10만 건&lt;/li&gt;
&lt;li&gt;K6 VU: 최대 7명 동시 접속&lt;/li&gt;
&lt;li&gt;테스트 시간: 총 61초&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;==========================================
  최종 K6 테스트 결과    
==========================================

✅ 총 요청: 1,507개
⏱️  평균 응답: 7.27ms 
  95% 응답: 14.0ms
  캐시 히트율: 100%
  처리량: 24.5 req/s
  실패율: 0%

  캐시 성능 상세:
├── 1차 요청 (Cache Miss): 7.67ms
├── 2차 요청 (Cache Hit):  7.14ms  
└── 3차 요청 (Cache Hit):  6.87ms

  페이지 깊이별 성능:
├── Shallow (0-5페이지):   7.3ms 
├── Medium (10-50페이지):  7.4ms
├── Deep (100-500페이지):  7.7ms  
└── Extreme (1000+페이지): 6.8ms

  성능 개선 비율: 평균 121%
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  단계별 성능 비교&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&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;span style=&quot;color: #333333; text-align: start;&quot;&gt;단계&amp;nbsp;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt; &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;평균 응답시간&lt;/td&gt;
&lt;td&gt;P95 응답시간&lt;/td&gt;
&lt;td&gt;&amp;nbsp;특징&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;인덱스 최적화 전&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;~80ms&lt;/td&gt;
&lt;td&gt;~300ms&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;~25ms&lt;/td&gt;
&lt;td&gt;~50ms&lt;/td&gt;
&lt;td&gt;정렬 인덱스 활용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Redis 캐시 적용&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;7.27ms&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;14.0ms&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;캐시 히트 100%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&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;91% 응답시간 단축&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;인덱스 최적화 대비 &lt;b&gt;71% 추가 개선&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  배운 것들 &amp;amp; 아직 부족한 것들&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&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;/li&gt;
&lt;li&gt;&lt;b&gt;타입 안전성&lt;/b&gt;: 컴파일 타임에 타입 체크로 런타임 에러 방지&lt;/li&gt;
&lt;li&gt;&lt;b&gt;체계적 테스트&lt;/b&gt;: K6로 정량적 성능 측정&lt;/li&gt;
&lt;li&gt;&lt;b&gt;이벤트 기반&lt;/b&gt;: 트랜잭션-캐시 라이프사이클 깔끔하게 분리&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&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;Redis 메모리 사용량 모니터링 부족&lt;/b&gt; &amp;rarr; Grafana 대시보드 추가 예정&lt;/li&gt;
&lt;li&gt;&lt;b&gt;캐시 키 네이밍 룰 정리 필요&lt;/b&gt; &amp;rarr; 팀 가이드라인 만들어야함&lt;/li&gt;
&lt;li&gt;&lt;b&gt;캐시 일관성 54%&lt;/b&gt; &amp;rarr; 80% 목표 달성을 위한 추가 최적화 필요&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;인덱스 최적화 + Redis 캐시 조합으로 91% 성능 개선 달성!&lt;/b&gt;  &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 의미있는 건 &lt;b&gt;단계적 접근&lt;/b&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;: p95 tail latency 집중 분석&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;: K6로 객관적 성능 검증&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 교훈&lt;/b&gt;:&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;성능 개선은 한 번에 끝나지 않는다&quot;&lt;br /&gt;&quot;측정할 수 없으면 개선할 수 없다&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <author>그zi운아이</author>
      <guid isPermaLink="true">https://dev-th.tistory.com/68</guid>
      <comments>https://dev-th.tistory.com/68#entry68comment</comments>
      <pubDate>Fri, 15 Aug 2025 18:42:18 +0900</pubDate>
    </item>
  </channel>
</rss>