<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>최고의 백엔드 개발자 (예정)</title>
    <link>https://developer-jinnie.tistory.com/</link>
    <description>쉬지마 이경진</description>
    <language>ko</language>
    <pubDate>Fri, 8 May 2026 08:30:24 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>쉬지마 이굥진</managingEditor>
    <image>
      <title>최고의 백엔드 개발자 (예정)</title>
      <url>https://tistory1.daumcdn.net/tistory/6520564/attach/ccad96a7d5c549b5bb38ee904e1e85da</url>
      <link>https://developer-jinnie.tistory.com</link>
    </image>
    <item>
      <title>[트러블슈팅] 분산락 걸었는데도 부하테스트 결과가 이상하다 : JPA 갱신 유실 트러블슈팅</title>
      <link>https://developer-jinnie.tistory.com/132</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;필자는 MSA 기반 대규모 트래픽 처리 예약구매 이커머스 서비스를 개발중이었다. &lt;a href=&quot;https://github.com/pickple-ecommerce/backend&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;깃허브 주소&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1086&quot; data-origin-height=&quot;517&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ZSA2s/dJMcah5pjdK/hbz1nwpFiHGadVwbmzroQk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ZSA2s/dJMcah5pjdK/hbz1nwpFiHGadVwbmzroQk/img.png&quot; data-alt=&quot;주요 개발 환경: java, spring, JPA, MSA&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ZSA2s/dJMcah5pjdK/hbz1nwpFiHGadVwbmzroQk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FZSA2s%2FdJMcah5pjdK%2Fhbz1nwpFiHGadVwbmzroQk%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;526&quot; height=&quot;517&quot; data-origin-width=&quot;1086&quot; data-origin-height=&quot;517&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;주요 개발 환경: java, spring, JPA, MSA&lt;/figcaption&gt;
&lt;/figure&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;u&gt;프로젝트의 핵심 기능 중 하나가 &lt;b&gt;예약구매&lt;/b&gt;&lt;/u&gt;였다. 한정된 재고에 대해 여러 사용자가 동시에 주문을 넣는 구조라 &lt;b&gt;동시성 문제&lt;/b&gt;가 필연적으로 따라오는 기능이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재고에 대한 동시성 문제(Race condition)는 Redis 분산락으로 테스트코드를 통해 해결됐음을 확인했다! &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;(자세한 분산락 핸들링 기록은 아래 분산락 포스팅 참고)&lt;/span&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1778070006892&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[프로젝트/기술적 의사결정] Redis 분산락으로 재고 감소 동시성 이슈 해결하기 (1/2)&quot; data-og-description=&quot;필자는 MSA 기반 이커머스 프로젝트에서 상품/재고/예약구매 도메인을 맡아 진행중이다. 지난 프로젝트에서 쿠폰 도메인을 맡아 개발했을 때 Race condition 문제를 예상치 못하게 겪고 (..) 이번 프&quot; data-og-host=&quot;developer-jinnie.tistory.com&quot; data-og-source-url=&quot;https://developer-jinnie.tistory.com/99&quot; data-og-url=&quot;https://developer-jinnie.tistory.com/99&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bNDlg8/dJMb82MG6Ls/txYzI8UvhuJK6TfsTx8UU0/img.png?width=800&amp;amp;height=236&amp;amp;face=0_0_800_236,https://scrap.kakaocdn.net/dn/boPiU5/dJMb8PGz9eV/qTD5KkMuUqfk5epaOpfdvk/img.png?width=800&amp;amp;height=236&amp;amp;face=0_0_800_236,https://scrap.kakaocdn.net/dn/c35ymz/dJMb83kwXRl/B9XCXpQqYilTablFJrFyYK/img.png?width=993&amp;amp;height=925&amp;amp;face=0_0_993_925&quot;&gt;&lt;a href=&quot;https://developer-jinnie.tistory.com/99&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://developer-jinnie.tistory.com/99&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bNDlg8/dJMb82MG6Ls/txYzI8UvhuJK6TfsTx8UU0/img.png?width=800&amp;amp;height=236&amp;amp;face=0_0_800_236,https://scrap.kakaocdn.net/dn/boPiU5/dJMb8PGz9eV/qTD5KkMuUqfk5epaOpfdvk/img.png?width=800&amp;amp;height=236&amp;amp;face=0_0_800_236,https://scrap.kakaocdn.net/dn/c35ymz/dJMb83kwXRl/B9XCXpQqYilTablFJrFyYK/img.png?width=993&amp;amp;height=925&amp;amp;face=0_0_993_925');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[프로젝트/기술적 의사결정] Redis 분산락으로 재고 감소 동시성 이슈 해결하기 (1/2)&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;필자는 MSA 기반 이커머스 프로젝트에서 상품/재고/예약구매 도메인을 맡아 진행중이다. 지난 프로젝트에서 쿠폰 도메인을 맡아 개발했을 때 Race condition 문제를 예상치 못하게 겪고 (..) 이번 프&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;developer-jinnie.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 후 예약구매 생성 JMeter를 통해 부하테스트를 진행했더니..! 당연히 통과할 줄 알았더니만 기댓값과 다른 결과값이 도출되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;문제 발생&lt;/b&gt; ㅡ 부하테스트 과정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt; 재고가 50개인 상품의 경우, &lt;u&gt;주문 &lt;/u&gt;&lt;u&gt;500&lt;/u&gt;&lt;u&gt;개의 요청 시 재고 만큼 주문도 &lt;/u&gt;&lt;u&gt;50&lt;/u&gt;&lt;u&gt;개만 생성되어야&lt;/u&gt; 한다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;783&quot; data-origin-height=&quot;427&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bRUlXa/dJMcahEijkf/dREB6RQ70KbSMmYBKHOA5k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bRUlXa/dJMcahEijkf/dREB6RQ70KbSMmYBKHOA5k/img.png&quot; data-alt=&quot;총 스레드 수는 50 * 10 = 500개로 설정&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bRUlXa/dJMcahEijkf/dREB6RQ70KbSMmYBKHOA5k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbRUlXa%2FdJMcahEijkf%2FdREB6RQ70KbSMmYBKHOA5k%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;337&quot; data-origin-width=&quot;783&quot; data-origin-height=&quot;427&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;총 스레드 수는 50 * 10 = 500개로 설정&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1010&quot; data-origin-height=&quot;83&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/beS6U6/dJMcahqNQVv/AK0Y9eBTREkkDtRv5yO0VK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/beS6U6/dJMcahqNQVv/AK0Y9eBTREkkDtRv5yO0VK/img.png&quot; data-alt=&quot;재고가 부족해서 정상적으로 error 로그 뜨는 것까지 확인했다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/beS6U6/dJMcahqNQVv/AK0Y9eBTREkkDtRv5yO0VK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbeS6U6%2FdJMcahqNQVv%2FAK0Y9eBTREkkDtRv5yO0VK%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;1010&quot; height=&quot;83&quot; data-origin-width=&quot;1010&quot; data-origin-height=&quot;83&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;재고가 부족해서 정상적으로 error 로그 뜨는 것까지 확인했다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;512&quot; data-origin-height=&quot;307&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/SwLLs/dJMcajvlAsg/W4K3SE7gkqkAyrAcbU2X51/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/SwLLs/dJMcajvlAsg/W4K3SE7gkqkAyrAcbU2X51/img.png&quot; data-alt=&quot;재고 수량(stock_quantity)도 0으로 잘 떨어진다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/SwLLs/dJMcajvlAsg/W4K3SE7gkqkAyrAcbU2X51/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FSwLLs%2FdJMcajvlAsg%2FW4K3SE7gkqkAyrAcbU2X51%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;397&quot; height=&quot;238&quot; data-origin-width=&quot;512&quot; data-origin-height=&quot;307&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;재고 수량(stock_quantity)도 0으로 잘 떨어진다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;480&quot; data-origin-height=&quot;210&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/LdL6A/dJMcaiDdfxT/NjllE4CzB0N2a4UphECjU0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/LdL6A/dJMcaiDdfxT/NjllE4CzB0N2a4UphECjU0/img.png&quot; data-alt=&quot;하지만 !!!!!!!!!!!! 왜 주문은 430여개가 생기지?&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/LdL6A/dJMcaiDdfxT/NjllE4CzB0N2a4UphECjU0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FLdL6A%2FdJMcaiDdfxT%2FNjllE4CzB0N2a4UphECjU0%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;414&quot; height=&quot;181&quot; data-origin-width=&quot;480&quot; data-origin-height=&quot;210&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;하지만 !!!!!!!!!!!! 왜 주문은 430여개가 생기지?&lt;/figcaption&gt;
&lt;/figure&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;아니 재고가 50개면 주문도 50개만 생겨야 할 거 아니냐!!!!! &lt;b&gt;재고가 없는데 주문이 왜 계속 생기는건지..&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재고 로직에 분산락을 걸어두어 재고 갯수가 음수로 떨어지지도 않고, 0개로 정상적으로 떨어지는데도 왜 이런 일이 생기는걸까?&lt;/p&gt;
&lt;pre id=&quot;code_1778071521664&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;JMeter 부하 테스트 시나리오
──────────────────────────────────────────────────────
재고 50개 상품에 대해 500개 동시 주문 요청
(스레드 50개 &amp;times; 반복 10회)
기대값: 주문 50개 생성, 재고 0개
실제값: 재고는 0개인데 주문이 400개 이상 생성됨  
──────────────────────────────────────────────────────&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;&lt;b&gt;원인 분석&lt;/b&gt; ㅡ 갱신 유실 (Lost Update)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 기능 개발할 때가 24년도 후반일때라 AI가 지금처럼 뾰족하게 발전하지 않았을 때여서, AI에게는뾰족한 답을 얻을 수 없었고 정말 온갖 구글링을 다해보고 스택오버플로우도 뒤져보고 유튜브도 뒤져보고 많은 기업들의 기술 블로그도 뒤져봤다. 부트캠프에서 기술 멘토님들의 도움도 받아봤는데 &lt;u&gt;기술 멘토님들도 처음 겪어보시는 문제&lt;/u&gt;라고 하셨다..&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;286&quot; data-origin-height=&quot;212&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cVRXFt/dJMcadolXqT/s2GHYvdX2f5oWACFwDssg1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cVRXFt/dJMcadolXqT/s2GHYvdX2f5oWACFwDssg1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cVRXFt/dJMcadolXqT/s2GHYvdX2f5oWACFwDssg1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcVRXFt%2FdJMcadolXqT%2Fs2GHYvdX2f5oWACFwDssg1%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;286&quot; height=&quot;212&quot; data-origin-width=&quot;286&quot; data-origin-height=&quot;212&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;p data-ke-size=&quot;size16&quot;&gt;꼬박 이틀동안 잠을 못자가면서 문제 원인을 찾으려고 했는데 이걸 적용해보고 저걸 적용해봐도 부하테스트에 실패하는 것이었다 ㅠ 동시성 문제를 포기해야하나 했는데 예약구매가 우리 서비스의 핵심 서비스이기도 하고, 대규모 트래픽을 전제로 한 프로젝트라 무조건 내 힘으로 해결하고 싶다라고 생각하던 찰나에 가뭄에 단비같은 트러블슈팅 영상을 보게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.youtube.com/watch?v=UOWy6zdsD-c&amp;amp;t=424s&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.youtube.com/watch?v=UOWy6zdsD-c&amp;amp;t=424s&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;822&quot; data-origin-height=&quot;267&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/z83sg/dJMcahj3wDg/3cLKecaRLcXWvtsPVeyDsk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/z83sg/dJMcahj3wDg/3cLKecaRLcXWvtsPVeyDsk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/z83sg/dJMcahj3wDg/3cLKecaRLcXWvtsPVeyDsk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fz83sg%2FdJMcahj3wDg%2F3cLKecaRLcXWvtsPVeyDsk%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;579&quot; height=&quot;267&quot; data-origin-width=&quot;822&quot; data-origin-height=&quot;267&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;p data-ke-size=&quot;size16&quot;&gt;실제로 토스에서 발생한 문제를 어떻게 해결했는지 공유하신 영상이다. 영상을 보니 현재 우리 서비스 예약구매 기능에서 겪고있던 바로 그 문제와 정말 동일했다...!! 이 영상에서는 이 문제를 '갱신 유실' 이라고 명명하고 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 갱신 유실이라는 단어 자체를 이번 문제로 처음 알게 되었다. 낯설은 단어였다. 갱신 유실이 뭔지부터 자세히 알아봤다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt; 갱신 유실이란?&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;분산락이 정상적으로 동작해도 갱신 유실이 발생할 수 있다. 핵심은 &lt;b&gt;분산락의 타임아웃과 DB 트랜잭션의 커밋 타이밍이 어긋날 수 있다&lt;/b&gt;는 것이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;587&quot; data-origin-height=&quot;472&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bmBa7x/dJMcahj3wE0/R8L1r1M8HKIKVFBrYPoVe0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bmBa7x/dJMcahj3wE0/R8L1r1M8HKIKVFBrYPoVe0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bmBa7x/dJMcahj3wE0/R8L1r1M8HKIKVFBrYPoVe0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbmBa7x%2FdJMcahj3wE0%2FR8L1r1M8HKIKVFBrYPoVe0%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;405&quot; height=&quot;326&quot; data-origin-width=&quot;587&quot; data-origin-height=&quot;472&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위의 표를 보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;0초에 T1과 T2 트랜잭션이 동시에 발생했다고 가정한다. T1은 락을 획득하고 T2는 락을 대기하고 있는 상태다.&lt;/li&gt;
&lt;li&gt;2초 뒤에 T1의 락이 락 타임아웃으로 인해 해제된다 (트랜잭션은 아직 살아있음)&lt;/li&gt;
&lt;li&gt;T2가 락을 획득하여 재고를 읽고 5개 차감한다.&lt;/li&gt;
&lt;li&gt;그리고 2초 뒤인 4초에 &lt;u&gt;지연된 T1 트랜잭션 처리&lt;/u&gt;가 완료되어 재고를 20개 차감하게 되면,&lt;/li&gt;
&lt;li&gt;재고는 0개가 되어 갱신 유실이 발생한다.&lt;/li&gt;
&lt;/ol&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;blockquote data-ke-style=&quot;style2&quot;&gt;분산락 타임아웃이 지나 락 자체는 해제됐지만, 트랜잭션이 아직 끝나지 않은 상태여서 다른 트랜잭션과 경합이 일어날 수 있는 환경&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;u&gt;분산락을 해제하기 전에 DB 트랜잭션이 커밋&lt;/u&gt;되거나, &lt;u&gt;분산락을 해제하고 나서 커밋이 되는 경우&lt;/u&gt; 모두 갱신 유실이 발생할 수 있다. &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;JPA에서는 '쿼리 쓰기 지연' 등으로 인해 이 타이밍 문제가 발생할 확률이 비교적 높다고 한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;갱신 유실 문제에 대해 자세히 알아봤으니 이제 이 문제를 어떻게 해결할지 알아보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;b&gt;해결 방법&lt;/b&gt; ㅡ 기술적 의사결정&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;갱신 유실을 방지하는 방법은 크게 네 가지다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;&lt;b&gt;1. 원자적 연산 사용&lt;/b&gt; (❌)&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;DBMS에 의존적이기 때문에 ORM과 궁합이 좋지 않다. (e.g. ORM에서는 DBMS의 고유한 원자적 연산을 직접 지원하지 않는 경우가 많다) &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;또한 JPA 같은 ORM을 사용하는 경우, 데이터베이스에 직접적인 SQL 연산을 보내는 대신 객체 상태를 추적하고 변경하는 방식으로 동작하기 때문에 원자적 연산을 사용하려면 SQL 네이티브 쿼리를 사용해야 할 때가 많다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;&lt;b&gt;2. 명시적 잠금&lt;/b&gt; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;(❌)&lt;/span&gt; &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;여러 테이블을 갱신하는 트랜잭션에서는 비용이 매우 비싸다. ( = 락을 걸고 유지하는 동안, 여러 테이블에 걸쳐 발생하는 데이터 변경 작업이 성능 저하를 일으킬 수 있다는 말이다.)&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;&lt;b&gt;3. 갱신 손실 자동 감지&lt;/b&gt; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;(❌)&lt;/span&gt; &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;원자적 연산 사용 방법과 마찬가지로, 이 방법 또한 DBMS에 의존적이라 ORM과 궁합이 좋지 않다. 또한 DBMS가 제공하는 버전 관리나 갱신 손실 감지 기능을 ORM과 함께 사용하는 경우 ORM의 추상화 계층 때문에 이 기능이 제대로 동작하지 않거나 비효율적일 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;&lt;b&gt;4. CAS 연산 (Compare-and-set)&lt;/b&gt; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;(⭕)&lt;/span&gt; &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;JPA에서는 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;OptimisticLocking&lt;/span&gt; 애너테이션을 통해 간단하게 CAS 연산이 가능하다. OptimisticLocking은 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;version&lt;/span&gt;을 통해 갱신 유실을 방지할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt; CAS 연산이란?&lt;br /&gt;메모리에서 값을 읽고, 그 값이 예상한 값과 동일할 때만 새 값을 설정하는 방식. 이는 '동시에 두 스레드가 같은 데이터를 수정하려고 하면, 한 쪽만 성공하고 다른 쪽은 실패' 한다는 개념이다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4-1. OptimisticLocking 이 갱신 유실을 막는 원리 &lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;740&quot; data-origin-height=&quot;341&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/wOngz/dJMb990DrpM/ExnMPrwga9P9KcJ8gY18D0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/wOngz/dJMb990DrpM/ExnMPrwga9P9KcJ8gY18D0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/wOngz/dJMb990DrpM/ExnMPrwga9P9KcJ8gY18D0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FwOngz%2FdJMb990DrpM%2FExnMPrwga9P9KcJ8gY18D0%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;532&quot; height=&quot;245&quot; data-origin-width=&quot;740&quot; data-origin-height=&quot;341&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위에서 언급한 것 처럼, 낙관적 락은 버전(version) 번호로 동작한다. 데이터를 읽을 때 버전을 함께 읽어두고, 커밋 시점에 버전이 내가 읽었던 것과 같은지 확인한다. 다른 트랜잭션이 중간에 수정했다면 버전이 올라가 있을 테고, 그러면 커밋을 거부한다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;T1이 v1을 읽은 상태에서 대기하는 동안 T2가 먼저 커밋해서 버전이 v2가 됐다.&lt;/li&gt;
&lt;li&gt;이후 T1이 커밋을 시도하면 '내가 읽었을 때 v1이었는데 지금은 v2야, 누군가 수정했군'하고 감지해서 Exception을 던지고 커밋을 거부한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;분산락만 있을 때는 락이 해제된 사이에 T1의 커밋이 T2의 결과를 덮어쓸 수 있었는데, 낙관적 락이 있으면 &lt;b&gt;버전 불일치 &amp;rarr; 커밋 거부&lt;/b&gt;로 갱신 유실을 막아주는 것이다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&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;blockquote data-ke-style=&quot;style3&quot;&gt; &lt;span style=&quot;color: #000000;&quot;&gt;그럼 분산락 없이 Optimistic Locking 만으로도 지금 내 케이스의 동시성을 충분히 제어할 수 있나?&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;답은 &lt;i&gt;No&lt;/i&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;u&gt;1. 분산락으로 동시성을 제어&lt;/u&gt;하고,&lt;u&gt; 2. 만약의 상황에서도 OptimisticLocking을 통해 데이터 정합성이 틀어지지 않도록&lt;/u&gt; 할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주요 테이블들은 하이버네이트 envers를 이용해 변경 히스토리를 저장하여 원활히 데이터 흐름을 파악할 수 있도록 할 수도 있다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기술적 의사결정을 요약한 표는 아래와 같다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 105px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 24.1472%; height: 21px; text-align: center;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;방법&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 34.031%; height: 21px; text-align: center;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;설명&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 41.8217%; height: 21px; text-align: center;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;이 상황에서의 문제&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 24.1472%; height: 21px; text-align: center;&quot;&gt;&lt;b&gt;원자적 연산 사용&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 34.031%; height: 21px;&quot;&gt;DB 네이티브 연산 사용&lt;/td&gt;
&lt;td style=&quot;width: 41.8217%; height: 21px;&quot;&gt;DBMS 의존적, JPA/ORM과 궁합 나쁨&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 24.1472%; height: 21px; text-align: center;&quot;&gt;&lt;b&gt;명시적 잠금&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 34.031%; height: 21px;&quot;&gt;DB 레벨 락&lt;/td&gt;
&lt;td style=&quot;width: 41.8217%; height: 21px;&quot;&gt;여러 테이블 갱신 시 비용이 매우 비쌈&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 24.1472%; height: 21px; text-align: center;&quot;&gt;&lt;b&gt;갱신 손실 자동 감지&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 34.031%; height: 21px;&quot;&gt;DBMS 기능 활용&lt;/td&gt;
&lt;td style=&quot;width: 41.8217%; height: 21px;&quot;&gt;DBMS 의존적, ORM과 충돌 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 24.1472%; height: 21px; text-align: center;&quot;&gt;&lt;b&gt;CAS 연산&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 34.031%; height: 21px;&quot;&gt;비교 후 조건 업데이트&lt;/td&gt;
&lt;td style=&quot;width: 41.8217%; height: 21px;&quot;&gt;✅ JPA &lt;span style=&quot;background-color: #dddddd;&quot;&gt;@OptimisticLocking&lt;/span&gt;으로 간단하게 구현 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;문제 해결&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;1차 시도 - 낙관적 락 적용&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재고 엔티티에 JPA 낙관적 락을 도입했다.&lt;/p&gt;
&lt;pre id=&quot;code_1778074849933&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    @Version
    @Column(name = &quot;version&quot;)
    private Long version = 0L;&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;u&gt;조금만 스레드 갯수를 늘려도 아래와 같은 락 충돌 에러(&lt;span style=&quot;color: #ef5369; text-align: start;&quot;&gt;StaleObjectStateException&lt;/span&gt;)가 발생&lt;/u&gt;한다  &lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;885&quot; data-origin-height=&quot;178&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/l0NAL/dJMcadolYhZ/UKtSTwjvy3vL3UXXdWGBe0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/l0NAL/dJMcadolYhZ/UKtSTwjvy3vL3UXXdWGBe0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/l0NAL/dJMcadolYhZ/UKtSTwjvy3vL3UXXdWGBe0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fl0NAL%2FdJMcadolYhZ%2FUKtSTwjvy3vL3UXXdWGBe0%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;885&quot; height=&quot;178&quot; data-origin-width=&quot;885&quot; data-origin-height=&quot;178&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;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;이유를 분석해보니 낙관적 락을 사용한 경우에는 트랜잭션 충돌로 인해 StaleObjectStateException이 발생할 수 있고(@Version 필드가 있으면 업데이트 시 버전을 비교해서 내가 읽은 이후로 다른 트랜잭션이 수정했다면 해당 예외 던지는 것), 이를 해결하려면 예외 발생 시 재시도하는 로직을 추가해야 한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;즉 트랜잭션 충돌이 발생해서 재시도를 해야 하는데, 재시도 로직이 없으니 그냥 실패로 끝난 상황이라고 이해하면 된다 &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #333333;&quot;&gt;2차 시도 - @Retryable로 재시도 로직 추가&lt;/span&gt;&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1778074902074&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - spring-projects/spring-retry&quot; data-og-description=&quot;Contribute to spring-projects/spring-retry development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/spring-projects/spring-retry&quot; data-og-url=&quot;https://github.com/spring-projects/spring-retry&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/b22AIE/dJMb8XR9KOQ/4TtVX8m2Gy3byr2osVpNZ1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/JUsH9/dJMb8YXPzU8/8vArLnZt6KlqzIqTUAxiDk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/spring-projects/spring-retry&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/spring-projects/spring-retry&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/b22AIE/dJMb8XR9KOQ/4TtVX8m2Gy3byr2osVpNZ1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/JUsH9/dJMb8YXPzU8/8vArLnZt6KlqzIqTUAxiDk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - spring-projects/spring-retry&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to spring-projects/spring-retry development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 라이브러리&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;의 &lt;/span&gt;&lt;b&gt;@Retryable&lt;/b&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt; 어노테이션을 활용하면 낙관적 락의 재시도 로직을 aop 방식으로 손쉽게 수행할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재시도 간격 설정을 위한 @Backoff, retry 후 최종적으로 Exception이 발생했을 때 처리를 돕는 @Recover 어노테이션도 제공한다. (의존성 등록해야한다)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;낙관적 락의 버전 충돌 시 발생하는 예외는 아래와 같이 세 가지이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;(JPA) javax.persistence.OptimisticLockException&lt;/li&gt;
&lt;li&gt;(Hibernate) org.hibernate.StaleObjectStateException&amp;nbsp;in Hibernate&lt;/li&gt;
&lt;li&gt;(Spring) org.springframework.orm.ObjectOptimisticLockingFailureException&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Data JPA에서 낙관적락을 사용하게 되면 버전 충돌 시 Hibernate는&amp;nbsp;&lt;span style=&quot;color: #ef5369;&quot;&gt;StaleStateException&lt;/span&gt;을 발생시킨다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring은 이를 &lt;span style=&quot;color: #ef5369;&quot;&gt;OptimisticLockingFailureException&lt;/span&gt;으로 래핑하여 처리한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나의 경우에는 Spring Data JPA를 사용했으므로, &amp;nbsp;&lt;span style=&quot;color: #ef5369;&quot;&gt;ObjectOptimisticLockingFailureException&lt;/span&gt; 발생 시 재시도하도록 작성했다.&lt;/p&gt;
&lt;pre id=&quot;code_1778075210594&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt; // 재고 1 감소 메서드에 @Retryable 적용
    @Retryable(
        retryFor = {ObjectOptimisticLockingFailureException.class}, // 낙관적 락 예외에 대해 재시도
        maxAttempts = 500,  // 최대 500번까지 재시도
        backoff = @Backoff(100)  // 재시도 간격 100ms
    )
    @Transactional
    public void decreaseStockQuantity(UUID productId) {
        Stock stock = findStockByProductId(productId);
        stock.decreaseStock();  // 수량 1 감소
        stockRepository.save(stock);
    }&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;하지만!! 재시도 로직까지 구현했는데도 불구하고 부하 테스트 시 여전히 StaleObjectStateException이 발생했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;3차 시도 - 낙관적 락 전략 변경&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;낙관적 락 전략을 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;OPTIMISTIC_FORCE_INCREMENT&lt;/span&gt;로 변경했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;715&quot; data-origin-height=&quot;112&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bBUb1i/dJMcagZGzy2/mthSobMrPIkpO9CxHJEbak/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bBUb1i/dJMcagZGzy2/mthSobMrPIkpO9CxHJEbak/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bBUb1i/dJMcagZGzy2/mthSobMrPIkpO9CxHJEbak/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbBUb1i%2FdJMcagZGzy2%2FmthSobMrPIkpO9CxHJEbak%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;664&quot; height=&quot;104&quot; data-origin-width=&quot;715&quot; data-origin-height=&quot;112&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre id=&quot;code_1778075303839&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt; // 예약 구매: 재고 1 감소 메서드
    @Retryable(
            retryFor = {StaleObjectStateException.class, OptimisticLockException.class, ObjectOptimisticLockingFailureException.class},
            maxAttempts = 500,
            backoff = @Backoff(100)
    )
    @Transactional
    public void decreaseStockQuantityWithRetry(UUID productId) {
        Stock stock = stockRepository.findByProduct_ProductIdWithLock(productId) // 호출 메서드 findByProduct_ProductIdWithLock으로 변경
                .orElseThrow(() -&amp;gt; new CustomException(CommerceErrorCode.STOCK_DATA_NOT_FOUND_FOR_PRODUCT));
        stock.decreaseStock();
        stockRepository.save(stock);
    }&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;span data-token-index=&quot;0&quot;&gt;일반적인 &lt;span style=&quot;background-color: #dddddd; color: #333333; text-align: start;&quot;&gt;OPTIMISTIC&lt;/span&gt; &lt;/span&gt;&lt;span style=&quot;letter-spacing: 0px;&quot; data-token-index=&quot;2&quot;&gt;락&lt;/span&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;에서는 엔티티가 조회될 때, 버전 필드를 확인만 하고 아무 변화가 없다. &lt;/span&gt;&lt;span style=&quot;color: #000000;&quot; data-token-index=&quot;4&quot;&gt;&lt;u&gt;그 이후 엔티티가 수정될 때만 버전이 증가&lt;/u&gt;한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면에 &lt;span style=&quot;background-color: #dddddd; color: #333333; text-align: start;&quot;&gt;OPTIMISTIC_FORCE_INCREMENT&lt;/span&gt;는 &lt;b&gt;&lt;span data-token-index=&quot;3&quot;&gt;조회하는 순간 버전 필드를 강제로 증가&lt;/span&gt;&lt;/b&gt;시킨다. 즉, 해당 엔티티가 읽히면 JPA가 그 엔티티를 '수정된 것처럼' 간주하고, 즉시 버전을 1 증가시켜 &lt;u&gt;&lt;span data-token-index=&quot;5&quot;&gt;다른 트랜잭션에서 동시에 접근할 때 충돌 가능성을 더 엄격하게 관리&lt;/span&gt;&lt;/u&gt;할 수 있는 것이다.&lt;/p&gt;
&lt;pre id=&quot;code_1778075447828&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;낙관적 락 전략 비교
──────────────────────────────────────────────────────
OPTIMISTIC                읽은 시점과 커밋 시점의 버전 불일치 시에만 예외 발생

OPTIMISTIC_FORCE_INCREMENT 읽을 때 무조건 버전을 증가시킴
                           &amp;rarr; 다른 트랜잭션이 같은 행을 읽었다면 반드시 충돌로 감지됨
──────────────────────────────────────────────────────&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;에는 OPTIMISTIC_FORCE_INCREMENT가 더 강력하게 충돌을 감지해준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;최종 구조 - 분산락 + 낙관적 락&lt;/b&gt; (OPTIMISTIC_FORCE_INCREMENT)&lt;/p&gt;
&lt;pre id=&quot;code_1778075510929&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;분산락 (1차 방어)
&amp;rarr; 대부분의 동시 요청을 직렬화해서 처리

낙관적 락 OPTIMISTIC_FORCE_INCREMENT (2차 방어)
&amp;rarr; 분산락 타임아웃 등으로 경합이 발생하더라도
  버전 충돌로 갱신 유실을 감지하고 차단&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대규모 트래픽이 전제되는 상황에선 분산락 하나만으로는 갱신 유실에 취약하고, 낙관적 락을 함께 쓰면서 분산락이 뚫리는 상황에서도 정합성을 보장할 수 있도록 장치해두는 구조라고 보면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;결과&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종 구조 적용 후 JMeter 부하 테스트를 다시 돌렸다.&lt;/p&gt;
&lt;pre id=&quot;code_1778075627459&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;최종 부하 테스트 결과
──────────────────────────────────────────────────────
시나리오: 재고 30개 상품에 1,000개 동시 주문 요청
기대값:   주문 30개 생성, 최종 재고 0개
결과:     주문 30개 생성 ✅, 최종 재고 0개 ✅
──────────────────────────────────────────────────────&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;476&quot; data-origin-height=&quot;92&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ty9Hf/dJMcai4eEUb/gjsA7GKh8kMnKYjgskjos1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ty9Hf/dJMcai4eEUb/gjsA7GKh8kMnKYjgskjos1/img.png&quot; data-alt=&quot;재고(stock_quantity)가 0개로 잘 떨어졌고,&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ty9Hf/dJMcai4eEUb/gjsA7GKh8kMnKYjgskjos1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fty9Hf%2FdJMcai4eEUb%2FgjsA7GKh8kMnKYjgskjos1%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;476&quot; height=&quot;92&quot; data-origin-width=&quot;476&quot; data-origin-height=&quot;92&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;재고(stock_quantity)가 0개로 잘 떨어졌고,&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;552&quot; data-origin-height=&quot;178&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/IKNrT/dJMcaarF0h7/aKDukM906BcFvUnaQ2Bg6k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/IKNrT/dJMcaarF0h7/aKDukM906BcFvUnaQ2Bg6k/img.png&quot; data-alt=&quot;주문 데이터 또한 30개만 생성된 것을 확인했다&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/IKNrT/dJMcaarF0h7/aKDukM906BcFvUnaQ2Bg6k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FIKNrT%2FdJMcaarF0h7%2FaKDukM906BcFvUnaQ2Bg6k%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;552&quot; height=&quot;178&quot; data-origin-width=&quot;552&quot; data-origin-height=&quot;178&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;주문 데이터 또한 30개만 생성된 것을 확인했다&lt;/figcaption&gt;
&lt;/figure&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;1,000개의 동시 요청에서도 재고 30개만큼 정확히 주문이 생성됐다. 갱신 유실 없이 데이터 정합성 보장 성공..!&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-04-27 172329.png&quot; data-origin-width=&quot;486&quot; data-origin-height=&quot;490&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/V2F51/dJMcadaMHBP/VLb9MmSKAq3JJwPqMWvHTk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/V2F51/dJMcadaMHBP/VLb9MmSKAq3JJwPqMWvHTk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/V2F51/dJMcadaMHBP/VLb9MmSKAq3JJwPqMWvHTk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FV2F51%2FdJMcadaMHBP%2FVLb9MmSKAq3JJwPqMWvHTk%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;227&quot; height=&quot;490&quot; data-filename=&quot;스크린샷 2026-04-27 172329.png&quot; data-origin-width=&quot;486&quot; data-origin-height=&quot;490&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;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;마무리&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 트러블슈팅 경험으로 배운 게 몇 가지 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 분산락이 만능이 아니라는 것.&lt;/b&gt; 분산락은 동시 접근을 제어하지만, 락 타임아웃과 트랜잭션 커밋 타이밍이 어긋나면 갱신 유실이 발생한다. '락 걸었으니 되겠지'라고 생각했다가 부하 테스트에서 발견했는데, 실제 서비스에서 발견했다면 해결하기까지 유저 불편함이 컸을 것이다. (테스트의 중요성도 함께 깨달았다고 하겠다 )&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 트랜잭션 커밋 시점과 동시성 이슈가 맞물린다는 것&lt;/b&gt;도 있다&lt;b&gt;.&lt;/b&gt; JPA의 쓰기 지연 특성 때문에 락이 해제된 뒤에도 트랜잭션이 살아있을 수 있고, 그 사이에 다른 트랜잭션이 끼어드는 게 갱신 유실의 원인이었다. 락을 다룰 때는 트랜잭션의 생명주기까지 함께 고려해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 다음은&lt;b&gt; 계층적 방어가 필요하다는 것.&lt;/b&gt; 분산락으로 1차 제어를 하고, 낙관적 락으로 2차 안전망을 깔았더니 비로소 안정적인 구조가 됐다. 각 계층이 서로 보완하는 구조가 대규모 트래픽 환경에서 데이터 정합성을 지킬 수 있는 방법 중 하나이고 실제 현업에서도 이런 방법을 쓴다는걸 해결책을 찾아보면서 더 깊게 알게되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. 그리고 마지막으론, 기술적인 해결책 외에도 &lt;b&gt;트래픽 자체를 제어하는 방법도 고민하게 됐다.&lt;/b&gt; 아무리 잘 짠 동시성 코드라도 트래픽이 폭발적으로 몰리면 한계가 있다. 애초에 대규모 트래픽을 한 엔드포인트로 받으려고 하는 것 보단 그 전 단계에 정적 html을 둬서 트래픽을 한 차례 분산되게 한다던지, 대기열 시스템이나 요청 제한(Rate Limiting) 같은 방법으로 서버에 들어오는 트래픽 자체를 조절하는 구조도 함께 고민해봐야 대규모 트래픽 환경에서 더 견고한 서비스를 만들 수 있다는 걸 깨닫게 되었다.&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;문제 확인부터 해결까지 3일 정도는 쓴 트러블슈팅이었는데, 문제 원인이 감이 안잡혀서 여러 방면으로 생각해보고 적용해본 것이 개발 시야를 넓히는 것에 도움이 많이 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 해결책을 찾고 찾다가 토스에서 해당 트러블슈팅을 유튜브로 공유해주신 것 덕분에 내 트러블슈팅을 해결한것 처럼(진짜 눈물날 뻔 했음) 역시 개발에서는 문제 해결 경험을 서로 공유하는 것이 모두에게 도움이 된다라는 것도  느끼게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비슷한 문제를 겪고 있는 분이 있다면 이 글이 그 3일을 조금이라도 줄여줄 수 있었으면 한다!&lt;/p&gt;</description>
      <category>Project/대용량 트래픽 프로젝트</category>
      <category>JPA</category>
      <category>LostUpdate</category>
      <category>MSA</category>
      <category>OptimisticLock</category>
      <category>REDIS</category>
      <category>갱신유실</category>
      <category>낙관적락</category>
      <category>동시성제어</category>
      <category>부하테스트</category>
      <category>분산락</category>
      <author>쉬지마 이굥진</author>
      <guid isPermaLink="true">https://developer-jinnie.tistory.com/132</guid>
      <comments>https://developer-jinnie.tistory.com/132#entry132comment</comments>
      <pubDate>Thu, 7 May 2026 19:23:55 +0900</pubDate>
    </item>
    <item>
      <title>[N+1 삽질] 무조건 fetch join, @BatchSize가 정답은 아니다 (feat. 집계 쿼리 최적화)</title>
      <link>https://developer-jinnie.tistory.com/131</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;필자는 현재 스마트폰 역경매 플랫폼 bidr(비더)를 개발 중이다. (5월 런칭 목표)&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;bidr의 견적 목록 화면에는 &lt;b&gt;각 견적마다 '입찰 5개&amp;middot;최저 8만원'&lt;/b&gt; 같은 정보가 표시된다. 구매자 입장에서 내 견적에 입찰이 얼마나 몰렸는지, 가장 저렴한 입찰가가 얼마인지 한눈에 볼 수 있어야 하기 때문이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;561&quot; data-origin-height=&quot;425&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zQSwO/dJMcagSV5cy/IZaVM5dirMhxpdXiGEMGh0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zQSwO/dJMcagSV5cy/IZaVM5dirMhxpdXiGEMGh0/img.png&quot; data-alt=&quot;견적 목록 화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zQSwO/dJMcagSV5cy/IZaVM5dirMhxpdXiGEMGh0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FzQSwO%2FdJMcagSV5cy%2FIZaVM5dirMhxpdXiGEMGh0%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;376&quot; height=&quot;285&quot; data-origin-width=&quot;561&quot; data-origin-height=&quot;425&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;견적 목록 화면&lt;/figcaption&gt;
&lt;/figure&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&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-01-26 235013.png&quot; data-origin-width=&quot;530&quot; data-origin-height=&quot;409&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mzZfw/dJMcadPmkDF/FfFtPWsyYHitvoI2px1311/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mzZfw/dJMcadPmkDF/FfFtPWsyYHitvoI2px1311/img.png&quot; data-alt=&quot;정색하실게요&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mzZfw/dJMcadPmkDF/FfFtPWsyYHitvoI2px1311/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmzZfw%2FdJMcadPmkDF%2FFfFtPWsyYHitvoI2px1311%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;299&quot; height=&quot;231&quot; data-filename=&quot;스크린샷 2026-01-26 235013.png&quot; data-origin-width=&quot;530&quot; data-origin-height=&quot;409&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;정색하실게요&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;문제 발견&lt;/b&gt; &amp;mdash; 견적 20개를 조회했는데 쿼리가 41번?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 구현은, 견적 목록을 불러온 뒤 각 견적마다 입찰 수량 최저가를 개별로 조회하면 된다고 생각해서 &lt;i&gt;아주 단순하게&amp;nbsp; &lt;/i&gt;구현했다. 그런데 기능 구현을 마치고 로컬에서 테스트하다가 콘솔을 무심코 봤는데 뭔가 이상했다. 견적 목록 조회 한 번에 로그가 너무 많이 찍히는 거였다..!&lt;/p&gt;
&lt;pre id=&quot;code_1777817214612&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Hibernate: select ... from quotes where status='OPEN' ...
Hibernate: select count(b) from bids where quote_id=?
Hibernate: select min(b.installment_principal) from bids where quote_id=?
Hibernate: select count(b) from bids where quote_id=?
Hibernate: select min(b.installment_principal) from bids where quote_id=?
Hibernate: select count(b) from bids where quote_id=?
Hibernate: select min(b.installment_principal) from bids where quote_id=?
...&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;견적이 20개면 저 패턴이 20번 반복됐다. 세어보니 총 &lt;b&gt;41번&lt;/b&gt;이었다. &amp;lt;뭐야이게&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드를 다시 한 번 찬찬히 뜯어봤다.&lt;/p&gt;
&lt;pre id=&quot;code_1777817422087&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 개선 전 &amp;mdash; N+1 발생 구조
List&amp;lt;Quote&amp;gt; quotes = quoteRepository.findLatestQuotesByStatus(QuoteStatus.OPEN);

quotes.stream().map(quote -&amp;gt; {
    long bidCount = bidRepository.countByQuoteId(quote.getId());               // 쿼리 1번
    Integer lowestPrice = bidRepository.findMinInstallmentPrincipalByQuoteId(  // 쿼리 2번
        quote.getId(), BidStatus.ACTIVE
    );
    return QuoteResponseDto.from(quote, bidCount, lowestPrice);
});

// 견적 N개 &amp;rarr; 총 2N+1개 쿼리 실행&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;견적 목록을 불러온 뒤 각 견적마다 쿼리를 2번씩 날리는 구조였다. &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;견적이 20개면 41번, 100개면 201번.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;데이터가 늘어날수록 쿼리가 선형으로 증가하는 구조&lt;/b&gt;였다. (= N+1 문제)&lt;/p&gt;
&lt;pre id=&quot;code_1777807588629&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;쿼리 수 증가 시나리오
──────────────────────────────────────────────────────
견적  20개 &amp;rarr; 쿼리  41번
견적  50개 &amp;rarr; 쿼리 101번
견적 100개 &amp;rarr; 쿼리 201번
데이터가 늘어날수록 쿼리 횟수가 선형으로 증가
──────────────────────────────────────────────────────&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;bidr 초기엔 데이터가 적으니 당장 문제가 터지진 않겠지만 서비스가 커질수록 이 구조는 DB 부하를 선형으로 키운다. 넘어가기 불편했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;해결 선택지 비교 &lt;/b&gt;ㅡ 익숙한 방법부터&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPA를 쓰면서 N+1 문제를 만나면 보통 제일 먼저 떠오르는 게 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;fetch join&lt;/span&gt;이나 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;@BatchSize &lt;/span&gt;다. 하지만 결론부터 말하면 이번엔 둘 다 맞지 않았다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;1. @EntityGraph / fetch join &lt;/b&gt;(❌)&lt;b&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 유명한 fetch join은 &lt;b&gt;연관 엔티티를 통째로 메모리에 올리는 방식&lt;/b&gt;이다.&lt;/p&gt;
&lt;pre id=&quot;code_1777808004757&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// fetch join으로 Bid를 함께 로딩
List&amp;lt;Quote&amp;gt; quotes = quoteRepository.findAllWithBids();

quotes.stream().map(quote -&amp;gt; {
    long count = quote.getBids().size();              // 애플리케이션에서 직접 count
    int minPrice = quote.getBids().stream()
            .mapToInt(Bid::getInstallmentPrincipal)
            .min().orElse(0);                         // 애플리케이션에서 직접 min
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쿼리 횟수는 줄어든다. 하지만 문제가 있다. &lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;필요한 건 &lt;/b&gt;'입찰 수'와 '최저 할부원금',&lt;b&gt; 딱 숫자 2개&lt;/b&gt;인데, &lt;u&gt;그걸 구하기 위해 Bid 엔티티의 모든 필드(가격, 배송일, 요금제, 통신사, 개통방법, 부가서비스...)를 전부 메모리에 올려야 한다&lt;/u&gt;.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입찰이 많은 견적일수록 메모리에 올라오는 데이터가 폭발적으로 늘어난다(= 즉 메모리 낭비가 심해짐). &lt;b&gt;집계 연산도 DB가 아니라 애플리케이션이&lt;/b&gt; 떠안게 된다. 쿼리 횟수가 많은 문제는 해결했지만 더 큰 문제를 새로 만드는 셈이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;2. @BatchSize &lt;/b&gt;(❌)&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@BatchSize는 Hibernate가 지연 로딩 시 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;WHERE id = ?&lt;/span&gt;를 개별로 날리는 대신 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;WHERE id IN (?, ?, ...)&lt;/span&gt;으로 묶어주는 최적화다. 쿼리 횟수는 확실히 줄어듦을 보장한다.&lt;/p&gt;
&lt;pre id=&quot;code_1777808279349&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;-- @BatchSize 없을 때
SELECT * FROM bids WHERE quote_id = '1'
SELECT * FROM bids WHERE quote_id = '2'
SELECT * FROM bids WHERE quote_id = '3'

-- @BatchSize(size=100) 있을 때
SELECT * FROM bids WHERE quote_id IN ('1', '2', '3') -- 쿼리 1번으로 감소&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;전에 N+1 문제를 만났을 때는 &lt;a href=&quot;https://developer-jinnie.tistory.com/38&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;@BatchSize를 써서 성능을 엄청 개선시켰던 경험&lt;/a&gt;이 있어 이번에도 @BatchSize를 쓸까 했는데, 하지만 이번 문제같은 경우는 궤가 다르다. 위의 예시 SQL을 보면 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;SELECT *&lt;/span&gt; 로 반환하는 건 여전히 bid 엔티티 객체 전체임을 알 수 있다.&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;@BatchSize는 엔티티를 N+1 문제 없이 효율적으로 로딩하는 도구다. 하지만 필요한 게 엔티티가 아닌 집계값인 상황에서는, 쿼리를 IN 절로 묶어줘도 불필요한 데이터를 메모리에 올리는 문제는 여전히 존재한다. fetch join과 마찬가지로 이 상황을 본질적으로 해결하는 방법은 아니라는 생각을 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt; &lt;span style=&quot;background-color: #ffc1c8;&quot;&gt;즉, fetch join / @BatchSize의 공통 한계&lt;/span&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; 입찰 수(숫자 1개), 최저가(숫자 1개)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;실제로 올라오는 것:&lt;/b&gt; Bid 엔티티 전체 (수십 개의 필드)&lt;/li&gt;
&lt;li&gt;두 방법 모두 엔티티 로딩을 전제로 한 최적화 &amp;rarr; 이 상황에서의 본질적인 문제는 '엔티티 로딩 자체가 불필요하다'는 것&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 두 방법 모두 현재 상황에 맞는 도구가 아니라고 느꼈다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;3. IN절 배치 쿼리 + GROUP BY 집계&lt;/b&gt; (⭕)&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPA N+1 문제는 무조건 fetch join이나 batchsize를 써서 해결한다는 발상(?)을 바꿔보자. 어차피 필요한 건 숫자 2개 뿐이니,&lt;b&gt; DB에서 집계까지 끝내고 숫자만 반환&lt;/b&gt;하면 되지 않을까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ID 목록을 한 번에 넘기고, GROUP BY로 집계해서 '필요한 데이터'만 효율적으로 돌려받아보자.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 105px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style13&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center; height: 21px;&quot;&gt;&lt;b&gt;fetch join / @BatchSize&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center; height: 21px;&quot;&gt;&lt;b&gt;IN절 배치 쿼리 + group by&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center; height: 21px;&quot;&gt;반환 데이터&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;Bid 엔티티 전체&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;집계값(숫자)만&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center; height: 21px;&quot;&gt;집계 주체&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;애플리케이션&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;DB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center; height: 21px;&quot;&gt;메모리 사용&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;높음 (Bid 필드 전부)&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;낮음 (숫자만)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center; height: 21px;&quot;&gt;쿼리 횟수&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;줄어들지만 가변&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;항상 고정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;적합한 상황&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;엔티티 자체가 필요할 때&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;집계값만 필요할 때&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;구현&lt;/b&gt; ㅡ 쿼리 2개 + Map 매핑&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. Repository ㅡ 배치 집계 쿼리 작성&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1777809819079&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 입찰 수 배치 조회
@Query(&quot;SELECT b.quote.id as quoteId, COUNT(b) as bidCount &quot; +
       &quot;FROM Bid b &quot; +
       &quot;WHERE b.quote.id IN :quoteIds &quot; +
       &quot;AND (b.isDelete = false OR b.isDelete IS NULL) &quot; +
       &quot;GROUP BY b.quote.id&quot;)
List&amp;lt;BidCountDto&amp;gt; countByQuoteIds(@Param(&quot;quoteIds&quot;) List&amp;lt;UUID&amp;gt; quoteIds);

// 최저 할부원금 배치 조회
@Query(&quot;SELECT b.quote.id as quoteId, MIN(b.installmentPrincipal) as minPrice &quot; +
       &quot;FROM Bid b &quot; +
       &quot;WHERE b.quote.id IN :quoteIds &quot; +
       &quot;AND b.status = :status &quot; +
       &quot;AND (b.isDelete = false OR b.isDelete IS NULL) &quot; +
       &quot;GROUP BY b.quote.id&quot;)
List&amp;lt;BidMinPriceDto&amp;gt; findMinInstallmentPrincipalByQuoteIds(
        @Param(&quot;quoteIds&quot;) List&amp;lt;UUID&amp;gt; quoteIds,
        @Param(&quot;status&quot;) BidStatus status);&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;실행되는 SQL은 아래와 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1777809850855&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SELECT b.quote_id AS quoteId, COUNT(b.id) AS bidCount
FROM bids b
WHERE b.quote_id IN ('1번-uuid', '2번-uuid', '3번-uuid', ...)
AND (b.is_delete = false OR b.is_delete IS NULL)
GROUP BY b.quote_id&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;u&gt;견적이 100개면 IN 절 안의 값만 100개로 늘어날 뿐, 쿼리 수는 그대로 1번&lt;/u&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. Service ㅡ Map으로 변환 후 애플리케이션 레이어 매핑&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쿼리 결과를 List로 받은 다음, 바로 Map으로 변환하는 게 핵심이다.&lt;/p&gt;
&lt;pre id=&quot;code_1777809981251&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;private List&amp;lt;QuoteResponseDto&amp;gt; convertToListDto(List&amp;lt;Quote&amp;gt; quotes) {
    List&amp;lt;UUID&amp;gt; quoteIds = quotes.stream()
            .map(Quote::getId)
            .collect(Collectors.toList());

    // 입찰 수: Map&amp;lt;견적ID, 입찰수&amp;gt;
    Map&amp;lt;UUID, Long&amp;gt; bidCountMap = bidRepository.countByQuoteIds(quoteIds).stream()
            .collect(Collectors.toMap(
                    BidRepository.BidCountDto::getQuoteId,
        			BidRepository.BidCountDto::getBidCount
            ));
    // { '1번-uuid' &amp;rarr; 5, '2번-uuid' &amp;rarr; 3, '3번-uuid' &amp;rarr; 8 }

    // 최저가: Map&amp;lt;견적ID, 최저가&amp;gt;
    Map&amp;lt;UUID, Integer&amp;gt; lowestPriceMap = bidRepository
            .findMinInstallmentPrincipalByQuoteIds(quoteIds, BidStatus.ACTIVE)
            .stream()
            .collect(Collectors.toMap(
                    BidRepository.BidMinPriceDto::getQuoteId,
                    BidRepository.BidMinPriceDto::getMinPrice
            ));
    // { '1번-uuid' &amp;rarr; 350000, '2번-uuid' &amp;rarr; 280000 }

    return quotes.stream()
            .map(quote -&amp;gt; QuoteResponseDto.from(
                    quote,
                    bidCountMap.getOrDefault(quote.getId(), 0L),
                    lowestPriceMap.get(quote.getId())
            ))
            .collect(Collectors.toList());
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt; getOrDefault(quote.getId(), 0L)의 의미&lt;br /&gt;입찰이 하나도 없는 견적은 GROUP BY 결과에 포함되지 않는다. Map에서 해당 키를 찾지 못하면 기본값 0을 반환하도록 처리한 것이다. 최저가도 마찬가지로 입찰 없는 견적은 null 을 반환한다.&lt;/blockquote&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;Q.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;여기서 굳이 List를 Map으로 변환해서 쓰는 이유&lt;/b&gt;가 뭘까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리스트를 그대로 쓰면 특정 견적의 값을 찾기 위해 매번 리스트를 순회해야 한다.&lt;/p&gt;
&lt;pre id=&quot;code_1777810188785&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Map 없이 리스트를 그대로 사용하면
for (Quote quote : quotes) {
    long bidCount = bidCountList.stream()
            .filter(dto -&amp;gt; dto.getQuoteId().equals(quote.getId()))
            .findFirst()
            .map(BidCountDto::getBidCount)
            .orElse(0L);
    // 견적 N개 &amp;times; 리스트 순회 &amp;rarr; O(N&amp;sup2;) 
}&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;복잡도가 O(N^2)&lt;/b&gt;가 되는데, quoteId를 key로 하는 Map으로 변환해서 쓰면 &lt;span style=&quot;background-color: #ffc1c8;&quot;&gt;getOrDefault()&lt;/span&gt; 한 번으로 &lt;b&gt;O(1) 에 꺼낼 수 있다. &lt;/b&gt;Map 생성과 견적 목록 순회가 각각 O(N), 각 순회에서의 Map 조회가 O(1)이니 전체 복잡도는 O(N)이 된다. List 순회 방식의 O(N^2)과 비교하면 데이터가 클수록 차이가 커진다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt; 이 방식의 한 가지 주의할 점&lt;br /&gt;&lt;span style=&quot;background-color: #ffc1c8;&quot;&gt;IN&lt;/span&gt; 절에 들어가는 ID 수가 매우 많아지면 DB에 따라 쿼리 길이 제한에 걸릴 수 있다. 이 경우 ID 목록을 일정 크기로 나눠 배치 처리하는 방식으로 대응할 수 있다. 현재 bidr의 페이지당 조회 건수 기준에서는 해당 없지만, 스케일업 시 챙겨야 할 포인트다.&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt; 쿼리 갯수를 더 줄일 수 있다면?&lt;br /&gt;현재는 입찰 수 조회와 최저가 조회를 별도 쿼리로 나눠 총 3번이다. 역할 분리와 가독성을 위한 선택이었는데, 성능을 더 끌어올려야 하는 시점이 오면 두 집계를 하나의 쿼리로 합쳐 2번으로 줄이는 것도 가능하다.&lt;/blockquote&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;결과&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 방법대로 개선 후, 콘솔에는 &lt;b&gt;견적이 몇 개든 항상 쿼리가 3개로 고정&lt;/b&gt;된다.&lt;/p&gt;
&lt;pre id=&quot;code_1777810532762&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;-- 1번: 견적 목록 조회
SELECT ... FROM quotes WHERE status = 'OPEN' ORDER BY created_at DESC

-- 2번: 입찰 수 배치 조회
SELECT b.quote_id, COUNT(b.id) FROM bids b
WHERE b.quote_id IN ('uuid1', 'uuid2', ...) GROUP BY b.quote_id

-- 3번: 최저가 배치 조회
SELECT b.quote_id, MIN(b.installment_principal) FROM bids b
WHERE b.quote_id IN ('uuid1', 'uuid2', ...) GROUP BY b.quote_id&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;페이지당 20건 기준&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;: 2N + 1 = 41번&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;: &lt;span style=&quot;color: #ee2323;&quot;&gt;92.7%&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로 정리할 수 있다. 견적이 20개든 200개든 쿼리는 항상 3번으로 고정되니 데이터가 늘어나도 DB 부하가 늘어나지 않는 것이다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 126px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style13&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 23.7984%; height: 21px; text-align: center;&quot;&gt;-&lt;/td&gt;
&lt;td style=&quot;width: 26.2016%; height: 21px; text-align: center;&quot;&gt;N+1 구조 (개선 전)&lt;/td&gt;
&lt;td style=&quot;width: 24.2248%; text-align: center;&quot;&gt;fetch join / @BatchSize&lt;/td&gt;
&lt;td style=&quot;width: 25.7752%; height: 21px; text-align: center;&quot;&gt;IN절 + GROUP BY (개선 후)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 23.7984%; height: 21px; text-align: center;&quot;&gt;쿼리 횟수&lt;/td&gt;
&lt;td style=&quot;width: 26.2016%; height: 21px;&quot;&gt;2N + 1 (가변)&lt;/td&gt;
&lt;td style=&quot;width: 24.2248%;&quot;&gt;1 ~ 소수 (가변)&lt;/td&gt;
&lt;td style=&quot;width: 25.7752%; height: 21px;&quot;&gt;3 (고정)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 23.7984%; height: 21px; text-align: center;&quot;&gt;DB 부하&lt;/td&gt;
&lt;td style=&quot;width: 26.2016%; height: 21px;&quot;&gt;견적 수에 비례&lt;/td&gt;
&lt;td style=&quot;width: 24.2248%;&quot;&gt;낮음&lt;/td&gt;
&lt;td style=&quot;width: 25.7752%; height: 21px;&quot;&gt;견적 수 무관&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 23.7984%; height: 21px; text-align: center;&quot;&gt;집계 주체&lt;/td&gt;
&lt;td style=&quot;width: 26.2016%; height: 21px;&quot;&gt;DB (N번 반복)&lt;/td&gt;
&lt;td style=&quot;width: 24.2248%;&quot;&gt;애플리케이션&lt;/td&gt;
&lt;td style=&quot;width: 25.7752%; height: 21px;&quot;&gt;DB (1번으로 통합)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 23.7984%; height: 21px; text-align: center;&quot;&gt;메모리 사용&lt;/td&gt;
&lt;td style=&quot;width: 26.2016%; height: 21px;&quot;&gt;Bid 엔티티 전체&lt;/td&gt;
&lt;td style=&quot;width: 24.2248%;&quot;&gt;&lt;span style=&quot;background-color: #f9f9f9; color: #333333; text-align: start;&quot;&gt;Bid 엔티티 전체&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25.7752%; height: 21px;&quot;&gt;숫자값만 변환&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt; &lt;b&gt; &lt;/b&gt; 사용자 입장에서는?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쿼리 횟수가 줄었다는 건 일단 기술적인 개선점이다. 그럼 사용자 입장에서는 어떤 점이 달라졌을까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(당연히) &lt;b&gt;견적 목록 화면이 더 빠르게&lt;/b&gt; 뜬다. bidr(비더)는 유저 본인이 올려둔 견적을 수시로 들여다보는 서비스다. 새 입찰이 들어올 때 마다 목록을 확인하는 패턴이 잦을 텐데, 그 조회 하나하나가 가벼워진 셈이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;비즈니스 관점&lt;/b&gt;에서도 의의가 있다. 서비스 초기에는 견적 수 자체가 적어서 큰 차이가 없어 보일 것이다. 하지만 견적 데이터가 쌓일 수록 개선 전 구조는 응답이 느려지고, 최악의 경우 속도가 느려 답답했던 사용자가 목록 화면에서 이탈하는 원인이 된다. 이번 개선으로 데이터가 아무리 쌓여도 목록 조회 성능이 일정하게 유지되는 구조가 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;마무리&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;N+1 문제라고 하면 fetch join이나 @BatchSize 부터 떠올리게 되는데, 이번 케이스는 그게 맞는 도구가 아니었다. 두 방법 모두 전제가 '엔티티 로딩'이었는데, 필요한 건 엔티티가 아니라 숫자 2개였으니까. 필요한 게 '엔티티'냐 '집계값'이냐에 따라 접근이 달라지고 결과는 천지차이로 바뀔 수 있다는 걸 이번 경험을 통해 배웠다.&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;쿼리가 아무리 N+1 이슈 없이 잘 최적화돼 있어도, &lt;u&gt;반환하는 데이터 자체가 불필요하게 크면 그걸 최적화라고 말할 수 있을까?&lt;/u&gt; 라는 생각이 든다. '뭘 가져올 것인가'가 '어떻게 가져올 것인가'만큼 중요한 것 같다.&lt;/p&gt;</description>
      <category>Project/phonebid</category>
      <category>@batchsize</category>
      <category>@EntityGraph</category>
      <category>batchsize</category>
      <category>Fetch Join</category>
      <category>Groupby</category>
      <category>JPA</category>
      <category>N+1</category>
      <category>성능개선</category>
      <category>쿼리최적화</category>
      <author>쉬지마 이굥진</author>
      <guid isPermaLink="true">https://developer-jinnie.tistory.com/131</guid>
      <comments>https://developer-jinnie.tistory.com/131#entry131comment</comments>
      <pubDate>Sun, 3 May 2026 23:56:47 +0900</pubDate>
    </item>
    <item>
      <title>계약하기 버튼, 두 번 누르면 어떻게 될까 ㅡ 비관적 락 적용기</title>
      <link>https://developer-jinnie.tistory.com/130</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;필자는 현재 스마트폰 역경매 플랫폼 bidr(비더)를 개발 중이다. (5월 런칭 목표)&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;bidr에서 계약이 만들어지는 흐름은 이렇다. 구매자가 견적을 올리면 여러 판매자가 입찰을 넣고, 구매자가 마음에 드는 입찰을 선택하면 계약이 체결된다. 간단한 구조다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;문제 인식&lt;/b&gt; &amp;mdash; 이 패턴, 어디서 많이 봤는데&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;과거에 이커머스 프로젝트 개발 중 재고 차감 기능을 구현한 적이 있다. 여러 사용자가 동시에 같은 상품을 주문할 때 재고가 0 아래로 내려가는 걸 막아야 하는 상황이었다. 그때 낙관적 락과 Redis 분산락을 직접 써보면서 동시성 문제를 어떻게 다루는지 익히게 되었다.&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;그 경험이 있어서인지, bidr에서 계약 생성 기능을 구현하다가 코드를 보는데 뭔가 익숙하고 찝찝한(?) 느낌이 드는것이었다..!&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;588&quot; data-origin-height=&quot;438&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/9KK6Y/dJMcad2PLXS/AWK9rAvmMfudelMzieJww1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/9KK6Y/dJMcad2PLXS/AWK9rAvmMfudelMzieJww1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/9KK6Y/dJMcad2PLXS/AWK9rAvmMfudelMzieJww1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F9KK6Y%2FdJMcad2PLXS%2FAWK9rAvmMfudelMzieJww1%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;281&quot; height=&quot;438&quot; data-origin-width=&quot;588&quot; data-origin-height=&quot;438&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;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;'구매자가 계약하기 버튼을 두 번 누르면 어떻게 되지?'&lt;/b&gt; &lt;b&gt;(일명 따닥)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재고 문제랑 구조가 똑같았다. SELECT 하고 검증하고 UPDATE 하는 사이에 다른 요청이 끼어드는 패턴. 재고 문제에서 이미 겪어본 그것.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;계약은 &lt;b&gt;반드시 단 한 번만 생성되어야 한다.&lt;/b&gt; 같은 견적에 계약이 두 개 생기면 어떤 입찰이 선택된 건지 알 수 없고, 금전 거래가 통째로 꼬여버린다. 비즈니스 적으로 보면 돈이 걸려있기에 재고 문제보다 더 철저해야한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드를 다시 봤다.&lt;/p&gt;
&lt;pre id=&quot;code_1777293664132&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 개선 전 &amp;mdash; @Transactional만 있고 락이 없다
@Transactional
public Contract createContract(UUID quoteId, UUID bidId, User user) {
    Quote quote = quoteRepository.findById(quoteId)...  // &amp;larr; 일반 조회, 락 없음
    Bid bid = bidRepository.findById(bidId)...

    bid.select();
    quote.markContracted();

    return contractRepository.save(contract);  // &amp;larr; DB 제약조건만 믿는 구조
}&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;@Transactional이 있으면 다 해결되는 거 아닌가? 그렇지 않다. @Transactional은 하나의 트랜잭션이 원자적으로 실행되는 것을 보장할 뿐, 동시에 들어온 두 요청이 같은 데이터를 동시에 읽는 것까지 막아주지는 않는다. (동시성 문제를 이미 다뤄보신 분이라면 잘 아실 것)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 코드를 시간 순서로 보면 이렇다.&lt;/p&gt;
&lt;pre id=&quot;code_1777294029826&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;더블 클릭 시나리오 (락이 없을 때)

시간    요청 1 (첫 번째 클릭)          요청 2 (두 번째 클릭)
───────────────────────────────────────────────────────────────
T1      Quote 조회 &amp;rarr; 상태: OPEN        Quote 조회 &amp;rarr; 상태: OPEN
T2      권한 확인 통과                  권한 확인 통과
T3      Bid 조회 &amp;rarr; 상태: ACTIVE        Bid 조회 &amp;rarr; 상태: ACTIVE
T4      bid.select() 실행              bid.select() 실행
T5      quote.markContracted()         quote.markContracted() 시도
T6      계약 생성 &amp;rarr; 성공 ✅             (상황에 따라)
                                     - quote 상태 체크에서 400으로 실패하거나
                                     - DB 유니크 제약 위반 예외가 나서 500으로 떨어질 수 있음
───────────────────────────────────────────────────────────────&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;중요한&amp;nbsp;건&amp;nbsp;&amp;ldquo;DB&amp;nbsp;유니크&amp;nbsp;제약이&amp;nbsp;중복&amp;nbsp;계약&amp;nbsp;저장&amp;nbsp;자체는&amp;nbsp;막아줄&amp;nbsp;수&amp;nbsp;있지만&amp;rdquo;,&amp;nbsp;&lt;b&gt;그&amp;nbsp;예외가&amp;nbsp;사용자&amp;nbsp;친화적인&amp;nbsp;에러로&amp;nbsp;변환되지&amp;nbsp;않으면&lt;/b&gt;&amp;nbsp;사용자는&amp;nbsp;500&amp;nbsp;같은&amp;nbsp;의미&amp;nbsp;없는&amp;nbsp;에러를&amp;nbsp;받기&amp;nbsp;쉽다는&amp;nbsp;점이다. &lt;br /&gt;금전&amp;nbsp;거래가&amp;nbsp;엮이는&amp;nbsp;기능에서&amp;nbsp;이&amp;nbsp;UX는&amp;nbsp;좋지&amp;nbsp;않다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt; Race Condition이 발생할 수 있는 현실적인 상황들 &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;&lt;b&gt;네트워크 재시도&lt;/b&gt; : 응답이 늦어지자 재요청&lt;/li&gt;
&lt;li&gt;크로스 디바이스 : (흔하진 않지만) 스마트폰과 PC에서 동시에 같은 견적 처리&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;bidr는 모바일 사용자가 많을 것으로 예상된다. 지하철처럼 네트워크가 불안정한 환경에서 응답이 늦으면 버튼을 한 번 더 누르는 건 지극히 자연스러운 행동이다. 이미 비슷한 문제를 한 번 겪어봤으니, 이번엔 미리 잡기로 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;락 전략 선택&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 동시성 문제를 해결하기 위해 생각했던 방법들은 크게 세 가지다. &lt;b&gt;낙관적 락, 비관적 락, Redis 분산락.&lt;/b&gt;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style13&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 19.3798%; text-align: center;&quot;&gt;&lt;b&gt;전략&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 31.4728%; text-align: center;&quot;&gt;&lt;b&gt;방식&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 49.1473%; text-align: center;&quot;&gt;&lt;b&gt;이 상황에서의 문제&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 19.3798%;&quot;&gt;&lt;b&gt;낙관적 락&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 31.4728%;&quot;&gt;충돌 발생 후 예외 &amp;rarr; 재시도&lt;/td&gt;
&lt;td style=&quot;width: 49.1473%;&quot;&gt;계약은 재시도가 불가능 (이미 다른 입찰이 선택됐을 수 있음)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 19.3798%;&quot;&gt;&lt;b&gt; Redis 분산락 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 31.4728%;&quot;&gt;외부 저장소로 락 관리&lt;/td&gt;
&lt;td style=&quot;width: 49.1473%;&quot;&gt;현재 단일 서버 구성에서 인프라 복잡도만 늘어남&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 19.3798%;&quot;&gt;&lt;b&gt; 비관적 락 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 31.4728%;&quot;&gt;요청 자체를 원천 차단&lt;/td&gt;
&lt;td style=&quot;width: 49.1473%;&quot;&gt;✅ 계약처럼 한 번만 생성되어야 하는 리소스에 적합&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전에 재고 동시성 문제 잡을 때 썼던 Redis 분산락부터 제외했다. 현재 비더는 단일 서버 구성이다. 외부 인프라를 추가하는 복잡도 대비 얻는 이점이 압도적이지 않다고 생각했기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 다음은 &lt;b&gt;낙관적 락&lt;/b&gt;을 생각했는데, 낙관적 락이 성능은 좋지만 이 상황엔 맞지 않는다. 낙관적 락은 충돌이 발생하면 재시도를 하는 구조인데 &lt;b&gt;계약에서 재시도는 의미가 없다&lt;/b&gt;. '먼저 온 요청이 이미 특정 입찰을 선택해버렸다면, 두 번째 요청은 어떤 입찰을 선택해야 하나?' 라는 질문에 답이 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 &lt;b&gt;비관적 락&lt;/b&gt;이 가장 적합하다고 보고 선택하기로 했다. 비관적 락은 요청 자체를 DB 수준에서 직렬화해서 동시 접근을 원천 차단한다. 계약처럼 충돌이 나면 되돌릴 방법이 없는 리소스에는 비관적 락으로 동시성 보장을 확실하게 하는게 맞다고 생각했다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;구분&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;&lt;b&gt;비관적 락 (PESSIMISTIC_WRITE)&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;&lt;b&gt;낙관적 락 (@Version)&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;동시성 보장&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;확실&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;충돌 발생 시 예외&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;성능&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;낙관적 락에 비해 느림&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;(일반적으로) 빠름&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;사용자 경험&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;명확한 처리 결과 수신 (안정적)&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;즉시 실패 후 재시도 유도&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;구현 복잡도&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;간단&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;재시도 로직 구현 필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;데드락 위험&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;(확률은 낮지만) 있음&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;없음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;구현&lt;/b&gt; &amp;mdash; 배타적 락 + 이중 체크&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;락을 걸어서 동시 접근을 막고, 락을 획득한 뒤에도 한 번 더 상태를 확인하는 로직으로 구현을 진행했다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 배타적 락으로 조회&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt; PESSIMISTIC_WRITE&lt;/span&gt;는 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;SELECT ... FOR UPDATE&lt;/span&gt; 쿼리를 통해 행을 선점한다. 한 트랜잭션이 락을 획득하면, 다른 트랜잭션은 &lt;b&gt;데이터를 수정하거나 같이 락을 걸고 조회하는 작업&lt;/b&gt;을 수행하기 위해 앞선 트랜잭션이 종료 (커밋/롤백) 될 때까지 기다려야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(락을 사용하지 않는 일반적인 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;SELECT&lt;/span&gt;는 MVCC 덕분에 대기 없이 조회가 가능하다)&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt; MVCC(Multi-Version Concurrency Control) 기술&lt;br /&gt;대부분의 RDBMS(MySQL InnoDB, PostgreSQL 등)는 MVCC 기술을 사용한다.&lt;br /&gt;&lt;br /&gt;&lt;b&gt;- 일반 SELECT&lt;/b&gt;: 락이 걸려 있어도 Undo Log 등을 통해 이전 버전의 데이터를 즉시 읽어옴&lt;br /&gt;&lt;b&gt;- SELECT ... FOR UPDATE&lt;/b&gt;: &quot;나 이거 고칠 거니까 아무도 건드리지 마&quot;라고 선언하는 조회. 이때는 먼저 락을 잡은 트랜잭션이 끝날 때까지 대기해야 함&lt;/blockquote&gt;
&lt;pre id=&quot;code_1777294766696&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// QuoteRepository
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query(&quot;SELECT q FROM Quote q WHERE q.id = :id&quot;)
Optional&amp;lt;Quote&amp;gt; findByIdWithLock(@Param(&quot;id&quot;) UUID id);

// BidRepository
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query(&quot;SELECT b FROM Bid b WHERE b.id = :id&quot;)
Optional&amp;lt;Bid&amp;gt; findByIdWithLock(@Param(&quot;id&quot;) UUID id);&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;생성되는 SQL은 개념적으로 이런 형태다.&lt;/p&gt;
&lt;pre id=&quot;code_1777294799440&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SELECT * FROM quotes WHERE id = ? FOR UPDATE;
-- 이 행에 대해 다른 트랜잭션이 락을 걸거나 수정하려 하면 대기&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;2. 락 획득 후 이중 체크&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;락을 획득했다고 끝이 아니다. 두 요청이 거의 동시에 들어왔을 때, 첫 번째 요청이 락을 쥐고 계약을 생성한 뒤 락을 해제하면 두 번째 요청이 락을 획득한다. 이 시점에서 아무 체크 없이 진행하면 두 번째 요청도 계약 생성을 시도하게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(아래의 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;existsByQuoteId()&lt;/span&gt; 메서드)&lt;/p&gt;
&lt;pre id=&quot;code_1777294839482&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Transactional
public Contract createContract(UUID quoteId, UUID bidId, User user) {
    // 1. 비관적 락으로 견적 조회 (배타적 락 획득)
    Quote quote = quoteRepository.findByIdWithLock(quoteId)
            .orElseThrow(() -&amp;gt; new CustomException(AuctionErrorCode.QUOTE_NOT_FOUND));

    // 2. 권한 확인
    if (!quote.getUser().getId().equals(user.getId())) {
        throw new CustomException(AuctionErrorCode.QUOTE_NOT_OWNED_BY_USER);
    }

    // 3. 이중 체크 &amp;mdash; 락 획득 후에도 계약 존재 여부 재확인
    if (contractRepository.existsByQuoteId(quoteId)) {
        throw new CustomException(TradeErrorCode.CONTRACT_ALREADY_EXISTS);
    }

    // 4. 견적 상태 확인
    if (!quote.getStatus().isOpen()) {
        throw new CustomException(AuctionErrorCode.INVALID_QUOTE_STATUS);
    }

    // 5. 비관적 락으로 입찰 조회
    Bid bid = bidRepository.findByIdWithLock(bidId)
            .orElseThrow(() -&amp;gt; new CustomException(AuctionErrorCode.BID_NOT_FOUND));

    // 6. 입찰이 해당 견적에 속하는지 확인
    if (!bid.getQuote().getId().equals(quoteId)) {
        throw new CustomException(TradeErrorCode.INVALID_BID_FOR_QUOTE);
    }

    // 7. 입찰 상태 변경 &amp;rarr; 견적 상태 변경 &amp;rarr; 계약 생성 (순서 중요)
    bid.select();
    bidRepository.save(bid); // JPA의 변경 감지 사용해도 되지만 명확한 의도를 위해 save 호출

    quote.markContracted();
    quoteRepository.save(quote);
    // 부모 리소스인 Quote의 상태를 마지막에 변경하여 데이터 정합성을 맞춤

    Contract contract = Contract.builder()
            .quote(quote)
            .selectedBid(bid)
            .build();

    return contractRepository.save(contract);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;물론 이 경우에도 quote.markContracted() 내부에서 상태 체크가 막아준다. Quote 상태가 이미 &lt;b&gt;&lt;i&gt;CONTRACTED&lt;/i&gt;&lt;/b&gt;로 바뀌어 있기 때문이다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그런데 이 경우 &lt;b&gt;사용자에게 돌아가는 에러&lt;/b&gt;는 &lt;span style=&quot;background-color: #ffc1c8;&quot;&gt;INVALID_QUOTE_STATUS&lt;/span&gt;(&quot;견적 상태가 올바르지 않습니다&quot;)다. &lt;u&gt;두 번째 요청을 보낸 사람 입장에서는 무슨 상황인지 알기 어렵다. &lt;/u&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그래서 락을 획득한 직후, &lt;b&gt;계약이 이미 존재하는지&lt;/b&gt; 한 번 더 확인한다. (existsByQuoteId 메서드) 이미 계약이 있으면 직접 정의한 커스텀 에러를 던진다.&lt;/p&gt;
&lt;pre id=&quot;code_1777466236143&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;if (contractRepository.existsByQuoteId(quoteId)) {
    throw new CustomException(TradeErrorCode.CONTRACT_ALREADY_EXISTS);
    // &amp;rarr; 409 &quot;이미 해당 견적에 대한 계약이 존재합니다.&quot;
}&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;정리해보면, 동시성 안전성 자체는 비관적 락 + 상태 체크 + DB 유니크 제약이 담당한다. existsByQuoteId 이중 체크는 거기에 더해 &lt;b&gt;두 번째 요청을 보낸 사람에게 '이미 계약이 완료됐다'는 명확한 메시지&lt;/b&gt;를 돌려주기 위한 사용자 친화적 장치인 것!&lt;/p&gt;
&lt;pre id=&quot;code_1777466279140&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;에러 비교
──────────────────────────────────────────────────────
이중 체크 없을 때  &amp;rarr; INVALID_QUOTE_STATUS (400)
                    &quot;견적 상태가 올바르지 않습니다&quot; &amp;mdash; 맥락 파악 어려움
이중 체크 있을 때  &amp;rarr; CONTRACT_ALREADY_EXISTS (409)
                    &quot;이미 해당 견적에 대한 계약이 존재합니다&quot; &amp;mdash; 명확 ✅
──────────────────────────────────────────────────────&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기까지 개선 후 아까와 동일한 시나리오가 어떻게 처리되는지 보면?!&lt;/p&gt;
&lt;pre id=&quot;code_1777294875155&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;개선 후 &amp;mdash; 더블 클릭 시나리오
───────────────────────────────────────────────────────────────
시간    요청 1 (첫 번째 클릭)          요청 2 (두 번째 클릭)
───────────────────────────────────────────────────────────────
T1      Quote 락 획득 ✅               Quote 락 대기 중...
T2      이중 체크 통과 (계약 없음)
T3      상태 변경 + 계약 생성 완료 ✅
T4      트랜잭션 커밋 &amp;rarr; 락 해제         Quote 락 획득 ✅
T5                                     이중 체크 &amp;rarr; 이미 계약 존재
T6                                     &amp;rarr; 409 에러 반환 ✅
───────────────────────────────────────────────────────────────&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. 락 획득 순서 고정 &amp;mdash; 데드락 방지 &lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비관적 락의 약점은 데드락이다. 확률은 크지 않지만 요청 A가 Quote 락을 쥐고 Bid 락을 기다리는 동안, 요청 B가 Bid 락을 쥐고 Quote 락을 기다리면 서로 영원히 기다리는 상황(=deadlock)이 생길 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 방지하기 위해 &lt;b&gt;항상 같은 순서로 락을 획득&lt;/b&gt;하도록 고정했다. Quote를 먼저, Bid를 나중에. 두 요청 모두 이 순서를 따르면 서로를 기다리는 상황이 생기지 않는다.&lt;/p&gt;
&lt;pre id=&quot;code_1777294952156&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Quote quote = quoteRepository.findByIdWithLock(quoteId);  // &amp;larr; 항상 1순위
Bid bid = bidRepository.findByIdWithLock(bidId);          // &amp;larr; 항상 2순위&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;&lt;b&gt;3중 방어 매커니즘&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종적으로 중복 계약을 막는 레이어는 세 개다.&lt;/p&gt;
&lt;pre id=&quot;code_1777295028753&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;계약 중복 방지 3중 방어
───────────────────────────────────────────────────────────────
1차 방어    비관적 락 (DB 레벨 직렬화)
            &amp;rarr; 동시 요청을 순서대로 처리
               &amp;darr;
2차 방어    existsByQuoteId() 이중 체크 (애플리케이션 레벨)
            &amp;rarr; 락 획득 후에도 계약 존재 여부 재확인, 두 번째 요청에 대해 409로 명확한 ux 제공
               &amp;darr;
3차 방어    DB 유니크 제약조건 (quote_id UNIQUE)
            &amp;rarr; 혹시 위 두 개를 뚫더라도 DB가 최종 차단
───────────────────────────────────────────────────────────────&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;1, 2차 방어가 있음에도 3차를 남겨둔 이유는 코드 레벨에서 놓치는 엣지 케이스가 항상 존재하기 때문이다. DB 제약조건은 비용이 거의 없는 최후의 보루라고 생각하면 될 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;사용자 경험 변화&lt;/b&gt;&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 76px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style13&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 23.6821%; height: 21px; text-align: center;&quot;&gt;&lt;b&gt;상황&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 36.0078%; height: 21px; text-align: center;&quot;&gt;&lt;b&gt;개선 전&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 40.31%; height: 21px; text-align: center;&quot;&gt;&lt;b&gt;개선 후&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 23.6821%; height: 21px; text-align: center;&quot;&gt;더블 클릭&lt;/td&gt;
&lt;td style=&quot;width: 36.0078%; height: 21px;&quot;&gt;DB 에러 (500)&lt;/td&gt;
&lt;td style=&quot;width: 40.31%; height: 21px;&quot;&gt;'이미 계약이 존재합니다' (409)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 23.6821%; height: 17px; text-align: center;&quot;&gt;네트워크 재시도&lt;/td&gt;
&lt;td style=&quot;width: 36.0078%; height: 17px;&quot;&gt;불안정한 동작&lt;/td&gt;
&lt;td style=&quot;width: 40.31%; height: 17px;&quot;&gt;안전하게 직렬화 처리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 23.6821%; height: 17px; text-align: center;&quot;&gt;크로스 디바이스&lt;/td&gt;
&lt;td style=&quot;width: 36.0078%; height: 17px;&quot;&gt;한쪽은 성공, 한쪽은 500 에러&lt;/td&gt;
&lt;td style=&quot;width: 40.31%; height: 17px;&quot;&gt;먼저 온 요청만 성공, 나머지는 명확한 메시지&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;500 에러와 409 에러는 다르다. 500은 '서버가 뭔가 잘못됐다'는 신호고, 409는 '이미 처리된 요청입니다'라는 의미 있는 응답이다. 사용자 입장에서, 그리고 프론트엔드에서 에러를 처리하는 입장에서도 훨씬 나을 것이다  &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;성능 영향은 얼마나 될까&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비관적 락을 쓰면 성능이 나빠지는 거 아닌가? 맞다. 락 대기 시간이 추가된다. 하지만 이 케이스에서는 문제가 되지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;계약 생성은 자주 발생하는 작업이 아니다. 초기 프로젝트 특성 상 하루 수십~수백 건 수준이 될 것이라고 판단했고, 동시에 같은 견적에 요청이 몰리는 확률은 매우 낮다. 실제로 락 경합이 발생하는 케이스 자체가 드물기 때문에 성능 영향은 허용 가능한 수준이라고 판단했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;금전 거래의 정확성이 성능보다 우선&lt;/b&gt;이라고 보고 이 선택을 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;추후 구현 사항&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 구현에서 한 가지 더 고려해볼 수 있는 게 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;락 타임아웃 설정&lt;/b&gt;이다. 지금은 락 대기에 타임아웃이 설정되어 있지 않다. 트래픽이 갑자기 몰려서 락 대기가 길어지면 요청이 무한정 대기하는 상황이 생길 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1777457663777&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 추후 적용 예정
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({
    @QueryHint(name = &quot;javax.persistence.lock.timeout&quot;, value = &quot;3000&quot;) // 3초 타임아웃
})
Optional&amp;lt;Quote&amp;gt; findByIdWithLock(@Param(&quot;id&quot;) UUID id);&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;3초 안에 락을 획득하지 못하면 타임아웃 예외를 던지고 사용자에게 '잠시 후 다시 시도해주세요'를 반환하는 방식이다. 현재 트래픽 수준에서는 당장 필요하지 않지만 런칭 후 트래픽이 늘어나면 챙겨야 할 부분이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;마무리&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재고 차감 기능에서 동시성 문제를 한 번 겪어봤기 때문에, 계약 생성 설계할 때부터 비슷한 문제 있겠는데 하고 바로 인식할 수 있었다. 평소 개발 스터디로 여러 서적이나 개념 공부하면서 익혀둔 것도 확실히 도움이 됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에 직접 시나리오를 그려보면서 다시 한번 확인한 것들이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@Transactional은 하나의 트랜잭션이 원자적으로 실행되는 것을 보장할 뿐, 동시에 들어온 여러 트랜잭션이 같은 데이터를 읽는 것까지 막지 못한다. 그 간극을 채우는 게 락이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;낙관적 락이냐 비관적 락이냐는 &lt;b&gt;&quot;충돌이 났을 때 재시도할 수 있는 작업인가&quot;&lt;/b&gt; 로 판단하면 선택이 빨라진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt; 재고 차감&lt;/b&gt;은 충돌이 나도 재시도가 가능하다. 재고가 1개 남은 상품을 두 명이 동시에 담았을 때, 한 명에게 &quot;다시 시도해주세요&quot;를 돌려줘도 크게 문제없으니 &amp;rarr; 낙관적 락.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt; 계약&lt;/b&gt;은 다르다. 기술적으로 트랜잭션 롤백은 가능하지만, 비즈니스적으론 재시도가 불가능하다. 첫 번째 요청이 이미 특정 입찰을 선택해버렸다면, 두 번째 요청은 어떤 입찰을 선택해야 하는가? 이미 선택된 입찰을 또 선택할 수도없고, 다른 입찰을 고르자니 사용자 의도와 달라져 답이 없으니 &amp;rarr; 요청 자체를 원천 차단하는 비관적 락!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 동시성 문제라도 &lt;b&gt;리소스의 성격에 따라 락 전략이 달라진다.&lt;/b&gt; 이번에 두 케이스를 직접 비교해보면서 그 판단 기준이 좀 더 선명해진 것 같다  &lt;/p&gt;</description>
      <category>Project/phonebid</category>
      <category>JPA</category>
      <category>pessimisticlock</category>
      <category>Race Condition</category>
      <category>동시성제어</category>
      <category>비관적락</category>
      <author>쉬지마 이굥진</author>
      <guid isPermaLink="true">https://developer-jinnie.tistory.com/130</guid>
      <comments>https://developer-jinnie.tistory.com/130#entry130comment</comments>
      <pubDate>Wed, 29 Apr 2026 22:45:02 +0900</pubDate>
    </item>
    <item>
      <title>&amp;quot;이거 수백 개 다 손으로 바꿔야 해요?&amp;quot; ㅡ 그리드 마이그레이션 자동화 회고</title>
      <link>https://developer-jinnie.tistory.com/129</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;필자는 ERP 시스템 유지보수 업무를 하면서 꽤 묵직한 작업을 맡게 되었다. dhtmlxGrid 라이브러리로 구성된 수백 개 화면을 SBGrid v3 라이브러리로 전면 전환하는 마이그레이션 프로젝트였다.&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;이 포스팅은 그 과정에서 반복 작업을 어떻게 자동화했는지, 그리고 폐쇄망 환경이 풀리고 나서 AI 프롬프트 배포까지 어떻게 이어졌는지에 대한 기록이다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt; 이 포스팅에 나오는 라이브러리&lt;br /&gt;&lt;b&gt;dhtmlxGrid&lt;/b&gt; &amp;mdash; 해외 웹 그리드 UI 라이브러리. 행/열로 구성된 데이터 테이블을 화면에 렌더링하고 편집할 수 있게 해준다. 유료 라이선스 제품이다.&lt;br /&gt;&lt;br /&gt;&lt;b&gt;SBGrid v3&lt;/b&gt; &amp;mdash; 국내 기업 소프트보울에서 만든 그리드 라이브러리. dhtmlxGrid와 동일한 역할을 하지만 라이선스 비용이 훨씬 저렴했다. 이 프로젝트에서 dhtmlxGrid를 대체한 라이브러리다.&lt;br /&gt;&lt;br /&gt;둘 다 '그리드'라는 같은 목적의 도구지만, API 구조가 완전히 달라서 단순 함수명 교체가 아닌 전면 재작성이 필요했다.&lt;/blockquote&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;  작업 시점 안내&lt;br /&gt;이 작업은 2025년 4월 기준으로 진행됐습니다. 당시는 Cursor, Claude Code와 같은 AI 에이전트 도구들이 정식 출시되지 않았던 시점입니다. AI 활용이라고 하면 채팅 인터페이스에서 직접 프롬프트를 작성해 붙여넣는 방식이었고, 폐쇄망 환경에서는 불가능한 작업이었다는 것 미리 알아주세요!&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;i&gt;(실무에서 진행한 작업을 다루고 있어, 실제 서버 주소&amp;middot;파일명&amp;middot;메뉴 경로 등 일부 내용은 보안상 예시로 대체했습니다. 함수명도 일부 변경했습니다.)&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;배경&lt;/b&gt;&amp;nbsp;ㅡ 전환이 필요했던 이유&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 시스템은 dhtmlxGrid 기반이었는데, 유지보수를 진행하면서 라이선스 문제가 수면 위로 올라왔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;dhtmlxGrid는 사용료가 상당한 반면, SBGrid v3는 그에 비해 훨씬 저렴&lt;/b&gt;했다. 수백 개 화면에 걸쳐 쓰이고 있는 라이브러리다 보니 비용 차이가 작지 않았고 결국 SBGrid v3로 전면 전환이 결정됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 라이브러리는 구조 자체가 완전히 다르다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 105px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style13&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 27.0542%; text-align: center; height: 21px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;b&gt;구분&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 35.4264%; text-align: center; height: 21px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;b&gt;dhtmlxGrid&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 37.5193%; text-align: center; height: 21px;&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;b&gt;SBGrid v3&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 27.0542%; text-align: center; height: 21px;&quot;&gt;설계 방식&lt;/td&gt;
&lt;td style=&quot;width: 35.4264%; height: 21px;&quot;&gt;&lt;b&gt;절차적 API&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 37.5193%; height: 21px;&quot;&gt;&lt;b&gt;컬럼 중심 구조&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 27.0542%; text-align: center; height: 21px;&quot;&gt;데이터 접근&lt;/td&gt;
&lt;td style=&quot;width: 35.4264%; height: 21px;&quot;&gt;row/col &lt;b&gt;인덱스 기반&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 37.5193%; height: 21px;&quot;&gt;row/col &lt;b&gt;객체 기반&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 27.0542%; text-align: center; height: 21px;&quot;&gt;주요 이벤트&lt;/td&gt;
&lt;td style=&quot;width: 35.4264%; height: 21px;&quot;&gt;&lt;b&gt;셀 편집 이벤트&lt;/b&gt; (e.g. 함수명 onEditCell)&lt;/td&gt;
&lt;td style=&quot;width: 37.5193%; height: 21px;&quot;&gt;&lt;b&gt;명령 기반 이벤트&lt;/b&gt; (e.g. 함수명 doCommand)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 27.0542%; text-align: center; height: 21px;&quot;&gt;컬럼 타입 지정&lt;/td&gt;
&lt;td style=&quot;width: 35.4264%; height: 21px;&quot;&gt;&lt;b&gt;타입 배열&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 37.5193%; height: 21px;&quot;&gt;&lt;b&gt;컬럼 객체 배열&lt;/b&gt; (type, field, caption, ...)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;API가 1: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;화면이 수백 개였다. 각 팀원들이 일정 갯수의 화면을 맡아서 작업하는 방식이었는데, 처음엔 그냥 눈으로 보면서 하나씩 옮겼다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;문제&lt;/b&gt; &amp;mdash; 반복 작업이 병목이 되버림&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;작업을 시작하고 나니 패턴이 보였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 화면에는 공통 구조가 있었다. &lt;b&gt;그리드 초기화 &amp;rarr; 이벤트 연결 &amp;rarr; 데이터 조회&lt;/b&gt;.&amp;nbsp;&amp;nbsp;&lt;b&gt;이 틀은 수백 개 화면이 전부 같았다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리드 설정도 마찬가지였다. dhtmlxGrid의 컬럼 설정 배열들을 보고 SBGrid의 컬럼 배열로 변환하는 작업이 화면마다 반복됐다.&lt;/p&gt;
&lt;pre id=&quot;code_1777276848931&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;dhtmlxGrid 컬럼 설정 구조 (예시)
──────────────────────────────────────────────────────
columnIds  : ['itemCd', 'itemNm', 'useYn']
hdrLabels  : ['코드', '코드명', '사용여부']
cellType   : ['ro', 'ed', 'co']
cellWidthPX: ['100', '200', '100']
cellAlign  : ['center', 'left', 'center']
──────────────────────────────────────────────────────

&amp;darr; 이걸 SBGrid v3 columns 배열로 변환해야 함

{ field: 'itemCd', caption: '코드',    type: 'text',  editable: false, width: '100', align: 'center' }
{ field: 'itemNm', caption: '코드명',  type: 'text',  width: '200', align: 'left' }
{ field: 'useYn',  caption: '사용여부',type: 'combo', items: 배열,  width: '100', align: 'center' }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 두 가지였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하나는 &lt;b&gt;속도&lt;/b&gt;. 화면 하나 전환하는 데 컬럼 수가 많으면 꽤 시간이 걸렸다. 수백 개 화면을 이 속도로 하면 일정이 맞지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다른 하나는 &lt;b&gt;일관성&lt;/b&gt;. 여러 사람이 각자 방식으로 변환하다 보면 코드 스타일이 제각각이 된다. 나중에 유지보수할 때 같은 패턴인데도 코드가 다르게 생기면 혼란이 생길 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1차 해결&lt;/b&gt; &amp;mdash; gridConvert 함수 설계 (폐쇄망 환경)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;당시 환경이 폐쇄망이었다. 인터넷도 안 되고 (당연히) AI도 못 쓰는 상황이었다  &lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;852&quot; data-origin-height=&quot;601&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bh9CnP/dJMcaiwkpIy/08i5GXwiZWuQlkT1Im82E1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bh9CnP/dJMcaiwkpIy/08i5GXwiZWuQlkT1Im82E1/img.png&quot; data-alt=&quot;저 안울어요&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bh9CnP/dJMcaiwkpIy/08i5GXwiZWuQlkT1Im82E1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbh9CnP%2FdJMcaiwkpIy%2F08i5GXwiZWuQlkT1Im82E1%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;248&quot; height=&quot;175&quot; data-origin-width=&quot;852&quot; data-origin-height=&quot;601&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;저 안울어요&lt;/figcaption&gt;
&lt;/figure&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;그래서 직접 만들기로 했다. 두 라이브러리의 API 구조를 분석해서 &lt;b&gt;기존 dhtmlxGrid 객체를 받아 SBGrid v3 설정 코드를 콘솔에 출력해주는 gridConvert 함수&lt;/b&gt;를 설계했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아이디어는 간단했다. 기존 그리드 객체 안에는 컬럼 정보가 이미 다 담겨 있었다. 이걸 읽어서 SBGrid 설정 형태로 변환해주는 함수를 만들면 손으로 하나씩 옮기지 않아도 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1777277103068&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// dhtmlxGrid cellType &amp;rarr; SBGrid v3 컬럼 타입 매핑
if (cellTypeArr[i] == &quot;ed&quot;)          print += &quot;... gStringInpCol, &quot;;   // 편집 가능한 텍스트
if (cellTypeArr[i] == &quot;ro&quot;)          print += &quot;... gStringRoCol, &quot;;    // 읽기 전용 텍스트
if (cellTypeArr[i] == &quot;edn&quot;)         print += &quot;... gNumInpCol, &quot;;      // 편집 가능한 숫자
if (cellTypeArr[i] == &quot;ron&quot;)         print += &quot;... gNumRoCol, &quot;;       // 읽기 전용 숫자
if (cellTypeArr[i] == &quot;ch&quot;)          print += &quot;... gCheckInpCol, &quot;;    // 체크박스
if (cellTypeArr[i] == &quot;co&quot;)          print += &quot;... gComboInpCol, items: 배열, &quot;; // 콤보박스
if (cellTypeArr[i] == &quot;dhxCalendar&quot;) print += &quot;... gCalenderInpCol, &quot;; // 날짜
if (cellTypeArr[i] == &quot;img&quot;)         print += &quot;... gSearchIconCol, &quot;;  // 검색 아이콘&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;dhtmlxGrid의 타입 문자열을 SBGrid의 컬럼 타입으로 대응시키는 컬럼 타입 매핑이 핵심!!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;함수가 어느 정도 완성되고 나서 &lt;b&gt;사용법도 단순하게 만들었다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;  gridConvert 함수 사용법&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;브라우저 개발자도구 &amp;rarr; 콘솔 탭 열기&lt;/li&gt;
&lt;li&gt;&lt;b&gt;그리드가 있는 프레임으로 컨텍스트 전환&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt; gridConvert(그리드이름) 입력&lt;/b&gt; (&lt;i&gt;e.g. gridConvert(MstGrid) 혹은 gridConvert(DtlGrid) 등)&lt;/i&gt;&lt;/li&gt;
&lt;li&gt;출력된 SBGrid v3 설정 코드를 복사해서 붙여넣기&lt;/li&gt;
&lt;li&gt;변수명, 이벤트 부분만 화면에 맞게 수정&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;콘솔에 gridConvert(MstGrid)를 입력하면 아래처럼 SBGrid v3 설정 코드가 그대로 출력된다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1331&quot; data-origin-height=&quot;294&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/w1qcz/dJMcagZAtkX/P3jmFq6HkumHcpvm68HA21/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/w1qcz/dJMcagZAtkX/P3jmFq6HkumHcpvm68HA21/img.png&quot; data-alt=&quot;실제 출력 화면 (초기 버전)&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/w1qcz/dJMcagZAtkX/P3jmFq6HkumHcpvm68HA21/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fw1qcz%2FdJMcagZAtkX%2FP3jmFq6HkumHcpvm68HA21%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;1331&quot; height=&quot;294&quot; data-origin-width=&quot;1331&quot; data-origin-height=&quot;294&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;실제 출력 화면 (초기 버전)&lt;/figcaption&gt;
&lt;/figure&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;기존 dhtmlxGrid가 살아있는 화면에서 그리드 객체를 그대로 콘솔에 넘기면, 변환된 SBGrid 설정이 출력된다. 복사해서 붙여넣고 주석 처리된 container, height와 이벤트 핸들러 내용만 화면에 맞게 채워넣으면 됐다. 컬럼이 20개짜리 화면도 순식간에 뼈대가 완성된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리드 하나를 손으로 옮기던 시간이 크게 줄었고&amp;nbsp;&lt;b&gt;특히 컬럼 수가 많은 화면일수록 효과가 컸다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기 버전에서 피드백을 받아 최종 버전으로 개선하면서 기능도 보강했다.&lt;/p&gt;
&lt;pre id=&quot;code_1777277789525&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;gridConvert 함수 개선 내역
──────────────────────────────────────────────────────
초기 버전   기본 컬럼 매핑만 지원
           var 키워드 사용 (ES5 스타일)

최종 버전   입력값 검증 추가 (null/undefined 체크)
           dataSource.schema.fields로 데이터 타입 명시
           filters, validators, 날짜 포맷 지원
           canCommand / doCommand 이벤트 핸들러 자동 생성
           let 키워드 사용 (ES6 스타일)
──────────────────────────────────────────────────────&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1777292392953&quot; class=&quot;javascript&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;// 최종 버전 콘솔 출력 결과 (예시)
let gridConfig = {
    ... gInsertGridObj,
    //container: '#gridArea',
    //height: '358px',
    alternateCss: 1,
    dataSource: {
        schema: {
            fields: {
                itemCd: { dataType: 'string' },
                itemNm: { dataType: 'string' },
                useYn:  { dataType: 'string' },
            }
        }
    },
    columns: [
        { ...gStringRoCol,  field: 'itemCd', caption: '코드',    visible: true, align: 'center', width: '100', },
        { ...gStringInpCol, field: 'itemNm', caption: '코드명',  visible: true, align: 'left',   width: '200', },
        { ...gComboInpCol,  field: 'useYn',  caption: '사용여부',visible: true, align: 'center', width: '100', items: 배열, },
    ],
    canCommand: { ... },
    doCommand:  { ... },
}
그리드객체 = SBGrid3.createGrid(gridConfig);&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;gridConvert 함수와 함께, 팀 전체가 공통으로 쓸 수 있는 &lt;b&gt;마이그레이션 작업 기본 원칙도 팀원들과 함께 문서화&lt;/b&gt;해서 배포했다.&lt;/p&gt;
&lt;pre id=&quot;code_1777277854782&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;마이그레이션 작업 순서 (팀 공유 문서로)
──────────────────────────────────────────────────────
1. 원본 파일 복사 후 파일명에 _old 붙이기 (예: sampleMenu.jsp &amp;rarr; sampleMenu_old.jsp)
2. include 대상 파일을 신규 헤더 파일로 변경
3. 기존 JS 코드 삭제 후 _old에서 하나씩 이식
4. gridConvert 함수로 SBGrid 그리드 설정 추출
5. dhtmlx 관련 코드는 주석이 아닌 완전 삭제
6. 공통 함수 추가는 js_new.js, 스타일은 css_new.css
──────────────────────────────────────────────────────&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;여기에 더해 팀에서 공통으로 쓸 파일에 자주 쓰는 함수들도 정리해뒀다. dhtmlxGrid에서 직접 호출하던 메서드들을 SBGrid 방식으로 래핑한 함수들이다. '변경된 행 목록 조회', '선택된 행 key 반환'처럼 &lt;b&gt;화면마다 반복적으로 쓰이는 패턴들을 함수로 묶어&lt;/b&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;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2차 해결&lt;/b&gt; &amp;mdash; 폐쇄망이 풀렸다, AI 프롬프트 배포&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한참 작업이 진행되던 중에 환경이 바뀌었다. &lt;b&gt;폐쇄망 제한이 풀리면서 AI를 쓸 수 있게 됐다!!!!!!!!&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;486&quot; data-origin-height=&quot;490&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/pZQsJ/dJMcahqHiYe/7nNA3TJg5A2uskH00Bj9K1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/pZQsJ/dJMcahqHiYe/7nNA3TJg5A2uskH00Bj9K1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/pZQsJ/dJMcahqHiYe/7nNA3TJg5A2uskH00Bj9K1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FpZQsJ%2FdJMcahqHiYe%2F7nNA3TJg5A2uskH00Bj9K1%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;260&quot; height=&quot;262&quot; data-origin-width=&quot;486&quot; data-origin-height=&quot;490&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;p data-ke-size=&quot;size16&quot;&gt;그런데 문제가 하나 있었다. 팀원 중에는 오랜 폐쇄망 개발 환경 탓에 ChatGPT나 Claude 같은 AI를 한 번도 써본 적 없는 분들도 계셨다. AI를 쓰더라도 어떻게 프롬프트를 써야 하는지, 어떻게 활용해야 하는지 자체가 낯선 상황이었다.&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;그래서 그냥 AI를 쓸 수 있게 됐다고 끝내는 게 아니라 &lt;b&gt;팀 전체가 바로 써먹을 수 있는 프롬프트를 직접 만들어서 txt 파일로 배포&lt;/b&gt;하기로 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞서 말했듯 2025년 4월 시점이었다. 지금처럼 AI가 코드베이스를 직접 읽고 자율적으로 전환해주는 환경이 아니라 채팅창에 코드를 붙여넣고 변환을 요청하는 방식이었다. (이 포스팅 쓰면서 AI 발전속도를 다시 체감하게 됨..) 그래도 gridConvert 함수가 컬럼 설정 변환을 자동화해줬다면, AI는 &lt;b&gt;함수 전체의 변환 흐름을 잡아주는 것&lt;/b&gt;까지 가능했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프롬프트의 핵심은 &lt;b&gt;두 라이브러리의 구조적 차이를 AI에게 충분히 설명해주는 것&lt;/b&gt;이었다. (AI가 dhtmlx 코드를 보고 SBGrid로 올바르게 변환하려면 두 라이브러리가 어떻게 다른지 맥락을 알아야 하니)&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;프롬프트 핵심 내용 (팀 배포용 txt 파일, 일부 발췌)&lt;/blockquote&gt;
&lt;pre id=&quot;code_1777278418880&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;  기본 전제
- 기존 시스템은 dhtmlXGrid 기반, 현재는 SBGrid3 기반으로 마이그레이션 중
- dhtmlX는 절차적 API 사용
- SBGrid는 컬럼 중심 구조, 함수 기반 이벤트 시스템, row/col 객체 중심 데이터 접근

  핵심 구조 차이
- row 접근:  cells(row, col)   &amp;rarr; getRowData(grid, rowKey)
- 이벤트:    onEditCell        &amp;rarr; doCommand.updated
- 컬럼 타입: cellType 배열     &amp;rarr; columns[].type

  그리드 이벤트 흐름
- canCommand &amp;rarr; 명령 전 검사 (edit, focus 등 차단 가능)
- doCommand  &amp;rarr; 명령 후 처리 (edit 완료 후, updated 후, loaded 후 후처리)
- edit 이벤트:   enter === true  &amp;rarr; 편집 시작 / false &amp;rarr; 편집 종료
- updated 이벤트: command.values[]로 변경된 필드 확인

  주요 변환 패턴
- onEditCell(stage==2) &amp;rarr; doCommand.edit (enter==false)
- onCheck              &amp;rarr; doCommand.updated (cNm == 'chk')
- getColIndexById      &amp;rarr; 공통 함수로 래핑

//..생략&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;AI가 변환 중에 막히는 케이스들을 하나씩 만날 때마다 프롬프트에 추가&lt;/b&gt;하면서 점점 두꺼워졌다  &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI에게 &quot;이 화면의 dhtmlx 코드를 SBGrid v3로 변환해줘&quot; 라고 하면 어느 정도 뼈대가 나왔고, gridConvert 함수로 컬럼 설정을 맞추면 실제 작업량이 크게 줄었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;폐쇄망 환경에서는 gridConvert 함수와 가이드 문서로, 환경이 풀린 후에는 AI 프롬프트를 더해서 두 단계로 도구를 발전시킨 셈이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;결과&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 기간은 2025년 4월부터 6월까지였다. 수백 개 화면을 팀이 함께 전환했다.&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;gridConvert 함수 하나가 없었다면 컬럼 수십 개짜리 그리드를 손으로 하나씩 옮겼을 것이다. 팀원들이 각자 다른 방식으로 변환하면서 코드 스타일이 제각각이 됐을 확률도 매우 크다고 생각한다.&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;자동화 도구와 가이드 문서 덕분 새로 합류하는 팀원도 원칙에 맞게 일관된 방식으로 작업할 수 있었고, 마이그레이션 완료 후 불필요한 dhtmlx 코드 제거와 리팩토링도 깔끔하게 마무리할 수 있었다.&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;&lt;b&gt;반복되는 패턴이 보이면 자동화부터 생각하자&quot;.&lt;/b&gt; &lt;u&gt;도구 만드는 데 시간이 좀 들더라도 수백 번 반복될 작업이라면 결국 그게 빠르다.&lt;/u&gt;&lt;/p&gt;</description>
      <category>Project/ERP 프로젝트</category>
      <category>dhtmlxGrid</category>
      <category>erp개발</category>
      <category>SBGrid</category>
      <category>그리드전환</category>
      <category>라이브러리마이그레이션</category>
      <category>레거시마이그레이션</category>
      <category>리팩토링</category>
      <category>자동화</category>
      <author>쉬지마 이굥진</author>
      <guid isPermaLink="true">https://developer-jinnie.tistory.com/129</guid>
      <comments>https://developer-jinnie.tistory.com/129#entry129comment</comments>
      <pubDate>Mon, 27 Apr 2026 20:38:50 +0900</pubDate>
    </item>
    <item>
      <title>SSE 기반 실시간 알림 구현기 : 기술 선택부터 프로덕션 안정화까지</title>
      <link>https://developer-jinnie.tistory.com/128</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;필자는 현재 스마트폰 역경매 플랫폼 bidr(비더)를 개발 중이다. (5월 런칭 목표)&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;bidr에서는 입찰 도착, 최저가 갱신, 계약 체결 등 실시간으로 사용자에게 전달해야 하는 이벤트가 많다. 구매자 입장에서는 내 견적에 새로운 입찰이 들어왔을 때 빠르게 인지해야 하고, 판매자 입장에서는 내가 제시한 입찰이 선택됐을 때 즉각적으로 알아야 이후 계약 흐름이 자연스럽게 이어진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 포스팅에서는 이 실시간 알림을 어떤 방식으로 구현할지 선택하는 과정부터, 실제 구현 후 프로덕션 환경에서 안정적으로 동작하도록 개선한 과정까지 정리해보려 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;기술적 의사결정 &amp;mdash; 폴링, WebSocket, SSE&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실시간 알림을 구현하는 방법은 크게 세 가지다. 폴링, 웹소켓, SSE.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;1. 폴링 (Polling)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트가 주기적으로 서버에 &quot;새 알림 있어요?&quot; 하고 요청을 보내는 방식이다.&lt;/p&gt;
&lt;pre id=&quot;code_1777015237621&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;클라이언트: 새 알림 있어요? (3초마다 반복)
서버: 없어요 / 있어요&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;835&quot; data-origin-height=&quot;598&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/0659m/dJMcacbN8OC/qLTlpvOa3HLLEpbvcl1X9k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/0659m/dJMcacbN8OC/qLTlpvOa3HLLEpbvcl1X9k/img.png&quot; data-alt=&quot;있어요?있어요?&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/0659m/dJMcacbN8OC/qLTlpvOa3HLLEpbvcl1X9k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F0659m%2FdJMcacbN8OC%2FqLTlpvOa3HLLEpbvcl1X9k%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;331&quot; height=&quot;237&quot; data-origin-width=&quot;835&quot; data-origin-height=&quot;598&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;있어요?있어요?&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구현이 가장 단순하지만 문제가 명확하다. 알림이 없어도 요청이 계속 발생하고, 폴링 간격만큼 알림이 늦게 도착한다. 사용자가 많아질수록 서버에 불필요한 요청이 폭발적으로 늘어난다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;2. WebSocket &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;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;bidr는 이미 채팅 기능에 WebSocket(STOMP)을 사용하고 있다. 그래서 원래 알림도 그냥 WebSocket으로 구현하려고 했었다. 하지만 고민 결과 결국 WebSocket은 채팅에만 사용하기로 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이유인 즉:&lt;/p&gt;
&lt;pre id=&quot;code_1777015576341&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;알림의 특성:
  서버 &amp;rarr; 클라이언트: 입찰 도착, 최저가 갱신, 계약 체결 ...
  클라이언트 &amp;rarr; 서버: (없음)&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;u&gt; 알림은 서버에서 클라이언트 방향으로만 흐른다.&lt;/u&gt; 양방향 통신이 필요 없는데 WebSocket을 쓰면 그만큼 리소스가 낭비된다. 거기에 더해 채팅 서버의 WebSocket 세션과 알림용 세션을 모두 관리해야 하고, 채팅 쪽에 장애가 나면 알림까지 같이 마비될 수 있는 결합도 문제도 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;3. SSE (Server-Sent Events)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버에서 클라이언트로만 데이터를 보내는 단방향 HTTP 기반 기술이다.&lt;/p&gt;
&lt;pre id=&quot;code_1777015697064&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;클라이언트: 연결 한 번 맺음 (GET /api/v1/notifications/stream)
서버: 이벤트 있을 때마다 밀어줌 &amp;rarr; 알림 도착!&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;우리 서비스에 웹소켓 대비 SSE가 더 적합하다고 판단한 이유는 아래 표와 같다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 126px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style13&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px; text-align: center;&quot;&gt;&lt;b&gt;항목&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px; text-align: center;&quot;&gt;&lt;b&gt;WebSocket&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px; text-align: center;&quot;&gt;&lt;b&gt;SSE&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px; text-align: center;&quot;&gt;통신 방향&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;양방향&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;단방향 (서버 &amp;rarr; 클라이언트)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px; text-align: center;&quot;&gt;프로토콜&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;ws://&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;HTTP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px; text-align: center;&quot;&gt;자동 재연결&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;직접 구현&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;브라우저 기본 지원&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px; text-align: center;&quot;&gt;서버 리소스&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;높음&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;낮음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px; text-align: center;&quot;&gt;구현 복잡도&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;높음&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;낮음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 bidr는 모바일 사용자가 많을 것으로 예상되는데, 지하철처럼 네트워크가 자주 끊기는 환경에서 SSE의 &lt;b&gt;자동 재연결&lt;/b&gt; 기능이 알림 유실을 줄여주는 큰 이점이 됐다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;838&quot; data-origin-height=&quot;387&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bCNkGM/dJMcahYu1If/xGOrowtM0ZLg3JmslGNY5K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bCNkGM/dJMcahYu1If/xGOrowtM0ZLg3JmslGNY5K/img.png&quot; data-alt=&quot;폴링 vs SSE vs 웹소켓&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bCNkGM/dJMcahYu1If/xGOrowtM0ZLg3JmslGNY5K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbCNkGM%2FdJMcahYu1If%2FxGOrowtM0ZLg3JmslGNY5K%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;721&quot; height=&quot;333&quot; data-origin-width=&quot;838&quot; data-origin-height=&quot;387&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;폴링 vs SSE vs 웹소켓&lt;/figcaption&gt;
&lt;/figure&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;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;기본 구현&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot에서 SSE를 구현하는 건 비교적 간단하다. 구현은 가볍게 언급하겠다.&lt;/p&gt;
&lt;pre id=&quot;code_1777015994919&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// SSE 연결 엔드포인트
@GetMapping(value = &quot;/stream&quot;, produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public ResponseEntity&amp;lt;SseEmitter&amp;gt; streamNotifications(
        @AuthenticationPrincipal UserDetailsImpl userDetails) {

    UUID userId = userDetails.getUser().getId();
    SseEmitter emitter = sseEmitterManager.createConnection(userId);

    return ResponseEntity.ok()
            .header(&quot;X-Accel-Buffering&quot;, &quot;no&quot;)  // Nginx 버퍼링 방지
            .header(&quot;Cache-Control&quot;, &quot;no-cache&quot;)
            .header(&quot;Connection&quot;, &quot;keep-alive&quot;)
            .body(emitter);
}&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;span style=&quot;background-color: #dddddd;&quot;&gt;/api/v1/notifications/stream&lt;/span&gt;에 GET 요청을 보내면 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;SseEmitter&lt;/span&gt;가 반환되고, 이후 서버는 이 emitter를 통해 이벤트를 밀어줄 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;알림을 보낼 때는 이렇게 한다.&lt;/p&gt;
&lt;pre id=&quot;code_1777016054229&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// SSE로 알림 발송
SseEmitter emitter = sseEmitterManager.getConnection(userId);

NotificationResponseDto dto = NotificationResponseDto.from(notification);
String jsonData = objectMapper.writeValueAsString(dto);

emitter.send(SseEmitter.event()
        .name(&quot;notification&quot;)
        .data(jsonData));&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;문제 1. 유령 연결이 쌓인다 &lt;/b&gt;&amp;mdash; Zombie Connection&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SSE의 특성상 클라이언트가 브라우저 탭을 닫거나 네트워크가 끊겨도 &lt;b&gt;서버는 이를 즉시 알 수 없다.&lt;/b&gt; 데이터를 보내려고 할 때 실패해야 비로소 연결이 끊겼다는 걸 알게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과적으로 이미 죽은 연결이 서버 메모리에 계속 남아있는 &lt;b&gt;유령 연결(a.k.a Zombie Connection)&lt;/b&gt; 문제가 생긴다.&lt;/p&gt;
&lt;pre id=&quot;code_1777123610165&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;시간 흐름
────────────────────────────────────────────────────
t=0    사용자 A 연결
t=1    사용자 B 연결
t=2    사용자 A 브라우저 닫음 (서버는 모름)
t=3    사용자 C 연결
...
t=100  연결 수 누적 &amp;rarr; 메모리 낭비  
────────────────────────────────────────────────────&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좀비 커넥션 문제를 어떻게 해결할 수 있을까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt; 해결 &amp;mdash; 주기적 Heartbeat &lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;u&gt;모든 활성 연결에 주기적으로 ping을&lt;/u&gt; 보내면 된다. 전송이 실패하면 그 연결은 죽은 것으로 판단하고 즉시 제거하는 Heartbeat 방식을 쓰면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 처음엔 이렇게 구현했다.&lt;/p&gt;
&lt;pre id=&quot;code_1777200124282&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 처음 구현 &amp;mdash; 연결마다 전용 스레드로 heartbeat
private void startHeartbeat(UUID userId, SseEmitter emitter) {
    new Thread(() -&amp;gt; {          // &amp;larr; 연결마다 새 Thread 생성 ⚠️
        try {
            long interval = sseEmitterManager.getHeartbeatInterval();
            while (sseEmitterManager.isConnected(userId)) {
                Thread.sleep(interval); // &amp;larr; 블로킹 대기
                emitter.send(SseEmitter.event()
                        .name(&quot;heartbeat&quot;)
                        .data(&quot;ping&quot;));
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }, &quot;sse-heartbeat-&quot; + userId).start();
}&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;i&gt; zombie connection&lt;/i&gt; 문제는 해결했지만, &lt;b&gt;이 구현 자체에 새로운 문제가 있었다.&lt;/b&gt; 기본 구현을 마친 후 PR을 올렸는데, CodeRabbit이 아래와 같은 문제를 짚어준 것.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;974&quot; data-origin-height=&quot;234&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/PKWii/dJMcaiwjJmu/wQSN5FKyRzRKmUfm5up3Yk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/PKWii/dJMcaiwjJmu/wQSN5FKyRzRKmUfm5up3Yk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/PKWii/dJMcaiwjJmu/wQSN5FKyRzRKmUfm5up3Yk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FPKWii%2FdJMcaiwjJmu%2FwQSN5FKyRzRKmUfm5up3Yk%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;677&quot; height=&quot;234&quot; data-origin-width=&quot;974&quot; data-origin-height=&quot;234&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&quot;현재 방식은 사용자 수가 늘면 heartbeat 스레드가 선형 증가합니다. TaskScheduler / ScheduledExecutorService 기반의 공유 스케줄러로 전환해 연결별 작업만 등록/해제하는 구조가 필요합니다.&quot;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조의 문제는 직관적이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;연결마다 &lt;span style=&quot;background-color: #ffc1c8;&quot;&gt;new Thread()&lt;/span&gt;를 만들고, 그 스레드는 &lt;span style=&quot;background-color: #ffc1c8;&quot;&gt;Thread.sleep()&lt;/span&gt;으로 블로킹 대기를 하면서 heartbeat 간격을 맞추는 구조였기 때문에 이런 리뷰를 받았다..!&lt;/p&gt;
&lt;pre id=&quot;code_1777200291092&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;연결 수 증가 시나리오
──────────────────────────────────────────────────────
연결 10개   &amp;rarr; &quot;sse-heartbeat-xxx&quot; 스레드 10개 (각각 sleep 중)
연결 100개  &amp;rarr; &quot;sse-heartbeat-xxx&quot; 스레드 100개  ⚠️
연결 1000개 &amp;rarr; &quot;sse-heartbeat-xxx&quot; 스레드 1000개   스레드 풀 고갈
──────────────────────────────────────────────────────&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;zombie connection 잡다가 스레드 폭증이라는 새로운 문제가 생겼다. 끄응&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;500&quot; data-origin-height=&quot;446&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bxG7k9/dJMcagL5sK3/lfMOaMrKiq96xYR7N1BLQ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bxG7k9/dJMcagL5sK3/lfMOaMrKiq96xYR7N1BLQ0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bxG7k9/dJMcagL5sK3/lfMOaMrKiq96xYR7N1BLQ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbxG7k9%2FdJMcagL5sK3%2FlfMOaMrKiq96xYR7N1BLQ0%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;243&quot; height=&quot;446&quot; data-origin-width=&quot;500&quot; data-origin-height=&quot;446&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;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;다음 해결&lt;/b&gt; &amp;mdash; 공유 스케줄러로 전체 연결 순회&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스레드 폭증 문제 해결의 핵심 아이디어는 단순하다. &lt;b&gt;연결별로 스케줄러를 만드는 게 아니라, 하나의 스케줄러가 주기적으로 모든 연결을 순회하며 heartbeat를 보내면 된다.&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1777200356325&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;개선 후
──────────────────────────────────────────────────────
공유 스케줄러 1개 &amp;rarr; 30초마다 전체 연결 순회 &amp;rarr; ping 전송
연결이 10개든 1000개든 스케줄러는 1개 ✅
Thread.sleep()으로 블로킹하는 스레드도 사라짐 ✅
──────────────────────────────────────────────────────&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;span style=&quot;background-color: #ffc1c8;&quot;&gt;@Scheduled&lt;/span&gt;를 활용해 단일 스케줄러가 전체 연결을 순회하도록 하면 될 것 같다고 생각했다.&lt;/p&gt;
&lt;pre id=&quot;code_1777123703105&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 개선 후 &amp;mdash; 공유 스케줄러로 전체 연결 순회
@Scheduled(fixedDelayString = &quot;${sse.heartbeat.interval:30000}&quot;, initialDelay = 30000)
public void sendHeartbeat() {
    if (!heartbeatEnabled) return;

    Iterator&amp;lt;Map.Entry&amp;lt;UUID, ConnectionInfo&amp;gt;&amp;gt; iterator = connections.entrySet().iterator();

    while (iterator.hasNext()) {
        Map.Entry&amp;lt;UUID, ConnectionInfo&amp;gt; entry = iterator.next();
        UUID userId = entry.getKey();
        ConnectionInfo info = entry.getValue();

        try {
            info.emitter.send(SseEmitter.event()
                    .name(&quot;ping&quot;)
                    .data(&quot;heartbeat&quot;));
        } catch (IOException e) {
            // 전송 실패 = 죽은 연결 &amp;rarr; 즉시 제거
            log.warn(&quot;SSE Heartbeat 전송 실패, 연결 제거: userId={}&quot;, userId);
            info.emitter.completeWithError(e);
            iterator.remove();
        }
    }
}&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;30초마다 ping을 보내고, 실패한 연결은 그 자리에서 제거한다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt; 왜 &lt;i&gt;forEach &lt;/i&gt;가 아닌 &lt;i&gt;Iterator&lt;/i&gt;&lt;/b&gt; 를 썼음?&lt;br /&gt;ConcurrentHashMap의 forEach는 순회 중에 요소를 삭제해도 예외가 발생하지는 않는다. 하지만 forEach 람다 내부에서 &lt;br /&gt;map.remove(key)를 직접 호출하면, 순회 로직과 삭제 로직이 겉돌아 일관성 없는 동작이 발생할 수 있다. &lt;br /&gt;&lt;br /&gt;반면 Iterator의 remove()를 사용하면 현재 순회 중인 요소를 컨테이너에서 안전하게 제거 할 수 있어, 순회 중 삭제가 필요한 로직에서는 이 방식이 가장 확실하고 정석적인 방법이라고 판단했다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Cleanup 스케줄러도 함께&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Heartbeat만으로는 부족하다. Heartbeat는 ping 전송 실패를 감지해서 죽은 연결을 제거하는 능동적 감지 방식이다. 그런데 &lt;u&gt;콜백이 어떤 이유로 누락되면 타임아웃이 지났는데도 연결이 남아있을 수 있다&lt;/u&gt;. 이를 대비해 주기적으로 생성 시간을 체크해서 타임아웃된 연결을 강제 정리하는 Cleanup 스케줄러도 함께 구현했다.&lt;/p&gt;
&lt;pre id=&quot;code_1777123870719&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Scheduled(fixedDelayString = &quot;${sse.cleanup.interval:600000}&quot;, initialDelay = 600000)
public void cleanupStaleConnections() {
    Instant now = Instant.now();
    long timeoutMillis = sseTimeout.toMillis();

    Iterator&amp;lt;Map.Entry&amp;lt;UUID, ConnectionInfo&amp;gt;&amp;gt; iterator = connections.entrySet().iterator();
    while (iterator.hasNext()) {
        Map.Entry&amp;lt;UUID, ConnectionInfo&amp;gt; entry = iterator.next();
        long elapsedMillis = Duration.between(entry.getValue().createdAt, now).toMillis();

        if (elapsedMillis &amp;gt; timeoutMillis) {
            entry.getValue().emitter.complete();
            iterator.remove();
        }
    }
}&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;Heartbeat가 &lt;b&gt;능동적 감지&lt;/b&gt;라면, Cleanup은 &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;style1&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt; 문제 2. 연결 직후 Nginx가 자꾸 503을 뱉어요...&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 배포했을 때 이상한 현상이 있었다. 로컬에서는 잘 되는데 Nginx를 거치면 연결 직후 503 에러가 간헐적으로 났다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;처음엔 설정 문제인가 싶어 Nginx 설정을 들여다봤는데,, 원인은 Nginx의 동작 방식 자체에 있었음&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Nginx는 기본적으로 서버 응답을 버퍼에 쌓아두었다가 한꺼번에 클라이언트에 전달하려고 한다. SSE는 데이터를 실시간으로 조금씩 계속 보내야 하는데, &lt;b&gt;연결 직후 아무 데이터도 없으면 Nginx 입장에서는 '응답이 없네'하고 타임아웃으로 판단해버려서 503을 반환하는 것이었다.&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1777123955108&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;연결 직후 데이터 없음 시나리오:
──────────────────────────────────────────────────────
클라이언트 &amp;rarr; Nginx &amp;rarr; Spring: SSE 연결 요청
Spring: SseEmitter 생성 완료, 대기 중...
Nginx: &quot;응답이 없네? 타임아웃!&quot; &amp;rarr; 503 반환  
클라이언트: 연결 실패 &amp;rarr; 자동 재연결 시도 &amp;rarr; 또 503...
──────────────────────────────────────────────────────&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;SSE 특성상 처음 연결하고 나서 실제 알림이 오기 전까지는 서버에서 아무것도 안 보내는 게 자연스럽다. 그런데 그 자연스러운 공백이 인프라 계층에서 문제가 됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;해결 1&amp;nbsp;&lt;/b&gt; &amp;mdash; 연결 직후 즉시 Handshake 이벤트 전송&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;연결이 됐으면 즉시 뭔가를 보내서 Nginx에게 나 살아있어!!!를 증명하자.&lt;/p&gt;
&lt;pre id=&quot;code_1777124017693&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public SseEmitter createConnection(UUID userId) {
    SseEmitter emitter = new SseEmitter(sseTimeout.toMillis());

    // ... 콜백 등록 ...

    connections.compute(userId, (key, existing) -&amp;gt; {
        if (existing != null) existing.emitter.complete();
        return new ConnectionInfo(emitter);
    });

    // ✅ 연결 직후 즉시 확인 이벤트 전송 (Handshake)
    try {
        emitter.send(SseEmitter.event()
                .name(&quot;connected&quot;)
                .data(&quot;SSE 연결이 성공적으로 확립되었습니다.&quot;));
    } catch (IOException e) {
        // Handshake 실패 시 즉시 연결 제거 + 재연결 유도
        removeConnection(userId);
        throw new RuntimeException(&quot;SSE 연결 확립에 실패했습니다. 재연결을 시도해주세요.&quot;, e);
    }

    return emitter;
}&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;이 Handshake 이벤트는 두 가지 역할을 한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;nginx에게 '응답 왔어, 타임아웃 아님' 신호 전달 &amp;rarr; &lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;503 방지&lt;/b&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;클라이언트에게 '연결 성공' 즉시 확인 &amp;rarr; &lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt;UX 개선&lt;/b&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트 입장에서도 나쁘지 않다. 연결 요청을 보내고 나서 connected 이벤트가 오면 '아, 연결됐구나'를 즉시 확인할 수 있으니까.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;해결 2&lt;/b&gt; &amp;mdash; Nginx 설정 맞춤 조정&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Handshake만으론 부족하다. Nginx가 SSE 트래픽을 제대로 처리하려면 설정도 맞춰줘야 한다.&lt;/p&gt;
&lt;pre id=&quot;code_1777124033565&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;location = /api/notifications/stream {
    proxy_pass http://backend:8080/api/notifications/stream;
    proxy_http_version 1.1;

    proxy_buffering off;           # SSE 핵심: 버퍼링 비활성화
    proxy_cache off;
    add_header X-Accel-Buffering no;

    proxy_read_timeout 1800s;      # 30분 연결 유지
    proxy_send_timeout 1800s;
    proxy_set_header Connection ''; # keep-alive
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설정해둔 게 많은데 이런 설정들을 왜 맞추었냐면:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt; proxy_buffering off&lt;/b&gt;&lt;/span&gt; : 데이터를 버퍼에 쌓지 말고 바로 전달 (이게 없으면 SSE 메시지가 실시간 전달 안 됨)&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt; X-Accel-Buffering no&lt;/b&gt;&lt;/span&gt; : 일부 Nginx 설정에서 추가로 버퍼링 비활성화&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt; proxy_read_timeout 1800s&lt;/b&gt;&lt;/span&gt; : 30분 동안 데이터 없어도 연결 유지 (기본값 60초면 heartbeat 보내기도 전에 끊김)&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;&lt;b&gt; Connection ' '&lt;/b&gt;&lt;/span&gt; : HTTP/1.1 keep-alive 활성화&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 proxy_read_timeout 기본값이 60초라는 걸 주의하면 좋을 것 같다. Heartbeat를 30초마다 보내도록 설정해뒀는데, 타임아웃이 60초면 간발의 차로 끊기는 상황이 생길 수 있다. SSE 연결 타임아웃(30분)과 맞춰서 넉넉하게 1800초로 설정해줬다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt; 문제 3. 동시 재연결 시 race condition 문제 &amp;mdash; 원자적 연산&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 탭을 새로고침하거나 네트워크가 불안정해서 SSE가 끊기면 브라우저는 자동으로 재연결을 시도한다. 이건 SSE의 기본 동작이라 좋은 기능이지만, &lt;b&gt;기존 연결과 새 연결이 거의 동시에 처리되는 상황&lt;/b&gt;이 생길 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 구현 시의 코드를 보면 문제가 보인다.&lt;/p&gt;
&lt;pre id=&quot;code_1777124073812&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 개선 전 &amp;mdash; race condition 발생 가능
SseEmitter existing = emitters.remove(userId); 	// &amp;larr; (A) 기존 연결 제거
if (existing != null) {
    existing.complete();             
}
emitters.put(userId, newEmitter);   		  // &amp;larr; (B) 새 연결 등록
// (A)와 (B) 사이에 다른 스레드가 끼어들 수 있음 ⚠️&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;b&gt;remove()와 put() 사이의 미세한 시간차&lt;/b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;에 다른 스레드가 끼어들면&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;연결이 누락&lt;/b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;되거나&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;중복 저장&lt;/b&gt;될 수 있었다.&lt;/p&gt;
&lt;pre id=&quot;code_1777204810613&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Race condition 시나리오
──────────────────────────────────────────────────────
Thread 1: remove(userId) 완료 &amp;rarr; 기존 연결 제거됨
Thread 2: remove(userId) 시도 &amp;rarr; 이미 없음 (null)
Thread 1: put(userId, emitter1) 등록
Thread 2: put(userId, emitter2) 등록  &amp;larr; emitter1 덮어씌워짐!  

결과: emitter1은 map에서 사라졌지만 complete() 미호출
      &amp;rarr; 클라이언트에게 종료 신호 없이 연결이 공중에 붕 뜸
──────────────────────────────────────────────────────&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;&quot;재현 안 되는데 간헐적으로 알림이 안 온다&quot;는 유형의 버그&lt;/b&gt;가 (뭔지아시죠) 바로 이것임!&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt; 해결 &amp;mdash; ConcurrentHashMap.compute()로 원자적 처리&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffc1c8;&quot;&gt; ConcurrentHashMap.compute()&lt;/span&gt;는 키에 대한 연산 전체를 &lt;b&gt;단일 원자적 연산&lt;/b&gt;으로 처리해서 다른 스레드가 중간에 끼어들 수 없다.&lt;/p&gt;
&lt;pre id=&quot;code_1777124107996&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 개선 후 &amp;mdash; 원자적 연산
connections.compute(userId, (key, existing) -&amp;gt; {
    if (existing != null) {
        existing.emitter.complete(); // 기존 연결 종료
    }
    return new ConnectionInfo(emitter); // 새 연결 등록
    // 제거와 등록이 단일 원자적 연산으로 처리됨 ✅
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드 리팩토링 후 아까와 동일 시나리오로 상황을 비교해보자.&lt;/p&gt;
&lt;pre id=&quot;code_1777205226710&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;개선 후 동일 시나리오
──────────────────────────────────────────────────────
Thread 1: compute(userId, ...) 진입 &amp;rarr; 해당 사용자에 대한 원자적 연산 시작
Thread 2: compute(userId, ...) 진입 시도 &amp;rarr; Thread 1이 끝날 때까지 대기
Thread 1: 기존 연결 종료 + 새 연결 등록 완료 &amp;rarr; 연결 갱신 완료
Thread 2: Thread 1이 등록한 연결 종료 + 자신의 연결 최종 등록

결과: 어떤 순서로 오든 마지막 연결만 남고, 이전 연결은 반드시 정리됨 ✅
──────────────────────────────────────────────────────&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;  &lt;b&gt;&lt;span style=&quot;background-color: #ffc1c8;&quot;&gt;ConcurrentHashMap&lt;/span&gt;이 스레드 안전한데 왜 race condition이 생길까?&lt;/b&gt;&lt;br /&gt;ConcurrentHashMap 자체는 put()과 remove() 각각에 대해 thread-safe하다. 하지만 'remove 한 다음에 put'이라는 복합 연산에 대해서는 thread-safe를 보장하지 않는다. compute()를 쓰면 이 복합 연산 전체를 하나의 원자적 연산으로 묶을 수 있다.&lt;br /&gt;&lt;br /&gt;이 개선 덕분에 사용자가 탭을 빠르게 새로고침하거나 네트워크가 불안정한 환경에서도 연결이 꼬이지 않고 항상 정확히 하나의 연결만 유지된다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(tmi: ConcurrentHashMap을 전에 자바 병렬 프로그래밍 책 보면서 스터디로 경험했었었는데 이렇게 써먹을 수 있게 되어서 너무 기뻤다!)&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt; 문제 4. 서버 재시작 시 연결이 정리되지 않는다 &amp;mdash; Graceful Shutdown&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드래빗의 두 번째 지적은 이랬다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;978&quot; data-origin-height=&quot;240&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/STB95/dJMcaiC51vF/71SPQ6pKzqP1F3LZFdy86k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/STB95/dJMcaiC51vF/71SPQ6pKzqP1F3LZFdy86k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/STB95/dJMcaiC51vF/71SPQ6pKzqP1F3LZFdy86k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FSTB95%2FdJMcaiC51vF%2F71SPQ6pKzqP1F3LZFdy86k%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;648&quot; height=&quot;159&quot; data-origin-width=&quot;978&quot; data-origin-height=&quot;240&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&quot;현재는 &lt;i&gt;IOException&lt;/i&gt;만 연결을 정리하고, 그 외 실패는 &lt;i&gt;false &lt;/i&gt;만 반환합니다. &lt;br /&gt;&lt;i&gt;IllegalStateException&lt;/i&gt; 같은 종료성 예외를 분리해 &lt;i&gt;removeConnection()&lt;/i&gt; 을 호출해 주세요.&quot;&lt;/blockquote&gt;
&lt;pre id=&quot;code_1777202103662&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 개선 전 &amp;mdash; Exception으로 광범위하게 잡고 removeConnection() 미호출
} catch (Exception e) {
    log.error(&quot;SSE 알림 발송 중 예상치 못한 에러: userId={}, notificationId={}&quot;,
             userId, notification.getId(), e);
    return false;
    // &amp;larr; removeConnection() 호출 없음!
    // IllegalStateException 발생 시 죽은 연결이 map에 그대로 남음 ⚠️
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제가 된 코드는 이런 코드였는데.. Exception으로 광범위하게 잡으면서 removeConnection()을 호출하지 않는 구조였다. SSE 연결이 끊겼을 때 발생하는 예외는 IOException만이 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;✔️ SSE 연결 종료 시 발생 가능한 예외들&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;IOException&lt;/b&gt; : 네트워크 I/O 오류 (연결 끊김, 전송 실패)&lt;/li&gt;
&lt;li&gt;&lt;b&gt; IllegalStateException&lt;/b&gt; : emitter가 이미 완료/만료된 상태에서 send() 호출&lt;/li&gt;
&lt;li&gt;&lt;b&gt; RuntimeException&lt;/b&gt; : 그 외 예상치 못한 런타임 오류&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;IllegalStateException은 emitter가 이미 완료되거나 만료된 상태에서 send()를 호출할 때 발생한다. 이걸 잡지 않으면 이미 죽은 연결인데 removeConnection()이 호출되지 않아 connections 맵에 계속 남아있게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 예외를 추가로 잡는 게 아니라, &lt;b&gt;예외 성격에 따라 처리 방식을 다르게&lt;/b&gt; 해야 한다는 게 핵심이다.&lt;/p&gt;
&lt;pre id=&quot;code_1777202249640&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;예외 성격별 처리 방식
──────────────────────────────────────────────────────
IOException           &amp;rarr; 종료성 예외   &amp;rarr; removeConnection() 후 조용히 처리
IllegalStateException &amp;rarr; 종료성 예외   &amp;rarr; removeConnection() 후 조용히 처리
RuntimeException      &amp;rarr; 비종료성 예외 &amp;rarr; removeConnection() 후 예외 재던짐
──────────────────────────────────────────────────────&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1777202283466&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 개선 후 &amp;mdash; 예외 성격별 분리 처리
} catch (IllegalStateException e) {
    log.error(&quot;SSE 연결 상태 에러: userId={}, notificationId={}&quot;,
             userId, notification.getId(), e);
    sseEmitterManager.removeConnection(userId);
    return false;
} catch (IOException e) {
    log.error(&quot;SSE 알림 발송 실패 (I/O): userId={}, notificationId={}&quot;,
             userId, notification.getId(), e);
    sseEmitterManager.removeConnection(userId);
    return false;
} catch (RuntimeException e) {
    log.error(&quot;SSE 알림 발송 중 런타임 에러: userId={}, notificationId={}&quot;,
             userId, notification.getId(), e);
    sseEmitterManager.removeConnection(userId);
    throw e; // 상위에서 인지할 수 있도록
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이거 고치면서 깨달은 한 가지 원칙&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;어떤 상황에서도 끊긴 연결은 반드시 정리되어야 한다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 한가지 생각이 더 들었다. 코드리뷰를 통해 예외 발생 시 removeConnection()으로 커넥션을 제거하는 것 까지는 보장했다. 근데 서버가 종료될 때는? &lt;b&gt;서버가 재시작될 때 열려있는 SSE 연결들은 어떻게 되는거지?&lt;/b&gt; 아무 처리도 없으면 클라이언트 쪽에서는 연결이 살아있다고 생각할텐데 서버는 이미 꺼진 상태가 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;해결&lt;/b&gt; &lt;span style=&quot;letter-spacing: 0px;&quot;&gt;&amp;mdash; &lt;i&gt;@PreDestroy &lt;/i&gt;로 Graceful Shutdown&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다시 정리하면! 서버가 재시작될 때 기존 SSE 연결들이 정리되지 않으면 클라이언트 쪽에서 연결이 끊겼는지 모르는 상태가 된다. 예외 처리에서 세운 원칙을 서버 종료 상황에도 그대로 적용해서, &lt;span style=&quot;background-color: #ffc1c8;&quot;&gt;@PreDestroy&lt;/span&gt;로 서버 종료 시 모든 연결을 명시적으로 닫도록 했다.&lt;/p&gt;
&lt;pre id=&quot;code_1777124155104&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@PreDestroy
public void shutdown() {
    log.info(&quot;SSE Emitter Manager 종료 시작: 총 {}개 연결 정리&quot;, connections.size());
    connections.forEach((userId, info) -&amp;gt; {
        try {
            info.emitter.complete();
        } catch (Exception e) {
            log.warn(&quot;SSE 연결 종료 중 에러: userId={}&quot;, userId, e);
        }
    });
    connections.clear();
    log.info(&quot;SSE Emitter Manager 종료 완료&quot;);
}&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;span style=&quot;background-color: #dddddd;&quot;&gt;removeConnection()&lt;/span&gt;이 반드시 실행되도록 모든 콜백을 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;try-finally&lt;/span&gt;로 감쌌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1777124172939&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;emitter.onTimeout(() -&amp;gt; {
    try {
        log.debug(&quot;SSE 연결 타임아웃: userId={}&quot;, userId);
    } finally {
        removeConnection(userId); // 예외 발생해도 반드시 실행
    }
});&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;코드래빗의 지적이 단순히 &quot;이 예외도 잡아라&quot;가 아니라, '&lt;b&gt;어떤 상황에서든 끊긴 연결은 정리되어야 한다'는 설계 원칙을 다시 생각하게 만든 계기&lt;/b&gt;가 됐고 그 원칙이 Graceful Shutdown까지 자연스럽게 이어진 셈이 되었다. Graceful Shutdown이라는 단어는 어깨너머로 많이 들어봤던 단어였는데 이렇게 쓰이는 거라고 알게 되어서 좋았고 개발 시야가 이렇게 또 넓어졌다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;최종 구조 요약&lt;/b&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1777124193776&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SseEmitterManager 구성 요소
──────────────────────────────────────────────────────────────
ConcurrentHashMap&amp;lt;UUID, ConnectionInfo&amp;gt;
    │
    ├── createConnection()     연결 생성 + Handshake 이벤트 전송
    ├── getConnection()        연결 조회 (알림 발송 시 사용)
    ├── removeConnection()     연결 제거
    │
    ├── sendHeartbeat()        [30초마다] 유령 연결 감지 및 제거
    ├── cleanupStaleConnections() [10분마다] 타임아웃 연결 강제 정리
    └── shutdown()             [서버 종료 시] 모든 연결 안전하게 닫음
──────────────────────────────────────────────────────────────&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 기능에 문제가 좀 많았는데, (ㅎㅎ..) 각 개선 사항이 해결하는 문제를 정리하면 이렇다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 93px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style13&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px; text-align: center;&quot;&gt;&lt;b&gt;문제&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px; text-align: center;&quot;&gt;&lt;b&gt;해결책&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px; text-align: center;&quot;&gt;&lt;b&gt;효과&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px; text-align: center;&quot;&gt;유령 연결 누적&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;Heartbeat + Cleanup 스케줄러&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;메모리 누수 방지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px; text-align: center;&quot;&gt;Nginx 503/504 에러&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;연결 직후 Handshake 이벤트 전송&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;인프라 타임아웃 방지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px; text-align: center;&quot;&gt;동시 재연결 race condition&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt; compute()&lt;/span&gt; 원자적 연산&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;연결 누락/중복 방지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px; text-align: center;&quot;&gt;서버 재시작 시 연결 미정리&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt; @PreDestroy&lt;/span&gt; Graceful Shutdown&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;안전한 리소스 정리&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;마무리&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 SSE 연결 하나 만드는 게 뭐가 어렵겠어 했는데, 막상 프로덕션을 고려하니 신경 써야 할 게 생각보다 많았다.&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;SSE 자체는 구현이 단순한 편이다. 컨트롤러에 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;SseEmitter&lt;/span&gt; 반환하고, 알림 발송 시 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;emitter.send()&lt;/span&gt; 호출하면 기본 동작은 된다. 하지만 &lt;b&gt;'동작하는 것'과 '안정적으로 동작하는 것'은 다르다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유령 연결은 메모리를 조용히 갉아먹고, Nginx는 아무 데이터도 없다고 연결을 끊어버리고, 동시 재연결은 타이밍이 맞으면 연결을 날려버린다고 하고, 서버 재시작은 클라이언트를 계속 기다리게 만들고,, 각각 따로 보면 별것 아닌 것 같은데 하나라도 빠지면 '간헐적으로 알림이 안 온다'는 원인 파악도 어려운 버그가 된다는 거.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 네 가지를 챙기고 나서야 비로소 프로덕션에 올릴 수 있겠다는 확신이 생겼다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개인적으로 이번 구현에서 가장 인상 깊었던 건 &lt;b&gt;CodeRabbit의 리뷰가 단순히 '이렇게 고쳐라'가 아니라 더 넓은 시야를 열어줬다는 것&lt;/b&gt;이다. 예외 처리 지적 하나가 '끊긴 연결은 반드시 정리되어야 한다'는 원칙으로 이어졌고 그 원칙이 자연스럽게 Graceful Shutdown까지 연결됐다. Heartbeat 스레드 지적도 단순한 리소스 절약 차원을 넘어 공유 스케줄러 패턴을 제대로 이해하는 계기가 됐다.&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;특히 Heartbeat와 Cleanup을 함께 쓰는 이중 안전장치, 그리고 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;compute()&lt;/span&gt;로 원자적 연산을 보장하는 패턴은 SSE뿐 아니라 비슷한 커넥션 풀을 직접 관리하는 상황에서도 그대로 적용할 수 있는 방법이라고 생각한다  &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;&lt;a href=&quot;https://medium.com/@priyasrivastava18official/long-polling-vs-sse-vs-websocket-d8e474940feb&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://medium.com/@priyasrivastava18official/long-polling-vs-sse-vs-websocket-d8e474940feb&lt;/a&gt;&lt;/p&gt;</description>
      <category>Project/phonebid</category>
      <category>gracefulshutdown</category>
      <category>nginx</category>
      <category>SeverSentEvents</category>
      <category>SSE</category>
      <category>WebSocket비교</category>
      <category>실시간알림</category>
      <author>쉬지마 이굥진</author>
      <guid isPermaLink="true">https://developer-jinnie.tistory.com/128</guid>
      <comments>https://developer-jinnie.tistory.com/128#entry128comment</comments>
      <pubDate>Sun, 26 Apr 2026 21:52:53 +0900</pubDate>
    </item>
    <item>
      <title>[리팩토링] 알림 발송 로직을 동기에서 이벤트 기반 비동기로 개선하기 (feat. @TransactionalEventListener)</title>
      <link>https://developer-jinnie.tistory.com/127</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;필자는 현재 스마트폰 역경매 플랫폼 bidr(비더)를 개발 중이다. (5월 런칭 목표)&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1528&quot; data-origin-height=&quot;701&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kGaVE/dJMcadaCVZQ/tLq1RvNGl1DIxqjRYSaTsK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kGaVE/dJMcadaCVZQ/tLq1RvNGl1DIxqjRYSaTsK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kGaVE/dJMcadaCVZQ/tLq1RvNGl1DIxqjRYSaTsK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkGaVE%2FdJMcadaCVZQ%2FtLq1RvNGl1DIxqjRYSaTsK%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;703&quot; height=&quot;701&quot; data-origin-width=&quot;1528&quot; data-origin-height=&quot;701&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;p data-ke-size=&quot;size16&quot;&gt;bidr는 구매자가 원하는 스마트폰 사양과 희망 가격 등의 견적을 올리면, 판매자들이 역으로 입찰을 넣는 구조다. 구매자 입장에선 여러 판매자의 견적을 한 번에 받아볼 수 있고, 판매자 입장에선 구매 의사가 확실한 고객에게 직접 제안할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조에서 &lt;b&gt;알림&lt;/b&gt;은 꽤 중요한 역할을 한다. 구매자가 견적을 등록하면 승인된 판매자 전원에게 &lt;b&gt;카카오 알림톡&lt;/b&gt;으로 새 견적이 올라왔다는 알림이 발송되고, 구매자가 마음에 드는 입찰을 선택해 &lt;b&gt;계약이 체결되면 구매자와 판매자 양쪽 모두에게&lt;/b&gt; 알림이 발송된다. 판매자가 빠르게 인지하고 입찰에 참여해야 서비스가 돌아가고, 계약 체결 순간에도 양쪽이 즉각적으로 인지해야 이후 결제와 배송 흐름이 자연스럽게 이어지기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 포스팅에서는 알림 기능을 구현하면서 동기 방식으로 짜여진 코드 때문에 겪었던 문제와 해결한 방식에 대해 써보려한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;s&gt;(스압주의.. 꽤나 긴 여정이었습니다,, )&lt;/s&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;개요&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;알림 발송 기능을 구현하고 직접 테스트해보는데, 견적 등록 요청 후 응답이 생각보다 오래 걸린다는 걸 느꼈다. 이상하다 싶어서 코드를 다시 들여다봤더니, &lt;b&gt;&lt;i&gt;카카오 알림톡 발송이 트랜잭션 안에 동기적으로 묶여 있었다&lt;/i&gt;.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;당시 구조를 그려보면 이렇다.&lt;/p&gt;
&lt;pre id=&quot;code_1776928310905&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;[클라이언트 요청]
       │
       ▼
┌──────────────────────────────────────────────────┐
│                  트랜잭션 시작                    │
├──────────────────────────────────────────────────┤
│  1. 견적 등록 DB 저장                  (약 10ms) │
│  2. 카카오 API &amp;rarr; 판매자 #1 알림      (약 500ms) &amp;larr; 대기 │
│  3. 카카오 API &amp;rarr; 판매자 #2 알림      (약 500ms) &amp;larr; 대기 │
│  4. 카카오 API &amp;rarr; 판매자 #3 알림      (약 500ms) &amp;larr; 대기 │
│  ...                                             │
│  N. 카카오 API &amp;rarr; 판매자 #N 알림      (약 500ms) &amp;larr; 대기 │
├──────────────────────────────────────────────────┤
│                  트랜잭션 커밋     (N &amp;times; 500ms 후) │
└──────────────────────────────────────────────────┘
       │
       ▼
[클라이언트 응답] &amp;larr; 한참 후에야 수신&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;수신 대상마다 외부 API 응답을 순차적으로 기다리는 구조였으니, 응답이 느릴 수밖에 없었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 두 가지였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1️⃣첫째, &lt;b&gt;이 구조는 수신 대상 수에 선형으로 비례한다.&lt;/b&gt; 지금은 테스트 계정 몇 개로만 돌리고 있지만, 실제 서비스라면 판매자가 수백 명도 될 수 있다. 판매자가 100명이고 알림 발송이 평균 500ms라면 &amp;mdash; 트랜잭션이 &lt;b&gt;50초 동안&lt;/b&gt; 열려 있게 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1776928348182&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;판매자 수     트랜잭션 점유 시간    DB 커넥션 점유
──────────────────────────────────────────────
10명      &amp;rarr;   5초                5초 동안 점유
50명      &amp;rarr;   25초               25초 동안 점유
100명     &amp;rarr;   50초               50초 동안 점유  ⚠️
500명     &amp;rarr;   250초              250초 동안 점유  &lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;그 시간 동안 DB 커넥션은 반환되지 않고, 동시 요청이 몰리면 커넥션 풀이 고갈되어 서비스 전체가 멈출 수 있는 구조였다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;2️⃣둘째, &lt;b&gt;외부 API 장애가 핵심 비즈니스 로직까지 영향을 줄 수 있다.&lt;/b&gt; 카카오 API가 다운되면 알림 발송이 실패하고, 그 실패가 트랜잭션 안에 있으니 거래의 핵심 순간인 &lt;b&gt;계약 체결 자체가 롤백될 수 있는&lt;/b&gt; 구조였다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;알림은 부가 기능인데, 부가 기능의 실패가 핵심 기능을 망가뜨리는 셈이었다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;문제 상황&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 코드는 이렇게 생겼다.&lt;/p&gt;
&lt;pre id=&quot;code_1776928490991&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 변경 전
@Transactional
public void createAndSendNotification(User user, NotificationType type,
                                     List&amp;lt;NotificationChannel&amp;gt; channels, UUID referenceId) {
    for (NotificationChannel channel : channels) {
        Notification notification = notificationFactory.createNotification(...);
        Notification savedNotification = notificationRepository.save(notification);

        // ❌ 트랜잭션 내부에서 카카오 API 동기 호출
        sendNotification(savedNotification);
    }
}

private void sendNotification(Notification notification) {
    NotificationSender sender = findSender(notification.getChannel());
    sender.send(notification); // 외부 API 호출 &amp;mdash; 500ms~3000ms
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;얼핏 보면 문제없어 보이지만, 실제로 어떤 일이 일어나는지 그려보면 이렇다.&lt;/p&gt;
&lt;pre id=&quot;code_1776928521207&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;[트랜잭션 시작]
  ├─ DB 저장 (10ms)
  ├─ 카카오 API #1 호출 (500ms) &amp;larr; 대기
  ├─ 카카오 API #2 호출 (500ms) &amp;larr; 대기
  ├─ ...
  └─ 카카오 API #100 호출 (500ms) &amp;larr; 대기
[트랜잭션 커밋] &amp;larr; 50초 후&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;span style=&quot;color: #000000;&quot;&gt;구매자가 견적을 등록하면 승인된 판매자 전원에게 알림을 발송해야 한다. 판매자가 100명이고 알림 발송이 평균 500ms라면, &lt;b&gt;트랜잭션이 50초 동안 열려 있게 된다.&lt;/b&gt; &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;이건 세 가지 문제를 동시에 발생시킨다. &lt;b&gt;&lt;/b&gt;&lt;/span&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;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;DB 커넥션 점유 문제&lt;/span&gt;:&lt;/b&gt; 트랜잭션이 살아있는 동안 DB 커넥션은 반환되지 않는다. 판매자가 100명이면 50초, 동시 요청이 10개면 커넥션 풀이 순식간에 고갈된다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt; &lt;b&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;응답 지연 문제&lt;/span&gt;:&lt;/b&gt; 사용자는 견적 등록 버튼을 눌렀는데 50초 동안 로딩만 보게 된다. 이탈률이 올라가고, 인내심 없는 사용자는 중복 클릭을 시도할 수도 있다. &lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt; &lt;b&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;장애 전파 문제&lt;/span&gt;:&lt;/b&gt; 카카오 API가 다운되면 알림 발송이 실패하고, 그 실패가 트랜잭션 안에 있으니 핵심 비즈니스 로직인 &lt;b&gt;견적 등록까지 영향을 받을 수 있는 구조&lt;/b&gt;였다.&lt;/span&gt;&lt;/li&gt;
&lt;/ol&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;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;해결 방향&lt;/b&gt; - 단순히 비동기로 분리하면 될까?&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt; 처음엔 &lt;b&gt;@Async &lt;/b&gt;로 발송 메서드만 감싸면 되는 거 아닌가? 하는 생각을 했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;당연히 안된다. (^-^)&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt; @Async만 쓰면 &lt;b&gt;트랜잭션 커밋 전에 알림이 발송&lt;/b&gt;될 수 있다. 이말인 즉 DB 저장이 아직 커밋되지 않은 상태에서 알림이 먼저 나가버린다는 말이다. (= 수신자가 알림을 보고 앱을 열었는데 데이터가 없는 상황이 생길 수 있다)&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;필요한 건 두 가지를 동시에 만족시키는 방식이었다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;트랜잭션 커밋 이후에만 알림을 발송&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;별도 스레드에서 비동기로 처리&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;이걸 동시에 해결해주는 게 바로 &lt;b&gt;@TransactionalEventListener&lt;/b&gt; 다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1777009626302&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Async만 사용했을 때 (❌)
──────────────────────────────────────────────
[트랜잭션 시작]
  ├─ DB 저장 (아직 커밋 안 됨)
  └─ @Async &amp;rarr; 별도 스레드에서 알림 발송 시작 &amp;larr; 커밋 전에 실행!

수신자: 알림 수신 &amp;rarr; 앱 열기 &amp;rarr; 데이터 없음  
[트랜잭션 커밋] &amp;larr; 알림 발송보다 나중에 됨&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1777009684612&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@TransactionalEventListener(AFTER_COMMIT) 사용했을 때 (✅)
──────────────────────────────────────────────
[트랜잭션 시작]
  ├─ DB 저장
  └─ 이벤트 발행 (등록만 해둠)
[트랜잭션 커밋] &amp;larr; DB 반영 완료
  │
  ▼
커밋 완료 신호 &amp;rarr; 별도 스레드에서 알림 발송 시작
수신자: 알림 수신 &amp;rarr; 앱 열기 &amp;rarr; 데이터 있음 ✅&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;b&gt;1차 해결&lt;/b&gt; - 이벤트 기반 비동기 분리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;[구조 설계]&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;흐름을 이렇게 바꿨다.&lt;/p&gt;
&lt;pre id=&quot;code_1776929011312&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;[트랜잭션 시작]
  ├─ DB 저장 (10ms)
  └─ 이벤트 발행 (1ms) &amp;larr; 즉시 반환
[트랜잭션 커밋] &amp;larr; 0.01초 후

          &amp;darr; 커밋 완료 신호

[별도 스레드 &amp;mdash; notificationExecutor]
  ├─ 이벤트 수신
  ├─ 카카오 API #1 (500ms)
  ├─ 카카오 API #2 (500ms)
  └─ ... (사용자와 무관하게 백그라운드 처리)&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;size18&quot;&gt;&lt;b&gt;[코드 구현]&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 알림 발송 이벤트 추가&lt;/b&gt; (NotificationSendEvent)&lt;/p&gt;
&lt;pre id=&quot;code_1776929076422&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;/**
 * 알림 발송 이벤트
 * 알림이 DB에 저장된 후 발송을 위해 발행되는 이벤트
 */
@Getter
public class NotificationSendEvent extends ApplicationEvent {
    private final Notification notification;

    public NotificationSendEvent(Object source, Notification notification) {
        super(source);
        this.notification = notification;
    }
}&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;2. NotificationService - 동기 발송 제거, 이벤트 발행으로 교체&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1776929128657&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 변경 전
private final List&amp;lt;NotificationSender&amp;gt; notificationSenders;     // &amp;larr; 제거
private final RetryableNotificationSender retryableNotificationSender; // &amp;larr; 제거

@Transactional
public void createAndSendNotification(...) {
    Notification savedNotification = notificationRepository.save(notification);
    sendNotification(savedNotification); // &amp;larr; 동기 API 호출
}

// 변경 후
private final ApplicationEventPublisher eventPublisher; // &amp;larr; 추가

@Transactional
public void createAndSendNotification(...) {
    Notification savedNotification = notificationRepository.save(notification);
    
    // 이벤트 발행 &amp;mdash; 즉시 반환, 실제 발송은 커밋 후 별도 스레드에서
    eventPublisher.publishEvent(new NotificationSendEvent(this, savedNotification));
}&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;3. 이벤트 리스너 추가&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1776929144605&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 NotificationSendListener {

    private final List&amp;lt;NotificationSender&amp;gt; notificationSenders;
    private final RetryableNotificationSender retryableNotificationSender;

    @Async(&quot;notificationExecutor&quot;)           // 별도 스레드 풀에서 실행
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) // 커밋 후에만 실행
    public void handleNotificationSend(NotificationSendEvent event) {
        Notification notification = event.getNotification();

        NotificationSender sender = findSender(notification);
        if (sender == null) {
            log.warn(&quot;지원하지 않는 채널: channel={}&quot;, notification.getChannel());
            return;
        }

        // 외부 API 연동이 필요한 채널은 재시도 메커니즘 적용
        boolean success;
        if (notification.getChannel().requiresExternalApi()) {
            success = retryableNotificationSender.sendWithRetry(sender, notification);
        } else {
            success = sender.send(notification);
        }

        if (!success) {
            log.warn(&quot;알림 발송 실패: notificationId={}&quot;, notification.getId());
        }
    }
}&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;4. 알림 전용 스레드 풀 설정&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1776929170656&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Bean(name = &quot;notificationExecutor&quot;)
public Executor notificationExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(5);
    executor.setMaxPoolSize(10);
    executor.setQueueCapacity(100);
    executor.setThreadNamePrefix(&quot;notification-async-&quot;);
    executor.setWaitForTasksToCompleteOnShutdown(true);
    executor.setAwaitTerminationSeconds(60);
    executor.initialize();
    return executor;
}&lt;/code&gt;&lt;/pre&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 25%; text-align: center;&quot;&gt;구성 요소&lt;/td&gt;
&lt;td style=&quot;width: 25%; text-align: center;&quot;&gt;역할&lt;/td&gt;
&lt;td style=&quot;width: 25%; text-align: center;&quot;&gt;실행 위치&lt;/td&gt;
&lt;td style=&quot;width: 25%; text-align: center;&quot;&gt;실행 스레드&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;NotificationService&lt;/td&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;알림 저장 후 이벤트 발행&lt;/td&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;트랜잭션 내부&lt;/td&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;요청 처리 스레드&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;NotificationSendEvent&lt;/td&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;발송할 알림 정보 전달&lt;/td&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;-&lt;/td&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;NotificationSendListener&lt;/td&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;이벤트 수신 후 실제 발송&lt;/td&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;트랜잭션 커밋 후&lt;/td&gt;
&lt;td style=&quot;width: 25%;&quot;&gt;notificationExecutor 스레드풀&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;이렇게 짜면 트랜잭션은 DB 저장 + 이벤트 발행만 하고 즉시 커밋된다. 카카오 API 호출은 커밋 이후 별도 스레드에서 처리되니, &lt;b&gt;DB 커넥션 점유 구간에서 외부 API 호출이 완전히 사라지도록 &lt;/b&gt;만들었다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;그런데 여기서 문제가 몇개 더 생겼다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;586&quot; data-origin-height=&quot;465&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cGKZWH/dJMcahRGIMB/1R4A6WwKzifhxVwe1H8vAk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cGKZWH/dJMcahRGIMB/1R4A6WwKzifhxVwe1H8vAk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cGKZWH/dJMcahRGIMB/1R4A6WwKzifhxVwe1H8vAk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcGKZWH%2FdJMcahRGIMB%2F1R4A6WwKzifhxVwe1H8vAk%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;365&quot; height=&quot;290&quot; data-origin-width=&quot;586&quot; data-origin-height=&quot;465&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;추가 문제 발생&lt;/b&gt;&lt;b&gt; &lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;문제 1. 트랜잭션이 끝난 엔티티를 그대로 넘기면 생기는 일&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt; 1차 리팩토링 내용을 PR로 올렸는데, CodeRabbit이 아래와 같은 리뷰를 남겨줬다. &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;943&quot; data-origin-height=&quot;400&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Gk3vL/dJMb990ts6M/6kYKYAcxoqkopXtsDON1p1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Gk3vL/dJMb990ts6M/6kYKYAcxoqkopXtsDON1p1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Gk3vL/dJMb990ts6M/6kYKYAcxoqkopXtsDON1p1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FGk3vL%2FdJMb990ts6M%2F6kYKYAcxoqkopXtsDON1p1%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;608&quot; height=&quot;400&quot; data-origin-width=&quot;943&quot; data-origin-height=&quot;400&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;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;처음 읽었을 땐 잘 이해가 안 돼서 실행 흐름에 따라 3단계로 쪼개 생각해봤다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;  detached 상태란?&lt;/b&gt;&lt;br /&gt;JPA에서 엔티티는 영속성 컨텍스트(EntityManager)가 관리하는 동안 managed 상태다. 트랜잭션이 커밋되면 영속성 컨텍스트가 닫히고, 그 안에서 관리되던 엔티티는 detached 상태가 된다. 쉽게 말해 &quot;JPA의 관리권 밖으로 떨어진 상태&quot;다.&lt;br /&gt;&lt;br /&gt;managed 상태에서는 엔티티의 변경사항이 자동으로 DB에 반영되지만, detached 상태에서는 그렇지 않다. detached 엔티티를 save()하면 JPA는 DB에서 최신값을 가져오는 게 아니라, detached 시점의 스냅샷을 그대로 DB에 덮어쓴다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 정상적으로 보이는 1차 코드의 흐름&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1776936544688&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;[트랜잭션 A 시작]
  │
  ├─ notification DB 저장 (managed 상태)
  │
  └─ 이벤트 발행 &amp;rarr; NotificationSendEvent(notification 엔티티 통째로 담음)
  
[트랜잭션 A 커밋] &amp;larr; 여기서 영속성 컨텍스트 종료
  │
  ▼
  notification 엔티티 &amp;rarr; detached 상태로 전환 ⚠️&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;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;2. 문제가 발생하는 지점&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt; 트랜잭션이 커밋되는 순간 영속성 컨텍스트가 닫힌다. 이벤트 객체가 들고 있던 notification 엔티티는 이 시점부터 &lt;b&gt;detached 상태&lt;/b&gt;, &lt;b&gt;&lt;u&gt;즉 JPA가 더 이상 관리하지 않는 상태&lt;/u&gt;&lt;/b&gt;가 된다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1776936629385&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;리스너 실행 (별도 스레드)
  │
  ├─ @Transactional &amp;rarr; 새 트랜잭션 B 시작
  │   └─ 새 EntityManager 생성
  │       ❌ event.getNotification()은 관리 대상이 아님
  │
  ├─ notification.markAsSent() 호출
  │
  └─ notificationRepository.save(notification) 호출
      └─ JPA: &quot;detached 엔티티니까 merge 해야겠다&quot;
              │
              ▼
         DB에서 최신값을 가져오는 게 아니라
         detached 스냅샷을 그대로 덮어씀 ⚠️&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;3. 그래서 실제로 어떤 값이 덮어써지는지&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1776936731859&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;타임라인
──────────────────────────────────────────────────────
t=0   트랜잭션 A 커밋
      notification 상태: { isRead: false, updatedAt: 10:00:00 }

t=1   사용자가 알림 읽음 처리 (다른 스레드)
      DB 상태: { isRead: true, updatedAt: 10:00:01 } ✅

t=2   리스너에서 detached 엔티티로 save() 호출
      merge 시 스냅샷: { isRead: false, updatedAt: 10:00:00 } &amp;larr; t=0 시점 값
      DB 상태: { isRead: false, updatedAt: 10:00:00 }   &amp;larr; 읽음 처리가 사라짐!
──────────────────────────────────────────────────────&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;span style=&quot;color: #000000;&quot;&gt;정리하면 이렇다.&lt;/span&gt;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;상황&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;문제&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt; 리스너 실행 전 다른 스레드가 isRead 변경 &lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt; detached 스냅샷으로 덮어써져 변경사항 유실 &lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt; 리스너 실행 전 다른 스레드가 updatedAt 변경 &lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt; 이전 시간으로 되돌아감 &lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt; 리스너 실행 전 다른 스레드가 updatedBy 변경 &lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 50%;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt; 이전 값으로 덮어써짐 &lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt; 결국 '&lt;b&gt;누가 언제 알림을 읽었는지'&lt;/b&gt; 같은 데이터가 조용히 사라질 수 있는 구조였다. 에러가 나지 않으니 인지하기도 어렵다는 게 더 무서운 점..!! 솔직히 1차 작업 당시엔 이 부분을 놓쳤다. 코드래빗이 짚어주지 않았다면 추후 문제가 생긴 뒤에 인지했을 수도 있었음  (정말 붙이기 잘했다)&lt;/span&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;문제 2. 같은 이벤트가 두 번 처리되면?&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt; 그리고 코드를 다시 들여다보면서 한 가지 더 눈에 걸리는 게 있었다. &lt;b&gt;멱등성&lt;/b&gt;이 전혀 보장되지 않는다는 점이었다. &lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt; 멱등성(Idempotency)이란?&lt;br /&gt;같은 작업을 여러 번 수행해도 결과가 동일한 성질을 말한다. 예를 들어 알림 발송 이벤트가 어떤 이유로 두 번 처리되더라도, 알림은 딱 한 번만 발송되어야 한다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;1차 코드에는 이런 보호 장치가 없었다. &lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1777010215162&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;이벤트가 두 번 처리될 수 있는 시나리오 (Spring Events 기준)
──────────────────────────────────────────────────────
1. 서버 재시작 타이밍
   이벤트 처리 중 &amp;rarr; 서버 재시작 &amp;rarr; 이벤트 재처리 &amp;rarr; 중복 발송  

2. 트랜잭션 재시도 로직
   트랜잭션 재시도 &amp;rarr; 이벤트 재발행 &amp;rarr; 중복 발송  

3. 향후 메시지 브로커 전환 시
   Kafka/RabbitMQ 도입 시 at-least-once 보장으로 중복 처리 가능  
   &amp;rarr; 지금 멱등성을 보장해두면 브로커 전환 시에도 안전&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;span style=&quot;color: #000000;&quot;&gt; 수신자 입장에서는 같은 알림이 두 번, 세 번 오는 상황이 생길 수 있었다. 지금은 테스트 환경이라 실제로 발생하지 않았지만, 운영 환경에서는 충분히 일어날 수 있는 케이스다.&lt;/span&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;2차 개선&lt;/b&gt; - ID 기반으로 교체 + 멱등성 보장&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;앞에서 인지한 문제들을 이제 해결해보자. detached 문제는 CodeRabbit이 문제를 짚어주면서 해결 방향도 함께 제시해준 걸 참고했다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&quot;표준 해결책은 &lt;br /&gt;@TransactionalEventListener(AFTER_COMMIT) + @Transactional(propagation = REQUIRES_NEW)를 함께 사용하고, 리스너 내부의 새 트랜잭션 안에서 repository.findById(notificationId)로 최신 엔티티를 다시 조회하는 것입니다.&quot;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt; 핵심은 두 가지다!&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;1.&lt;/b&gt; &lt;b&gt;이벤트에는 엔티티 전체 대신 ID만 전달&lt;/b&gt;한다. (이벤트 경량화)&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;detached 문제의 근본 원인은 트랜잭션이 끝난 엔티티를 이벤트 객체에 담아서 다른 스레드로 넘기는 것이었다. 엔티티 대신 ID만 넘기면 이 문제 자체가 생기지 않는다. 또한 이벤트가 가벼워지기 때문에, 나중에 Kafka나 RabbitMQ 같은 메시지 브로커로 전환할 때도 직렬화가 훨씬 수월해진다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1776937962048&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 변경 전
public class NotificationSendEvent extends ApplicationEvent {
    private final Notification notification; // &amp;larr; detached 위험
}

// 변경 후
public class NotificationSendEvent extends ApplicationEvent {
    private final UUID notificationId; // &amp;larr; ID만 전달

    public NotificationSendEvent(Object source, UUID notificationId) {
        super(source);
        this.notificationId = notificationId;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1776938159071&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Transactional
public void createAndSendNotification(...) {
    Notification savedNotification = notificationRepository.save(notification);

    // 엔티티 전체 대신 ID만 전달 ⭐
    eventPublisher.publishEvent(
        new NotificationSendEvent(this, savedNotification.getId())
    );
}&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;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;2. 리스너에서 새 트랜잭션&lt;/b&gt;을 열고 &lt;b&gt;최신 managed 엔티티를 직접 조회&lt;/b&gt;한다. (리스너 개선)&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;ID만 받았으니 리스너에서 repository.findById()로 DB에서 최신 엔티티를 다시 조회한다. 이렇게 하면 새 트랜잭션의 EntityManager가 해당 엔티티를 managed 상태로 관리하게 되어, detached 스냅샷 덮어쓰기 문제가 사라진다. 조회 시점의 최신 상태를 기준으로 동작하기 때문에 다른 스레드의 변경사항도 안전하게 보존된다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1776938390821&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 NotificationSendListener {

    //...생략

    @Async(&quot;notificationExecutor&quot;)
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    @Transactional(propagation = Propagation.REQUIRES_NEW) // &amp;larr; 새 트랜잭션
    public void handleNotificationSend(NotificationSendEvent event) {
        UUID notificationId = event.getNotificationId();

        // 최신 managed 엔티티 조회 (동시성 안전성 보장)
        Notification notification = notificationRepository.findById(notificationId)
                .orElseThrow(() -&amp;gt; new IllegalStateException(
                    &quot;Notification not found: &quot; + notificationId));

        //.. 생략
}&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;3. 멱등성 보장&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해결 방법은 단순하다. 발송을 시도하기 전에 &lt;b&gt;이미 발송된 알림인지 먼저 확인&lt;/b&gt;하는 것이다. Notification 엔티티에는 sendStatus 필드가 있고, 발송 완료 시 SENT로 업데이트된다. 이걸 이용하면 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1777010303029&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// NotificationSendListener 클래스에 추가
// 멱등성 보장 &amp;mdash; 이미 발송된 알림은 재발송하지 않음
if (notification.isSent()) {
    log.debug(&quot;알림이 이미 발송됨, 스킵: notificationId={}&quot;, notificationId);
    return;
}&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;isSent() 체크로 이미 발송된 알림은 재발송하지 않도록 막았다. 흐름으로 보면?&lt;/p&gt;
&lt;pre id=&quot;code_1777010406469&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;이벤트 수신
    │
    ▼
DB에서 최신 엔티티 조회
    │
    ▼
isSent() 체크 ──── true ──&amp;rarr; 스킵 (중복 발송 방지) ✅
    │
   false
    │
    ▼
알림 발송
    │
    ▼
markAsSent() &amp;rarr; DB 저장
    │
    ▼
같은 이벤트 재처리 시 &amp;rarr; isSent() = true &amp;rarr; 스킵 ✅&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;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;이렇게 ID 기반 조회와 멱등성 체크를 함께 써서 더 안전한 이벤트 기반 시스템을 구현했다!&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;&lt;b&gt;문제&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;&lt;b&gt;해결책&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;&lt;b&gt;효과&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;detached 엔티티 덮어쓰기&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;ID 기반 최신 엔티티 조회&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;동시성 안전&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;중복 이벤트 처리&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;isSent() 체크&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;중복 발송 방지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;=&amp;gt; 두 문제의 조합&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;ID 조회 후 isSent() 체크&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;최신 발송 상태 기준으로 판단 ✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ID 기반으로 최신 엔티티를 조회하기 때문에, 혹시 다른 스레드에서 이미 SENT로 업데이트했다면 isSent()가 true를 반환해서 중복 발송을 막아준다. &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;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 1차 리팩토링 후 2차 리팩토링까지 마쳐봤다. 최종 구조를 요약해보면 아래와 같다. 1차 개선 사항과 2차 개선 사항을 한번에 비교해볼 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;최종 구조 요약&lt;/b&gt;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 168px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style13&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 21px; text-align: center;&quot;&gt;&lt;b&gt;항목&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px; text-align: center;&quot;&gt;&lt;b&gt;변경 전&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px; text-align: center;&quot;&gt;&lt;b&gt;1차 개선&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px; text-align: center;&quot;&gt;&lt;b&gt;2차 개선&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 21px; text-align: center;&quot;&gt;발송 방식&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;동기 메서드 호출&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;이벤트 발행&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;ID 기반 이벤트 발행&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 21px; text-align: center;&quot;&gt;실행 시점&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;트랜잭션 내부&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;커밋 후&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;커밋 후&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 21px; text-align: center;&quot;&gt;이벤트 데이터&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;-&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;Notification(알림) 엔티티&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;UUID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 21px; text-align: center;&quot;&gt;엔티티 상태&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;managed&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;detached ⚠️&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;managed ✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 21px; text-align: center;&quot;&gt;동시성 안전성&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;-&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;❌ 덮어쓰기 위험&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;✅ 안전&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 21px; text-align: center;&quot;&gt;멱등성&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;❌&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;❌&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;✅ isSent() 체크&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 21px; text-align: center;&quot;&gt;DB 커넥션 점유&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;50초+&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;0.01초&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;0.01초&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;결과&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;[성능 개선]&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;API 응답 시간&lt;/b&gt;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 84px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 25%; text-align: center; height: 21px;&quot;&gt;시나리오&lt;/td&gt;
&lt;td style=&quot;width: 25%; text-align: center; height: 21px;&quot;&gt;변경 전&lt;/td&gt;
&lt;td style=&quot;width: 25%; text-align: center; height: 21px;&quot;&gt;변경 후&lt;/td&gt;
&lt;td style=&quot;width: 25%; text-align: center; height: 21px;&quot;&gt;개선율&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 21px; text-align: center;&quot;&gt;판매자 10명 알림&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;5초&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;약 0.1초 수준&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;98%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 21px; text-align: center;&quot;&gt;판매자 100명 알림&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;50초&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;약 0.1초 수준&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;99.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 21px; text-align: center;&quot;&gt;판매자 1000명 알림&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;500초&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;약 0.1초 수준&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;99.98%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;DB 커넥션 점유 시간 (추정)&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1777011600487&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;판매자 100명, API 평균 응답 500ms 기준:
변경 전: 100 &amp;times; 500ms = 약 50초
변경 후: DB 저장만 &amp;rarr; 약 수십ms 수준
개선율: 99.98% 감소&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;DB 커넥션 점유 시간이 50초에서 0.01초 수준으로 줄어들면서, 커넥션 풀을 훨씬 효율적으로 사용할 수 있게 됐다. 기존에는 커넥션 하나가 50초 동안 묶여 있었으니 동시 처리 가능한 요청 수가 극히 제한적이었지만, 변경 후에는 커넥션이 즉시 반환되어 같은 커넥션 풀로 훨씬 많은 요청을 처리할 수 있는 구조가 됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비즈니스 관점에서 이 개선이 가지는 의미는 조금 더 크다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1️⃣트랜잭션에서 외부 API 호출을 제거했다는 건 단순히 빨라졌다는 게 아니다. &lt;b&gt;카카오 API가 다운되더라도 견적 등록은 정상적으로 완료&lt;/b&gt;된다. 알림 발송 실패가 핵심 비즈니스 로직의 롤백을 유발하지 않도록 &lt;b&gt;책임이 완전히 격리&lt;/b&gt;됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2️⃣또한 판매자 수가 늘어날수록 응답 시간이 선형으로 증가하는 구조에서, &lt;b&gt;판매자가 몇 명이든 응답 시간이 일정하게 유지되는 구조&lt;/b&gt;로 바뀌었다. 서비스가 성장해도 이 부분에서 병목이 생기지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경험을 통해 기술적으로는 아래와 같은 부분들을 배웠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. @Async 만으로는 부족하다&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;u&gt; 단순히 비동기로 분리하는 것과, 트랜잭션 커밋 이후에 실행을 보장하는 것은 다른 문제&lt;/u&gt;다. 커밋 전에 알림이 나가버리면 데이터 정합성이 깨질 수 있다. @TransactionalEventListener(phase = AFTER_COMMIT)로 해결할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt; 2. 이벤트에 엔티티 전체를 담으면 위험하다 &lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트랜잭션이 끝나면 엔티티는 detached 상태가 된다. 이걸 다른 스레드에서 그대로 쓰면 동시성 문제가 생길 수 있다. &lt;b&gt;이벤트는 가능한 한 가볍게&lt;/b&gt; (예를 들면 ID만) &lt;b&gt;전달&lt;/b&gt;하고 리스너에서 새 트랜잭션으로 최신 상태를 다시 조회하는 패턴이 훨씬 안전하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. 멱등성은 의식적으로 처음부터 고려하자&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1차 개선에서 놓쳤던 부분이다. 이벤트 기반 시스템에서는 같은 이벤트가 두 번 처리될 수 있는 가능성을 항상 염두에 두어야 한다. isSent() 체크 하나로 중복 발송을 방지하는 건 단순해 보이지만, 설계 단계부터 의식적으로 넣지 않으면 빠뜨리기 쉽다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4. 외부 API 호출은 트랜잭션 밖으로&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;당연한 말이지만, 이건 이번 경험에서 가장 크게 와닿은 부분이다. 외부 API는 언제든 느려지거나 실패할 수 있다. 그 불확실성을 트랜잭션 안에 가두면 DB 커넥션과 응답 시간이 외부 서비스에 종속된다. &lt;b&gt;외부 API 호출은 트랜잭션 바깥에서 격리된 방식으로 처리하는 것이 원칙&lt;/b&gt;이라는 걸 이번에 확실히 체감했다  &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;[UX 관점에서의 변화]&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;숫자 개선과 기술적 리팩토링보다 중요한 것은 그래서 &lt;u&gt;이걸로 결국 사용자가 실제로 어떤 변화를 체감하느냐&lt;/u&gt;라고 생각한다.&lt;/p&gt;
&lt;pre id=&quot;code_1777010799729&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;변경 전 사용자 경험
──────────────────────────────────────────────
사용자: &quot;견적 등록&quot; 버튼 클릭
    │
    ▼
[━━━━━━━━━━━━━━━━━━━━━━━━━━] 로딩 중... (50초)
    │
    ▼
결과: &quot;등록 완료&quot;

&amp;rarr; 사용자 이탈 가능성 높음
&amp;rarr; &quot;버튼이 안 눌렸나?&quot; 싶어서 중복 클릭 시도
&amp;rarr; 서비스 신뢰도 하락&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1777010819502&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;변경 후 사용자 경험
──────────────────────────────────────────────
사용자: &quot;견적 등록&quot; 버튼 클릭
    │
    ▼
[━] 0.1초
    │
    ▼
결과: &quot;등록 완료! 판매자들에게 알림 발송 중입니다.&quot;

&amp;rarr; 즉각적인 피드백
&amp;rarr; 중복 클릭 없음
&amp;rarr; 서비스 신뢰도 유지&lt;/code&gt;&lt;/pre&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;항목&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;변경 전&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;변경 후&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: left;&quot;&gt;버튼 클릭 후 응답 시간&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;판매자 수 &amp;times; 500ms&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;약 0.1초 (판매자 수 무관)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: left;&quot;&gt;사용자 대기 체감&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;수십 초 로딩&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;즉각 응답&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: left;&quot;&gt;중복 클릭 가능성&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;높음 (응답 느려서)&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;낮음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: left;&quot;&gt;외부 API 장애 시 사용자 영향&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;견적 등록 실패에 노출&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;견적 등록은 정상 완료&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: left;&quot;&gt;서비스 성장 시 응답 시간&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;판매자 늘수록 선형 증가&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;일정하게 유지&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금은 테스트 환경이라 판매자가 몇 명 없지만, 서비스가 성장해서 &lt;b&gt;판매자가 수백 명이 되어도 사용자가 느끼는 응답 시간은 동일&lt;/b&gt;하다. 알림 발송이 아무리 늘어나도 사용자 경험에 영향을 주지 않는 구조로 바뀌었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;향후 개선 방향&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 리팩토링으로 당장의 문제는 해결했지만, 아직 개선할 여지가 남아있다.&lt;/p&gt;
&lt;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;1. 메시지 큐 도입&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재는 Spring Events 기반의 인메모리 방식이라 서버가 재시작되면 처리 중이던 이벤트가 유실될 수 있다. Kafka나 RabbitMQ 같은 메시지 브로커를 도입하면 서버 재시작 상황에서도 알림 유실 없이 처리할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에 이벤트에 ID만 담도록 리팩토링 한 건 메시지 브로커 전환을 더 쉽게 할 수 있도록 도와준다. 엔티티 전체를 직렬화할 필요 없이 ID만 전달하면 되니, 메시지 브로커로의 전환이 훨씬 수월하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. Circuit Breaker 적용&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;카카오 API가 일시적으로 불안정한 상황에서 계속 호출을 시도하면 오히려 시스템 전체에 부하를 줄 수 있다. Circuit Breaker를 적용해서 일정 횟수 이상 실패 시 호출을 차단하고, 일정 시간 후 다시 시도하는 방식으로 외부 서비스 장애가 내부로 전파되지 않도록 격리할 예정이다. (별도 포스팅으로 정리할 예정)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. 배치 발송&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재는 수신 대상마다 API를 개별 호출하는 구조다. 일정 시간 동안 발송 대상을 모아서 한 번에 처리하는 배치 방식으로 전환하면 API 호출 횟수 자체를 줄일 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;마무리&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 단순히 '알림 발송이 느리네, 비동기로 바꾸면 되겠다'라고 생각했던 작업이었다. 그런데 막상 파고들다 보니 트랜잭션 경계, JPA 엔티티 생명주기, 멱등성까지 꼬리에 꼬리를 무는 문제들이 나왔다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;409&quot; data-origin-height=&quot;623&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cQzCNe/dJMcabRuHdI/TcmL1qE4LQcBOrSDJuCSu0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cQzCNe/dJMcabRuHdI/TcmL1qE4LQcBOrSDJuCSu0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cQzCNe/dJMcabRuHdI/TcmL1qE4LQcBOrSDJuCSu0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcQzCNe%2FdJMcabRuHdI%2FTcmL1qE4LQcBOrSDJuCSu0%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;235&quot; height=&quot;358&quot; data-origin-width=&quot;409&quot; data-origin-height=&quot;623&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1차에서 놓쳤던 detached 문제를 코드리뷰 봇이 짚어준 덕분에 2차 개선까지 이어질 수 있었는데, 코드리뷰 봇 없이 혼자 개발했다면 운영 환경이나 QA때 조용히 데이터가 오염되는 걸 한참 뒤에야 발견했겠다 싶다.. 자동화된 코드리뷰의 중요성을 새삼 느꼈다  &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;긴 글 읽어주셔서 감사합니다  &amp;zwj;♀️&lt;/p&gt;</description>
      <category>Project/phonebid</category>
      <category>Detached</category>
      <category>Idempotency</category>
      <category>JPA</category>
      <category>TransactionalEventListener</category>
      <category>동시성</category>
      <category>리팩토링</category>
      <category>멱등성</category>
      <category>비동기처리</category>
      <category>이벤트기반</category>
      <category>카카오알림톡</category>
      <author>쉬지마 이굥진</author>
      <guid isPermaLink="true">https://developer-jinnie.tistory.com/127</guid>
      <comments>https://developer-jinnie.tistory.com/127#entry127comment</comments>
      <pubDate>Thu, 23 Apr 2026 19:05:32 +0900</pubDate>
    </item>
    <item>
      <title>레거시 쿼리 리팩토링으로 응답속도 99.8% 개선하기 (3.569s &amp;rarr; 0.005s)</title>
      <link>https://developer-jinnie.tistory.com/126</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;필자는 직전 회사에서 ERP 급여 모듈 유지보수 및 고도화를 맡고 있었다.&lt;br /&gt;&lt;br /&gt;GS 인증 취득을 위해 성능 기준 통과를 해야 하는 상황이 생겼는데, 데이터 건수가 많지 않음에도 불구하고 급여 모듈 곳곳에서 &lt;b&gt;응답속도가&amp;nbsp;3~4초&lt;/b&gt;에 달하는 화면들이 발견됐다. (테스트 데이터 건수들은 약 100건)&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt; GS(Good Software) 인증이란?&lt;br /&gt;가전제품에 붙는 &lt;b&gt;KS 마크&lt;/b&gt;나 &lt;b&gt;식품의 HACCP 인증&lt;/b&gt;처럼, 쉽게 말해 &quot;이 소프트웨어는 기능도 제대로 동작하고, 성능 기준도 충족한다&quot;는 걸 공인 기관인 한국정보통신기술협회(TTA)이 검증해주는 것이다.&lt;br /&gt;공공기관이나 기업에 소프트웨어를 납품할 때 GS 인증이 있으면 신뢰도 면에서 유리하고, 일부 공공 조달 시장에서는 사실상 필수 요건으로 작용하기도 한다.&lt;br /&gt;인증 심사 항목 중에는 응답속도 기준이 포함되어 있는데 특정 시간 내에 응답이 완료되지 않으면 해당 항목에서 탈락한다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이번 포스팅에서는 원인을 어떻게 파악했는지,&amp;nbsp;어떤 방식으로 리팩토링했는지를 정리해보려 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;(코드 보안상 테이블명&amp;middot;함수명은 예시로 대체했습니다)&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;문제 상황&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;급여 모듈의 여러 화면에서 조회 버튼 클릭 시 응답이 3초 이상 걸리는 현상이 발생했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DBeaver에서 직접 쿼리를 실행해봤을 때도 결과는 마찬가지였다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;A 화면 사원 조회: &lt;b&gt;2.235s&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;B 화면 사원 조회: &lt;b&gt;3.569s&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;421&quot; data-origin-height=&quot;47&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/x23Nh/dJMcaf7oujV/hoyCgKQu5hnvHtAyZ2PnUk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/x23Nh/dJMcaf7oujV/hoyCgKQu5hnvHtAyZ2PnUk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/x23Nh/dJMcaf7oujV/hoyCgKQu5hnvHtAyZ2PnUk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fx23Nh%2FdJMcaf7oujV%2FhoyCgKQu5hnvHtAyZ2PnUk%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;421&quot; height=&quot;47&quot; data-origin-width=&quot;421&quot; data-origin-height=&quot;47&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터 건수가 많지 않은데 왜 이렇게 느린 걸까?  &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;원인 분석&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 돌아가는 쿼리를 콘솔에서 복사해 분석해봤다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre id=&quot;code_1776776598694&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;-- 문제가 된 쿼리 (테이블명&amp;middot;함수명은 예시로 대체)
SELECT
    e.emp_no,
    e.emp_name,
    d.dept_name,
    GET_CODE_NAME('SAL_GRP', s.salary_group, s.company_cd, null) AS salary_group,
    GET_CODE_NAME('JOB_LV',  e.job_level,    e.company_cd, null) AS job_level
FROM employees e
LEFT JOIN departments d
    ON e.company_cd = d.company_cd AND e.dept_cd = d.dept_cd
LEFT JOIN salary_auth s
    ON e.company_cd = s.company_cd AND e.emp_no = s.emp_no
WHERE e.company_cd = 'XXX'
  AND e.resign_yn  = '0'
ORDER BY s.salary_group, e.dept_cd, e.emp_name;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SELECT 절에서 GET_CODE_NAME 이라는 &lt;b&gt;사용자 정의 함수(UDF, User-Defined Function)&lt;/b&gt;를 호출하고 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 함수는 코드값을 코드명으로 변환해주는 공통 함수인데, 함수를 까보니 이거 좀 수상하다(?) 싶은 구조가 눈에 들어왔다.&lt;/p&gt;
&lt;pre id=&quot;code_1776863897077&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;-- 예시로 치환한 GET_CODE_NAME 함수 내부 (일부 발췌)
CREATE FUNCTION GET_CODE_NAME(
    p_type     VARCHAR(2000),
    p_code     VARCHAR(2000),
    p_company  VARCHAR(2000),
    p_year     VARCHAR(2000)
) RETURNS VARCHAR(2000)
BEGIN
    DECLARE result VARCHAR(2000) DEFAULT '';

    IF SUBSTR(p_type, 1, 1) = '^' THEN
        SELECT code_name INTO result
        FROM code_master
        WHERE company_cd = p_company
          AND code_id    = p_type
          AND code_cd    = p_code;

    ELSEIF p_type = 'DEPT' THEN
        SELECT dept_name INTO result
        FROM departments
        WHERE company_cd = p_company
          AND dept_cd    = p_code;

    ELSEIF p_type = 'SAL_GRP' THEN
        SELECT code_name INTO result
        FROM code_master
        WHERE company_cd = p_company
          AND code_cd    = p_code
          AND code_id    = '^SAL_GRP';

    -- ... 이하 동일한 패턴의 ELSEIF 분기가 수십 개 반복됨

    END IF;

    RETURN result;
END&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;타입 인자에 따라 분기하면서 각각 다른 테이블을 SELECT하는 구조였다. 그리고 이런 패턴의 분기가 &lt;b&gt;실제 함수 내부에는 50개 이상&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;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;  &lt;b&gt;왜 느릴까?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제의 핵심은 이 함수가 놓인 &lt;b&gt;위치&lt;/b&gt;였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 함수는 SELECT 절에 위치하기 때문에 &lt;b&gt;결과 행마다 독립적으로 실행&lt;/b&gt;된다. 즉, 결과가 100건이고 함수를 2번 호출하면, 내부 SELECT가 최대 &lt;b&gt;200번&lt;/b&gt; 발생한다는 뜻이다. 이건 JPA/ORM에서 말하는 &lt;b&gt;N+1 문제와 구조적으로 동일&lt;/b&gt;하다. 데이터가 많지 않아도 함수 내부 쿼리 때문에 전체 쿼리가 매우 느려진다는 것.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기에 더해, UDF는 옵티마이저 입장에서 &lt;b&gt;블랙박스&lt;/b&gt;다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반 쿼리라면 옵티마이저가 전체 SQL을 분석해서 인덱스 사용 여부를 결정할 수 있다. 하지만 UDF 내부는 들여다볼 수 없기 때문에, 옵티마이저는 데이터를 한 줄씩 읽을 때마다 함수를 실행하는 &lt;b&gt;row-by-row 방식&lt;/b&gt;으로 처리할 수밖에 없다. 인덱스가 존재해도 충분히 활용되지 못하는 이유가 바로 이것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추측한 원인들을 정리하면 이렇다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 84px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 24.5349%; height: 21px;&quot;&gt;원인&lt;/td&gt;
&lt;td style=&quot;width: 75.4651%; height: 21px;&quot;&gt;설명&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 24.5349%; height: 21px;&quot;&gt;&lt;b&gt; Row-by-row 실행 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 75.4651%; height: 21px;&quot;&gt;SELECT 절의 UDF는 결과 행마다 독립 실행 &amp;rarr; N+1과 동일한 구조&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 24.5349%; height: 21px;&quot;&gt;&lt;b&gt; 블랙박스 처리 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 75.4651%; height: 21px;&quot;&gt;옵티마이저가 UDF 내부를 분석할 수 없어 최적 실행계획 수립 불가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 24.5349%; height: 21px;&quot;&gt;&lt;b&gt; 컨텍스트 스위칭 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 75.4651%; height: 21px;&quot;&gt;UDF 호출마다 SQL 엔진 &amp;harr; 함수 실행 엔진 간 전환 오버헤드 발생&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 세 가지가 겹치면서 &lt;b&gt;데이터 건수가 적어도 느린&lt;/b&gt; 현상이 발생한 것이다. 데이터 양의 문제가 아니라 쿼리 실행 구조 자체의 문제였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시험 삼아 함수 호출 부분만 제거하고 돌려봤더니? &lt;b&gt;0.005s&lt;/b&gt;. 원인은 명확했다  &lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;354&quot; data-origin-height=&quot;63&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/djUynt/dJMcahKWsmh/OAbrkgHxIDV1pBw0YaSAJk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/djUynt/dJMcahKWsmh/OAbrkgHxIDV1pBw0YaSAJk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/djUynt/dJMcahKWsmh/OAbrkgHxIDV1pBw0YaSAJk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdjUynt%2FdJMcahKWsmh%2FOAbrkgHxIDV1pBw0YaSAJk%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;354&quot; height=&quot;63&quot; data-origin-width=&quot;354&quot; data-origin-height=&quot;63&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;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;해결 방법 검토&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인이 &quot;행마다 코드 테이블을 반복 조회하는 것&quot;이라는 걸 파악했으니, 해결 방향은 &lt;b&gt;코드 테이블 조회를 한 번으로 줄이는 것&lt;/b&gt;이었다. 방법은 크게 세 가지를 검토했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 인라인 서브쿼리&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1776777841482&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SELECT
    e.emp_no,
    (SELECT c.code_name FROM code_master c
     WHERE c.company_cd = s.company_cd
       AND c.code_id = 'SAL_GRP'
       AND c.code_cd = s.salary_group) AS salary_group,
    ...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 직관적이지만, 여전히 행마다 서브쿼리가 실행되는 구조라 &lt;b&gt;근본적인 해결이 아니&lt;/b&gt;라고 판단했다. UDF 호출과 같은 row-by-row 문제가 그대로 남는다 (실제로 팀 내에서도 선호하지 않는 방식이었다).&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 애플리케이션 레벨 처리&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드 테이블 전체를 서버단에 캐싱해두고, 쿼리에서는 코드값만 가져온 뒤 변환하는 방법이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캐싱 전략이 더해지면 성능 면에서 가장 유리할 수 있지만, 레거시 시스템의 공통 함수 의존도가 높고 수십 개 화면에 일괄 적용하기엔 변경 범위가 너무 크고 사이드 이펙트 리스크도 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. LEFT JOIN으로 코드 테이블 연결 &lt;/b&gt;(✔️채택)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드 테이블을 JOIN 절에서 미리 연결해두면, &lt;b&gt;단 한 번의 쿼리 실행으로&lt;/b&gt; 코드명을 포함한 전체 결과를 가져올 수 있다. 옵티마이저도 JOIN 구조는 분석할 수 있기 때문에 인덱스를 제대로 활용할 수 있다. 변경 범위가 쿼리 XML 내부로 한정되고, 기존 동작과 결과도 동일하게 유지된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레거시 시스템에 가장 안전하면서도, 구조적으로 문제를 근본 해결할 수 있는 방법이라고 생각되어 이 방법으로 채택!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;리팩토링 - &lt;i&gt;LEFT JOIN&lt;/i&gt; 적용&lt;/b&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1776778018729&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;-- 개선 후 쿼리
SELECT
    e.emp_no,
    e.emp_name,
    d.dept_name,
    c1.code_name AS salary_group,
    c2.code_name AS job_level
FROM employees e
LEFT JOIN departments d
    ON e.company_cd = d.company_cd AND e.dept_cd = d.dept_cd
LEFT JOIN salary_auth s
    ON e.company_cd = s.company_cd AND e.emp_no = s.emp_no
LEFT JOIN code_master c1
    ON c1.company_cd = s.company_cd
   AND c1.code_cd   = s.salary_group
   AND c1.code_id   = 'SAL_GRP'
LEFT JOIN code_master c2
    ON c2.company_cd = e.company_cd
   AND c2.code_cd   = e.job_level
   AND c2.code_id   = 'JOB_LV'
WHERE e.company_cd = 'XXX'
  AND e.resign_yn  = '0'
ORDER BY s.salary_group, e.dept_cd, e.emp_name;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;변경 포인트는 딱 두 가지다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;GET_CODE_NAME('SAL_GRP', ...) ➡️ c1.code_name AS salary_group&lt;/li&gt;
&lt;li&gt;GET_CODE_NAME('JOB_LV', ...) ➡️ c2.code_name AS job_level&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드명 변환을 함수 반복 호출 대신 &lt;b&gt;JOIN 한 번으로&lt;/b&gt; 처리하도록 구조를 바꿨다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt; 한 화면에서 끝내지 않았다 &lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 A 화면에서 원인을 파악한 뒤 바로 PR을 올리는 대신, &lt;b&gt;이 패턴이 모듈 전체에 얼마나 퍼져 있는지부터 확인&lt;/b&gt;했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예상대로였다. GET_CODE_NAME 함수 호출 패턴은 급여 모듈 내 수십 개 화면의 쿼리에 동일하게 적용되어 있었다. 공통 함수라는 이유로 편리하게 쓰였겠지만, 그만큼 성능 부채도 전체에 누적되어 있었던 것이다.&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;A 화면 하나에서 원인을 발견한 것을 기점으로, &lt;b&gt;동일 패턴이 적용된 쿼리를 전수 조사하고 일괄 리팩토링&lt;/b&gt;을 진행했다. 덕분에 급여 모듈 전반에 걸쳐 99% 이상의 성능 향상을 이끌어낼 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;결과&lt;/b&gt;&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 84px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;화면&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;개선 전&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;개선 후&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;개선율&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;&lt;b&gt;A 화면 사원 조회&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;2.235s&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;&lt;span style=&quot;color: #006dd7;&quot;&gt;&lt;b&gt;0.006s&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;약 99.73%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;&lt;b&gt;B&lt;b&gt; 화면 사원 조회&lt;/b&gt;&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;3.569s&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;&lt;span style=&quot;color: #006dd7;&quot;&gt;&lt;b&gt;0.005s&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;99.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;&lt;b&gt;그 외 동일 패턴 화면들&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;측정값 유사&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;0.005s 수준&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 21px;&quot;&gt;99% 이상&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt; 비즈니스 관점에서의 의미&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;급여 모듈에는 &lt;b&gt;급여 마감&lt;/b&gt;처럼 특정 시점에 수백~수천 건의 사원 데이터를 일괄 조회&amp;middot;처리하는 작업이 존재한다. 기존 구조라면 이 시점에 대량의 UDF 내부 쿼리가 폭발적으로 발생해 DB에 큰 부하를 줄 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리팩토링 이후에는 &lt;b&gt;쿼리 실행 횟수 자체가 줄었기 때문에&lt;/b&gt;, 데이터가 많아지는 마감 시점에도 안정적인 응답이 보장된다. GS 인증 기준을 통과하는 것은 물론이고 실제 사용자가 업무 중단 없이 시스템을 사용할 수 있는 환경을 만들었다고 생각한다.&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;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;b&gt;1.&lt;/b&gt; SELECT 절 UDF 호출은 SQL 레벨의 N+1이다&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ORM을 쓰지 않는 레거시 환경에서도 N+1은 발생한다. SELECT 절에 함수를 넣는 순간 (그 함수가 내부에서 쿼리를 실행한다면) 행 수만큼 쿼리가 발생한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기에 더해, UDF는 옵티마이저 입장에서 &lt;b&gt;블랙박스&lt;/b&gt;다. 일반 쿼리라면 전체 SQL을 분석해서 인덱스 사용 여부를 결정할 수 있지만, UDF 내부는 들여다볼 수 없기 때문에 옵티마이저는 데이터를 한 줄씩 읽을 때마다 함수를 실행하는 row-by-row 방식으로 처리한다. 인덱스가 존재해도 충분히 활용되지 못하는 이유가 바로 이것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과적으로 &quot;데이터가 적어도 느린&quot; 현상이 발생한다. 데이터 건수의 문제가 아니라 &lt;b&gt;쿼리 실행 구조&lt;/b&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;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;b&gt;2.&lt;/b&gt; 원인 분석을 먼저, 수정은 그 다음&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음엔 단순히 &quot;느린 화면 하나를 빠르게 고치자&quot;는 생각으로 접근했다. 그런데 원인을 파악하고 나니 자연스럽게 이런 의문이 생겼다. &quot;이 함수가 이 화면에만 쓰이고 있을까?&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 한 화면에서 원인을 제대로 이해한 것이 모듈 전체를 개선하는 출발점이 됐다. 빠르게 고치는 것도 중요하지만, &lt;b&gt;왜 느린지를 먼저 정확히 이해하는 것&lt;/b&gt;이 더 중요하다는 걸 이번에 다시 한 번 느꼈다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;b&gt;3.&lt;/b&gt; 10년 넘은 레거시 코드, '무조건 리팩토링'이 능사가 아니다&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 시스템은 10년이 넘은 레거시 코드였다. 레거시라고 하면 흔히 '낡고 바꿔야 할 것'으로 인식하기 쉽지만, 1n년 동안 수많은 엣지 케이스를 거치며 검증된 비즈니스 로직이 녹아 있는 코드라고 생각한다. 그 맥락을 모른 채 섣불리 건드리게 되면 예상치 못한 곳에서 문제가 반드시 터질 것이라고 생각했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;u&gt;그래서 이번 리팩토링에서 가장 중요하게 생각한 건 &lt;b&gt;변경 범위를 최소화하는 것&lt;/b&gt;&lt;/u&gt;이었다. 인라인 서브쿼리나 애플리케이션 캐싱 같은 방법도 검토했지만, 결국 쿼리 XML 내부만 수정하는 LEFT JOIN 방식을 선택했다. 기존 비즈니스 로직과 결과는 그대로 유지하면서 성능 문제만 구조적으로 해결할 수 있는 방법이었기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레거시를 다룰 때는 '얼마나 많이 바꾸느냐'보다 '&lt;b&gt;얼마나 안전하게 바꾸느냐'&lt;/b&gt;&amp;nbsp;가 더 중요한 기준이 된다는 걸 이번에 체감했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;b&gt;4.&lt;/b&gt; 실행계획 분석 습관의 중요성&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 이슈는 코드 리뷰만으로는 잡기 어려운 종류의 문제였다. 쿼리 자체는 멀쩡해 보이고, 함수 호출도 관행적으로 쓰이던 방식이여서 실제 실행 시간과 실행계획을 직접 확인하기 전까지는 어디가 문제인지 전혀 보이지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실행계획(&lt;i&gt;EXPLAIN&lt;/i&gt;)을 보는 습관이 있으면 이런 문제를 훨씬 빨리 발견할 수 있다. 풀스캔이 발생하고 있는지, 인덱스를 제대로 타고 있는지, 서브쿼리나 함수 호출이 반복 실행되고 있지는 않은지를 한눈에 파악할 수 있기 때문이다. 기능이 '동작하는지'는 테스트로 잡을 수 있지만, 쿼리가 '효율적으로 동작하는지'는 실행계획을 봐야만 알 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발하면서 성능이 의심될 때마다 &lt;i&gt;EXPLAIN &lt;/i&gt;을 찍어보는 습관 하나가, 나중에 시스템 전체의 병목을 찾는 실마리가 될 수 있다는 걸 이번 경험을 통해 실감했다  &lt;/p&gt;</description>
      <category>Project/ERP 프로젝트</category>
      <category>GS인증</category>
      <category>N+1문제</category>
      <category>SQL튜닝</category>
      <category>UDF</category>
      <category>레거시리팩토링</category>
      <category>성능개선</category>
      <category>실행계획</category>
      <category>쿼리최적화</category>
      <author>쉬지마 이굥진</author>
      <guid isPermaLink="true">https://developer-jinnie.tistory.com/126</guid>
      <comments>https://developer-jinnie.tistory.com/126#entry126comment</comments>
      <pubDate>Wed, 22 Apr 2026 20:07:13 +0900</pubDate>
    </item>
    <item>
      <title>[Spring/JPA] refresh token 통합 테스트 중 데이터 불일치 문제 삽질기 (@Modifying의 flushAutomatically 옵션)</title>
      <link>https://developer-jinnie.tistory.com/125</link>
      <description>&lt;h3 data-path-to-node=&quot;4&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1. 문제 상황&lt;/b&gt;: &quot;분명히 지웠는데 왜 남아있지?&quot;&lt;/h3&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;최근 진행 중인 사이드 프로젝트의 access, refresh token 인증 로직을 검증하기 위해 통합 테스트 코드를 작성하고 있었다. 로그인, 토큰 갱신, 로그아웃으로 이어지는 전체 플로우를 테스트하던 중, &lt;b data-index-in-node=&quot;103&quot; data-path-to-node=&quot;5&quot;&gt;로그아웃 시 DB에서 Refresh Token이 삭제되지 않는 문제&lt;/b&gt;가 생겼다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwichLH7gIuSAxUAAAAAHQAAAAAQyQs&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 문제의 테스트 코드 일부
@Test
@DisplayName(&quot;로그아웃 -&amp;gt; RefreshToken 삭제 확인&quot;)
void logout_ShouldDeleteRefreshToken() throws Exception {
    // 1. 로그인하여 토큰 생성
    userService.login(loginRequest); 
    
    // 2. 로그아웃 수행 (내부적으로 deleteByExpiresAtBefore 실행)
    mockMvc.perform(post(&quot;/api/v1/users/logout&quot;)...);

    // 3. 검증: 토큰이 삭제되어 Optional.empty()여야 함
    Optional&amp;lt;RefreshToken&amp;gt; deletedTokenOpt = refreshTokenService.findByUserId(testUser.getId());
    assertThat(deletedTokenOpt).isEmpty(); // &amp;lt;--- 여기서 테스트 실패!
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;분명 로그아웃 로직 내에서 삭제 쿼리가 실행되었음에도 불구하고, 테스트 검증 단계에서는 토큰이 여전히 조회되어 테스트가 실패했던거다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;9&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2. 원인 분석&lt;/b&gt;: JPA 벌크 연산과 영속성 컨텍스트의 속성&lt;/h3&gt;
&lt;p data-path-to-node=&quot;3&quot; data-ke-size=&quot;size16&quot;&gt;이 테스트 클래스 상단에는 @Transactional이 붙어 있다. 이는 테스트가 끝나면 데이터를 롤백하기 위함이지만, 동시에&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b data-path-to-node=&quot;3&quot; data-index-in-node=&quot;73&quot;&gt;테스트 코드 내의 모든 작업이 하나의 영속성 컨텍스트(1차 캐시) 안에서 일어난다&lt;/b&gt;는 뜻이기도 하다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;3&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;문제의 원인은 &lt;b&gt;@Query&lt;/b&gt;를 이용해 직접 작성한 &lt;b&gt;벌크 연산&lt;/b&gt;(Bulk Operation)에 있었다.&lt;/p&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwichLH7gIuSAxUAAAAAHQAAAAAQygs&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Modifying
@Query(&quot;DELETE FROM RefreshToken rt WHERE rt.expiresAt &amp;lt; :dateTime&quot;)
void deleteByExpiresAtBefore(@Param(&quot;dateTime&quot;) LocalDateTime dateTime);
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-path-to-node=&quot;12&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;12&quot; data-ke-size=&quot;size16&quot;&gt;JPA에서 @Query를 통한 &lt;b&gt;UPDATE&lt;/b&gt;나 &lt;b&gt;DELETE&lt;/b&gt;는 &lt;u&gt;일반적인 엔티티 조작과 다르게 동작&lt;/u&gt;한다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;13&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;13,0,0&quot;&gt;영속성 컨텍스트 무시:&lt;/b&gt; 벌크 연산은 1차 캐시를 거치지 않고 DB에 직접 쿼리를 날린다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;13,1,0&quot;&gt;쓰기 지연의 함정:&lt;/b&gt; 테스트 코드 내에서 save()된 데이터는 아직 DB에 반영되지 않고 메모리에 머물러 있는데, 벌크 삭제 쿼리가 DB로 먼저 날아가 버린다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;13,2,0&quot;&gt;결과:&lt;/b&gt; DB 입장에서는 아직 들어오지도 않은 데이터를 지우라고 하니 아무 일도 일어나지 않았고, 메모리에는 생성된 토큰이 그대로 남아있게 된다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;530&quot; data-origin-height=&quot;409&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bokfcS/dJMcagElM7Y/EULKbraIxIDakBnHNPBUZK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bokfcS/dJMcagElM7Y/EULKbraIxIDakBnHNPBUZK/img.png&quot; data-alt=&quot;몰랐어요 몰랏다고요&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bokfcS/dJMcagElM7Y/EULKbraIxIDakBnHNPBUZK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbokfcS%2FdJMcagElM7Y%2FEULKbraIxIDakBnHNPBUZK%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;430&quot; height=&quot;332&quot; data-origin-width=&quot;530&quot; data-origin-height=&quot;409&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;몰랐어요 몰랏다고요&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;15&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3. 해결 방법&lt;/b&gt;: @Modifying 옵션 제대로 활용하기&lt;/h3&gt;
&lt;p data-path-to-node=&quot;16&quot; data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하기 위해 @Modifying 어노테이션의 옵션을 조정했다.&lt;/p&gt;
&lt;h4 data-path-to-node=&quot;17&quot; data-ke-size=&quot;size20&quot;&gt;수정된 코드&lt;/h4&gt;
&lt;div data-ved=&quot;0CAAQhtANahgKEwichLH7gIuSAxUAAAAAHQAAAAAQyws&quot; data-hveid=&quot;0&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Modifying(clearAutomatically = false, flushAutomatically = true)
@Query(&quot;DELETE FROM RefreshToken rt WHERE rt.expiresAt &amp;lt; :dateTime&quot;)
void deleteByExpiresAtBefore(@Param(&quot;dateTime&quot;) LocalDateTime dateTime);
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h4 data-path-to-node=&quot;19&quot; data-ke-size=&quot;size20&quot;&gt;적용된 옵션 설명&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;20&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;20,0,0&quot;&gt;flushAutomatically = true&lt;/b&gt;: 쿼리를 실행하기 직전에 영속성 컨텍스트의 변경사항을 DB에 먼저 반영(flush)한다. 덕분에 방금 생성된 토큰이 DB에 적재된 후 삭제 쿼리가 실행되어 정상적으로 지워지게 되는 것.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;20,1,0&quot;&gt;clearAutomatically = false&lt;/b&gt;: 쿼리 실행 후 1차 캐시를 비우지 않는다. 현재 테스트 코드에서는 동일한 트랜잭션 내에서 testUser 등 다른 객체들을 계속 참조해야 하므로, 불필요한 재조회를 막기 위해 false로 유지했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;22&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;4. 마치며&lt;/b&gt;: 테스트 코드 필요성&lt;/h3&gt;
&lt;p data-path-to-node=&quot;23&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 약간 주객전도가 된 느낌이었다. 어떻게 보면 테스트 코드 통과를 위해 레포지토리 코드를 수정한 거니 말 그대로 '통과만을 위한 코드 수정? 이 맞나 싶은 것..!&lt;/p&gt;
&lt;p data-path-to-node=&quot;23&quot; data-ke-size=&quot;size16&quot;&gt;하지만 이번 경험을 통해 깨달은 점은 다음과 같다.&lt;/p&gt;
&lt;blockquote data-path-to-node=&quot;24&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-path-to-node=&quot;24,0&quot; data-ke-size=&quot;size16&quot;&gt;테스트 코드가 실패해서 내 코드의 잠재적 위험(여기선 데이터 정합성 문제)을 미리 알게된 것&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-path-to-node=&quot;25&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;25&quot; data-ke-size=&quot;size16&quot;&gt;단순히 save()만 믿고 있다가 &lt;u&gt;운영 환경에서 벌크 연산으로 인한 데이터 불일치 버그&lt;/u&gt;를 만났다면 원인을 찾기 훨씬 힘들었을 거다. 나는 @Modifying에 이런 옵션이 있다는 것도 몰랐으니..  &lt;/p&gt;
&lt;p data-path-to-node=&quot;25&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;25&quot; data-ke-size=&quot;size16&quot;&gt;이번 기회에 JPA의 벌크 연산 매커니즘과 영속성 컨텍스트의 관계를 확실히 정리할 수 있었다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-path-to-node=&quot;27&quot; data-ke-style=&quot;style1&quot;&gt;&lt;b&gt;한 줄 요약&lt;/b&gt;: &lt;br /&gt;&lt;br /&gt;&lt;/blockquote&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;JPA에서&lt;b&gt; @Query로 수정/삭제를 할 때&lt;/b&gt;는 반드시 현재 메모리 상태와 DB 상태의 동기화(flush, clear)를 고민하자!!!!&lt;/p&gt;</description>
      <category>Project/phonebid</category>
      <category>1차캐시</category>
      <category>@Modifying</category>
      <category>@Query</category>
      <category>Flush</category>
      <category>flushAutomatically</category>
      <category>JPA</category>
      <category>벌크연산</category>
      <category>영속성컨텍스트</category>
      <author>쉬지마 이굥진</author>
      <guid isPermaLink="true">https://developer-jinnie.tistory.com/125</guid>
      <comments>https://developer-jinnie.tistory.com/125#entry125comment</comments>
      <pubDate>Tue, 27 Jan 2026 00:15:28 +0900</pubDate>
    </item>
    <item>
      <title>[Spring 트래픽 제한] 인증 없는 API에 안전장치 달기: Bucket4j로 Rate Limiting 구현</title>
      <link>https://developer-jinnie.tistory.com/124</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-01-15 201715.png&quot; data-origin-width=&quot;473&quot; data-origin-height=&quot;164&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/noWgx/dJMcafL6YMX/5FxkYPOo0L5qv5oWkHNf41/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/noWgx/dJMcafL6YMX/5FxkYPOo0L5qv5oWkHNf41/img.png&quot; data-alt=&quot;출처: bucket4j Github&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/noWgx/dJMcafL6YMX/5FxkYPOo0L5qv5oWkHNf41/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnoWgx%2FdJMcafL6YMX%2F5FxkYPOo0L5qv5oWkHNf41%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;473&quot; height=&quot;164&quot; data-filename=&quot;스크린샷 2026-01-15 201715.png&quot; data-origin-width=&quot;473&quot; data-origin-height=&quot;164&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;출처: bucket4j Github&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-path-to-node=&quot;3&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;3&quot; data-ke-size=&quot;size16&quot;&gt;서비스를 운영하다 보면 보안상 가장 고민되는 지점 중 하나가 바로 '&lt;b&gt;인증되지 않은 사용자의 요청&lt;/b&gt;' 이다. 특히 파일 업로드 API는 악의적인 사용자의 타겟이 되기 쉽다. 주변에서도 왕왕 그러 악의적인 접근 때문에 aws 요금을 폭탄맞았다는 경우도 왕왕 들었다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;3&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;4&quot; data-ke-size=&quot;size16&quot;&gt;오늘은 최근 프로젝트 코드 리뷰 중 발견한 보안 취약점을 해결하기 위해, &lt;b data-index-in-node=&quot;35&quot; data-path-to-node=&quot;4&quot;&gt;Bucket4j&lt;/b&gt;를 도입하여 &lt;b&gt;트래픽 제한(Rate Limiting)&lt;/b&gt;을 적용한 과정을 공유하려 한다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;4&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;4&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1️⃣ 현재 필자의 상황 (왜 트래픽 제한이 필요한가)&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;현재 개발 중인 프로젝트의 판매자 회원가입 로직은 다음과 같다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;8&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;판매자가 회원가입 폼을 작성하며 사업자등록증 등 증빙 서류를 업로드한다.&lt;/li&gt;
&lt;li&gt;이 서류는 S3의 temp/ 경로에 임시 저장된다. (아직 가입 전이므로 &lt;b data-index-in-node=&quot;41&quot; data-path-to-node=&quot;8,1,0&quot;&gt;비로그인 상태&lt;/b&gt;)&lt;/li&gt;
&lt;li&gt;회원가입 완료 시 해당 파일을 정식 경로로 이동시킨다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-path-to-node=&quot;12&quot; data-ke-size=&quot;size16&quot;&gt;여기서 문제는 &lt;b data-index-in-node=&quot;8&quot; data-path-to-node=&quot;12&quot;&gt;1번(임시 업로드)&lt;/b&gt; 단계다. 아직 회원가입 전이라 &lt;b&gt;인증(Authentication)&lt;/b&gt;이 불가능한 엔드포인트인데, 누군가 악의적으로 10MB 크기의 파일을 초당 수백 번씩 업로드한다면 어떻게 될까?&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;13&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;13,0,0&quot;&gt;S3 비용 폭증&lt;/b&gt;: 대역폭 비용과 저장 비용이 기하급수적으로 늘어남&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;13,1,0&quot;&gt;서버 자원 고갈&lt;/b&gt;: 파일 처리 프로세스가 CPU와 메모리를 점유하여 정상적인 서비스 이용이 어려워짐&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-path-to-node=&quot;9&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot; data-path-to-node=&quot;4&quot;&gt;&lt;b&gt;2️⃣ &lt;/b&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;5&quot;&gt;Bucket4j란 무엇인가?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-path-to-node=&quot;6&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;6&quot;&gt;Bucket4j&lt;/b&gt;는 자바 기반의 &lt;b data-index-in-node=&quot;17&quot; data-path-to-node=&quot;6&quot;&gt;Rate Limiting(속도 제한)&lt;/b&gt; 라이브러리다. 흔히 알고 있는 &lt;b data-index-in-node=&quot;57&quot; data-path-to-node=&quot;6&quot;&gt;Token Bucket 알고리즘&lt;/b&gt;을 사용하여 애플리케이션의 요청 흐름을 제어한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;7&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;7,0,0&quot;&gt;Token Bucket 알고리즘&lt;/b&gt;: 일정한 속도로 토큰이 채워지는 '바구니(Bucket)'가 있고, 요청이 올 때마다 토큰을 하나씩 소비함, 토큰이 없으면 요청은 거절됨&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;7,1,0&quot;&gt;특징&lt;/b&gt;: 매우 가볍고, 외부 인프라(Redis 등) 없이도 동작하며, 유연한 설정이 가능합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-path-to-node=&quot;4&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3️⃣ &lt;/b&gt;&lt;b&gt;왜 Bucket4j인가?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;트래픽 제어를 위해 여러 대안(Redis, Resilience4j , Nginx 등)이 있었지만, 현재 프로젝트 상황에 비추어 &lt;b data-index-in-node=&quot;78&quot; data-path-to-node=&quot;16&quot;&gt;Bucket4j&lt;/b&gt;를 선택한 이유다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;17&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;17,0,0&quot;&gt;서버 단일 환경&lt;/b&gt;: 현재 우리 서버는 1대다. Redis 같은 분산 저장소 없이 메모리 내(In-memory)에서 동작하는 Bucket4j만으로도 충분한 효과를 낼 수 있다. (현재 서비스에서 Redis는 사용하고 있지 않음)&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;17,1,0&quot;&gt;예상 트래픽 규모&lt;/b&gt;: 초기 서비스 단계이며 하루 업로드 건수가 1,000건 미만으로 예상하고 있다. 복잡한 인프라 설정보다는 가볍고 빠르게 적용할 수 있는 라이브러리가 적합하다고 판단했다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;17,2,0&quot;&gt;세밀한 제어&lt;/b&gt;: Spring Security 설정이나 Interceptor 단계에서 특정 IP별로 버킷을 할당하여 유연하게 요청을 제한할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;&lt;b&gt;라이브러리&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;&lt;b&gt;장점&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center;&quot;&gt;&lt;b&gt;단점&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;&lt;b&gt; Bucket4j &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;간단, 다양한 알고리즘, Redis 지원&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;Java 전용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;&lt;b&gt; Resilience4j &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;Circuit Breaker 등 다기능&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;Rate Limiting은 부가 기능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;&lt;b&gt; Spring Cloud Gateway &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;API Gateway 레벨 제어&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;마이크로서비스용&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;19&quot; data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-path-to-node=&quot;19&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;4️⃣ &lt;/b&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;19&quot;&gt;서비스 성장 시 고려할 만한 다음 단계&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;20&quot; data-ke-size=&quot;size16&quot;&gt;서비스가 커지고 사용자가 많아지면 단일 서버 메모리에 의존하는 방식은 한계가 온다. 추후 다음과 같은 선택지를 고려할 예정이다. (물론 이 정도 상황까지 오면 증말 해피해피 한 상황일 것 ^^)&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;21&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;21,0,0&quot;&gt;Redis + Bucket4j (Distributed)&lt;/b&gt;: 서버가 여러 대로 늘어나면 각 서버가 트래픽 정보를 공유해야 한다. Bucket4j는 Redis를 백엔드로 사용할 수 있는 기능을 지원하여 분산 환경에서도 속도 제한을 유지할 수 있다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;21,1,0&quot;&gt;API Gateway 레벨의 제한&lt;/b&gt;: Spring Cloud Gateway나 Nginx 레벨에서 요청을 사전에 차단하는 방식이다. 애플리케이션 로직에 도달하기 전 인프라 단에서 막아주므로 서버 부하를 더 확실히 줄일 수 있다. (마이크로서비스에서)&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;21,2,0&quot;&gt;Cloud Native 솔루션&lt;/b&gt;: AWS WAF(Web Application Firewall) 등을 사용하여 비정상적인 IP 패턴을 자동으로 차단하는 방식을 도입할 수 있다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-path-to-node=&quot;4&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;5️⃣ &lt;/b&gt;&lt;b&gt;구현 예시&lt;/b&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1768475366395&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// build.gradle에 의존성 추가
implementation 'com.bucket4j:bucket4j-core:8.7.0'
implementation 'com.github.ben-manes.caffeine:caffeine:3.1.8'&lt;/code&gt;&lt;/pre&gt;
&lt;p data-path-to-node=&quot;4&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1768475377731&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// RateLimitingService.java
@Service
public class RateLimitingService {
    private final Cache&amp;lt;String, Bucket&amp;gt; cache = Caffeine.newBuilder()
        .expireAfterWrite(Duration.ofMinutes(10))
        .build();

    public Bucket resolveBucket(String key, int requestsPerMinute) {
        return cache.get(key, k -&amp;gt; createNewBucket(requestsPerMinute));
    }

    private Bucket createNewBucket(int requestsPerMinute) {
        Bandwidth limit = Bandwidth.builder()
            .capacity(requestsPerMinute)
            .refillGreedy(requestsPerMinute, Duration.ofMinutes(1))
            .build();
        return Bucket.builder()
            .addLimit(limit)
            .build();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1768475395978&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// RateLimitInterceptor.java
@Component
public class RateLimitInterceptor implements HandlerInterceptor {
    
    @Autowired
    private RateLimitingService rateLimitingService;

    @Override
    public boolean preHandle(HttpServletRequest request, 
                            HttpServletResponse response, 
                            Object handler) throws Exception {
        
        String endpoint = request.getRequestURI();
        
        // 특정 엔드포인트에만 적용
        if (endpoint.startsWith(&quot;/api/v1/sellers/documents/temp&quot;)) {
            String clientIp = getClientIP(request);
            String key = &quot;temp-upload:&quot; + clientIp;
            
            // IP당 분당 5회로 제한
            Bucket bucket = rateLimitingService.resolveBucket(key, 5);
            
            if (bucket.tryConsume(1)) {
                return true;
            } else {
                response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
                response.setContentType(MediaType.APPLICATION_JSON_VALUE);
                response.getWriter().write(
                    &quot;{\&quot;error\&quot;:\&quot;Too many requests. Please try again later.\&quot;}&quot;
                );
                return false;
            }
        }
        
        return true;
    }

    private String getClientIP(HttpServletRequest request) {
        String xfHeader = request.getHeader(&quot;X-Forwarded-For&quot;);
        if (xfHeader == null) {
            return request.getRemoteAddr();
        }
        return xfHeader.split(&quot;,&quot;)[0].trim();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1768475414685&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// WebMvcConfig.java
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    
    @Autowired
    private RateLimitInterceptor rateLimitInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(rateLimitInterceptor)
                .addPathPatterns(&quot;/api/v1/sellers/documents/temp&quot;);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-path-to-node=&quot;4&quot; data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-path-to-node=&quot;4&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;6️⃣ &lt;/b&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;23&quot;&gt;마치며&lt;/b&gt;&lt;/h3&gt;
&lt;p data-path-to-node=&quot;4&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;4&quot; data-ke-size=&quot;size16&quot;&gt;&lt;u&gt;보안은 '기능 구현'만큼이나 중요하다.&lt;/u&gt; 이걸 회사 외에 사이드 프로젝트를 하면서 내가 담당하는 부분이 늘어나며 더 여실히 느끼고 있다.. 특히 비용과 직결되는 파일 업로드 기능에서는 Bucket4j와 같은 도구를 통해 최소한의 방어선을 구축하는 것이 필수적이라는 것을 이번 리뷰를 통해 다시 한번 배웠다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;4&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;4&quot; data-ke-size=&quot;size16&quot;&gt;AI를 통해 코딩을 하면서 뭐가 더 현재 상황에 적합한 방법인지 내가 결정할 수 있는 능력과, 보안이 얼마나 중요한지 깨닫고 있는 요즘이다.&lt;/p&gt;</description>
      <category>Project/phonebid</category>
      <category>APIGateway</category>
      <category>AWSS3</category>
      <category>aws트래픽제한</category>
      <category>bucket4j</category>
      <category>RateLimit</category>
      <category>RateLimiting</category>
      <category>resilience4j</category>
      <category>spring</category>
      <category>트래픽제한</category>
      <author>쉬지마 이굥진</author>
      <guid isPermaLink="true">https://developer-jinnie.tistory.com/124</guid>
      <comments>https://developer-jinnie.tistory.com/124#entry124comment</comments>
      <pubDate>Thu, 15 Jan 2026 20:18:57 +0900</pubDate>
    </item>
    <item>
      <title>주절주절 7-8월 회고</title>
      <link>https://developer-jinnie.tistory.com/122</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;409&quot; data-origin-height=&quot;376&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bL2Hhq/btsQjj8RtIE/eoYtqKZ6B1oTAyxPnHJiH1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bL2Hhq/btsQjj8RtIE/eoYtqKZ6B1oTAyxPnHJiH1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bL2Hhq/btsQjj8RtIE/eoYtqKZ6B1oTAyxPnHJiH1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbL2Hhq%2FbtsQjj8RtIE%2FeoYtqKZ6B1oTAyxPnHJiH1%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;296&quot; height=&quot;272&quot; data-origin-width=&quot;409&quot; data-origin-height=&quot;376&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;벌써 두 달이 지나서 9월이 됐다. 새삼스럽지만 시간 참~빠르다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;벌써 올해를 3달밖에 남겨두지 않고 있다닝&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;9월을 맞이하야 어김없이 돌아온 회고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1. 열일&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1191&quot; data-origin-height=&quot;646&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dHtMu2/btsQjxs2Bxs/ykoCakhhUciyrFdcWdu0C1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dHtMu2/btsQjxs2Bxs/ykoCakhhUciyrFdcWdu0C1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dHtMu2/btsQjxs2Bxs/ykoCakhhUciyrFdcWdu0C1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdHtMu2%2FbtsQjxs2Bxs%2FykoCakhhUciyrFdcWdu0C1%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;703&quot; height=&quot;381&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1191&quot; data-origin-height=&quot;646&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;6월에 여태 하던 프로젝트가 끝나고 새로운 프로젝트를 딱 7월부터 들어갔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어떻게 성과 더 낼 거 없나 배울 거 없나 하고 눈에 불을 켜고 찾아다녔음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요새 회사의 이런 저런 사정으로 이 마음이 잠깐 헤이해졌는데 회고 글 쓰면서 다시 정신 붙잡아본다.. ㅋㅋㅋㅋㅋ 회사가 잘되야 나도 잘된다는 마인드&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2. 오픽&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;7월 초엔 오픽을 봤다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 개발자 공고를 보면 오픽이나 토익 점수가 있으면 우대해주는 경우를 왕왕 봐서, 곧 다가올 하반기에 유리하지 않을까 하고 본 거였는데 직장 다니다보니 3일 벼락치기로 보게 됨 (내 돈...)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어디 서류낼 때 커트라인에는 만족하지만 내 씅에는 안찼음 ㅋㅋㅋㅋ 다음에는 일주일 공부하고 봐야지&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3. 스터디 새로운 주제 시작&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;6월까지 '주니어 백엔드 개발자가 반드시 알아야 할 실무 지식' 스터디를 끝내고 새로운 주제로 스터디가 시작됐다&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;책 이름은 'JAVA 병렬 프로그래밍'.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에 책 표지 보고 와 이거 전공책 아님? 같은 위압감(?)을 느껴서 고민했지만 후기를 보니까 옛날 책임에도 불구하고 최근까지도 후기가 많고, 내용들이 모두 n회독 하고 싶을 정도로 영양가 있다길래 바로 스터디 join&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;378&quot; data-origin-height=&quot;498&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/DM2JM/btsQiJNBvxE/BgBMQOMscDpJYdnurEAukk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/DM2JM/btsQiJNBvxE/BgBMQOMscDpJYdnurEAukk/img.png&quot; data-alt=&quot;험악한 표지..&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/DM2JM/btsQiJNBvxE/BgBMQOMscDpJYdnurEAukk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FDM2JM%2FbtsQiJNBvxE%2FBgBMQOMscDpJYdnurEAukk%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;250&quot; height=&quot;329&quot; data-origin-width=&quot;378&quot; data-origin-height=&quot;498&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;험악한 표지..&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a title=&quot;멀티코어를 100% 활용하는 자바 병렬 프로그래밍&quot; href=&quot;https://product.kyobobook.co.kr/detail/S000000935083&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;https://product.kyobobook.co.kr/detail/S000000935083&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1756865804327&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;자바 병렬 프로그래밍 | 브라이언 게츠 - 교보문고&quot; data-og-description=&quot;자바 병렬 프로그래밍 | 자바 병렬 처리 프로그램 작성 안내서!이 책은 자바 병렬 프로그래밍 참고 매뉴얼이다. 병렬 처리 관련 기능에 어떤 것이 있고, 어떻게 사용하는지에 대한 방법뿐 아니라&quot; data-og-host=&quot;product.kyobobook.co.kr&quot; data-og-source-url=&quot;https://product.kyobobook.co.kr/detail/S000000935083&quot; data-og-url=&quot;https://product.kyobobook.co.kr/detail/S000000935083&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bAV1fW/hyZGbU7A56/WgcgV5xmvRlznsG80bEDD0/img.jpg?width=458&amp;amp;height=609&amp;amp;face=0_0_458_609,https://scrap.kakaocdn.net/dn/RWiUu/hyZGlXIf5m/TalAIfCI1gBlxO0gBKtgTK/img.jpg?width=458&amp;amp;height=609&amp;amp;face=0_0_458_609,https://scrap.kakaocdn.net/dn/3bOpi/hyZGj6FvQ4/oYdCQFVrZkYny7t1tLFnY0/img.png?width=335&amp;amp;height=335&amp;amp;face=0_0_335_335&quot;&gt;&lt;a href=&quot;https://product.kyobobook.co.kr/detail/S000000935083&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://product.kyobobook.co.kr/detail/S000000935083&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bAV1fW/hyZGbU7A56/WgcgV5xmvRlznsG80bEDD0/img.jpg?width=458&amp;amp;height=609&amp;amp;face=0_0_458_609,https://scrap.kakaocdn.net/dn/RWiUu/hyZGlXIf5m/TalAIfCI1gBlxO0gBKtgTK/img.jpg?width=458&amp;amp;height=609&amp;amp;face=0_0_458_609,https://scrap.kakaocdn.net/dn/3bOpi/hyZGj6FvQ4/oYdCQFVrZkYny7t1tLFnY0/img.png?width=335&amp;amp;height=335&amp;amp;face=0_0_335_335');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;자바 병렬 프로그래밍 | 브라이언 게츠 - 교보문고&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;자바 병렬 프로그래밍 | 자바 병렬 처리 프로그램 작성 안내서!이 책은 자바 병렬 프로그래밍 참고 매뉴얼이다. 병렬 처리 관련 기능에 어떤 것이 있고, 어떻게 사용하는지에 대한 방법뿐 아니라&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;product.kyobobook.co.kr&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;책 읽어보니 밸런스가 괜찮다. 코드 컨디션 별(좋은 코드, 별로인 코드, 그럭저럭인 코드)로 설명도 해줘서 실제 코드 보면서 이해할 수 있고, 그렇다고 이론적인 부분들이 없는 것도 아니다. 깊이가 너무 얕지도 않다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금 (25.09.03 기준) 한창 진행 중인데, 멀티코어와 병렬 프로그래밍 뿐만 아니라 멀티스레드, 스레드 안정성, 자바에서 락을 거는 유형들과 jvm 동작 방식 등등 주니어 기준 도움이 많이 되는 내용들이 많아서 한번쯤 정독할 수 있다면 정독하길 권한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음주에는 나 발표인데 ,, 발표준비 해야함 행운을빌어주새요&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;4. 사이드 프로젝트 시작&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기획자 한 분이랑 디자이너 한 분이랑 총괄 한 분이랑 개발자 두 명 (은 주창이), 총 다섯 명이서 진행하는 사이드 프로젝트를 하게 됐다!&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그냥 부트캠프에서 학습 차원으로 하는 팀 플젝이 아니라, 우리 서비스를 실제로 필요로 하는 실사용자들이 생긴다고 생각하니 뭔가 기능 한 개를 만들더라도 느껴지는 중압감(?)이 커지는 걸 느낀다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 기능 하나를 만들 때 더 신중하게 생각하고 만들게 되는데, 이러다 보니 평소 내 개발 속도보다 더 뒤쳐지는 것 같다. (퇴근하고 개발해서 그런가 ㅠㅋㅋㅋ) 하나하나 장단점 생각하면서 기술적 의사결정 하다보면 실력이 더 늘겠거니,, 하면서 재밌게 하려고 노력중이다. 그래도 일로서 회사에서 코딩할 때랑은 다르게 사이드 플젝 코딩할때는 더 재밌게 코딩한다. &lt;s&gt;(도파민 풀충전)&lt;/s&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;466&quot; data-origin-height=&quot;259&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/UyFVp/btsQgUpyKpd/6vZGZ9la6ieplsqkbTFvxk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/UyFVp/btsQgUpyKpd/6vZGZ9la6ieplsqkbTFvxk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/UyFVp/btsQgUpyKpd/6vZGZ9la6ieplsqkbTFvxk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FUyFVp%2FbtsQgUpyKpd%2F6vZGZ9la6ieplsqkbTFvxk%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;403&quot; height=&quot;224&quot; data-origin-width=&quot;466&quot; data-origin-height=&quot;259&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사이드 플젝도 잘하고 싶고 일도 잘하고 싶고.. 일과 퇴근 후 루틴 사이에 균형을 잘 찾아봐야 할 것 같다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;5. 인강 도장깨기&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;출퇴근/점심시간에 짬짬히 갖고 있는 인강들을 다 들었다 (드디어..)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생각보다 김영한님 강의는 도움이 많이 됐다. 사실 갓 개발에 입문한 상태에서 김영한님 강의 들었을 때는 너무 나에겐 지엽적이라는 생각이 들어서 재미가 없었는데, 지금 들어보니 오히려 지엽적이여서 재밌고 깊이감이 느껴진다. 입문했을 땐 배움의 깊이가 짧아서 알아듣는 것들이 없으니까 재미없게 느껴졌었나보다&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;952&quot; data-origin-height=&quot;390&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bicHgZ/btsQjOg9LDr/ydBy65u4iMuVNcip80rRtK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bicHgZ/btsQjOg9LDr/ydBy65u4iMuVNcip80rRtK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bicHgZ/btsQjOg9LDr/ydBy65u4iMuVNcip80rRtK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbicHgZ%2FbtsQjOg9LDr%2FydBy65u4iMuVNcip80rRtK%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;633&quot; height=&quot;259&quot; data-origin-width=&quot;952&quot; data-origin-height=&quot;390&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;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;6. 1일 1커밋 점검&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지난 달 들에 비하면 중간중간 잔디 빵꾸가 많다. 9-10월에는 잔디 빵꾸를 내지말자..★&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;124&quot; data-origin-height=&quot;126&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bqKHha/btsQiW7RTum/gZKBsHgjbcCGpQWKnTRhzK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bqKHha/btsQiW7RTum/gZKBsHgjbcCGpQWKnTRhzK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bqKHha/btsQiW7RTum/gZKBsHgjbcCGpQWKnTRhzK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbqKHha%2FbtsQiW7RTum%2FgZKBsHgjbcCGpQWKnTRhzK%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;124&quot; height=&quot;126&quot; data-origin-width=&quot;124&quot; data-origin-height=&quot;126&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;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;요약 및 7-8월 KPT 회고&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;432&quot; data-origin-height=&quot;414&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/6Nu5l/btsQkToJxF1/migokFo7SekkeKFG8eY2Kk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/6Nu5l/btsQkToJxF1/migokFo7SekkeKFG8eY2Kk/img.png&quot; data-alt=&quot;5-6월 KPT 회고&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/6Nu5l/btsQkToJxF1/migokFo7SekkeKFG8eY2Kk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F6Nu5l%2FbtsQkToJxF1%2FmigokFo7SekkeKFG8eY2Kk%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;299&quot; height=&quot;320&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;432&quot; data-origin-height=&quot;414&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;5-6월 KPT 회고&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지난 5-6월 회고 때, 뭔가 열심히 많이 했는데 결과가 없는게 속상해서 다음 달에는 결과를 내는 뭔갈 하자. 했는데.. 결과는 모르겠지만 뭔가를 많이 벌려놓긴 한거같다 ㅋㅋ&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt; Keep&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;뭐든 열심히 하긴 함 again&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt; &lt;b&gt; &lt;/b&gt; Problem&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;여러 활동을 병행하다보니 루틴이 다소 무너짐&amp;nbsp;&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; &lt;/b&gt;Try&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;소올~직히 8월 약간 설렁설렁 살았는데 담달부턴 다시 정신차리고 쉬지마이굥진 해보자 !~!!!~!&lt;/p&gt;</description>
      <category>Review</category>
      <category>KPT</category>
      <category>개발자</category>
      <category>회고</category>
      <author>쉬지마 이굥진</author>
      <guid isPermaLink="true">https://developer-jinnie.tistory.com/122</guid>
      <comments>https://developer-jinnie.tistory.com/122#entry122comment</comments>
      <pubDate>Wed, 3 Sep 2025 17:09:03 +0900</pubDate>
    </item>
  </channel>
</rss>