<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>아무튼, 쓰기</title>
    <link>https://jsw5913.tistory.com/</link>
    <description>개발, 독서, 생각</description>
    <language>ko</language>
    <pubDate>Thu, 16 Apr 2026 18:58:15 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>순원이</managingEditor>
    <image>
      <title>아무튼, 쓰기</title>
      <url>https://tistory1.daumcdn.net/tistory/5230810/attach/d2c7624e86be46ffb5e894e06be8cbea</url>
      <link>https://jsw5913.tistory.com</link>
    </image>
    <item>
      <title>Graceful Shutdown 딮다이브</title>
      <link>https://jsw5913.tistory.com/311</link>
      <description>&lt;style&gt;
/* 1. 기본 레이아웃 및 폰트 강제 적용 */
.notion-tistory-container {
    font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, &quot;Segoe UI Variable Display&quot;, &quot;Segoe UI&quot;, Helvetica, &quot;Apple Color Emoji&quot;, Arial, sans-serif !important;
    color: rgb(55, 53, 47) !important;
    line-height: 1.625 !important;
    max-width: 900px !important;
    margin: 0 auto !important;
    word-break: break-word !important;
    text-align: left !important;
}

/* 노션 특유의 display:contents 보정 */
.notion-tistory-container [style*=&quot;display:contents&quot;] {
    display: block !important;
}

/* 2. 제목 여백 (노션 여백 효과) */
.notion-tistory-container h1 { font-size: 1.875rem !important; font-weight: 700 !important; margin-top: 3.5rem !important; margin-bottom: 0.5rem !important; color: black !important; }
.notion-tistory-container h2 { font-size: 1.5rem !important; font-weight: 600 !important; margin-top: 2.5rem !important; margin-bottom: 0.3rem !important; }
.notion-tistory-container h3 { font-size: 1.25rem !important; font-weight: 600 !important; margin-top: 1.8rem !important; margin-bottom: 0.2rem !important; }


/* 3. 노션 하이라이트 배경색 (Pastel Tone 복구) */
.notion-tistory-container mark { background-color: transparent !important; } /* 기본 형광색 제거 */

.notion-tistory-container .highlight-yellow_background { background: #fbf3db !important; padding: 0.1em 0.2em; border-radius: 3px; }
.notion-tistory-container .highlight-gray_background   { background: #f1f1ef !important; padding: 0.1em 0.2em; border-radius: 3px; }
.notion-tistory-container .highlight-brown_background  { background: #f4eeee !important; padding: 0.1em 0.2em; border-radius: 3px; }
.notion-tistory-container .highlight-orange_background { background: #faebdd !important; padding: 0.1em 0.2em; border-radius: 3px; }
.notion-tistory-container .highlight-teal_background   { background: #edf3ec !important; padding: 0.1em 0.2em; border-radius: 3px; }
.notion-tistory-container .highlight-blue_background   { background: #e7f3f8 !important; padding: 0.1em 0.2em; border-radius: 3px; }
.notion-tistory-container .highlight-purple_background { background: #f6f3f9 !important; padding: 0.1em 0.2em; border-radius: 3px; }
.notion-tistory-container .highlight-pink_background   { background: #faf3f8 !important; padding: 0.1em 0.2em; border-radius: 3px; }
.notion-tistory-container .highlight-red_background    { background: #fdebec !important; padding: 0.1em 0.2em; border-radius: 3px; }

/* 4. 코드 블록 및 인라인 코드 (image_330116.jpg 버그 수정) */
/* (1) 큰 박스 형태 (pre/block code): 검정 글씨 + 연한 회색 배경 */
.notion-tistory-container pre, 
.notion-tistory-container .code {
    background: rgba(242, 241, 238, 0.6) !important; /* image_32fe30.png의 연한 회색 */
    color: rgb(55, 53, 47) !important; /* 빨간색 글씨 해결 */
    border: none !important;
    border-radius: 4px !important;
    padding: 1.5em !important;
    margin: 1em 0 !important;
    font-family: &quot;SFMono-Regular&quot;, Menlo, Consolas, monospace !important;
    font-size: 90% !important;
    white-space: pre-wrap !important;
}

/* (2) 인라인 코드 (문장 중간): 노션 특유의 붉은 텍스트 유지 */
.notion-tistory-container :not(pre) &gt; code {
    background: rgba(135, 131, 120, 0.15) !important;
    color: #eb5757 !important;
    padding: 0.2em 0.4em !important;
    border-radius: 3px !important;
    font-size: 85% !important;
}

/* 5. 인용구 (Blockquote) */
.notion-tistory-container blockquote {
    font-size: 1em !important;
    margin: 1.2em 0 !important;
    padding-left: 1.2em !important;
    border-left: 3px solid rgb(55, 53, 47) !important;
    border-top: none !important; border-right: none !important; border-bottom: none !important;
    background: transparent !important;
}

/* 6. 테이블 스타일 */
.notion-tistory-container table {
    border-collapse: collapse !important;
    width: 100% !important;
    margin-top: 1em !important;
    font-size: 0.9rem !important;
}
.notion-tistory-container th, .notion-tistory-container td {
    border: 1px solid rgba(55, 53, 47, 0.09) !important;
    padding: 0.6em !important;
    text-align: left !important;
}
.notion-tistory-container .simple-table-header-color {
    background: rgb(247, 246, 243) !important;
    font-weight: 500 !important;
}

/* 7. 체크박스 (To-do 리스트용 SVG 완전 복구) */
.notion-tistory-container .checkbox {
    display: inline-flex !important;
    vertical-align: text-bottom !important;
    width: 18px !important;
    height: 18px !important;
    margin-right: 7px !important;
}
.notion-tistory-container .checkbox-on {
    background-image: url(&quot;data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Crect%20width%3D%2216%22%20height%3D%2216%22%20fill%3D%22%2358A9D7%22%2F%3E%0A%3Cpath%20d%3D%22M6.71429%2012.2852L14%204.9995L12.7143%203.71436L6.71429%209.71378L3.28571%206.2831L2%207.57092L6.71429%2012.2852Z%22%20fill%3D%22white%22%2F%3E%0A%3C%2Fsvg%3E&quot;) !important;
}
.notion-tistory-container .checkbox-off {
    background-image: url(&quot;data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Crect%20x%3D%220.75%22%20y%3D%220.75%22%20width%3D%2214.5%22%20height%3D%2214.5%22%20fill%3D%22white%22%20stroke%3D%22%2336352F%22%20stroke-width%3D%221.5%22%2F%3E%0A%3C%2Fsvg%3E&quot;) !important;
}

/* 8. 리스트 및 기타 */
.notion-tistory-container ul, .notion-tistory-container ol {
    padding-inline-start: 1.7em !important;
    margin: 0.6em 0 !important;
}
.notion-tistory-container li { margin-bottom: 0.4em !important; }
.notion-tistory-container hr {
    border: none !important;
    border-bottom: 1px solid rgba(55, 53, 47, 0.09) !important;
    margin: 1.5em 0 !important;
}
&lt;/style&gt;

&lt;div class=&quot;notion-tistory-container&quot;&gt;
  &lt;div class=&quot;page-body&quot;&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;blockquote id=&quot;302d70fa-a068-81f8-a7ba-c529c1de76b8&quot; class=&quot;&quot;&gt;식당 문을 닫을 시간이 되었습니다. 손님들에게 ’지금 당장 나가!’라고 소리치시겠습니까, 아니면 ’마지막 주문은 끝났으니, 드시던 음식은 편안히 드시고 가세요’라고 안내하시겠습니까?&lt;/blockquote&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;hr id=&quot;302d70fa-a068-813b-8df0-c565da0009c1&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8051-8e74-e3b7561b1a0f&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h1 id=&quot;302d70fa-a068-813f-9666-d43ee3cee4fd&quot; class=&quot;&quot;&gt;Part 1. Why - 왜 우아하게 꺼야 하는가? &lt;/h1&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h2 id=&quot;302d70fa-a068-81a3-bca4-dc44ecaad7f3&quot; class=&quot;&quot;&gt;1. 탄생배경: 백화점 정전 사태&lt;/h2&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-819a-a005-cdee3e3d1dc4&quot; class=&quot;&quot;&gt;여러분이 백화점 에스컬레이터를 타고 3층에서 4층으로 올라가는 중이라고 상상해 봅시다. 그런데 갑자기 전기가 뚝 끊깁니다. 에스컬레이터는 급정거하고, 사람들은 휘청거립니다. 계산대에서 카드를 긁던 손님은 “결제가 된 거야, 만 거야?”라며 불안해합니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-81b4-9f72-f0c2f706d5fe&quot; class=&quot;&quot;&gt;서버 배포도 마찬가지입니다. 우리는 하루에도 수십 번씩 새로운 코드를 배포합니다. 그때마다 실행 중인 애플리케이션을 종료하고(&lt;code&gt;SIGKILL&lt;/code&gt;) 새 버전을 띄웁니다. 만약 아무런 대비 없이 프로세스를 죽인다면 어떤 일이 벌어질까요?&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-803a-b3e0-cedf1a34538b&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;302d70fa-a068-81ee-bcaa-c6c18136cb42&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;&lt;strong&gt;진행 중인 결제 요청 중단&lt;/strong&gt; 사용자는 돈이 빠져나갔는데, 주문은 생성되지 않습니다.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;302d70fa-a068-81e9-8d37-f066037033b3&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;&lt;strong&gt;데이터 정합성 깨짐&lt;/strong&gt; DB 트랜잭션이 커밋되지 않은 채 연결이 끊깁니다.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;302d70fa-a068-810a-869d-d20cd6d4708e&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;&lt;strong&gt;클라이언트 에러 급증&lt;/strong&gt; 502 Bad Gateway 에러가 사용자 화면을 뒤덮습니다.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-80d4-a8bb-ebea1e02ba05&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-81f3-b740-c160869a2548&quot; class=&quot;&quot;&gt;&lt;mark class=&quot;highlight-yellow_background&quot;&gt;&lt;strong&gt;Graceful Shutdown(우아한 종료)은 바로 이 갑작스러운 정전을 안전한 영업 종료로 바꾸는 기술입니다.&lt;/strong&gt;&lt;/mark&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h2 id=&quot;302d70fa-a068-81af-802d-d8d1c7043d9f&quot; class=&quot;&quot;&gt;2. 이 기술이 해결하는 것과 못하는 것&lt;/h2&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;table id=&quot;302d70fa-a068-81b7-a2a2-f48cded7f361&quot; class=&quot;simple-table&quot;&gt;&lt;thead class=&quot;simple-table-header&quot;&gt;&lt;tr id=&quot;302d70fa-a068-81ae-a2ce-ce9f1bf5a0ea&quot;&gt;&lt;th id=&quot;`fNv&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;구분&lt;/th&gt;&lt;th id=&quot;tRj&amp;gt;&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;Graceful Shutdown이 해결하는 것 (✅)&lt;/th&gt;&lt;th id=&quot;_qIC&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;해결하지 못하는 것 (❌)&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr id=&quot;302d70fa-a068-8143-a910-d6a67a7c53cd&quot;&gt;&lt;td id=&quot;`fNv&quot; class=&quot;&quot;&gt;&lt;strong&gt;요청 손실&lt;/strong&gt;&lt;/td&gt;&lt;td id=&quot;tRj&amp;gt;&quot; class=&quot;&quot;&gt;처리 중인 요청을 끝까지 완료 후 종료&lt;/td&gt;&lt;td id=&quot;_qIC&quot; class=&quot;&quot;&gt;이미 타임아웃이 발생한 악성 요청&lt;/td&gt;&lt;/tr&gt;&lt;tr id=&quot;302d70fa-a068-81ff-8f85-e9bc5fb1f58f&quot;&gt;&lt;td id=&quot;`fNv&quot; class=&quot;&quot;&gt;&lt;strong&gt;데이터&lt;/strong&gt;&lt;/td&gt;&lt;td id=&quot;tRj&amp;gt;&quot; class=&quot;&quot;&gt;DB 커넥션 풀을 안전하게 닫고 트랜잭션 종료&lt;/td&gt;&lt;td id=&quot;_qIC&quot; class=&quot;&quot;&gt;하드웨어 전원 차단 (플러그 뽑힘)&lt;/td&gt;&lt;/tr&gt;&lt;tr id=&quot;302d70fa-a068-8107-8245-f5e8b1d1704c&quot;&gt;&lt;td id=&quot;`fNv&quot; class=&quot;&quot;&gt;&lt;strong&gt;사용자 경험&lt;/strong&gt;&lt;/td&gt;&lt;td id=&quot;tRj&amp;gt;&quot; class=&quot;&quot;&gt;배포 중에도 500 에러 없이 무중단 서비스&lt;/td&gt;&lt;td id=&quot;_qIC&quot; class=&quot;&quot;&gt;배포 후 새 버전의 논리적 버그&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;hr id=&quot;302d70fa-a068-81ec-9c85-f489b91bb52d&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-804b-89fa-d93d0245295d&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h1 id=&quot;302d70fa-a068-8117-ba37-cad79648bfd2&quot; class=&quot;&quot;&gt;Part 2. How - 내부 동작 원리&lt;/h1&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-802a-80a8-e86bda9f194b&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h2 id=&quot;302d70fa-a068-8112-9ed1-e4c90a458fed&quot; class=&quot;&quot;&gt;3. 내부 동작: 3단계로 이해하기&lt;/h2&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8111-9ca5-d7b3c82818d6&quot; class=&quot;&quot;&gt;Spring Boot가 &lt;code&gt;SIGTERM&lt;/code&gt; 신호를 받으면 내부적으로 &lt;strong&gt;ContextClosedEvent가 발생&lt;/strong&gt;하며 다음과 같은 일이 순차적으로 일어납니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;302d70fa-a068-8130-963d-d892c5803459&quot; class=&quot;&quot;&gt;Step 1: 입장 금지 (WebServer Shutdown)&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js&quot; integrity=&quot;sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/script&gt;&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css&quot; integrity=&quot;sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;pre id=&quot;302d70fa-a068-8185-a30f-f174f3e69754&quot; class=&quot;code code-wrap language-plain&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;Text language-plain&quot; style=&quot;white-space:pre-wrap;word-break:break-all&quot;&gt;&quot;죄송합니다, 오늘 영업은 종료되었습니다&quot;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;302d70fa-a068-81d1-83a0-f801a1a59b18&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;&lt;strong&gt;Actuator 연동&lt;/strong&gt;  &lt;code&gt;/actuator/health/readiness&lt;/code&gt; 엔드포인트가  &lt;strong&gt;503 Service Unavailable&lt;/strong&gt;을 반환합니다&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;302d70fa-a068-8173-9d27-f2ad3fc0301a&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;&lt;strong&gt;톰캣(Tomcat)&lt;/strong&gt; 더 이상 새로운 HTTP 요청을 받지 않습니다&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;302d70fa-a068-81a1-89c3-e9fb67f9d072&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;&lt;strong&gt;기존 연결 유지&lt;/strong&gt; 이미 들어온 요청은 처리를 완료할 때까지 대기&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;302d70fa-a068-8129-8a09-fcf25611cdb2&quot; class=&quot;&quot;&gt;Step 2: 기존 손님 응대 (Thread Wait)&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js&quot; integrity=&quot;sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/script&gt;&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css&quot; integrity=&quot;sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;pre id=&quot;302d70fa-a068-8138-a223-ddfff5d2ee6b&quot; class=&quot;code code-wrap language-plain&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;Text language-plain&quot; style=&quot;white-space:pre-wrap;word-break:break-all&quot;&gt;&quot;드시던 음식은 편안히 드시고 가세요&quot;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;302d70fa-a068-81a6-b0d1-d3bb76b562ff&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;현재 처리 중인 스레드가 작업을 마칠 때까지 기다립니다&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;302d70fa-a068-812c-b4e2-d9ae6f59cf8a&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;이때 설정된 타임아웃(&lt;code&gt;timeout-per-shutdown-phase&lt;/code&gt;)이 중요합니다&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;302d70fa-a068-8107-ab30-dc5357c653ee&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;HTTP 요청, 비동기 작업, 스케줄러가 모두 완료될 때까지 대기&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;302d70fa-a068-8197-be1f-cba3642d92d9&quot; class=&quot;&quot;&gt;Step 3: 청소 및 셔터 내리기 (Resource Cleanup)&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js&quot; integrity=&quot;sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/script&gt;&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css&quot; integrity=&quot;sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;pre id=&quot;302d70fa-a068-812a-8b2a-fb8ca3e9f7f8&quot; class=&quot;code code-wrap language-plain&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;Text language-plain&quot; style=&quot;white-space:pre-wrap;word-break:break-all&quot;&gt;&quot;불 끄고, 문 잠그고, 가스 밸브 잠그기&quot;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;302d70fa-a068-819e-bc01-d94138ce392b&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;비동기 스레드풀 중단&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;302d70fa-a068-81bf-bc53-c842848c8555&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;Scheduler 중단&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;302d70fa-a068-8160-9f9e-eaedbf38e985&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;Controller, Service, Repository 종료&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;302d70fa-a068-81ca-bcde-ccafbb94003f&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;DB Connection Pool (HikariCP) 종료&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;302d70fa-a068-818c-a8e1-e0bb71c33031&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;ApplicationContext 파기&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;blockquote id=&quot;302d70fa-a068-81c0-b677-f04a562e0eba&quot; class=&quot;&quot;&gt;&lt;strong&gt;Spring Boot 설정 (application.yml)&lt;/strong&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js&quot; integrity=&quot;sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/script&gt;&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css&quot; integrity=&quot;sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;pre id=&quot;302d70fa-a068-81ca-94f6-c9ec13f96051&quot; class=&quot;code code-wrap language-yaml&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-yaml&quot; style=&quot;white-space:pre-wrap;word-break:break-all&quot;&gt;server:
shutdown: graceful # 기본값은 immediate

spring:
lifecycle:
timeout-per-shutdown-phase: 30s # 정리할 시간 30초 줌&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/blockquote&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8092-839c-ebbda1f985c8&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h2 id=&quot;302d70fa-a068-81be-9bc9-db24917c5667&quot; class=&quot;&quot;&gt;4. Deep Dive: OS Signal부터 TCP까지&lt;/h2&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;302d70fa-a068-811c-86c8-d7e0fce1d3af&quot; class=&quot;&quot;&gt;4.1 SIGTERM vs SIGKILL: 커널 레벨의 대화&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8164-9d24-c42f5e3852f2&quot; class=&quot;&quot;&gt;운영체제는 프로세스를 종료할 때 &lt;strong&gt;Signal&lt;/strong&gt;이라는 신호를 보냅니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;table id=&quot;302d70fa-a068-8136-811e-ca6e84f61cd1&quot; class=&quot;simple-table&quot;&gt;&lt;thead class=&quot;simple-table-header&quot;&gt;&lt;tr id=&quot;302d70fa-a068-819c-a9f4-e4c5cef983be&quot;&gt;&lt;th id=&quot;xU]:&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;Signal&lt;/th&gt;&lt;th id=&quot;k]va&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;코드&lt;/th&gt;&lt;th id=&quot;s|Eh&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;의미&lt;/th&gt;&lt;th id=&quot;@mqY&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;프로세스의 선택권&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr id=&quot;302d70fa-a068-815b-b128-e8ad71a52db3&quot;&gt;&lt;td id=&quot;xU]:&quot; class=&quot;&quot;&gt;&lt;strong&gt;SIGTERM&lt;/strong&gt;&lt;/td&gt;&lt;td id=&quot;k]va&quot; class=&quot;&quot;&gt;15&lt;/td&gt;&lt;td id=&quot;s|Eh&quot; class=&quot;&quot;&gt;“이제 곧 문 닫을 거야. 정리 좀 해.”&lt;/td&gt;&lt;td id=&quot;@mqY&quot; class=&quot;&quot;&gt;✅ 가능 (정중한 요청)&lt;/td&gt;&lt;/tr&gt;&lt;tr id=&quot;302d70fa-a068-8160-bc1d-ca80f475fe7a&quot;&gt;&lt;td id=&quot;xU]:&quot; class=&quot;&quot;&gt;&lt;strong&gt;SIGKILL&lt;/strong&gt;&lt;/td&gt;&lt;td id=&quot;k]va&quot; class=&quot;&quot;&gt;9&lt;/td&gt;&lt;td id=&quot;s|Eh&quot; class=&quot;&quot;&gt;“닥치고 짐 싸. 당장 나가.”&lt;/td&gt;&lt;td id=&quot;@mqY&quot; class=&quot;&quot;&gt;❌ 불가능 (강제 퇴거)&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8107-a62f-cd8388b1172a&quot; class=&quot;&quot;&gt;&lt;strong&gt;SIGTERM의 동작 원리&lt;/strong&gt;&lt;br&gt;1. 운영체제가 프로세스에게 Signal 15번 전송&lt;br&gt;2. JVM의 &lt;strong&gt;Shutdown Hook&lt;/strong&gt;이 이를 감지&lt;br&gt;3. Spring Boot의 &lt;code&gt;SpringApplication.exit()&lt;/code&gt; 메서드 호출&lt;br&gt;4. &lt;code&gt;ContextClosedEvent&lt;/code&gt; 발생 → Graceful Shutdown 시작&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8194-982d-d00475b9857a&quot; class=&quot;&quot;&gt;&lt;strong&gt;SIGKILL의 위험성&lt;/strong&gt;&lt;br&gt;- 프로세스는 이 시그널을 &lt;strong&gt;감지(Catch)하거나 무시(Ignore)할 수 없습니다&lt;/strong&gt;&lt;br&gt;- 커널이 프로세스의 PCB(Process Control Block)를 강제로 회수&lt;br&gt;- &lt;code&gt;try-catch-finally&lt;/code&gt; 블록의 &lt;code&gt;finally&lt;/code&gt;조차 실행되지 않음&lt;br&gt;- 열려있던 파일, 소켓, DB 연결이 모두 비정상 종료&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-805f-847f-caf8232b3400&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;302d70fa-a068-8144-b8f8-c0071c158944&quot; class=&quot;&quot;&gt;4.2 TCP 4-Way Handshake: 네트워크 연결 종료&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-81e8-92ea-f056b5c6ea6e&quot; class=&quot;&quot;&gt;애플리케이션이 종료될 때 네트워크 레벨에서는 소켓 연결 해제 과정인 &lt;strong&gt;4-Way Handshake&lt;/strong&gt;가 발생합니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js&quot; integrity=&quot;sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/script&gt;&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css&quot; integrity=&quot;sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;pre id=&quot;302d70fa-a068-8160-9037-e86daa594b03&quot; class=&quot;code code-wrap language-plain&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;Text language-plain&quot; style=&quot;white-space:pre-wrap;word-break:break-all&quot;&gt;Client                          Server
  |                               |
  |  &amp;lt;---- FIN (종료 요청) ----   | (1) Active Close
  |                               |
  |  ---- ACK (확인) ----&amp;gt;        | (2) CLOSE_WAIT
  |                               |
  |  &amp;lt;---- FIN (종료 준비 완료) - | (3) LAST_ACK
  |                               |
  |  ---- ACK (최종 확인) ----&amp;gt;   | (4) TIME_WAIT
  |                               |
  ✅ 연결 완전 종료&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-812d-9be6-d46930b78161&quot; class=&quot;&quot;&gt;&lt;strong&gt;⚠️ 주의할 점: SIGKILL의 경우&lt;/strong&gt;&lt;br&gt;- 서버가 갑자기 &lt;code&gt;SIGKILL&lt;/code&gt;로 죽으면, &lt;code&gt;FIN&lt;/code&gt; 패킷을 보낼 겨를도 없이 사라집니다&lt;br&gt;- 클라이언트는 연결이 끊어진 줄 모르고 계속 데이터를 보냅니다&lt;br&gt;- 결국 &lt;code&gt;RST(Reset)&lt;/code&gt; 패킷을 받거나 타임아웃이 발생 → &lt;code&gt;Connection Reset by Peer&lt;/code&gt; 에러&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-81e0-bcd6-cb5e0be91fd3&quot; class=&quot;&quot;&gt;&lt;strong&gt;Graceful Shutdown의 역할&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-81cb-8713-dedefee2ae5d&quot; class=&quot;&quot;&gt;&lt;code&gt;FIN&lt;/code&gt; 패킷을 정상적으로 보낼 시간을 벌어주어, 클라이언트에게 “나 이제 종료할게”라고 정중하게 알릴 수 있습니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8044-a58a-e00246cb0128&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;302d70fa-a068-81ea-89fd-c4c21990b04e&quot; class=&quot;&quot;&gt;4.3 BIO vs NIO: 인터럽트 처리의 차이&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8162-8a19-fe7d8d518eaa&quot; class=&quot;&quot;&gt;&lt;strong&gt;문제 상황&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-81af-9708-f657f5b07c7d&quot; class=&quot;&quot;&gt;종료 신호를 받아서 스레드에게 &lt;code&gt;interrupt()&lt;/code&gt;를 보냈는데도 스레드가 멈추지 않는 경우가 있습니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js&quot; integrity=&quot;sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/script&gt;&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css&quot; integrity=&quot;sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;pre id=&quot;302d70fa-a068-81a3-a6e3-c49c68b4e4e3&quot; class=&quot;code code-wrap language-java&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-java&quot; style=&quot;white-space:pre-wrap;word-break:break-all&quot;&gt;// ❌ 전통적인 BIO (Blocking I/O)
InputStream input = socket.getInputStream();
int data = input.read();  // interrupt() 받아도 계속 블로킹됨!&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8100-b26a-d73649391531&quot; class=&quot;&quot;&gt;&lt;strong&gt;왜 BIO는 인터럽트를 무시할까?&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ol type=&quot;1&quot; id=&quot;302d70fa-a068-8158-9a86-eaa498628cec&quot; class=&quot;numbered-list&quot; start=&quot;1&quot;&gt;&lt;li&gt;&lt;strong&gt;Thread.interrupt()의 실체&lt;/strong&gt;: 자바에서 &lt;code&gt;interrupt()&lt;/code&gt;를 호출하면 해당 스레드의 &lt;strong&gt;인터럽트 플래그(boolean flag)만 true로 설정&lt;/strong&gt;합니다. 실제로 CPU 실행을 강제로 멈추는 것이 아닙니다.&lt;/li&gt;&lt;/ol&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ol type=&quot;1&quot; id=&quot;302d70fa-a068-812d-bf77-d69e4db19444&quot; class=&quot;numbered-list&quot; start=&quot;2&quot;&gt;&lt;li&gt;&lt;strong&gt;Native Method의 무관심&lt;/strong&gt;: &lt;code&gt;InputStream.read()&lt;/code&gt;는 내부적으로 OS의 &lt;code&gt;recv()&lt;/code&gt; 시스템 콜을 호출합니다. 전통적인 BIO 구현체는 이 플래그를 주기적으로 확인하지 않도록 설계되었습니다.&lt;/li&gt;&lt;/ol&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ol type=&quot;1&quot; id=&quot;302d70fa-a068-8183-a410-d84c75a545d8&quot; class=&quot;numbered-list&quot; start=&quot;3&quot;&gt;&lt;li&gt;&lt;strong&gt;OS의 입장&lt;/strong&gt;: 리눅스 커널에서 해당 스레드는 I/O 대기 상태(&lt;code&gt;TASK_INTERRUPTIBLE&lt;/code&gt;)이지만, 자바 레벨의 &lt;code&gt;interrupt()&lt;/code&gt;는 OS 시그널이 아니므로 커널을 깨우지 못합니다.&lt;/li&gt;&lt;/ol&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ol type=&quot;1&quot; id=&quot;302d70fa-a068-810f-a5b5-e88fbb270c2f&quot; class=&quot;numbered-list&quot; start=&quot;4&quot;&gt;&lt;li&gt;&lt;strong&gt;반면 NIO는?&lt;/strong&gt;: &lt;code&gt;AbstractInterruptibleChannel&lt;/code&gt;이 인터럽트 발생 시 채널을 비동기적으로 닫아버려(&lt;code&gt;close()&lt;/code&gt;) OS 블로킹을 강제로 풉니다.&lt;/li&gt;&lt;/ol&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js&quot; integrity=&quot;sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/script&gt;&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css&quot; integrity=&quot;sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;pre id=&quot;302d70fa-a068-8153-b255-c1064afc8fa7&quot; class=&quot;code code-wrap language-java&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-java&quot; style=&quot;white-space:pre-wrap;word-break:break-all&quot;&gt;// ✅ NIO (Non-blocking I/O)
SocketChannel channel = SocketChannel.open();
ByteBuffer buffer = ByteBuffer.allocate(1024);
channel.read(buffer);  // interrupt() 받으면 ClosedByInterruptException 발생!&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8133-aa21-ea28ce5997c4&quot; class=&quot;&quot;&gt;&lt;strong&gt;실무 적용&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-81f1-9df4-f2838733a6c7&quot; class=&quot;&quot;&gt;BIO 기반 라이브러리를 사용한다면:&lt;br&gt;- &lt;code&gt;interrupt()&lt;/code&gt;만으로는 부족합니다&lt;br&gt;- &lt;strong&gt;소켓을 강제로 닫아버리는 것&lt;/strong&gt;(&lt;code&gt;socket.close()&lt;/code&gt;)이 확실한 종료 방법입니다&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-803a-a226-ee2c902645ab&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h2 id=&quot;302d70fa-a068-8167-86f5-c5df62738256&quot; class=&quot;&quot;&gt;5. Spring Boot는 어떤 순서로 죽는가? (Lifecycle)&lt;/h2&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-80da-bcdc-ea9be17cfe6f&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;302d70fa-a068-81ee-b2e4-cb5e40c7a74e&quot; class=&quot;&quot;&gt;5.1 SmartLifecycle과 Phase 개념&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-81dc-ab84-f7fd5653140f&quot; class=&quot;&quot;&gt;Spring Boot는 &lt;strong&gt;SmartLifecycle&lt;/strong&gt; 인터페이스를 통해 시작과 종료의 순서를 제어합니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-81db-abe2-f8af4fb24c19&quot; class=&quot;&quot;&gt;&lt;strong&gt;핵심 원칙&lt;/strong&gt;&lt;br&gt;- &lt;strong&gt;시작할 때&lt;/strong&gt; &lt;code&gt;Integer.MIN_VALUE&lt;/code&gt; (가장 낮은 값)부터 순차적으로 시작&lt;br&gt;- &lt;strong&gt;종료할 때&lt;/strong&gt; &lt;code&gt;Integer.MAX_VALUE&lt;/code&gt; (가장 높은 값)부터 역순으로 종료&lt;br&gt;- 즉, &lt;strong&gt;가장 늦게 시작한 녀석이 가장 먼저 종료&lt;/strong&gt; (LIFO 구조)&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-809d-9b5b-f1db93338ce6&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;302d70fa-a068-8155-b107-c5762b270dd2&quot; class=&quot;&quot;&gt;5.2 핵심 컴포넌트별 Phase 값과 종료 순서&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;table id=&quot;302d70fa-a068-8118-b04e-dc9be27fda0b&quot; class=&quot;simple-table&quot;&gt;&lt;thead class=&quot;simple-table-header&quot;&gt;&lt;tr id=&quot;302d70fa-a068-814e-a243-faec19a9c9eb&quot;&gt;&lt;th id=&quot;?~}@&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;순서&lt;/th&gt;&lt;th id=&quot;ivwm&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;컴포넌트&lt;/th&gt;&lt;th id=&quot;jyv&amp;gt;&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;Phase 값&lt;/th&gt;&lt;th id=&quot;vu&amp;gt;f&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;실제 정수값&lt;/th&gt;&lt;th id=&quot;Vqqi&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;설명&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr id=&quot;302d70fa-a068-81ae-a78f-f43dc7e61ef1&quot;&gt;&lt;td id=&quot;?~}@&quot; class=&quot;&quot;&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;&lt;td id=&quot;ivwm&quot; class=&quot;&quot;&gt;&lt;code&gt;WebServerGracefulShutdown&lt;/code&gt;&lt;/td&gt;&lt;td id=&quot;jyv&amp;gt;&quot; class=&quot;&quot;&gt;&lt;code&gt;MAX_VALUE - 1024&lt;/code&gt;&lt;/td&gt;&lt;td id=&quot;vu&amp;gt;f&quot; class=&quot;&quot;&gt;2,147,482,623&lt;/td&gt;&lt;td id=&quot;Vqqi&quot; class=&quot;&quot;&gt;가장 먼저 실행되어 새로운 HTTP 요청을 차단&lt;/td&gt;&lt;/tr&gt;&lt;tr id=&quot;302d70fa-a068-81e1-805a-c800001bad4a&quot;&gt;&lt;td id=&quot;?~}@&quot; class=&quot;&quot;&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;&lt;td id=&quot;ivwm&quot; class=&quot;&quot;&gt;&lt;code&gt;KafkaMessageListenerContainer&lt;/code&gt;&lt;/td&gt;&lt;td id=&quot;jyv&amp;gt;&quot; class=&quot;&quot;&gt;&lt;code&gt;MAX_VALUE - 100&lt;/code&gt;&lt;/td&gt;&lt;td id=&quot;vu&amp;gt;f&quot; class=&quot;&quot;&gt;2,147,483,547&lt;/td&gt;&lt;td id=&quot;Vqqi&quot; class=&quot;&quot;&gt;카프카 컨슈머가 메시지 수신을 중단&lt;/td&gt;&lt;/tr&gt;&lt;tr id=&quot;302d70fa-a068-81a3-b875-f7db6a68d59c&quot;&gt;&lt;td id=&quot;?~}@&quot; class=&quot;&quot;&gt;&lt;strong&gt;3&lt;/strong&gt;&lt;/td&gt;&lt;td id=&quot;ivwm&quot; class=&quot;&quot;&gt;&lt;code&gt;ThreadPoolTaskScheduler&lt;/code&gt;&lt;/td&gt;&lt;td id=&quot;jyv&amp;gt;&quot; class=&quot;&quot;&gt;&lt;code&gt;MAX_VALUE / 2&lt;/code&gt;&lt;/td&gt;&lt;td id=&quot;vu&amp;gt;f&quot; class=&quot;&quot;&gt;1,073,741,824&lt;/td&gt;&lt;td id=&quot;Vqqi&quot; class=&quot;&quot;&gt;스케줄러가 중단됨&lt;/td&gt;&lt;/tr&gt;&lt;tr id=&quot;302d70fa-a068-81d2-b53c-fcd6d5d6e675&quot;&gt;&lt;td id=&quot;?~}@&quot; class=&quot;&quot;&gt;&lt;strong&gt;4&lt;/strong&gt;&lt;/td&gt;&lt;td id=&quot;ivwm&quot; class=&quot;&quot;&gt;&lt;code&gt;ThreadPoolTaskExecutor&lt;/code&gt;&lt;/td&gt;&lt;td id=&quot;jyv&amp;gt;&quot; class=&quot;&quot;&gt;&lt;code&gt;MAX_VALUE / 2&lt;/code&gt;&lt;/td&gt;&lt;td id=&quot;vu&amp;gt;f&quot; class=&quot;&quot;&gt;1,073,741,824&lt;/td&gt;&lt;td id=&quot;Vqqi&quot; class=&quot;&quot;&gt;비동기 스레드 풀이 중단됨&lt;/td&gt;&lt;/tr&gt;&lt;tr id=&quot;302d70fa-a068-812a-997d-f5b0d3bcac2b&quot;&gt;&lt;td id=&quot;?~}@&quot; class=&quot;&quot;&gt;&lt;strong&gt;5&lt;/strong&gt;&lt;/td&gt;&lt;td id=&quot;ivwm&quot; class=&quot;&quot;&gt;&lt;code&gt;Controller&lt;/code&gt; / &lt;code&gt;Service&lt;/code&gt;&lt;/td&gt;&lt;td id=&quot;jyv&amp;gt;&quot; class=&quot;&quot;&gt;&lt;code&gt;Phaseless (0)&lt;/code&gt;&lt;/td&gt;&lt;td id=&quot;vu&amp;gt;f&quot; class=&quot;&quot;&gt;-&lt;/td&gt;&lt;td id=&quot;Vqqi&quot; class=&quot;&quot;&gt;비즈니스 로직 빈들이 파괴됨&lt;/td&gt;&lt;/tr&gt;&lt;tr id=&quot;302d70fa-a068-814c-9020-efde397f4fb3&quot;&gt;&lt;td id=&quot;?~}@&quot; class=&quot;&quot;&gt;&lt;strong&gt;6&lt;/strong&gt;&lt;/td&gt;&lt;td id=&quot;ivwm&quot; class=&quot;&quot;&gt;&lt;code&gt;HikariDataSource&lt;/code&gt;&lt;/td&gt;&lt;td id=&quot;jyv&amp;gt;&quot; class=&quot;&quot;&gt;&lt;code&gt;Phaseless (0)&lt;/code&gt;&lt;/td&gt;&lt;td id=&quot;vu&amp;gt;f&quot; class=&quot;&quot;&gt;-&lt;/td&gt;&lt;td id=&quot;Vqqi&quot; class=&quot;&quot;&gt;가장 마지막에 DB 연결을 닫음&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;blockquote id=&quot;302d70fa-a068-81ee-976f-c371facf7a05&quot; class=&quot;&quot;&gt;&lt;strong&gt;[주의]&lt;/strong&gt; 일반적인 Bean(&lt;code&gt;@Service&lt;/code&gt;)은 Phase 0으로 간주되지만, &lt;strong&gt;SmartLifecycle 종료 이후에&lt;/strong&gt; 컨텍스트가 닫히면서 (&lt;code&gt;ContextClosedEvent&lt;/code&gt;) &lt;strong&gt;생성의 역순(Reverse Dependency)&lt;/strong&gt;으로 파괴됩니다.&lt;/blockquote&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-80ab-b62c-c5ca4f2e32a4&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;302d70fa-a068-81ee-ac14-ec7f737dc272&quot; class=&quot;&quot;&gt;5.3 중요한 Q&amp;amp;A&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8081-b9bd-ee51558cb22d&quot; class=&quot;&quot;&gt;&lt;strong&gt;Q1. HTTP 요청은 중간에 끊기나요?&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-81f2-bbae-d31c6712d9b9&quot; class=&quot;&quot;&gt;❌ &lt;strong&gt;아니요, 안전합니다.&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;302d70fa-a068-8195-a559-f7cd9765f140&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;Step 1에서 WebServer가 기존에 들어온 요청이 다 끝날 때까지 기다려줍니다 (server.shutdown: graceful)&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;302d70fa-a068-81ea-8be6-d748fc2f2ca8&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;서비스 로직이 다 돌고 응답을 보낸 뒤에야 Step 2로 넘어갑니다&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;302d70fa-a068-814e-93ef-c9b94666e5bc&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;HTTP 요청은 &lt;strong&gt;Tomcat 내부의 스레드 풀&lt;/strong&gt;을 사용하므로 별도로 관리됩니다&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-80f6-a4c4-d1d284d763f4&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-819c-b699-e0b3407d28b3&quot; class=&quot;&quot;&gt;&lt;strong&gt;Q2. @Async (비동기 작업)은 어떻게 되나요?&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-818a-be53-f22280f887c1&quot; class=&quot;&quot;&gt;&lt;strong&gt;설정에 따라 다릅니다.&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8185-b05e-f927c5c015c1&quot; class=&quot;&quot;&gt;기본값(&lt;code&gt;await-termination: false&lt;/code&gt;)에서는:&lt;br&gt;- 스레드 풀이 닫히면서 &lt;mark class=&quot;highlight-yellow_background&quot;&gt;&lt;strong&gt;실행 중인 작업도 즉시 인터럽트되어 강제 종료&lt;/strong&gt;&lt;/mark&gt;&lt;br&gt;- 알림 발송, 정산 로직 등이 중간에 끊길 수 있음&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js&quot; integrity=&quot;sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/script&gt;&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css&quot; integrity=&quot;sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;pre id=&quot;302d70fa-a068-8147-84eb-e81a1bf39c00&quot; class=&quot;code code-wrap language-yaml&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-yaml&quot; style=&quot;white-space:pre-wrap;word-break:break-all&quot;&gt;# ✅ 해결책: 비동기 작업도 끝까지 기다리게 설정
spring:
task:
execution:
shutdown:
await-termination:true
await-termination-period: 20s&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-805f-97b3-fb10e361bfe9&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-811d-b4d1-c6114c52212a&quot; class=&quot;&quot;&gt;&lt;strong&gt;Q3. 서비스가 스레드 풀을 의존하는데, 왜 역순으로 안 꺼지나요?&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-813f-8622-ffa55108a95f&quot; class=&quot;&quot;&gt;&lt;strong&gt;DI 원칙 vs Lifecycle 원칙의 충돌&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;302d70fa-a068-8161-99c5-c8c73ff1c21e&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;보통 의존성 역순(Service → Repository)으로 꺼지는 게 원칙입니다&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;302d70fa-a068-8135-9c1e-fa9188db952e&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;하지만 &lt;strong&gt;SmartLifecycle은 계급이 다릅니다&lt;/strong&gt;&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;302d70fa-a068-8171-a84f-e046ddb45b75&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;&lt;code&gt;ThreadPoolTaskExecutor&lt;/code&gt;는 &lt;code&gt;SmartLifecycle&lt;/code&gt; 구현체로서 &lt;strong&gt;Phase 값(&lt;/strong&gt;&lt;code&gt;&lt;strong&gt;MAX_VALUE / 2&lt;/strong&gt;&lt;/code&gt;&lt;strong&gt;)&lt;/strong&gt;을 가지고 있어, 일반 빈(&lt;code&gt;Service&lt;/code&gt;, Phase 0)들이 파괴되기도 전에 &lt;strong&gt;먼저 &lt;/strong&gt;&lt;code&gt;&lt;strong&gt;stop()&lt;/strong&gt;&lt;/code&gt;&lt;strong&gt; 신호&lt;/strong&gt;를 받습니다&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;302d70fa-a068-8197-a413-f89daa10abc8&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;즉, &lt;code&gt;Service&lt;/code&gt;가 아직 살아있는 상태에서 스레드 풀이 먼저 문을 닫아버리는 상황이 발생합니다&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-81d4-96b4-f90b4fa1af6a&quot; class=&quot;&quot;&gt;&lt;strong&gt;해결 방법&lt;/strong&gt;&lt;br&gt;- ThreadPoolTaskExecutor의 Phase를 0보다 작게 설정하거나&lt;br&gt;&lt;mark class=&quot;highlight-yellow_background&quot;&gt;- (일반적) await-termination 을 켜서 “문을 닫더라도 하던 건 끝내라”고 지시&lt;/mark&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-80b1-abee-dab4e36d2ec7&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8127-b29e-dedda4e76e69&quot; class=&quot;&quot;&gt;&lt;strong&gt;Q4. &lt;/strong&gt;&lt;code&gt;SmartLifecycle&lt;/code&gt; 종료 후 컨텍스트 종료 작업인, &lt;strong&gt;@PreDestroy에서 비동기 작업을 호출하면?&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-81e3-9ed2-e4d4c0c72518&quot; class=&quot;&quot;&gt;❌ &lt;strong&gt;무조건 실패합니다.&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js&quot; integrity=&quot;sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/script&gt;&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css&quot; integrity=&quot;sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;pre id=&quot;302d70fa-a068-81bd-9384-dc8f9d992587&quot; class=&quot;code code-wrap language-java&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-java&quot; style=&quot;white-space:pre-wrap;word-break:break-all&quot;&gt;@Service
public class MyService {
    @Autowired
    private ThreadPoolTaskExecutor executor;

    @PreDestroy
    public void cleanup() {
        // ❌ 이미 executor는 Step 2에서 종료됨!
        executor.submit(() -&amp;gt; {
            log.info(&quot;마지막 정리 작업&quot;);  // 실행 안 됨!
        });
    }
}&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8196-bdcc-e789f8c51caf&quot; class=&quot;&quot;&gt;Step 3(&lt;code&gt;Service&lt;/code&gt; 파괴) 시점에는 이미 Step 2(&lt;code&gt;ThreadPool&lt;/code&gt;)가 문을 닫았습니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-81a5-9e91-d129da03767f&quot; class=&quot;&quot;&gt;종료 시점에는 &lt;mark class=&quot;highlight-yellow_background&quot;&gt;&lt;strong&gt;동기(main thread)로 처리&lt;/strong&gt;&lt;/mark&gt;하세요.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-802f-8065-edb97d77d44a&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;302d70fa-a068-81b8-8475-ecf3de9821b9&quot; class=&quot;&quot;&gt;6.4 설정 함정: Timeout 충돌 주의&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-81ff-a400-fd7d96eca82a&quot; class=&quot;&quot;&gt;&lt;strong&gt;[주의] Timeout 충돌&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js&quot; integrity=&quot;sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/script&gt;&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css&quot; integrity=&quot;sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;pre id=&quot;302d70fa-a068-813b-8bbe-c49887a4f576&quot; class=&quot;code code-wrap language-yaml&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-yaml&quot; style=&quot;white-space:pre-wrap;word-break:break-all&quot;&gt;spring:
task:
execution:
shutdown:
await-termination-period: 70s  # 비동기 작업 70초 대기
lifecycle:
timeout-per-shutdown-phase: 30s  # 전체 Phase는 30초만 대기!&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-81d7-9585-fb20c8dea991&quot; class=&quot;&quot;&gt;&lt;strong&gt;결과&lt;/strong&gt;: 30초 뒤에 강제 종료됩니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8185-9ef5-feebce055709&quot; class=&quot;&quot;&gt;&lt;code&gt;SmartLifecycle&lt;/code&gt;의 Phase Timeout이 더 상위 개념이기 때문에, 스레드 풀이 아무리 “70초 기다려줘”라고 해도 컨테이너가 “30초 지났어, 방 빼!” 하고 프로세스를 죽입니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8118-b491-f86e3a95af84&quot; class=&quot;&quot;&gt;&lt;strong&gt;✅ 올바른 설정&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js&quot; integrity=&quot;sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/script&gt;&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css&quot; integrity=&quot;sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;pre id=&quot;302d70fa-a068-8109-afc6-fffac4921826&quot; class=&quot;code code-wrap language-yaml&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-yaml&quot; style=&quot;white-space:pre-wrap;word-break:break-all&quot;&gt;spring:
task:
execution:
shutdown:
await-termination:true
await-termination-period: 70s
lifecycle:
timeout-per-shutdown-phase: 90s  # await-termination보다 넉넉하게!&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-80d6-aa89-d231873e2213&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;302d70fa-a068-81d3-8f8a-c99c96c48732&quot; class=&quot;&quot;&gt;6.5 왜 DB 커넥션 풀이 가장 늦게 닫히나요?&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8190-97d9-dad6a457acbf&quot; class=&quot;&quot;&gt;&lt;strong&gt;능동적 종료 vs 수동적 파괴의 차이&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8185-89f9-e95c5fbf96c8&quot; class=&quot;&quot;&gt;WebServer, KafkaListener, Scheduler 등은 &lt;strong&gt;새로운 일을 만들어내는 주체&lt;/strong&gt;입니다.&lt;br&gt;- 이들은 가장 먼저 멈춰야 합니다&lt;br&gt;- 그래야 더 이상 새로운 트랜잭션이 생기지 않으니까요 (수도꼭지 잠그기)&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-81f7-815c-c249e7062624&quot; class=&quot;&quot;&gt;Service, Repository, DataSource 등은 &lt;strong&gt;일을 처리하는 도구&lt;/strong&gt;입니다.&lt;br&gt;- 이들은 남은 설거지가 끝날 때까지 살아있어야 합니다&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8195-9c1b-e775f7dc8d39&quot; class=&quot;&quot;&gt;&lt;mark class=&quot;highlight-yellow_background&quot;&gt;&lt;strong&gt;Spring의 빈 종료 순서는 철저하게 의존성 역순입니다.&lt;/strong&gt;&lt;/mark&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js&quot; integrity=&quot;sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/script&gt;&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css&quot; integrity=&quot;sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;pre id=&quot;302d70fa-a068-81db-99e1-fa188b54b1c6&quot; class=&quot;code code-wrap language-plain&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;Text language-plain&quot; style=&quot;white-space:pre-wrap;word-break:break-all&quot;&gt;[생성 순서] DataSource → Repository → Service
           (서비스가 리포지토리를 쓰고, 리포지토리가 DB를 씀)

[종료 순서] Service → Repository → DataSource
           (서비스가 죽고, 리포지토리가 죽고, 마지막에 DB 문을 닫음)&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-81d9-81d4-c2e6cd098aa3&quot; class=&quot;&quot;&gt;만약 DB가 Service보다 먼저 종료된다면?&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8144-8818-c4864b0c1b35&quot; class=&quot;&quot;&gt;→ Service의 &lt;code&gt;@PreDestroy&lt;/code&gt; 메서드에서 마지막으로 DB에 로그를 남기려 할 때, 이미 DB 연결이 끊겨있어 예외가 발생할 것입니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8151-90b8-c3728128b403&quot; class=&quot;&quot;&gt;그래서 &lt;strong&gt;DB는 가장 마지막까지 불을 켜두고 있어야 합니다.&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-80dc-9b53-d3cf7af9c355&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h2 id=&quot;302d70fa-a068-81dc-b87b-e7d472e64ea4&quot; class=&quot;&quot;&gt;7.  실제 장애 사례로 검증: 원리를 알면 보이는 것들&lt;/h2&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-81c1-9cc4-f0489cbf489a&quot; class=&quot;&quot;&gt;이제 내부 동작을 이해했으니, 실제 장애 사례를 분석하면서 배운 원리를 확인해겠습니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;302d70fa-a068-816c-a2e2-ea573d41a09d&quot; class=&quot;&quot;&gt;사례 1: 매일 오후 3시마다 발생하는 502 에러&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8157-9231-c3dff0b1f696&quot; class=&quot;&quot;&gt;&lt;strong&gt;상황&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-81a7-bab5-c5751c673db3&quot; class=&quot;&quot;&gt;- 커머스 서비스&lt;br&gt;- 배포는 성공하는데, 사용자 화면에 간헐적으로 502 Bad Gateway 발생&lt;br&gt;- 에러 로그에는 &lt;code&gt;Connection Reset by Peer&lt;/code&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8114-8b57-f0f559b09d93&quot; class=&quot;&quot;&gt;&lt;strong&gt;원리로 분석하기&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js&quot; integrity=&quot;sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/script&gt;&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css&quot; integrity=&quot;sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;pre id=&quot;302d70fa-a068-818f-97d3-fb4ba8424927&quot; class=&quot;code code-wrap language-plain&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;Text language-plain&quot; style=&quot;white-space:pre-wrap;word-break:break-all&quot;&gt;[TCP 레벨 분석]
1. WAS가 SIGTERM 받음
   ↓
2. Phase 1: WebServerGracefulShutdown 시작
   - 새 요청 차단 (리스너 포트 닫음)
   - TCP FIN 패킷 전송 시작
   ↓
3. 하지만 ALB는 모름!
   - Health Check Interval 10초
   - 마지막 성공 체크 5초 전
   ↓
4. ALB: &quot;5초 전에 확인했는데 살아있었어&quot;
   - 죽어가는 WAS 소켓에 새 요청 전송
   ↓
5. WAS: 이미 소켓 닫힘
   - RST(Reset) 패킷 전송
   ↓
6. ALB → 클라이언트: 502 Bad Gateway&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-81f8-826a-cc5d43f0ab5a&quot; class=&quot;&quot;&gt;&lt;strong&gt;왜 발생했을까?&lt;/strong&gt;&lt;br&gt;- &lt;strong&gt;Race Condition&lt;/strong&gt; WAS의 &lt;code&gt;FIN&lt;/code&gt; 전송과 ALB의 트래픽 라우팅 사이의 타이밍 문제&lt;br&gt;- &lt;strong&gt;TCP 4-Way Handshake&lt;/strong&gt; 완료 전에 새 요청이 들어옴&lt;br&gt;- &lt;strong&gt;Phase 1&lt;/strong&gt;은 기존 요청만 보호하지, 헬스 체크 주기는 고려하지 않음&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8191-b2db-fa4bd2ec217f&quot; class=&quot;&quot;&gt;&lt;strong&gt;해결책: PreStop Hook&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js&quot; integrity=&quot;sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/script&gt;&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css&quot; integrity=&quot;sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;pre id=&quot;302d70fa-a068-81f3-9480-ddf128b748be&quot; class=&quot;code code-wrap language-yaml&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-yaml&quot; style=&quot;white-space:pre-wrap;word-break:break-all&quot;&gt;# Kubernetes 환경
lifecycle:
preStop:
exec:
command:[&quot;/bin/sh&quot;,&quot;-c&quot;,&quot;sleep 15&quot;]&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-817e-8eff-ee7c3d511058&quot; class=&quot;&quot;&gt;&lt;strong&gt;동작 원리&lt;/strong&gt;:&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js&quot; integrity=&quot;sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/script&gt;&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css&quot; integrity=&quot;sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;pre id=&quot;302d70fa-a068-814e-91f6-ca48c80a714a&quot; class=&quot;code code-wrap language-plain&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;Text language-plain&quot; style=&quot;white-space:pre-wrap;word-break:break-all&quot;&gt;SIGTERM 받음
  ↓
[PreStop Hook] 15초 대기
  - 헬스 체크만 503으로 변경
  - 아직 Phase 1 시작 안 함!
  - TCP 연결 유지
  ↓
ALB Health Check 수행 (10초 주기)
  - &quot;503이네? 트래픽 차단!&quot;
  ↓
15초 후 진짜 Phase 1 시작
  - 이미 트래픽 차단된 상태
  - 안전하게 FIN 전송&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8175-a377-ca5de54d20ef&quot; class=&quot;&quot;&gt;&lt;strong&gt;배운 원리 적용&lt;/strong&gt;&lt;br&gt;- SIGTERM → Phase 순서 PreStop은 Phase 시작 전에 실행&lt;br&gt;- TCP FIN 트래픽 차단 후에 보내야 안전&lt;br&gt;- 타이밍 계산 &lt;code&gt;PreStop Sleep &amp;gt;= Health Check Interval + 여유&lt;/code&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8030-a39e-e0fd6c8e6da2&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;302d70fa-a068-81bb-b3c8-d3b48eb95c52&quot; class=&quot;&quot;&gt;사례 2: 결제 완료 후 주문이 사라짐&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-81d7-8581-e09ec2dcd463&quot; class=&quot;&quot;&gt;&lt;strong&gt;상황&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8138-beca-f035e5f72175&quot; class=&quot;&quot;&gt;- 사용자가 결제 완료 후 주문 내역이 없다고 고객센터 문의&lt;br&gt;- PG사에는 결제 완료 기록이 있는데, DB에는 주문 데이터 없음&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-818e-9c40-c36814c1756d&quot; class=&quot;&quot;&gt;&lt;strong&gt;원리로 분석하기&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js&quot; integrity=&quot;sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/script&gt;&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css&quot; integrity=&quot;sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;pre id=&quot;302d70fa-a068-810a-90d4-e19c53230da4&quot; class=&quot;code code-wrap language-java&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-java&quot; style=&quot;white-space:pre-wrap;word-break:break-all&quot;&gt;@Transactional
public void createOrder(OrderRequest request) {
    // 1. 주문 생성 (DB INSERT)
    orderRepository.save(order);

    // 2. 결제 API 호출 (외부 통신 - 5초 소요)
    paymentClient.process(request);  // ← 이 시점에 서버 종료 신호!

    // 3. 주문 상태 업데이트
    order.setStatus(PAID);  // 실행 안 됨!
}&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-81a5-aaa2-e4fa5054758b&quot; class=&quot;&quot;&gt;&lt;strong&gt;Phase별 분석&lt;/strong&gt;:&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js&quot; integrity=&quot;sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/script&gt;&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css&quot; integrity=&quot;sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;pre id=&quot;302d70fa-a068-8144-a526-e9368615114c&quot; class=&quot;code code-wrap language-plain&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;Text language-plain&quot; style=&quot;white-space:pre-wrap;word-break:break-all&quot;&gt;설정:
timeout-per-shutdown-phase: 30s

[Phase 1] WebServer
  - HTTP 요청 처리 중 (createOrder 메서드 실행)
  - 20초 소요... 계속 실행 중
  ↓
30초 경과
  ↓
Spring Lifecycle: &quot;Phase 1 타임아웃!&quot;
  ↓
강제 종료 (SIGKILL 유사)
  ↓
트랜잭션 롤백
  - orderRepository.save(order) → 롤백
  - paymentClient.process() → 이미 완료 (외부 시스템)
  ↓
결과: 결제는 됐는데 주문은 없음!&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8103-85b2-f9c55c796de8&quot; class=&quot;&quot;&gt;&lt;strong&gt;왜 발생했을까?&lt;/strong&gt;&lt;br&gt;- &lt;strong&gt;Phase Timeout&lt;/strong&gt; &lt;code&gt;timeout-per-shutdown-phase&lt;/code&gt;보다 긴 트랜잭션&lt;br&gt;- &lt;strong&gt;DB 종료 순서&lt;/strong&gt; DB는 Phase 4에서 닫히지만, Phase 1 타임아웃에 트랜잭션이 중단됨&lt;br&gt;- &lt;strong&gt;외부 API 호출&lt;/strong&gt; 트랜잭션 안에서 네트워크 I/O (안티패턴)&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-80e0-91f1-facffd95e068&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8131-9ab6-c57058f002ee&quot; class=&quot;&quot;&gt;&lt;strong&gt;해결책&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8115-9f33-d46eb3e9a3af&quot; class=&quot;&quot;&gt;&lt;strong&gt;1. 타임아웃 증가&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js&quot; integrity=&quot;sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/script&gt;&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css&quot; integrity=&quot;sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;pre id=&quot;302d70fa-a068-81eb-91a5-f9e9732bdc6c&quot; class=&quot;code code-wrap language-yaml&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-yaml&quot; style=&quot;white-space:pre-wrap;word-break:break-all&quot;&gt;spring:
lifecycle:
timeout-per-shutdown-phase: 60s  # 가장 긴 트랜잭션 + 여유&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-812f-a884-ced83a4ba5f8&quot; class=&quot;&quot;&gt;&lt;strong&gt;2. 트랜잭션 분리&lt;/strong&gt; (더 나은 방법)&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js&quot; integrity=&quot;sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/script&gt;&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css&quot; integrity=&quot;sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;pre id=&quot;302d70fa-a068-8157-8334-c666247f3cd7&quot; class=&quot;code code-wrap language-java&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-java&quot; style=&quot;white-space:pre-wrap;word-break:break-all&quot;&gt;@Service
public class OrderService {

    @Transactional
    public Order createOrder(OrderRequest request) {
        // 1. 주문 생성 (빠름, 1초)
        Order order = orderRepository.save(new Order(request));
        order.setStatus(PENDING_PAYMENT);
        return order;
    }

    // 트랜잭션 밖으로 분리
    public void processPayment(Order order) {
        // 2. 결제 요청 (느림, 5초)
        PaymentResult result = paymentClient.process(order);

        // 3. 결과 반영
        updateOrderStatus(order.getId(), result);
    }

    @Transactional
    public void updateOrderStatus(Long orderId, PaymentResult result) {
        Order order = orderRepository.findById(orderId);
        order.setStatus(result.isSuccess() ? PAID : FAILED);
    }
}&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8093-8cbb-f2ee4363c44e&quot; class=&quot;&quot;&gt;&lt;strong&gt;배운 원리 적용&lt;/strong&gt;&lt;br&gt;- Phase Timeout 우선순위 모든 Phase는 &lt;code&gt;timeout-per-shutdown-phase&lt;/code&gt;에 제한됨&lt;br&gt;- DB 종료 순서 DB는 마지막(Phase 4)이지만, 트랜잭션은 Phase 1에서 중단될 수 있음&lt;br&gt;- Long Running Task 외부 API 호출은 트랜잭션 밖으로&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-805a-9a28-ee74d84d0fe2&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;302d70fa-a068-8176-9e6d-d81e9479a897&quot; class=&quot;&quot;&gt;사례 3: 비동기 작업이 중간에 끊긴 알림 미발송 &lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8153-96cc-c713ec256f80&quot; class=&quot;&quot;&gt;&lt;strong&gt;상황&lt;/strong&gt;&lt;br&gt;- 주문 완료 후 사용자에게 푸시 알림 발송&lt;br&gt;- 배포 후 일부 사용자가 “알림을 못 받았다”고 문의&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8133-bf56-f24ad39e2441&quot; class=&quot;&quot;&gt;&lt;strong&gt;코드&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js&quot; integrity=&quot;sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/script&gt;&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css&quot; integrity=&quot;sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;pre id=&quot;302d70fa-a068-812b-a172-ca81642abf33&quot; class=&quot;code code-wrap language-java&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-java&quot; style=&quot;white-space:pre-wrap;word-break:break-all&quot;&gt;@Service
public class OrderService {

    @Async
    public void sendNotification(Order order) {
        // 외부 푸시 서버 호출 (3초 소요)
        pushClient.send(order.getUserId(), &quot;주문이 완료되었습니다&quot;);
    }
}&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-81be-8c3c-f2e2fdb09b30&quot; class=&quot;&quot;&gt;&lt;strong&gt;Phase별 분석&lt;/strong&gt;:&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js&quot; integrity=&quot;sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/script&gt;&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css&quot; integrity=&quot;sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;pre id=&quot;302d70fa-a068-8134-8079-dd026ce8f2c0&quot; class=&quot;code code-wrap language-plain&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;Text language-plain&quot; style=&quot;white-space:pre-wrap;word-break:break-all&quot;&gt;설정:
timeout-per-shutdown-phase: 30s
await-termination-period: 70s  # 이건 무시됨!

[Phase 1] WebServer 종료 (10초 소요)
  ✅ 완료
  ↓
[Phase 3] ThreadPoolTaskExecutor 종료
  - 비동기 작업 10개 실행 중
  - await-termination-period 70s 설정했지만...
  ↓
30초 경과 (timeout-per-shutdown-phase)
  ↓
Spring Lifecycle: &quot;Phase 3 타임아웃!&quot;
  ↓
ThreadPool 강제 종료
  - interrupt() 호출
  - 실행 중이던 5개 작업 중단&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8194-b689-f59f47198b1c&quot; class=&quot;&quot;&gt;&lt;strong&gt;왜 발생했을까?&lt;/strong&gt;&lt;br&gt;- &lt;strong&gt;Timeout 충돌&lt;/strong&gt; &lt;code&gt;await-termination-period: 70s&lt;/code&gt; &amp;lt; &lt;code&gt;timeout-per-shutdown-phase: 30s&lt;/code&gt;&lt;br&gt;- &lt;strong&gt;SmartLifecycle Phase&lt;/strong&gt; ThreadPool은 Phase 3에서 종료되며, Phase Timeout이 우선&lt;br&gt;- &lt;strong&gt;min() 로직&lt;/strong&gt; 실제 대기 = &lt;code&gt;min(70s, 30s)&lt;/code&gt; = &lt;strong&gt;30초&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-819c-a872-cd187601bc23&quot; class=&quot;&quot;&gt;&lt;strong&gt;해결책&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js&quot; integrity=&quot;sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/script&gt;&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css&quot; integrity=&quot;sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;pre id=&quot;302d70fa-a068-8182-8f7b-fa8a977699ef&quot; class=&quot;code code-wrap language-yaml&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-yaml&quot; style=&quot;white-space:pre-wrap;word-break:break-all&quot;&gt;spring:
lifecycle:
timeout-per-shutdown-phase: 90s  # await-termination보다 길게!

task:
execution:
shutdown:
await-termination:true
await-termination-period: 70s  # 90초 안에 들어감&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-818d-a21b-e26136fa5524&quot; class=&quot;&quot;&gt;&lt;strong&gt;배운 원리 적용&lt;/strong&gt;&lt;br&gt;- SmartLifecycle Phase ThreadPool은 Phase 3 (MAX_VALUE / 2)&lt;br&gt;- Timeout 우선순위 &lt;code&gt;timeout-per-shutdown-phase&lt;/code&gt;가 상위 개념&lt;br&gt;- 설정 검증 &lt;code&gt;timeout-per-shutdown-phase &amp;gt;= await-termination-period + 여유&lt;/code&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;302d70fa-a068-81ee-a0fc-e6420556b3e2&quot; class=&quot;&quot;&gt;정리: 원리를 알면 장애를 예방할 수 있다&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;table id=&quot;302d70fa-a068-81f1-a2c0-fc49c05a7721&quot; class=&quot;simple-table&quot;&gt;&lt;thead class=&quot;simple-table-header&quot;&gt;&lt;tr id=&quot;302d70fa-a068-81b7-b1ca-df9425181cf9&quot;&gt;&lt;th id=&quot;S_p]&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;장애 사례&lt;/th&gt;&lt;th id=&quot;\EsY&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;관련 원리&lt;/th&gt;&lt;th id=&quot;xE]Y&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;핵심 교훈&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr id=&quot;302d70fa-a068-812a-b72e-e90893225d0d&quot;&gt;&lt;td id=&quot;S_p]&quot; class=&quot;&quot;&gt;502 에러&lt;/td&gt;&lt;td id=&quot;\EsY&quot; class=&quot;&quot;&gt;TCP FIN, Phase 1 타이밍&lt;/td&gt;&lt;td id=&quot;xE]Y&quot; class=&quot;&quot;&gt;PreStop으로 헬스 체크 대기 필요&lt;/td&gt;&lt;/tr&gt;&lt;tr id=&quot;302d70fa-a068-81d3-bb7b-f2053a8c3dfe&quot;&gt;&lt;td id=&quot;S_p]&quot; class=&quot;&quot;&gt;결제/주문 불일치&lt;/td&gt;&lt;td id=&quot;\EsY&quot; class=&quot;&quot;&gt;Phase Timeout, 트랜잭션&lt;/td&gt;&lt;td id=&quot;xE]Y&quot; class=&quot;&quot;&gt;외부 API는 트랜잭션 밖으로 분리&lt;/td&gt;&lt;/tr&gt;&lt;tr id=&quot;302d70fa-a068-813d-9c6b-cdd0fee3f68c&quot;&gt;&lt;td id=&quot;S_p]&quot; class=&quot;&quot;&gt;알림 미발송&lt;/td&gt;&lt;td id=&quot;\EsY&quot; class=&quot;&quot;&gt;Phase 3, Timeout 충돌&lt;/td&gt;&lt;td id=&quot;xE]Y&quot; class=&quot;&quot;&gt;&lt;code&gt;timeout-per-shutdown-phase&lt;/code&gt; &amp;gt; &lt;code&gt;await-termination&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;hr id=&quot;302d70fa-a068-811c-9ef5-dd08f1e9690e&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-80ac-838a-d00c30e4922c&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h1 id=&quot;302d70fa-a068-819c-9681-f20323438d39&quot; class=&quot;&quot;&gt;Part 3. 실전 적용&lt;/h1&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h2 id=&quot;302d70fa-a068-8061-ac42-d3cf65e88e5f&quot; class=&quot;&quot;&gt;8. 실전 적용: 설정과 패턴 구현&lt;/h2&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-80a1-8fec-cc25d000fdfd&quot; class=&quot;&quot;&gt; 필수 설정과 함께, 운영 환경에서 빈번하게 발생하는 이슈들의 해결 패턴을 정리했습니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;302d70fa-a068-80b8-8178-fd2145a4ab0d&quot; class=&quot;&quot;&gt;8.1 필수 설정 (application.yml)&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-80d5-9e63-ed085a688787&quot; class=&quot;&quot;&gt;가장 기본이 되는 Spring Boot 설정입니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js&quot; integrity=&quot;sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/script&gt;&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css&quot; integrity=&quot;sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;pre id=&quot;302d70fa-a068-804d-8394-ebbf06968386&quot; class=&quot;code code-wrap language-yaml&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-yaml&quot; style=&quot;white-space:pre-wrap;word-break:break-all&quot;&gt;# 기본 Graceful Shutdown 설정
server:
  shutdown: graceful
  tomcat:
    connection-timeout: 20s
    keep-alive-timeout: 75s  # ALB Idle Timeout(60s)보다 길게!

spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s

  # 비동기 작업 설정 (중요)
  task:
    execution:
      shutdown:
        await-termination: true  # 하던 작업은 끝내고 종료
        await-termination-period: 20s
    scheduling:
      shutdown:
        await-termination: true
        await-termination-period: 10s&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8088-8839-d2a207183dc7&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;302d70fa-a068-80bf-99bf-e5b9ef2b16aa&quot; class=&quot;&quot;&gt;8.2 컨테이너 환경: PID 1 문제 (Docker/K8s)&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-80de-a747-d04900d4ae45&quot; class=&quot;&quot;&gt;&lt;strong&gt;[면접 질문] &quot;PID 1 문제(Zombie Reaper)에 대해 설명해 주세요.&quot;&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-80dd-8a69-fde7fe2926b0&quot; class=&quot;&quot;&gt;&lt;strong&gt;문제&lt;/strong&gt;: Docker에서 &lt;code&gt;ENTRYPOINT [&quot;java&quot;, ...]&lt;/code&gt;로 실행하면 자바가 PID 1이 되어 &lt;code&gt;SIGTERM&lt;/code&gt;을 무시합니다. 결국 강제 종료(&lt;code&gt;SIGKILL&lt;/code&gt;) 당합니다. 이는 리눅스 커널에서 PID 1을 특수한(init) 프로세스로 취급하여 기본적인 시그널 핸들러를 등록하지 않기 때문입니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8085-98f0-fbb28f30f9b9&quot; class=&quot;&quot;&gt;&lt;strong&gt;해결책&lt;/strong&gt;: &lt;code&gt;tini&lt;/code&gt;를 사용하거나 &lt;code&gt;exec&lt;/code&gt;로 셸을 대체하여, 시그널 전파와 좀비 프로세스 회수(Reaping) 역할을 하는 init 프로세스를 둬야 합니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js&quot; integrity=&quot;sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/script&gt;&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css&quot; integrity=&quot;sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;pre id=&quot;302d70fa-a068-80b7-af98-dbe19d7104fc&quot; class=&quot;code code-wrap language-docker&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-docker&quot; style=&quot;white-space:pre-wrap;word-break:break-all&quot;&gt;# ✅ 좋은 예 1: tini 사용
FROM openjdk:17-slim
RUN apt-get update &amp;amp;&amp;amp; apt-get install -y tini
ENTRYPOINT [&quot;/usr/bin/tini&quot;, &quot;--&quot;]
CMD [&quot;java&quot;, &quot;-jar&quot;, &quot;app.jar&quot;]&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js&quot; integrity=&quot;sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/script&gt;&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css&quot; integrity=&quot;sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;pre id=&quot;302d70fa-a068-808d-83fb-dd91961cd93f&quot; class=&quot;code code-wrap language-bash&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-bash&quot; style=&quot;white-space:pre-wrap;word-break:break-all&quot;&gt;# ✅ 좋은 예 2: exec 사용 (entrypoint.sh)
#!/bin/bash
exec java -jar app.jar&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-80a2-926e-c922cc90f2b2&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;302d70fa-a068-80fc-9521-d191a90735a6&quot; class=&quot;&quot;&gt;8.3 로드밸런서와 502 에러 (PreStop Hook)&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8089-afb9-cb42df11e6ec&quot; class=&quot;&quot;&gt;&lt;strong&gt;[면접 질문] &quot;배포 중 502 에러가 간헐적으로 발생하는데, 원인이 뭘까요?&quot; (L7 vs L4)&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-803c-ada7-ea709d92eb0a&quot; class=&quot;&quot;&gt;&lt;strong&gt;시니어의 답변&lt;/strong&gt;: 단순 설정 문제가 아닐 가능성이 높습니다. AWS ALB(L7)와 EC2(WAS) 사이의 &lt;strong&gt;Keep-Alive Connection&lt;/strong&gt; 처리 방식에 따른 Race Condition일 수 있습니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8059-989e-f34a2c73b013&quot; class=&quot;&quot;&gt;&lt;strong&gt;문제 상황 (Race Condition)&lt;/strong&gt;: WAS는 종료되었지만 로드밸런서(ALB)는 헬스 체크 주기 때문에 이를 모르고 요청을 보냅니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ol type=&quot;1&quot; id=&quot;302d70fa-a068-8096-9658-ee0b8190928e&quot; class=&quot;numbered-list&quot; start=&quot;1&quot;&gt;&lt;li&gt;WAS가 &lt;code&gt;FIN&lt;/code&gt;을 보냄 (종료 시작)&lt;/li&gt;&lt;/ol&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ol type=&quot;1&quot; id=&quot;302d70fa-a068-809e-9481-cc5dd284f165&quot; class=&quot;numbered-list&quot; start=&quot;2&quot;&gt;&lt;li&gt;ALB는 그 직전(또는 동시에) 그 소켓으로 새 요청을 보냄&lt;/li&gt;&lt;/ol&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ol type=&quot;1&quot; id=&quot;302d70fa-a068-80b5-b657-f9422b2d1e99&quot; class=&quot;numbered-list&quot; start=&quot;3&quot;&gt;&lt;li&gt;WAS는 이미 닫혔으니 &lt;code&gt;RST&lt;/code&gt;를 보냄 → 502 Bad Gateway&lt;/li&gt;&lt;/ol&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-806c-b387-d4c84a77af68&quot; class=&quot;&quot;&gt;&lt;strong&gt;해결책&lt;/strong&gt;: 종료 신호를 받으면 바로 끄지 말고, &lt;code&gt;PreStop Hook&lt;/code&gt;으로 헬스 체크를 실패시키고 잠시 대기합니다. 또한 &lt;code&gt;Keep-Alive Timeout&lt;/code&gt;을 튜닝합니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ol type=&quot;1&quot; id=&quot;302d70fa-a068-80aa-b7fb-fd12d1f88d13&quot; class=&quot;numbered-list&quot; start=&quot;1&quot;&gt;&lt;li&gt;&lt;strong&gt;PreStop Hook&lt;/strong&gt;: 로드밸런서가 헬스 체크 실패를 인지할 시간을 벌어줍니다&lt;/li&gt;&lt;/ol&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js&quot; integrity=&quot;sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/script&gt;&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css&quot; integrity=&quot;sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;pre id=&quot;302d70fa-a068-808a-8b90-c0eb01124ce7&quot; class=&quot;code code-wrap language-yaml&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-yaml&quot; style=&quot;white-space:pre-wrap;word-break:break-all&quot;&gt;# Kubernetes lifecycle hook
lifecycle:
  preStop:
    exec:
      command: [&quot;/bin/sh&quot;, &quot;-c&quot;, &quot;sleep 15&quot;]&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8020-93ee-cceb21dc7703&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;302d70fa-a068-8054-b39a-fbcd1019e231&quot; class=&quot;&quot;&gt;8.4 Long Running Task와 타임아웃 전략&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8084-a68c-e7b98437d5a2&quot; class=&quot;&quot;&gt;&lt;strong&gt;문제&lt;/strong&gt;: 파일 업로드나 배치가 &lt;code&gt;timeout-per-shutdown-phase&lt;/code&gt;(기본 30초)보다 오래 걸리면 강제 종료됩니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8019-a278-e1b441199bb8&quot; class=&quot;&quot;&gt;&lt;strong&gt;해결책&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ol type=&quot;1&quot; id=&quot;302d70fa-a068-80f8-9565-f008dc4c4cf4&quot; class=&quot;numbered-list&quot; start=&quot;1&quot;&gt;&lt;li&gt;&lt;strong&gt;설정 변경&lt;/strong&gt;: &lt;code&gt;timeout-per-shutdown-phase&lt;/code&gt;를 넉넉하게 늘립니다.&lt;/li&gt;&lt;/ol&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ol type=&quot;1&quot; id=&quot;302d70fa-a068-80f5-a692-f1988691e633&quot; class=&quot;numbered-list&quot; start=&quot;2&quot;&gt;&lt;li&gt;&lt;strong&gt;코드 레벨 제한&lt;/strong&gt;: 트랜잭션 내에서 외부 API 호출을 빼거나, 비동기 작업에 타임아웃을 겁니다.&lt;/li&gt;&lt;/ol&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js&quot; integrity=&quot;sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/script&gt;&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css&quot; integrity=&quot;sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;pre id=&quot;302d70fa-a068-8067-b813-eacd45cbff40&quot; class=&quot;code code-wrap language-java&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-java&quot; style=&quot;white-space:pre-wrap;word-break:break-all&quot;&gt;// 예: CompletableFuture로 타임아웃 설정
CompletableFuture.supplyAsync(() -&amp;gt; process(file))
    .orTimeout(40, TimeUnit.SECONDS);&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8062-a3d3-ff15445ced28&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;302d70fa-a068-8011-93bb-e6b8764b692f&quot; class=&quot;&quot;&gt;8.5 카프카 컨슈머 (Kafka Consumer Lag)&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8034-9a75-c5d042fca005&quot; class=&quot;&quot;&gt;&lt;strong&gt;문제&lt;/strong&gt;: 메시지 처리 도중 종료되면, 오토 커밋(Auto Commit)으로 인해 메시지가 유실되거나 중복 처리될 수 있습니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8005-916a-f7a4522a761f&quot; class=&quot;&quot;&gt;&lt;strong&gt;해결책&lt;/strong&gt;: &lt;code&gt;Manual Ack&lt;/code&gt;를 사용하고, 종료 시점에는 커밋하지 않도록 합니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js&quot; integrity=&quot;sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/script&gt;&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css&quot; integrity=&quot;sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;pre id=&quot;302d70fa-a068-80b7-88a3-d52715897eb1&quot; class=&quot;code code-wrap language-yaml&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-yaml&quot; style=&quot;white-space:pre-wrap;word-break:break-all&quot;&gt;spring:
  kafka:
    listener:
      ack-mode: manual&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js&quot; integrity=&quot;sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/script&gt;&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css&quot; integrity=&quot;sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;pre id=&quot;302d70fa-a068-8081-9944-d1bba97e454b&quot; class=&quot;code code-wrap language-java&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-java&quot; style=&quot;white-space:pre-wrap;word-break:break-all&quot;&gt;// Kafka Listener 셧다운 로직 예시
@Component
public class KafkaGracefulShutdown implements DisposableBean {
    private final KafkaListenerEndpointRegistry registry;

    // 생성자 주입
    public KafkaGracefulShutdown(KafkaListenerEndpointRegistry registry) {
        this.registry = registry;
    }

    @Override
    public void destroy() throws Exception {
        // 컨테이너 우아한 종료 (메시지 처리 대기)
        registry.getListenerContainers().forEach(MessageListenerContainer::stop);
    }
}&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-80d5-acb1-f60888b6c458&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;302d70fa-a068-80f5-a489-c45fbeef6748&quot; class=&quot;&quot;&gt;8.6 분산 트랜잭션과 복구 (Saga Pattern)&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8017-8f3f-cc297a9cfd64&quot; class=&quot;&quot;&gt;&lt;strong&gt;[면접 질문] &quot;분산 트랜잭션(Saga/TCC) 중 서버가 꺼지면 어떻게 되나요?&quot;&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-80b8-83c9-ea4778933a1f&quot; class=&quot;&quot;&gt;&lt;strong&gt;문제&lt;/strong&gt;: &quot;주문 성공 -&amp;gt; 결제 요청 -&amp;gt; 서버 종료&quot; 시나리오에서 결제는 됐는데 주문 상태가 업데이트되지 않는 정합성 문제. Graceful Shutdown은 &lt;strong&gt;In-Memory 상태&lt;/strong&gt;만 보호해줄 뿐, 네트워크 건너편의 일은 보장 못 합니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8081-b230-e6a4116ea371&quot; class=&quot;&quot;&gt;&lt;strong&gt;해결책&lt;/strong&gt;: 셧다운 설정으로 해결하는 게 아니라, &lt;strong&gt;재시작 후 복구(Recovery)&lt;/strong&gt; 로직으로 풀어야 합니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;302d70fa-a068-805a-8ce8-f174b296090c&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;&lt;strong&gt;Outbox Pattern&lt;/strong&gt;: DB에 이벤트를 먼저 기록하고 비동기로 발송.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;302d70fa-a068-80d1-9884-c8f7e6f158ac&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;&lt;strong&gt;Recovery Batch&lt;/strong&gt;: 주기적으로 'Pending' 상태의 이벤트를 조회하여 상태 동기화.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;blockquote id=&quot;302d70fa-a068-8082-bc11-f45f609ebbaa&quot; class=&quot;&quot;&gt;&lt;strong&gt;'안 죽는 서버'를 만드는 게 아니라 '언제 죽어도 괜찮은 서버'를 만드는 것이 핵심입니다.&lt;/strong&gt;&lt;/blockquote&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;hr id=&quot;302d70fa-a068-8024-9179-d0153ecc0639&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h2 id=&quot;302d70fa-a068-80d5-8ad9-fc3f414c5c86&quot; class=&quot;&quot;&gt;9. Trade-off &amp;amp; Alternatives&lt;/h2&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;302d70fa-a068-80fd-bae4-ce272223315d&quot; class=&quot;&quot;&gt;9.1 무엇을 얻고 무엇을 잃는가?&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;table id=&quot;302d70fa-a068-8064-b472-cc8c822c1e39&quot; class=&quot;simple-table&quot;&gt;&lt;thead class=&quot;simple-table-header&quot;&gt;&lt;tr id=&quot;302d70fa-a068-802a-8bc3-de7c4d43ff2c&quot;&gt;&lt;th id=&quot;YvN:&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;항목&lt;/th&gt;&lt;th id=&quot;[c?Z&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;얻는 것 (Gain)&lt;/th&gt;&lt;th id=&quot;Sf;p&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;잃는 것 (Cost)&lt;/th&gt;&lt;th id=&quot;kcZg&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;측정 지표&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr id=&quot;302d70fa-a068-806d-93e2-f19a31068026&quot;&gt;&lt;td id=&quot;YvN:&quot; class=&quot;&quot;&gt;&lt;strong&gt;안정성&lt;/strong&gt;&lt;/td&gt;&lt;td id=&quot;[c?Z&quot; class=&quot;&quot;&gt;배포 시 에러율 0%에 수렴&lt;/td&gt;&lt;td id=&quot;Sf;p&quot; class=&quot;&quot;&gt;&lt;strong&gt;배포 속도 저하&lt;/strong&gt;. 기존 프로세스가 죽을 때까지 기다려야 함.&lt;/td&gt;&lt;td id=&quot;kcZg&quot; class=&quot;&quot;&gt;Deployment Time +30s~1m&lt;/td&gt;&lt;/tr&gt;&lt;tr id=&quot;302d70fa-a068-80da-85f8-ffff91de1d1c&quot;&gt;&lt;td id=&quot;YvN:&quot; class=&quot;&quot;&gt;&lt;strong&gt;리소스&lt;/strong&gt;&lt;/td&gt;&lt;td id=&quot;[c?Z&quot; class=&quot;&quot;&gt;데이터 정합성 보장&lt;/td&gt;&lt;td id=&quot;Sf;p&quot; class=&quot;&quot;&gt;종료 로직을 위한 &lt;strong&gt;구현 복잡도&lt;/strong&gt; 증가 (비동기 작업 처리 등)&lt;/td&gt;&lt;td id=&quot;kcZg&quot; class=&quot;&quot;&gt;코드 라인 수, 복잡도&lt;/td&gt;&lt;/tr&gt;&lt;tr id=&quot;302d70fa-a068-808a-8566-e96ea5b53cd5&quot;&gt;&lt;td id=&quot;YvN:&quot; class=&quot;&quot;&gt;&lt;strong&gt;운영&lt;/strong&gt;&lt;/td&gt;&lt;td id=&quot;[c?Z&quot; class=&quot;&quot;&gt;롤링 업데이트 시 부드러운 트래픽 전환&lt;/td&gt;&lt;td id=&quot;Sf;p&quot; class=&quot;&quot;&gt;&lt;strong&gt;좀비 프로세스&lt;/strong&gt; 위험. 종료되지 않고 버티는 스레드 발생 가능.&lt;/td&gt;&lt;td id=&quot;kcZg&quot; class=&quot;&quot;&gt;Force Kill 빈도&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;302d70fa-a068-800c-b951-de6a9818bfdc&quot; class=&quot;&quot;&gt;9.2 언제 오버엔지니어링이 될까?&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-80fd-8dc9-f6c33e767a57&quot; class=&quot;&quot;&gt;Graceful Shutdown이 불필요하거나 과도한 경우:&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ol type=&quot;1&quot; id=&quot;302d70fa-a068-80a0-a835-f084201da508&quot; class=&quot;numbered-list&quot; start=&quot;1&quot;&gt;&lt;li&gt;&lt;strong&gt;단순 통계 집계 배치 서버&lt;/strong&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;302d70fa-a068-80bf-b129-d981adabcb75&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;데이터 유실이 허용됨&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;302d70fa-a068-80a5-98b8-fba95e855068&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;재실행하면 그만&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;302d70fa-a068-80ce-a00e-d79d5d52aab7&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;오히려 빠른 재시작이 유리&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;/li&gt;&lt;/ol&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ol type=&quot;1&quot; id=&quot;302d70fa-a068-80e4-abf1-ce9b917c7985&quot; class=&quot;numbered-list&quot; start=&quot;2&quot;&gt;&lt;li&gt;&lt;strong&gt;로깅 전용 서버&lt;/strong&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;302d70fa-a068-80f8-b8a6-e3dcab579b4c&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;로그 몇 줄 유실은 큰 문제가 아님&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;302d70fa-a068-809c-a0ce-d9b1db0cac88&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;복잡한 종료 로직보다 단순함이 나음&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;/li&gt;&lt;/ol&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ol type=&quot;1&quot; id=&quot;302d70fa-a068-8045-a52f-de77d91af936&quot; class=&quot;numbered-list&quot; start=&quot;3&quot;&gt;&lt;li&gt;&lt;strong&gt;개발/테스트 환경&lt;/strong&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;302d70fa-a068-8022-8f67-c5a8fb53d9b9&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;완벽한 종료보다 빠른 개발 사이클이 중요&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;/li&gt;&lt;/ol&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;302d70fa-a068-80cb-9e0b-d3843d4e9100&quot; class=&quot;&quot;&gt;9.3 대안 방식들&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;table id=&quot;302d70fa-a068-80c4-8bfd-ee506c269c40&quot; class=&quot;simple-table&quot;&gt;&lt;thead class=&quot;simple-table-header&quot;&gt;&lt;tr id=&quot;302d70fa-a068-8084-815e-c74ce8bbf562&quot;&gt;&lt;th id=&quot;fY}:&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;대안 방식&lt;/th&gt;&lt;th id=&quot;Y\[Y&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;설명&lt;/th&gt;&lt;th id=&quot;rvns&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;장점&lt;/th&gt;&lt;th id=&quot;vMvW&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;단점&lt;/th&gt;&lt;th id=&quot;qOD=&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;언제 선택할까?&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr id=&quot;302d70fa-a068-80ef-8241-ec28aa5c4010&quot;&gt;&lt;td id=&quot;fY}:&quot; class=&quot;&quot;&gt;&lt;strong&gt;Blue/Green 배포&lt;/strong&gt;&lt;/td&gt;&lt;td id=&quot;Y\[Y&quot; class=&quot;&quot;&gt;트래픽을 아예 새 버전(Green)으로 돌리고, 구 버전(Blue)은 트래픽 0인 상태에서 종료&lt;/td&gt;&lt;td id=&quot;rvns&quot; class=&quot;&quot;&gt;완벽한 무중단, 롤백 즉시 가능&lt;/td&gt;&lt;td id=&quot;vMvW&quot; class=&quot;&quot;&gt;리소스 2배 필요&lt;/td&gt;&lt;td id=&quot;qOD=&quot; class=&quot;&quot;&gt;리소스가 풍부하고, 완벽한 무중단을 원할 때&lt;/td&gt;&lt;/tr&gt;&lt;tr id=&quot;302d70fa-a068-8085-8ce0-d665c85ed4d5&quot;&gt;&lt;td id=&quot;fY}:&quot; class=&quot;&quot;&gt;&lt;strong&gt;Canary 배포&lt;/strong&gt;&lt;/td&gt;&lt;td id=&quot;Y\[Y&quot; class=&quot;&quot;&gt;일부 트래픽만 새 버전으로 흘려보내며 간보기&lt;/td&gt;&lt;td id=&quot;rvns&quot; class=&quot;&quot;&gt;리스크 최소화, 점진적 배포&lt;/td&gt;&lt;td id=&quot;vMvW&quot; class=&quot;&quot;&gt;복잡한 트래픽 관리&lt;/td&gt;&lt;td id=&quot;qOD=&quot; class=&quot;&quot;&gt;대규모 시스템에서 리스크를 최소화할 때&lt;/td&gt;&lt;/tr&gt;&lt;tr id=&quot;302d70fa-a068-8083-b98a-c1e5773dbdaa&quot;&gt;&lt;td id=&quot;fY}:&quot; class=&quot;&quot;&gt;&lt;strong&gt;Idempotency (멱등성)&lt;/strong&gt;&lt;/td&gt;&lt;td id=&quot;Y\[Y&quot; class=&quot;&quot;&gt;요청이 실패해서 재시도(Retry)해도 결과가 같도록 설계&lt;/td&gt;&lt;td id=&quot;rvns&quot; class=&quot;&quot;&gt;구현 단순, 장애 회복력 강함&lt;/td&gt;&lt;td id=&quot;vMvW&quot; class=&quot;&quot;&gt;모든 로직에 적용 어려움&lt;/td&gt;&lt;td id=&quot;qOD=&quot; class=&quot;&quot;&gt;Graceful Shutdown 구현이 너무 복잡할 때&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;hr id=&quot;302d70fa-a068-80ce-a379-f15bcdcabb36&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h1 id=&quot;302d70fa-a068-808f-ba5e-f86b15e32655&quot; class=&quot;&quot;&gt;Part 4. Insight - 심화 학습 (선택 읽기)&lt;/h1&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h2 id=&quot;302d70fa-a068-8039-9f3f-da22ed9c0e8b&quot; class=&quot;&quot;&gt;10. Low-Level 엔지니어링 교훈&lt;/h2&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;302d70fa-a068-80b3-89fc-c092a6f2fd7d&quot; class=&quot;&quot;&gt; Lesson 1. TCP Half-Close의 오해와 진실&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8023-8e06-c46e0beee0af&quot; class=&quot;&quot;&gt;소켓을 닫을 때 &lt;code&gt;close()&lt;/code&gt;를 호출하면 양쪽 스트림을 모두 닫아버리지만, 사실 TCP는 &quot;나는 보낼 것 다 보냈다(FIN)&quot;와 &quot;너의 응답을 받을 준비는 되어있다&quot;는 상태를 구분할 수 있습니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-80b1-90f3-e2554bdbc6cb&quot; class=&quot;&quot;&gt;&lt;strong&gt;적용 포인트&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-809c-b74f-e0eff53eb6ce&quot; class=&quot;&quot;&gt;대용량 파일 전송이나 스트리밍 서버를 구현할 때, 무작정 &lt;code&gt;close()&lt;/code&gt;보다는 &lt;code&gt;socket.shutdownOutput()&lt;/code&gt;을 사용하여 &lt;strong&gt;Half-Close(반종료)&lt;/strong&gt; 상태를 활용하세요.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js&quot; integrity=&quot;sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/script&gt;&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css&quot; integrity=&quot;sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;pre id=&quot;302d70fa-a068-80f6-844a-f43098151dac&quot; class=&quot;code code-wrap language-java&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-java&quot; style=&quot;white-space:pre-wrap;word-break:break-all&quot;&gt;// ❌ 나쁜 예: 양쪽 스트림 모두 즉시 종료
socket.close();

// ✅ 좋은 예: Half-Close
socket.shutdownOutput();  // 송신만 종료, 수신은 계속 가능
// 클라이언트의 마지막 ACK나 에러 보고를 기다릴 수 있음
InputStream in = socket.getInputStream();
while (in.read() != -1) {
    // 클라이언트의 응답 수신
}
socket.close();  // 모든 작업 완료 후 완전 종료&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;302d70fa-a068-8005-8576-f0512a504a99&quot; class=&quot;&quot;&gt;Lesson 2. 가시성과 Memory Barrier&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8022-8f57-e80b6b1f05e6&quot; class=&quot;&quot;&gt;멀티스레드 환경에서 종료 플래그(&lt;code&gt;running = false&lt;/code&gt;)는 특정 CPU 코어의 L1 캐시에만 머물러 있고, 다른 스레드들은 이를 모른 채 계속 돌 수 있습니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-80eb-949e-e1ec5a2c6ed5&quot; class=&quot;&quot;&gt;&lt;strong&gt;문제 코드&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js&quot; integrity=&quot;sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/script&gt;&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css&quot; integrity=&quot;sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;pre id=&quot;302d70fa-a068-8017-a16f-eaa131978c0b&quot; class=&quot;code code-wrap language-java&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-java&quot; style=&quot;white-space:pre-wrap;word-break:break-all&quot;&gt;public class Worker implements Runnable {
    private boolean running = true;  // ❌ 가시성 보장 안 됨

    @Override
    public void run() {
        while (running) {
            // 작업 수행
        }
    }

    public void stop() {
        running = false;  // 다른 스레드가 못 볼 수 있음!
    }
}&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8038-a555-ff1634f4239c&quot; class=&quot;&quot;&gt;&lt;strong&gt;해결책&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js&quot; integrity=&quot;sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/script&gt;&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css&quot; integrity=&quot;sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;pre id=&quot;302d70fa-a068-80d0-bbd2-c75a8e039f1f&quot; class=&quot;code code-wrap language-java&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-java&quot; style=&quot;white-space:pre-wrap;word-break:break-all&quot;&gt;public class Worker implements Runnable {
    private volatile boolean running = true;  // ✅ volatile 사용

    // 또는
    private final AtomicBoolean running = new AtomicBoolean(true);  //

    @Override
    public void run() {
        while (running) {  // 모든 스레드가 최신 값을 봄
            // 작업 수행
        }
    }
}&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-806c-8bfc-dd42ef2f1254&quot; class=&quot;&quot;&gt;&lt;strong&gt;적용 포인트&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8074-b9e9-ef3bc7448f4a&quot; class=&quot;&quot;&gt;종료 플래그 같은 상태 제어 변수는 반드시 &lt;code&gt;volatile&lt;/code&gt; 키워드나 &lt;code&gt;AtomicBoolean&lt;/code&gt;을 사용하세요. 이는 CPU 캐시가 아닌 메인 메모리에 값을 즉시 기록(Flush)하게 하여, 모든 스레드가 동시에 종료 신호를 볼 수 있게 보장합니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;blockquote id=&quot;302d70fa-a068-80a9-b7eb-e9a4eae5dcd6&quot; class=&quot;&quot;&gt;&quot;보이는 것만 믿어라&quot; - 동시성 프로그래밍의 제1원칙&lt;/blockquote&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;302d70fa-a068-80b3-9e56-fb0942723852&quot; class=&quot;&quot;&gt;  Lesson 4. InterruptedException 처리&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8005-ae6a-cb1031d8025e&quot; class=&quot;&quot;&gt;종료 신호를 받아서 스레드를 중단시킬 때, &lt;code&gt;InterruptedException&lt;/code&gt;을 올바르게 처리하지 않으면 종료가 되지 않습니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-80c9-867e-cee7d93ba5b8&quot; class=&quot;&quot;&gt;&lt;strong&gt;❌ 나쁜 예: 예외 삼키기&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js&quot; integrity=&quot;sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/script&gt;&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css&quot; integrity=&quot;sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;pre id=&quot;302d70fa-a068-8011-a814-fa05d2aa4e69&quot; class=&quot;code code-wrap language-java&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-java&quot; style=&quot;white-space:pre-wrap;word-break:break-all&quot;&gt;try {
    Thread.sleep(10000);
} catch (InterruptedException e) {
    e.printStackTrace();  // 예외를 먹어버림
    // 스레드는 계속 실행됨!
}&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-808e-8bbe-ef0917363727&quot; class=&quot;&quot;&gt;&lt;strong&gt;✅ 좋은 예: 인터럽트 상태 복구&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js&quot; integrity=&quot;sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/script&gt;&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css&quot; integrity=&quot;sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;pre id=&quot;302d70fa-a068-8056-8738-c39c2a3591a2&quot; class=&quot;code code-wrap language-java&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-java&quot; style=&quot;white-space:pre-wrap;word-break:break-all&quot;&gt;try {
    Thread.sleep(10000);
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();  // 인터럽트 상태 복구
    log.warn(&quot;Shutdown signal received. Stopping task...&quot;);
    return;  // 즉시 메서드 종료
}&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-808c-a8ae-d579f6e384c1&quot; class=&quot;&quot;&gt;&lt;strong&gt;더 나은 예: 종료 플래그와 함께 사용&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js&quot; integrity=&quot;sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/script&gt;&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css&quot; integrity=&quot;sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;pre id=&quot;302d70fa-a068-805e-b758-cb1559065a7b&quot; class=&quot;code code-wrap language-java&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;language-java&quot; style=&quot;white-space:pre-wrap;word-break:break-all&quot;&gt;private volatile boolean running = true;

public void run() {
    while (running &amp;amp;&amp;amp; !Thread.currentThread().isInterrupted()) {
        try {
            doWork();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            log.info(&quot;Task interrupted, cleaning up...&quot;);
            cleanup();
            return;
        }
    }
}&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;hr id=&quot;302d70fa-a068-80f9-99c6-caa76555eb4d&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8048-b037-e7fdb5b54013&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h2 id=&quot;302d70fa-a068-809f-8964-f63508d6bd5f&quot; class=&quot;&quot;&gt;마치며: 우아한 이별을 위하여&lt;/h2&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8000-8012-c7bc007f0637&quot; class=&quot;&quot;&gt;&lt;strong&gt;체크리스트&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;302d70fa-a068-8058-9695-f434754a5922&quot; class=&quot;to-do-list&quot;&gt;&lt;li&gt;&lt;div class=&quot;checkbox checkbox-off&quot;&gt;&lt;/div&gt; &lt;span class=&quot;to-do-children-unchecked&quot;&gt;&lt;code&gt;server.shutdown: graceful&lt;/code&gt; 설정이 되어있는가?&lt;/span&gt;&lt;div class=&quot;indented&quot;&gt;&lt;/div&gt;&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;302d70fa-a068-8074-9662-d072da458d70&quot; class=&quot;to-do-list&quot;&gt;&lt;li&gt;&lt;div class=&quot;checkbox checkbox-off&quot;&gt;&lt;/div&gt; &lt;span class=&quot;to-do-children-unchecked&quot;&gt;&lt;code&gt;lifecycle.timeout&lt;/code&gt;이 우리 서비스의 가장 긴 트랜잭션보다 긴가?&lt;/span&gt;&lt;div class=&quot;indented&quot;&gt;&lt;/div&gt;&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;302d70fa-a068-80f5-89ed-c119921e28be&quot; class=&quot;to-do-list&quot;&gt;&lt;li&gt;&lt;div class=&quot;checkbox checkbox-off&quot;&gt;&lt;/div&gt; &lt;span class=&quot;to-do-children-unchecked&quot;&gt;비동기 작업도 끝까지 기다리도록 &lt;code&gt;await-termination: true&lt;/code&gt;로 설정했는가?&lt;/span&gt;&lt;div class=&quot;indented&quot;&gt;&lt;/div&gt;&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;302d70fa-a068-80cf-bc5c-e2b125c404f8&quot; class=&quot;to-do-list&quot;&gt;&lt;li&gt;&lt;div class=&quot;checkbox checkbox-off&quot;&gt;&lt;/div&gt; &lt;span class=&quot;to-do-children-unchecked&quot;&gt;로드 밸런서의 헬스 체크 주기 내에 트래픽이 유입될 틈(Gap)을 막았는가? (PreStop Hook)&lt;/span&gt;&lt;div class=&quot;indented&quot;&gt;&lt;/div&gt;&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;302d70fa-a068-8071-b68a-d711400d611e&quot; class=&quot;to-do-list&quot;&gt;&lt;li&gt;&lt;div class=&quot;checkbox checkbox-off&quot;&gt;&lt;/div&gt; &lt;span class=&quot;to-do-children-unchecked&quot;&gt;배포 스크립트가 &lt;code&gt;kill -9&lt;/code&gt; 대신 &lt;code&gt;kill -15&lt;/code&gt;를 보내고 충분히 기다리는가?&lt;/span&gt;&lt;div class=&quot;indented&quot;&gt;&lt;/div&gt;&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;302d70fa-a068-80a0-b0c0-e4212d65ba61&quot; class=&quot;to-do-list&quot;&gt;&lt;li&gt;&lt;div class=&quot;checkbox checkbox-off&quot;&gt;&lt;/div&gt; &lt;span class=&quot;to-do-children-unchecked&quot;&gt;컨테이너 환경에서 PID 1 문제를 해결했는가? (tini 또는 exec)&lt;/span&gt;&lt;div class=&quot;indented&quot;&gt;&lt;/div&gt;&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;302d70fa-a068-8078-bb7a-fcaa9cec45eb&quot; class=&quot;to-do-list&quot;&gt;&lt;li&gt;&lt;div class=&quot;checkbox checkbox-off&quot;&gt;&lt;/div&gt; &lt;span class=&quot;to-do-children-unchecked&quot;&gt;분산 트랜잭션 환경에서 재시작 후 복구 로직이 있는가? (Outbox Pattern)&lt;/span&gt;&lt;div class=&quot;indented&quot;&gt;&lt;/div&gt;&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-8036-b8f4-f67ff26e6780&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;302d70fa-a068-80d6-af81-d7157617e228&quot; class=&quot;&quot;&gt;우아한 종료 설정을 통해, 여러분의 퇴근길도 우아해지길 바랍니다.  &lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
    &lt;/div&gt;</description>
      <category>스프링</category>
      <category>gracefulshutdown</category>
      <category>딮다이브</category>
      <category>스프링</category>
      <author>순원이</author>
      <guid isPermaLink="true">https://jsw5913.tistory.com/311</guid>
      <comments>https://jsw5913.tistory.com/311#entry311comment</comments>
      <pubDate>Mon, 9 Feb 2026 13:14:00 +0900</pubDate>
    </item>
    <item>
      <title>Resilience4j 딮다이브</title>
      <link>https://jsw5913.tistory.com/310</link>
      <description>&lt;div&gt;
&lt;style&gt;
/* 1. 기본 레이아웃 및 폰트 강제 적용 */
.notion-tistory-container {
    font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, &quot;Segoe UI Variable Display&quot;, &quot;Segoe UI&quot;, Helvetica, &quot;Apple Color Emoji&quot;, Arial, sans-serif !important;
    color: rgb(55, 53, 47) !important;
    line-height: 1.625 !important;
    max-width: 900px !important;
    margin: 0 auto !important;
    word-break: break-word !important;
    text-align: left !important;
}

/* 노션 특유의 display:contents 보정 */
.notion-tistory-container [style*=&quot;display:contents&quot;] {
    display: block !important;
}

/* 2. 제목 여백 (노션 여백 효과) */
.notion-tistory-container h1 { font-size: 1.875rem !important; font-weight: 700 !important; margin-top: 3.5rem !important; margin-bottom: 0.5rem !important; color: black !important; }
.notion-tistory-container h2 { font-size: 1.5rem !important; font-weight: 600 !important; margin-top: 2.5rem !important; margin-bottom: 0.3rem !important; }
.notion-tistory-container h3 { font-size: 1.25rem !important; font-weight: 600 !important; margin-top: 1.8rem !important; margin-bottom: 0.2rem !important; }


/* 3. 노션 하이라이트 배경색 (Pastel Tone 복구) */
.notion-tistory-container mark { background-color: transparent !important; } /* 기본 형광색 제거 */

.notion-tistory-container .highlight-yellow_background { background: #fbf3db !important; padding: 0.1em 0.2em; border-radius: 3px; }
.notion-tistory-container .highlight-gray_background   { background: #f1f1ef !important; padding: 0.1em 0.2em; border-radius: 3px; }
.notion-tistory-container .highlight-brown_background  { background: #f4eeee !important; padding: 0.1em 0.2em; border-radius: 3px; }
.notion-tistory-container .highlight-orange_background { background: #faebdd !important; padding: 0.1em 0.2em; border-radius: 3px; }
.notion-tistory-container .highlight-teal_background   { background: #edf3ec !important; padding: 0.1em 0.2em; border-radius: 3px; }
.notion-tistory-container .highlight-blue_background   { background: #e7f3f8 !important; padding: 0.1em 0.2em; border-radius: 3px; }
.notion-tistory-container .highlight-purple_background { background: #f6f3f9 !important; padding: 0.1em 0.2em; border-radius: 3px; }
.notion-tistory-container .highlight-pink_background   { background: #faf3f8 !important; padding: 0.1em 0.2em; border-radius: 3px; }
.notion-tistory-container .highlight-red_background    { background: #fdebec !important; padding: 0.1em 0.2em; border-radius: 3px; }

/* 4. 코드 블록 및 인라인 코드 (image_330116.jpg 버그 수정) */
/* (1) 큰 박스 형태 (pre/block code): 검정 글씨 + 연한 회색 배경 */
.notion-tistory-container pre, 
.notion-tistory-container .code {
    background: rgba(242, 241, 238, 0.6) !important; /* image_32fe30.png의 연한 회색 */
    color: rgb(55, 53, 47) !important; /* 빨간색 글씨 해결 */
    border: none !important;
    border-radius: 4px !important;
    padding: 1.5em !important;
    margin: 1em 0 !important;
    font-family: &quot;SFMono-Regular&quot;, Menlo, Consolas, monospace !important;
    font-size: 90% !important;
    white-space: pre-wrap !important;
}

/* (2) 인라인 코드 (문장 중간): 노션 특유의 붉은 텍스트 유지 */
.notion-tistory-container :not(pre) &gt; code {
    background: rgba(135, 131, 120, 0.15) !important;
    color: #eb5757 !important;
    padding: 0.2em 0.4em !important;
    border-radius: 3px !important;
    font-size: 85% !important;
}

/* 5. 인용구 (Blockquote) */
.notion-tistory-container blockquote {
    font-size: 1em !important;
    margin: 1.2em 0 !important;
    padding-left: 1.2em !important;
    border-left: 3px solid rgb(55, 53, 47) !important;
    border-top: none !important; border-right: none !important; border-bottom: none !important;
    background: transparent !important;
}

/* 6. 테이블 스타일 */
.notion-tistory-container table {
    border-collapse: collapse !important;
    width: 100% !important;
    margin-top: 1em !important;
    font-size: 0.9rem !important;
}
.notion-tistory-container th, .notion-tistory-container td {
    border: 1px solid rgba(55, 53, 47, 0.09) !important;
    padding: 0.6em !important;
    text-align: left !important;
}
.notion-tistory-container .simple-table-header-color {
    background: rgb(247, 246, 243) !important;
    font-weight: 500 !important;
}

/* 7. 체크박스 (To-do 리스트용 SVG 완전 복구) */
.notion-tistory-container .checkbox {
    display: inline-flex !important;
    vertical-align: text-bottom !important;
    width: 18px !important;
    height: 18px !important;
    margin-right: 7px !important;
}
.notion-tistory-container .checkbox-on {
    background-image: url(&quot;data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Crect%20width%3D%2216%22%20height%3D%2216%22%20fill%3D%22%2358A9D7%22%2F%3E%0A%3Cpath%20d%3D%22M6.71429%2012.2852L14%204.9995L12.7143%203.71436L6.71429%209.71378L3.28571%206.2831L2%207.57092L6.71429%2012.2852Z%22%20fill%3D%22white%22%2F%3E%0A%3C%2Fsvg%3E&quot;) !important;
}
.notion-tistory-container .checkbox-off {
    background-image: url(&quot;data:image/svg+xml;charset=UTF-8,%3Csvg%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%0A%3Crect%20x%3D%220.75%22%20y%3D%220.75%22%20width%3D%2214.5%22%20height%3D%2214.5%22%20fill%3D%22white%22%20stroke%3D%22%2336352F%22%20stroke-width%3D%221.5%22%2F%3E%0A%3C%2Fsvg%3E&quot;) !important;
}

/* 8. 리스트 및 기타 */
.notion-tistory-container ul, .notion-tistory-container ol {
    padding-inline-start: 1.7em !important;
    margin: 0.6em 0 !important;
}
.notion-tistory-container li { margin-bottom: 0.4em !important; }
.notion-tistory-container hr {
    border: none !important;
    border-bottom: 1px solid rgba(55, 53, 47, 0.09) !important;
    margin: 1.5em 0 !important;
}
&lt;/style&gt;
&lt;/div&gt;
&lt;div class=&quot;notion-tistory-container&quot;&gt;
&lt;div class=&quot;page-body&quot;&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;blockquote id=&quot;300d70fa-a068-8068-adad-f9976848be97&quot; class=&quot;&quot;&gt;&lt;strong&gt;Version Note&lt;/strong&gt;: 이 문서는 &lt;strong&gt;Resilience4j v2.x (v2.3 이상) 및 v3.0.0&lt;/strong&gt; 소스 코드를 기준으로 작성되었습니다. (v1.x 버전과는 설정 변수명 등이 다를 수 있습니다.)&lt;/blockquote&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-8031-b524-cd647570b74c&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-80d7-b10a-e8018bdef07d&quot; class=&quot;&quot;&gt;본 글은 Resilience4j 라이브러리의 내부 코드를 분석하여, 각 모듈이 실제로 어떤 알고리즘과 데이터 구조를 사용하여 동작하는지 설명하고, 주요 설정의 기본값 과 동작 예시 레퍼런스를 제공합니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;hr id=&quot;300d70fa-a068-800f-b93d-e922841aa2b3&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-801e-88b5-f9c4edfa6edd&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h2 id=&quot;300d70fa-a068-80d4-85ba-e01e4a6ae4cc&quot; class=&quot;&quot;&gt;1. 알아보기 전에 왜 Resilience4j여야 하는가?&lt;/h2&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-800c-8858-da4de25cc7ab&quot; class=&quot;&quot;&gt;단순한 사용법을 넘어, &lt;strong&gt;설계자의 시선&lt;/strong&gt;에서 Resilience4j를 깊이 있게 분석해 봅니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-805a-8096-d7e61afb9772&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;300d70fa-a068-806f-9f95-ce44373d7256&quot; class=&quot;&quot;&gt;1-1. 탄생 배경과 문맥&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;blockquote id=&quot;300d70fa-a068-800e-90c2-efcf60a7cf73&quot; class=&quot;&quot;&gt;왜 Netflix Hystrix는 역사의 뒤안길로 사라지고 Resilience4j가 표준이 되었는가?&lt;/blockquote&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-8027-8ddb-f82b47f52d11&quot; class=&quot;&quot;&gt;Netflix Hystrix는 Java 6 시절에 만들어져 객체 지향적인 강결합 설계와 RxJava(무거운 의존성)를 필수로 요구했습니다. 하지만 Java 8의 등장으로 Lambda와 함수형 프로그래밍이 주류가 되면서, 이에 맞춰 등장한 Resilience4j는 &lt;strong&gt;함수형 디자인&lt;/strong&gt;을 채택했습니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-801f-bbba-f2d02fc1ff96&quot; class=&quot;&quot;&gt;덕분에 불필요한 의존성을 제거하여 가볍고, 서킷/리트라이 등 필요한 모듈만 골라 쓸 수 있는 것이 특징입니다. 결과적으로 분산 시스템의 &lt;strong&gt;연쇄 장애&lt;/strong&gt; 를 방지하고, &lt;strong&gt;Fail Fast&lt;/strong&gt; 를 통해 시스템의 전체적인 회복 탄력성을 확보하는 것을 목표로 합니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-80ba-83b8-c337fdcca8e5&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;300d70fa-a068-8008-bfad-c7b6b483a899&quot; class=&quot;&quot;&gt;1-2. 내부 동작과 추상화&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;blockquote id=&quot;300d70fa-a068-8019-985b-ea87339ac72c&quot; class=&quot;&quot;&gt;이 라이브러리는 어떻게 Lock 없이 스레드 안전성을 보장하는가?&lt;/blockquote&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-80cb-90c5-d204da68a1e2&quot; class=&quot;&quot;&gt;Resilience4j는 고성능을 위해 &lt;code&gt;synchronized&lt;/code&gt; 키워드를 거의 사용하지 않습니다. 대신 상태 변경이나 카운팅에 &lt;code&gt;AtomicReference&lt;/code&gt;, &lt;code&gt;LongAdder&lt;/code&gt;와 같은 &lt;strong&gt;CPU 수준의 원자적 연산(CAS)&lt;/strong&gt; 을 사용하여 병목(Contention)을 제거했습니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-8000-8928-d38baef0e166&quot; class=&quot;&quot;&gt;메모리 효율성 측면에서도, 초기 버전(v1)에서는 &lt;code&gt;BitSet&lt;/code&gt;을 사용했으나 v2부터는 정밀한 시간 측정을 위해 &lt;strong&gt;Object Ring Buffer&lt;/strong&gt; (&lt;code&gt;Measurement[]&lt;/code&gt;) 방식을 채택하여 최적화했습니다. (자세한 내용은 CircuitBreaker 섹션 참조)&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-8035-bd52-d723789887bc&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;300d70fa-a068-808e-86d0-e6f22a6a229e&quot; class=&quot;&quot;&gt;1-3. 트레이드오프&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;blockquote id=&quot;300d70fa-a068-8040-b631-de5d5335d568&quot; class=&quot;&quot;&gt;Resilience4j를 도입함으로써 우리가 치러야 할 비용은 무엇인가?&lt;/blockquote&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-8036-9837-c5bef17efce2&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;&lt;strong&gt;지연 시간&lt;/strong&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-80c3-973e-d982b0738fff&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:circle&quot;&gt;: 아무리 가볍다 해도, 모든 요청에 대해 CircuitBreaker.acquirePermission() 같은 검사 로직이 추가됩니다. (Nano-second 단위라 무시할 만하지만, 초고빈도 trading 시스템에서는 고려 대상입니다.)&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-80e1-84cd-ccf7a74a32e2&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;&lt;strong&gt;복잡도&lt;/strong&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-800e-9666-e4f2dca543fa&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:circle&quot;&gt;설정 폭탄: 타임아웃, 임계치, 윈도우 크기 등 튜닝해야 할 파라미터가 매우 많습니다. 잘못 설정하면 멀쩡한 요청도 차단해버리는 &lt;strong&gt;False Positive&lt;/strong&gt;가 발생합니다.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-805b-93f8-ffb04f7f0e54&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:circle&quot;&gt;디버깅의 어려움: AOP로 감싸져 있어 스택 트레이스가 복잡해지고, &quot;왜 차단되었는지&quot; 즉각적으로 알기 어려울 때가 있습니다. (그래서 writableStackTraceEnabled=false 같은 옵션도 존재함).&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-80fb-9863-f61b77389e20&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;300d70fa-a068-80f8-829d-ccd8417709b9&quot; class=&quot;&quot;&gt;1-4. 한계와 임계점&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;blockquote id=&quot;300d70fa-a068-804b-bc64-f582ab9ffa26&quot; class=&quot;&quot;&gt;이 서킷브레이커 자체가 장애 포인트가 될 수 있는가?&lt;/blockquote&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-801c-b9f3-f16e75ff7741&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;&lt;strong&gt;메모리 한계&lt;/strong&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-8082-8cd7-c802b66401f7&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:circle&quot;&gt;slidingWindowSize를 너무 크게 잡으면(예: 1,000,000), 각 요청마다 비트 연산 및 집계 비용이 커져 CPU 오버헤드가 발생할 수 있습니다.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-80cc-9f1d-dc4e7e93c41b&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;&lt;strong&gt;분산 환경의 한계와 해결책&lt;/strong&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-8070-9c2c-d1d6c59baa00&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:circle&quot;&gt;&lt;strong&gt;한계&lt;/strong&gt;: Resilience4j는 &lt;strong&gt;단일 JVM(인스턴스) 내부&lt;/strong&gt;에서만 동작합니다. (서버 10대 = 서킷 10개).&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-8009-959d-dc6c6a16c66d&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:circle&quot;&gt;&lt;strong&gt;해결책 1 (Client-side Load Balancing)&lt;/strong&gt;: Spring Cloud LoadBalancer와 결합하여, 인스턴스별로 서킷을 격리해서 관리합니다. (A서버용 서킷 OPEN -&amp;gt; B서버로 라우팅).&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-8038-bbfd-ee9bb976a158&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:circle&quot;&gt;&lt;strong&gt;해결책 2 (Global Rate Limiting)&lt;/strong&gt;: 전체 트래픽 제어가 필요하다면 Resilience4j 대신 &lt;strong&gt;Redis + Bucket4j&lt;/strong&gt; 조합이나 &lt;strong&gt;API Gateway (Kong, Spring Cloud Gateway)&lt;/strong&gt; 의 RateLimiter를 써야 합니다.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-80c8-a1d9-cfe58ea14b2a&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:circle&quot;&gt;&lt;strong&gt;Sticky Session&lt;/strong&gt;: 스티키 세션을 쓴다면 인스턴스별 서킷 동작이 유의미하지만, 라운드 로빈 환경에서는 &quot;운 나쁜&quot; 인스턴스만 서킷이 열리는 불균형이 발생할 수 있습니다.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-8035-b64a-eebda0627982&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;300d70fa-a068-80c4-9976-c598bdce3262&quot; class=&quot;&quot;&gt;1-5. 대안과 타당성&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;blockquote id=&quot;300d70fa-a068-8076-9431-d6390df1e5b7&quot; class=&quot;&quot;&gt;꼭 Resilience4j여야 하는가? Service Mesh는?&lt;/blockquote&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-8085-a937-f448d2cbef29&quot; class=&quot;&quot;&gt;대안 기술 장점 단점 적합한 상황&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;table id=&quot;300d70fa-a068-80f4-be45-fbc2cabf1721&quot; class=&quot;simple-table&quot;&gt;&lt;thead class=&quot;simple-table-header&quot;&gt;&lt;tr id=&quot;300d70fa-a068-80fc-a8a9-ddb2ae00018a&quot;&gt;&lt;th id=&quot;Gi?i&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;&lt;strong&gt;Spring Retry&lt;/strong&gt;&lt;/th&gt;&lt;th id=&quot;AR~H&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;매우 단순함. 어노테이션 하나로 끝.&lt;/th&gt;&lt;th id=&quot;wz}C&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;서킷 브레이커(상태 관리) 기능이 없음. 무지성 재시도만 가능.&lt;/th&gt;&lt;th id=&quot;J&amp;lt;W;&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;단순 API 재시도용.&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr id=&quot;300d70fa-a068-805b-925a-e1ede37c5e2e&quot;&gt;&lt;td id=&quot;Gi?i&quot; class=&quot;&quot;&gt;&lt;strong&gt;Istio (Service Mesh)&lt;/strong&gt;&lt;/td&gt;&lt;td id=&quot;AR~H&quot; class=&quot;&quot;&gt;인프라 레벨에서 처리. 언어 종속성 없음(Polyglot). 중앙 제어 가능.&lt;/td&gt;&lt;td id=&quot;wz}C&quot; class=&quot;&quot;&gt;구축 및 운영 복잡도가 매우 높음(Kubernetes 필수). Fallback 로직 구현이 까다로움.&lt;/td&gt;&lt;td id=&quot;J&amp;lt;W;&quot; class=&quot;&quot;&gt;대규모 MSA, 인프라 팀이 별도로 있는 경우.&lt;/td&gt;&lt;/tr&gt;&lt;tr id=&quot;300d70fa-a068-8033-a5c7-fc74a105a541&quot;&gt;&lt;td id=&quot;Gi?i&quot; class=&quot;&quot;&gt;&lt;strong&gt;Sentinel (Alibaba)&lt;/strong&gt;&lt;/td&gt;&lt;td id=&quot;AR~H&quot; class=&quot;&quot;&gt;유량 제어(Flow Control)에 특화됨. 대시보드 강력함.&lt;/td&gt;&lt;td id=&quot;wz}C&quot; class=&quot;&quot;&gt;중국어 문서 비중이 높음. Spring Cloud Alibaba 의존성.&lt;/td&gt;&lt;td id=&quot;J&amp;lt;W;&quot; class=&quot;&quot;&gt;트래픽 세이핑이 주 목적일 때.&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-8022-befe-fec2ff510132&quot; class=&quot;&quot;&gt;&lt;strong&gt;결론&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-8000-8ee8-d68ecdb9876f&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;비즈니스 로직(Fallback 처리, 데이터 기본값 반환 등)과 밀접하게 연동되어야 한다면 &lt;strong&gt;Resilience4j&lt;/strong&gt;가 압도적으로 유리합니다.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-8007-8edc-feec9e7a6067&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;단순히 &quot;죽은 서버로 요청 안 보내기&quot;가 목적이라면 &lt;strong&gt;Istio&lt;/strong&gt; 같은 인프라 솔루션이 더 깔끔할 수 있습니다.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;hr id=&quot;300d70fa-a068-80cc-8671-d347f3aa0d2d&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-8075-a554-d9b05e1149ef&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h2 id=&quot;300d70fa-a068-80e0-841f-d56e4804a28e&quot; class=&quot;&quot;&gt;2. Circuit Breaker&lt;/h2&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-800b-bd90-f41bf8897ccc&quot; class=&quot;&quot;&gt;Circuit Breaker는 서비스의 가용성을 보호하기 위해 &lt;strong&gt;유한 상태 기계&lt;/strong&gt;로 구현되어 있습니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-80d8-956c-c062f6f0ea6d&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;300d70fa-a068-8082-8d14-c1421dd1c303&quot; class=&quot;&quot;&gt;2-1. 상태 관리&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-80a0-8686-c1e4c79c522e&quot; class=&quot;&quot;&gt;핵심 클래스인 CircuitBreakerStateMachine은 3가지 주요 상태(CLOSED, OPEN, HALF_OPEN)와 2가지 특수 상태(DISABLED, FORCED_OPEN)를 가집니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-80da-b20f-f7a3b5ad3398&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;&lt;strong&gt;CLOSED&lt;/strong&gt;: 모든 요청을 통과시킴. 실패율이나 느린 응답률을 모니터링.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-801c-ad97-f12462b506b4&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;&lt;strong&gt;OPEN&lt;/strong&gt;: 모든 요청을 즉시 차단하고 CallNotPermittedException을 발생시킴. 설정된 waitDurationInOpenState만큼 대기.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-805c-981f-db81b8fd3b8d&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;&lt;strong&gt;HALF_OPEN&lt;/strong&gt;: 제한된 수의 요청(permittedNumberOfCallsInHalfOpenState)만 허용하여 백엔드 서버가 복구되었는지 확인.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-80a4-9754-d556bf942c64&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;&lt;strong&gt;DISABLED&lt;/strong&gt;: 서킷 브레이커를 강제로 끕니다. 모든 요청을 허용하며, 통계도 수집하지 않습니다. (장애 시 긴급 조치 혹은 부하 테스트용).&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-8027-9a82-fb08081d0265&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;&lt;strong&gt;FORCED_OPEN&lt;/strong&gt;: 서킷 브레이커를 강제로 엽니다. 모든 요청을 차단하며, 타임아웃이 지나도 자동으로 풀리지 않습니다. (완전한 점검 필요 시).&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-806e-8662-ee9f1cbeeb56&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;300d70fa-a068-80d8-aa36-de0ede0a49a4&quot; class=&quot;&quot;&gt;2-2. 통계 수집: Sliding Window (핵심 자료구조)&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-80a2-aa0b-c5186b84f76b&quot; class=&quot;&quot;&gt;통계 수집은 &lt;strong&gt;Sliding Window&lt;/strong&gt; 알고리즘을 사용하며, 메모리 효율성을 극대화하기 위해 두 가지 타입으로 구현됩니다. 두 방식 모두 공통적으로 AbstractAggregation을 상속받은 객체(Measurement 또는 PartialAggregation)를 사용하여 통계를 저장합니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;blockquote id=&quot;300d70fa-a068-80ff-b887-ddc98fd49a5d&quot; class=&quot;&quot;&gt;&lt;strong&gt;[참고] 통계 객체(Measurement/Bucket)의 내부 구조&lt;/strong&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-8073-bf1a-ed97571f8ead&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;numberOfCalls: 총 호출 수 (int)&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-8081-8177-da52ec8be896&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;numberOfFailedCalls: 실패한 호출 수 (int)&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-803a-98b8-c9f2d929a816&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;numberOfSlowCalls: 느린 호출 수 (int)&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-80ec-bd82-cd9b59620c62&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;numberOfSlowFailedCalls: 느리면서 실패한 호출 수 (int)&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-80dc-8e94-e7521b5e71b6&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;totalDurationInMillis: 총 소요 시간 (long)&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-809b-be56-d675458ffe5d&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;&lt;strong&gt;약 24 Bytes Payload + Object Header (12~16 Bytes) ≈ 40 Bytes&lt;/strong&gt;&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;/blockquote&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-80fb-8e8b-ec6476dc1805&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;300d70fa-a068-80de-8371-f1728a6d5c75&quot; class=&quot;&quot;&gt;A. Count-based Sliding Window (횟수 기반)&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-805a-b514-cc4c7e6f7a5c&quot; class=&quot;&quot;&gt;&lt;strong&gt;Count-based Sliding Window&lt;/strong&gt;는 &lt;code&gt;FixedSizeSlidingWindowMetrics&lt;/code&gt; 클래스 내부에서 &lt;code&gt;Measurement&lt;/code&gt; 객체의 원형 배열(Ring Buffer)을 관리합니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-8000-943c-c306189c3938&quot; class=&quot;&quot;&gt;&lt;code&gt;slidingWindowSize&lt;/code&gt;가 100이라면 정확히 100개의 &lt;code&gt;Measurement&lt;/code&gt; 객체를 미리 생성해두고, &lt;code&gt;headIndex&lt;/code&gt;를 이동시키며 &lt;strong&gt;데이터를 덮어쓰는(Overwrite)&lt;/strong&gt; 방식으로 동작합니다. 덕분에 객체를 매번 생성하지 않고 재사용(Reset)하여 GC 부담을 최소화했습니다. 메모리 사용량은 &lt;code&gt;WindowSize * 40 Bytes&lt;/code&gt; 정도로, 10,000건 설정 시 약 400KB 수준입니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-80f5-9e2f-cfcff8d299d6&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;300d70fa-a068-806f-96d7-c49b72fcda48&quot; class=&quot;&quot;&gt;B. Time-based Sliding Window (시간 기반)&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-805d-9e3a-e46631a94313&quot; class=&quot;&quot;&gt;&lt;strong&gt;Time-based Sliding Window&lt;/strong&gt; 역시 내부적으로 &lt;code&gt;PartialAggregation&lt;/code&gt; 객체의 원형 배열을 사용합니다. 차이점은 배열의 각 칸(Slot)에 개별 요청이 아닌 &lt;strong&gt;1초 단위의 집계 데이터(Bucket)&lt;/strong&gt; 가 담긴다는 점입니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-8057-b0ce-ce561d15aaff&quot; class=&quot;&quot;&gt;예를 들어 &lt;code&gt;slidingWindowSize&lt;/code&gt;가 10초라면, 1초 동안 100만 건의 요청이 들어와도 해당 초를 담당하는 &lt;strong&gt;단 1개의 버킷 객체&lt;/strong&gt; 안의 카운트 값만 증가합니다. 따라서 &lt;strong&gt;트래픽 양(TPS)과 무관하게&lt;/strong&gt; 메모리 사용량이 일정(&lt;code&gt;WindowSeconds * 40 Bytes&lt;/code&gt;)하다는 장점이 있어 대용량 트래픽 처리에 매우 유리합니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-807e-ad7b-cc37a100c26b&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;300d70fa-a068-8056-b2f9-e0a78326d5d2&quot; class=&quot;&quot;&gt;C. TotalAggregation (실시간 합계 캐시)&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-8009-b3cd-f19e83e21333&quot; class=&quot;&quot;&gt;&lt;strong&gt;TotalAggregation&lt;/strong&gt;은 &lt;strong&gt;O(1) 성능을 보장하기 위한 Check-sum 객체&lt;/strong&gt;로, 시간기반과 횟수기반 모두에서 공통적으로 사용됩니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-8085-8aa9-fdfebee5e714&quot; class=&quot;&quot;&gt;매번 100개의 버킷을 루프 돌며 합산하면 O(N) 비용이 발생하므로, &lt;strong&gt;실시간 합계&lt;/strong&gt;를 별도 객체에 유지합니다. 오래된 데이터가 빠질 때(Evict) 빼고, 새로운 데이터가 들어올 때 더하는 방식으로 효율적으로 관리됩니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-8090-8d89-fc975b13f770&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;300d70fa-a068-80fe-9fa7-d72e0ce7c264&quot; class=&quot;&quot;&gt;2-3. [Q&amp;amp;A] 자료구조와 동작 원리&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-8066-a887-f60b62af99b3&quot; class=&quot;&quot;&gt;&lt;strong&gt;Q. Measurement 객체는 BitSet을 사용하나요? (BitSet vs Object Array)&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-80ac-b32b-dc1396cc9cae&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;&lt;strong&gt;아니요, 사용하지 않습니다.&lt;/strong&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-80a6-8a17-e21e97fa3bcd&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:circle&quot;&gt;&lt;strong&gt;이유&lt;/strong&gt;: BitSet은 0과 1 (성공/실패)만 저장할 수 있습니다. 하지만 Resilience4j는 &lt;strong&gt;느린 호출&lt;/strong&gt; 판단을 위해 &lt;strong&gt;실행 시간 &lt;/strong&gt;정보가 필요합니다.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-8076-8ea1-f774bef29559&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:circle&quot;&gt;BitSet으로는 &quot;이 요청이 500ms 걸렸다&quot;는 정보를 저장할 수 없기 때문에, long duration 필드를 가진 Measurement 객체를 사용하는 것입니다.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-80ce-95c4-f94695786577&quot; class=&quot;&quot;&gt;&lt;strong&gt;Q. 횟수 기반(Count-based)과 시간 기반(Time-based)의 차이는 무엇인가요?&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-8013-8a00-c12625a55af6&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;&lt;strong&gt;핵심 차이: &quot;배열의 한 칸(Slot)에 무엇을 담는가?&quot;&lt;/strong&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-8052-ad7b-cc6422ad9b0d&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:circle&quot;&gt;&lt;strong&gt;Count-based&lt;/strong&gt;: 배열 한 칸 = &lt;strong&gt;단 1건의 호출 정보&lt;/strong&gt; (Measurement). (오래된 개별 호출 기억).&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-8082-a57b-f72ef0ea4353&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:circle&quot;&gt;&lt;strong&gt;Time-based&lt;/strong&gt;: 배열 한 칸 = &lt;strong&gt;1초 동안의 집계(Bucket)&lt;/strong&gt; (PartialAggregation). (1초 단위로 퉁쳐서 기억).&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-8067-83be-e63575c20393&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;&lt;strong&gt;메모리는 둘 다 설정된 크기만큼만 사용하므로 일정(Constant)합니다.&lt;/strong&gt;&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-80de-a700-e91bfcc484af&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;&lt;strong&gt;다만, 대용량 트래픽(TPS) 환경에서의 차이점&lt;/strong&gt;:&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-805d-b31b-de4398ba7245&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:circle&quot;&gt;&lt;strong&gt;Count-based&lt;/strong&gt;: TPS가 폭증하면 100건 윈도우가 &lt;strong&gt;순식간에 회전(Overwrite)&lt;/strong&gt; 하여, 바로 1초 전의 데이터도 사라질 수 있습니다. (과거 추적 불가).&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-8031-b0ab-fbf3da478a3c&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:circle&quot;&gt;&lt;strong&gt;Time-based&lt;/strong&gt;: TPS가 아무리 높아도 설정된 시간(예: 10초) 동안의 이력을 &lt;strong&gt;안정적으로 보존&lt;/strong&gt;합니다.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-808a-9984-c13e9fd58eb9&quot; class=&quot;&quot;&gt;&lt;strong&gt;Q. Count-based 방식의 Measurement는 어차피 1개의 호출만 담는데, 왜 boolean이 아니라 int를 쓰나요?&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-8044-9621-e6b3526a762a&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;&lt;strong&gt;코드 재사용과 다형성 &lt;/strong&gt;때문입니다.&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-80e3-aa6d-d0071c281f4a&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:circle&quot;&gt;AbstractAggregation이라는 상위 클래스로 Time-based(Bucket)와 Count-based(Single)를 모두 처리하기 위함입니다.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-8049-88b7-db8e5b3d81d9&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:circle&quot;&gt;또한 int끼리의 연산으로 &lt;strong&gt;Subtract-on-Evict(꼬리 빼기)&lt;/strong&gt; 로직을 단순화할 수 있습니다.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-80cf-9bd7-db35ea129b84&quot; class=&quot;&quot;&gt;&lt;strong&gt;Q. 서킷 브레이커는 Count-based와 Time-based 중 하나만 선택할 수 있나요?&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-80e5-8fac-c5f35f540c7a&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;&lt;strong&gt;네, 맞습니다.&lt;/strong&gt; 동시에 쓸 수 없습니다. SlidingWindowType 설정 하나만 가집니다.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-80c0-a19d-cc4bed91b0fa&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;300d70fa-a068-80c6-8209-fb4a57f5592d&quot; class=&quot;&quot;&gt;2-4. 주요 설정 디폴트값&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-8004-896a-e4b828fcb972&quot; class=&quot;&quot;&gt;설정값 디폴트(Default) 설명&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;table id=&quot;300d70fa-a068-80d6-834b-fbfc92acf096&quot; class=&quot;simple-table&quot;&gt;&lt;thead class=&quot;simple-table-header&quot;&gt;&lt;tr id=&quot;300d70fa-a068-80f1-915f-ccae71056d7a&quot;&gt;&lt;th id=&quot;K|&amp;gt;X&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;slidingWindowType&lt;/th&gt;&lt;th id=&quot;`KPw&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;COUNT_BASED&lt;/th&gt;&lt;th id=&quot;XyaT&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;윈도우 타입 (횟수 기반 vs 시간 기반)&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr id=&quot;300d70fa-a068-80c0-ae31-de3d3fa6ba27&quot;&gt;&lt;td id=&quot;K|&amp;gt;X&quot; class=&quot;&quot;&gt;slidingWindowSize&lt;/td&gt;&lt;td id=&quot;`KPw&quot; class=&quot;&quot;&gt;100&lt;/td&gt;&lt;td id=&quot;XyaT&quot; class=&quot;&quot;&gt;통계 집계에 사용할 윈도우 크기 (최근 100건)&lt;/td&gt;&lt;/tr&gt;&lt;tr id=&quot;300d70fa-a068-802d-be9d-cb44799f5da9&quot;&gt;&lt;td id=&quot;K|&amp;gt;X&quot; class=&quot;&quot;&gt;minimumNumberOfCalls&lt;/td&gt;&lt;td id=&quot;`KPw&quot; class=&quot;&quot;&gt;100&lt;/td&gt;&lt;td id=&quot;XyaT&quot; class=&quot;&quot;&gt;최소 집계 요청 수. 이 숫자보다 적을 땐 실패율이 높아도 서킷이 열리지 않음.&lt;/td&gt;&lt;/tr&gt;&lt;tr id=&quot;300d70fa-a068-80cc-a351-f6024a992b1e&quot;&gt;&lt;td id=&quot;K|&amp;gt;X&quot; class=&quot;&quot;&gt;failureRateThreshold&lt;/td&gt;&lt;td id=&quot;`KPw&quot; class=&quot;&quot;&gt;50 (%)&lt;/td&gt;&lt;td id=&quot;XyaT&quot; class=&quot;&quot;&gt;실패율 임계치. 50% 이상 실패 시 OPEN.&lt;/td&gt;&lt;/tr&gt;&lt;tr id=&quot;300d70fa-a068-80fb-8312-f70763ccdebb&quot;&gt;&lt;td id=&quot;K|&amp;gt;X&quot; class=&quot;&quot;&gt;slowCallRateThreshold&lt;/td&gt;&lt;td id=&quot;`KPw&quot; class=&quot;&quot;&gt;100 (%)&lt;/td&gt;&lt;td id=&quot;XyaT&quot; class=&quot;&quot;&gt;느린 응답 비율 임계치.&lt;/td&gt;&lt;/tr&gt;&lt;tr id=&quot;300d70fa-a068-80a7-8960-ffaad2f62e58&quot;&gt;&lt;td id=&quot;K|&amp;gt;X&quot; class=&quot;&quot;&gt;slowCallDurationThreshold&lt;/td&gt;&lt;td id=&quot;`KPw&quot; class=&quot;&quot;&gt;60000 (ms)&lt;/td&gt;&lt;td id=&quot;XyaT&quot; class=&quot;&quot;&gt;느린 응답으로 간주할 시간 기준 (60초).&lt;/td&gt;&lt;/tr&gt;&lt;tr id=&quot;300d70fa-a068-8085-bee9-c6562a5efeec&quot;&gt;&lt;td id=&quot;K|&amp;gt;X&quot; class=&quot;&quot;&gt;waitDurationInOpenState&lt;/td&gt;&lt;td id=&quot;`KPw&quot; class=&quot;&quot;&gt;60000 (ms)&lt;/td&gt;&lt;td id=&quot;XyaT&quot; class=&quot;&quot;&gt;OPEN 상태 유지 시간 (60초).&lt;/td&gt;&lt;/tr&gt;&lt;tr id=&quot;300d70fa-a068-80d5-a240-ff45858d3ca8&quot;&gt;&lt;td id=&quot;K|&amp;gt;X&quot; class=&quot;&quot;&gt;permittedNumberOfCallsInHalfOpenState&lt;/td&gt;&lt;td id=&quot;`KPw&quot; class=&quot;&quot;&gt;10&lt;/td&gt;&lt;td id=&quot;XyaT&quot; class=&quot;&quot;&gt;HALF_OPEN 상태에서 허용할 테스트 요청 수.&lt;/td&gt;&lt;/tr&gt;&lt;tr id=&quot;300d70fa-a068-801e-9e89-e272d413f584&quot;&gt;&lt;td id=&quot;K|&amp;gt;X&quot; class=&quot;&quot;&gt;automaticTransitionFromOpenToHalfOpen&lt;/td&gt;&lt;td id=&quot;`KPw&quot; class=&quot;&quot;&gt;false&lt;/td&gt;&lt;td id=&quot;XyaT&quot; class=&quot;&quot;&gt;대기 시간 종료 후 자동으로 HALF_OPEN 전환 여부. 기본은 요청이 들어와야 전환됨.&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-8010-a251-c5c20976dbe1&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;300d70fa-a068-80a9-923c-dcd0cac5a744&quot; class=&quot;&quot;&gt;2-5. 동작 예시&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;blockquote id=&quot;300d70fa-a068-80c2-bf37-f949c998b138&quot; class=&quot;&quot;&gt;&lt;strong&gt;상황&lt;/strong&gt;: slidingWindowSize=10, failureRateThreshold=50%&lt;/blockquote&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ol type=&quot;1&quot; id=&quot;300d70fa-a068-8058-9386-caa95b65da78&quot; class=&quot;numbered-list&quot; start=&quot;1&quot;&gt;&lt;li&gt;&lt;strong&gt;초기 상태 (CLOSED)&lt;/strong&gt;: 요청이 계속 들어옵니다.&lt;/li&gt;&lt;/ol&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ol type=&quot;1&quot; id=&quot;300d70fa-a068-8007-acfa-f1c11b2d6780&quot; class=&quot;numbered-list&quot; start=&quot;2&quot;&gt;&lt;li&gt;&lt;strong&gt;집계&lt;/strong&gt;: 10개의 요청 중 4개가 실패 (40%). &lt;strong&gt;상태 유지&lt;/strong&gt;.&lt;/li&gt;&lt;/ol&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ol type=&quot;1&quot; id=&quot;300d70fa-a068-8062-bf0a-e338f04a1102&quot; class=&quot;numbered-list&quot; start=&quot;3&quot;&gt;&lt;li&gt;&lt;strong&gt;임계치 초과&lt;/strong&gt;: 1개가 더 들어와서 실패 -&amp;gt; 최근 10개 중 5개 실패 (50%).&lt;/li&gt;&lt;/ol&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ol type=&quot;1&quot; id=&quot;300d70fa-a068-80b5-8f2c-f8d04c43d2aa&quot; class=&quot;numbered-list&quot; start=&quot;4&quot;&gt;&lt;li&gt;&lt;strong&gt;OPEN 전환&lt;/strong&gt;: 즉시 차단 시작. CallNotPermittedException 발생.&lt;/li&gt;&lt;/ol&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ol type=&quot;1&quot; id=&quot;300d70fa-a068-8066-b303-cf103483cec1&quot; class=&quot;numbered-list&quot; start=&quot;5&quot;&gt;&lt;li&gt;&lt;strong&gt;대기 (Wait)&lt;/strong&gt;: 60초 대기.&lt;/li&gt;&lt;/ol&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ol type=&quot;1&quot; id=&quot;300d70fa-a068-801f-847c-f2ab192ce241&quot; class=&quot;numbered-list&quot; start=&quot;6&quot;&gt;&lt;li&gt;&lt;strong&gt;HALF 상태 전환&lt;/strong&gt;: 60초 후 첫 요청 진입 시 &lt;strong&gt;HALF_OPEN&lt;/strong&gt;.&lt;/li&gt;&lt;/ol&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ol type=&quot;1&quot; id=&quot;300d70fa-a068-804f-b28a-e0921747d71a&quot; class=&quot;numbered-list&quot; start=&quot;7&quot;&gt;&lt;li&gt;&lt;strong&gt;테스트&lt;/strong&gt;: 정확히 10개의 요청만 허용(AtomicInteger로 카운팅). 나머지는 즉시 차단.&lt;/li&gt;&lt;/ol&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ol type=&quot;1&quot; id=&quot;300d70fa-a068-8058-b050-ca8d9525d5dc&quot; class=&quot;numbered-list&quot; start=&quot;8&quot;&gt;&lt;li&gt;&lt;strong&gt;결과 판단&lt;/strong&gt;: 10개 처리 후 성공률/응답속도에 따라 &lt;strong&gt;CLOSED&lt;/strong&gt; 또는 &lt;strong&gt;OPEN&lt;/strong&gt; 결정.&lt;/li&gt;&lt;/ol&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-80bb-b2ce-e17da95de008&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;300d70fa-a068-806f-8025-c22dc9f8a8f8&quot; class=&quot;&quot;&gt;2-6. [심화] 동시성 엣지 케이스&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-80c5-a9a6-db49805acfde&quot; class=&quot;&quot;&gt;&lt;strong&gt;Q. 윈도우 크기 10(임계치 50%)인데 20명이 동시에 들어오면 어떻게 되나요?&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-803c-b569-e2366e987a71&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;&lt;strong&gt;CLOSED 상태일 때&lt;/strong&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-80dc-8716-e27a27e3057c&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:circle&quot;&gt;&quot;입구 컷&quot;은 없습니다. 20명 다 들어갑니다. (Lock-Free 구조상 진입을 막지 않음).&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-80f7-bd6a-f0218818ed96&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:circle&quot;&gt;10번째 요청이 끝나는 순간 서킷이 OPEN 되지만, 이미 진입한 20번째 요청까지는 정상 실행됩니다.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-805f-9197-df5b77fb0544&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;&lt;strong&gt;HALF_OPEN 상태일 때&lt;/strong&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-806c-8053-eb9368ea5786&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:circle&quot;&gt;&lt;strong&gt;철저하게 막습니다.&lt;/strong&gt; permittedNumberOfCallsInHalfOpenState가 10이면, AtomicInteger를 사용해 정확히 10명만 태우고 11번째부터는 CallNotPermittedException을 던집니다.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-8007-87b3-cba8ce4b24be&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;300d70fa-a068-801e-83f2-ce1e84730fae&quot; class=&quot;&quot;&gt;2-7. [심화] HALF_OPEN 상태와 운영 엣지 케이스&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-80c4-9d78-f8022cec761b&quot; class=&quot;&quot;&gt;&lt;strong&gt;Q. HALF_OPEN에서 10개 허용했는데, 처음 1개가 실패하면 바로 OPEN 되나요? (Fail Fast)&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-8038-bf28-c19b9994f868&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;&lt;strong&gt;아니요, 10개 다 끝날 때까지 기다립니다.&lt;/strong&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-8099-aa5c-c0bc1e50fb4a&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:circle&quot;&gt;내부 로직상 minimumNumberOfCalls가 permittedNumberOfCallsInHalfOpenState와 동일하게 설정됩니다.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-80f0-bed2-e141535a9531&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:circle&quot;&gt;즉, 10개 결과가 모두 집계되기 전까지는 실패율 계산을 보류(-1 반환)하고 상태를 유지합니다.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-8049-b14f-e421403eec70&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:circle&quot;&gt;10개가 모두 완료된 시점에 실패율을 계산하여 OPEN vs CLOSED를 결정합니다.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-80ee-a8d8-c5b2a746ecdd&quot; class=&quot;&quot;&gt;&lt;strong&gt;Q. 10개 중 5개 성공, 5개 실패면 어떻게 되나요?&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-80b4-b9c0-f05426f1d67c&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;실패율(50%) &amp;gt;= 임계치(50%)라면 다시 &lt;strong&gt;OPEN&lt;/strong&gt; 됩니다.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-8064-88a0-f666ace2dd23&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;임계치보다 낮아야만 &lt;strong&gt;CLOSED&lt;/strong&gt;로 복구됩니다.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-802f-8fc7-ea435c1e57c0&quot; class=&quot;&quot;&gt;&lt;strong&gt;Q. 만약 10개가 모두 5초씩 걸리는 슬로우 쿼리라면? 11번째 요청은 50초를 기다려야 하나요?&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-8024-bdbc-f895a50584f0&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;&lt;strong&gt;아니요, 즉시 차단됩니다.&lt;/strong&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-804b-b218-f1a7061f187b&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:circle&quot;&gt;서킷 브레이커는 진입 시점(Entry)에 AtomicInteger 카운터를 감소시킵니다.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-8029-ac37-dbd1d684f86c&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:circle&quot;&gt;앞선 10개가 아직 실행 중이더라도, 카운터가 0이 되면 11번째 요청은 &lt;strong&gt;대기 없이 바로&lt;/strong&gt; CallNotPermittedException을 받습니다.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-808d-999c-c8e01909309f&quot; class=&quot;&quot;&gt;&lt;strong&gt;Q. minimumNumberOfCalls를 윈도우 크기보다 크게 잡으면(Min=200, Window=100) 고장 나나요?&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-80d8-93f3-ef337fe084a8&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;&lt;strong&gt;아니요, 자동으로 보정됩니다.&lt;/strong&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-80f2-a26f-cee164f39e89&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:circle&quot;&gt;생성자에서 Math.min(min, windowSize)를 수행하므로, 실질적으로는 윈도우 크기(100)만큼만 기다립니다.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-803c-9f88-d760fae8dcfd&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:circle&quot;&gt;설정 실수로 인한 오동작을 방지하는 안전장치가 있습니다.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-80aa-9f02-c65f3d40b8bb&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;300d70fa-a068-804b-830d-d2c14d917a38&quot; class=&quot;&quot;&gt;2-8. Fallback 전략 (Graceful Degradation)&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;300d70fa-a068-80f0-bd12-e4fab2e49c84&quot; class=&quot;&quot;&gt;A. 기본 사용법&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js&quot; integrity=&quot;sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/script&gt;&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css&quot; integrity=&quot;sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;pre id=&quot;300d70fa-a068-80f8-b78b-f5e7db84afd5&quot; class=&quot;code code-wrap language-plain&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;Text language-plain&quot; style=&quot;white-space:pre-wrap;word-break:break-all&quot;&gt;@CircuitBreaker(name = &quot;backendA&quot;, fallbackMethod = &quot;fallback&quot;)
public String doSomething(String param) {
    return remoteService.call(param);
}

// Fallback 메서드 시그니처는 원본과 같아야 하며, 마지막 파라미터로 Throwable을 받아야 함
private String fallback(String param, CallNotPermittedException e) {
    return &quot;차단됨: 캐시 데이터 반환&quot;;
}

private String fallback(String param, Exception e) {
    return &quot;그 외 에러: 기본값 반환&quot;;
}&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;300d70fa-a068-802b-9820-e1d76539645f&quot; class=&quot;&quot;&gt;B. [Q&amp;amp;A] Fallback의 함정&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-809d-a1e4-d2511899dbc8&quot; class=&quot;&quot;&gt;&lt;strong&gt;Q. Fallback 메서드 안에서 또 예외가 터지면 어떻게 되나요?&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-8048-9089-e51f9d410724&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;그 예외가 그대로 상위로 전파됩니다. &lt;strong&gt;Fallback의 Fallback&lt;/strong&gt;은 지원하지 않습니다.&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-80ca-9667-f422a1409009&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:circle&quot;&gt;해결책: Fallback 메서드 안에는 절대 실패하지 않는 로직(Static 리턴, 로컬 캐시 조회)만 넣으세요. DB 조회 같은 거 넣지 마세요.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-8032-a1ea-e33d21e64406&quot; class=&quot;&quot;&gt;&lt;strong&gt;Q. 캐시된 데이터를 Fallback으로 쓰고 싶어요.&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-80ff-bfef-cea2b62c5353&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;Caffeine이나 Ehcache를 사용하여 로컬 메모리에 있는 데이터를 반환하는 것이 가장 이상적인 패턴입니다.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-8069-ade8-ce42e50d2ef7&quot; class=&quot;&quot;&gt;&lt;strong&gt;Q. Fallback도 실패하면 2차 Fallback을 하고 싶어요.&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-8092-b62a-d8b3b6172229&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;Resilience4j 자체 기능으로는 불가능하며, try-catch로 감싸거나 Spring @Recover를 혼용해야 하는데 권장하지 않습니다. 구조가 너무 복잡해집니다.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;hr id=&quot;300d70fa-a068-8042-a557-c7b66970fa1a&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-80b2-be56-e78c3727a76f&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h2 id=&quot;300d70fa-a068-8005-9884-e6106a5353c3&quot; class=&quot;&quot;&gt;3. Retry&lt;/h2&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-803d-9366-f8570a3e0b69&quot; class=&quot;&quot;&gt;Retry 모듈은 데코레이터 패턴을 사용하여 함수 실행을 감싸고, 예외 발생 시 조건에 따라 재실행하는 단순하면서도 강력한 구조입니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-807c-ab30-c8d00442b546&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;300d70fa-a068-809e-9428-fa62f510fcab&quot; class=&quot;&quot;&gt;3-1. 동작 메커니즘&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-80e1-bf08-d4cda1cb9d2a&quot; class=&quot;&quot;&gt;Allowable 인터페이스를 통해 재시도 가능한 예외인지 판단합니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-80de-b40f-c1b0d8056dc0&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;&lt;strong&gt;동기 방식&lt;/strong&gt;: While 루프나 재귀 호출이 아닌, Supplier를 RetryContext로 감싸서 실행합니다.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-8040-bf76-f5bce1df4eaa&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;&lt;strong&gt;비동기 방식&lt;/strong&gt;: Flux.retryWhen()과 유사하게 동작하며 Context 내에서 시도 횟수를 관리합니다.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-80ae-8b9b-dc68856380ea&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;300d70fa-a068-8036-8e53-e1e682111046&quot; class=&quot;&quot;&gt;3-2. [중요] RateLimiter와 함께 사용 시 주의사항 (Critical Warning)&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;blockquote id=&quot;300d70fa-a068-80c1-8785-d22da7fd714e&quot; class=&quot;&quot;&gt;&lt;strong&gt;[!WARNING] Retry Storm 방지기본값으로는 RateLimiter의 예외(RequestNotPermitted)도 재시도 대상에 포함됩니다.&lt;/strong&gt;&lt;/blockquote&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-80ed-89ec-ff8ab536fe15&quot; class=&quot;&quot;&gt;만약 RateLimiter와 Retry를 같이 쓰는데 설정을 안 하면 &lt;strong&gt;무한 지옥(Retry Storm)&lt;/strong&gt; 이 발생합니다. (차단됨 -&amp;gt; 재시도 -&amp;gt; 또 차단됨 -&amp;gt; 또 재시도...)&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-8005-bb4a-ff69c67b6274&quot; class=&quot;&quot;&gt;&lt;strong&gt;해결책&lt;/strong&gt;: 반드시 ignoreExceptions에 추가해야 합니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js&quot; integrity=&quot;sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/script&gt;&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css&quot; integrity=&quot;sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;pre id=&quot;300d70fa-a068-80e6-85e6-dcdeea5a7eb6&quot; class=&quot;code code-wrap language-plain&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;Text language-plain&quot; style=&quot;white-space:pre-wrap;word-break:break-all&quot;&gt;RetryConfig config = RetryConfig.custom()
    .ignoreExceptions(RequestNotPermitted.class) // 필수 설정!
    .build();&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-80c2-9842-fa5ca29b2cd0&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;300d70fa-a068-807f-93c0-fe194dc2d2f0&quot; class=&quot;&quot;&gt;3-3. [Q&amp;amp;A] Retry 설정&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-8092-943b-c3fb89676802&quot; class=&quot;&quot;&gt;&lt;strong&gt;Q. retryExceptions / ignoreExceptions가 비어있으면(기본값) 어떻게 동작하나요?&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-8048-a492-d9d4bcf49f16&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;&lt;strong&gt;주의: 기본적으로 &quot;모든 예외(Throwable)&quot;에 대해 재시도합니다.&lt;/strong&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-809b-a5fe-ffe379ff7ddf&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:circle&quot;&gt;RetryConfig 소스 코드를 보면, 예외 필터가 없을 경우 기본 Predicate가 throwable -&amp;gt; true로 설정되어 있습니다.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-807a-a911-f99ee86b1901&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:circle&quot;&gt;따라서 NullPointerException 같은 프로그래밍 오류도 무지성으로 재시도할 수 있으므로, 명시적으로 retryExceptions를 지정하는 것이 좋습니다.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-80d7-8a4a-df3a3e17364a&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;300d70fa-a068-80fa-bb8a-d661840db49c&quot; class=&quot;&quot;&gt;3-4. 주요 설정 디폴트값&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-8068-823f-f8cf098cab32&quot; class=&quot;&quot;&gt;설정값 디폴트(Default) 설명&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;table id=&quot;300d70fa-a068-807f-b2b6-fd602cf98bab&quot; class=&quot;simple-table&quot;&gt;&lt;thead class=&quot;simple-table-header&quot;&gt;&lt;tr id=&quot;300d70fa-a068-8008-bb8b-d9fb9e45c630&quot;&gt;&lt;th id=&quot;eahk&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;maxAttempts&lt;/th&gt;&lt;th id=&quot;lEX@&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;3&lt;/th&gt;&lt;th id=&quot;w\&amp;gt;_&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;최대 시도 횟수 (최초 시도 포함).&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr id=&quot;300d70fa-a068-80f9-8d00-cf9a7864d955&quot;&gt;&lt;td id=&quot;eahk&quot; class=&quot;&quot;&gt;waitDuration&lt;/td&gt;&lt;td id=&quot;lEX@&quot; class=&quot;&quot;&gt;500 (ms)&lt;/td&gt;&lt;td id=&quot;w\&amp;gt;_&quot; class=&quot;&quot;&gt;재시도 사이의 대기 시간.&lt;/td&gt;&lt;/tr&gt;&lt;tr id=&quot;300d70fa-a068-8006-8499-e43f81354098&quot;&gt;&lt;td id=&quot;eahk&quot; class=&quot;&quot;&gt;retryExceptions&lt;/td&gt;&lt;td id=&quot;lEX@&quot; class=&quot;&quot;&gt;empty&lt;/td&gt;&lt;td id=&quot;w\&amp;gt;_&quot; class=&quot;&quot;&gt;재시도할 예외 클래스 목록. (비어있으면 모든 예외 재시도)&lt;/td&gt;&lt;/tr&gt;&lt;tr id=&quot;300d70fa-a068-801f-b6cb-fc8d43abb29b&quot;&gt;&lt;td id=&quot;eahk&quot; class=&quot;&quot;&gt;ignoreExceptions&lt;/td&gt;&lt;td id=&quot;lEX@&quot; class=&quot;&quot;&gt;empty&lt;/td&gt;&lt;td id=&quot;w\&amp;gt;_&quot; class=&quot;&quot;&gt;재시도하지 않고 즉시 실패 처리할 예외.&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;hr id=&quot;300d70fa-a068-8056-83f0-f96cc4075da1&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-80d4-8f1c-de107f3ca766&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h2 id=&quot;300d70fa-a068-8073-8538-fd831b9900b3&quot; class=&quot;&quot;&gt;4. Rate Limiter&lt;/h2&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-8079-a11b-d6aa9256ab8b&quot; class=&quot;&quot;&gt;Rate Limiter는 &lt;strong&gt;Token Bucket&lt;/strong&gt; 알고리즘을 기반으로 구현되어 있습니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-807d-b4c0-d39dbddc34f2&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;300d70fa-a068-80f7-b27e-ee2f7c951c70&quot; class=&quot;&quot;&gt;4-1. AtomicRateLimiter&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-8056-8390-dd271823d6d2&quot; class=&quot;&quot;&gt;Resilience4j의 구현체인 AtomicRateLimiter는 락을 사용하지 않고 AtomicReference를 사용하여 고성능을 보장합니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-805f-bbc9-efa44abed351&quot; class=&quot;&quot;&gt;현재 사이클 번호와 남은 토큰 수 등을 담은 불변 &lt;strong&gt;State 객체&lt;/strong&gt;를 &lt;strong&gt;CAS(Compare-And-Swap)&lt;/strong&gt; 연산으로 업데이트하여 동시성을 제어합니다. 또한 별도의 스레드가 토큰을 채워주는 것이 아니라, 요청이 들어올 때마다 &lt;code&gt;(현재 시간 - 마지막 갱신 시간) * 리필 속도&lt;/code&gt;를 계산하여 토큰을 채우는 &lt;strong&gt;Lazy Calculation&lt;/strong&gt; 방식을 사용합니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-8023-9637-e00266079df6&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;300d70fa-a068-8082-94df-d271a9b1f738&quot; class=&quot;&quot;&gt;4-2. [Q&amp;amp;A] Timeout 설정 팁&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-8029-8bac-fa368bead5ad&quot; class=&quot;&quot;&gt;&lt;strong&gt;Q. 운영 환경에서 timeoutDuration은 보통 0s로 하나요?&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-8093-b8ed-ff81acabf762&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;&lt;strong&gt;Case By Case지만, 'Fail Fast'가 원칙인 API 서버에서는 0s를 권장합니다.&lt;/strong&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-8042-afd7-d3b3e87515c3&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:circle&quot;&gt;&lt;strong&gt;0s (Zero)&lt;/strong&gt;: 토큰이 없으면 즉시 거절(RequestNotPermitted). 사용자에게 빨리 실패를 알리는 것이 시스템 전체 안정성에 좋습니다.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-800c-bb38-cc13630e8e03&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:circle&quot;&gt;&lt;strong&gt;Large Wait&lt;/strong&gt;: 절대 금지. 스레드 풀 고갈의 원인이 됩니다.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-8031-b8f2-d888db9ea86a&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;300d70fa-a068-800c-a8b9-e7fd1e028098&quot; class=&quot;&quot;&gt;4-3. 고급 활용: 여러 개의 RateLimiter 조합 (RPM + RPD)&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-80b9-8bea-d317d5e95ec4&quot; class=&quot;&quot;&gt;&quot;하루 1500회(RPD) 제한과 동시에 1분당 60회(RPM) 제한&quot;을 걸고 싶다면, &lt;strong&gt;데코레이터를 중첩&lt;/strong&gt;하면 됩니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js&quot; integrity=&quot;sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/script&gt;&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css&quot; integrity=&quot;sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;pre id=&quot;300d70fa-a068-8064-a496-e9b15450db0c&quot; class=&quot;code code-wrap language-plain&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;Text language-plain&quot; style=&quot;white-space:pre-wrap;word-break:break-all&quot;&gt;// RPD(하루) 안에 RPM(분당)을 넣어서 이중으로 감쌈
Supplier&amp;lt;String&amp;gt; decorated = RateLimiter.decorateSupplier(rpdLimiter,
    RateLimiter.decorateSupplier(rpmLimiter, service::call));&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-80aa-8207-ff0476cc3a7e&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;300d70fa-a068-8041-b460-fbdb259cf487&quot; class=&quot;&quot;&gt;4-4. 주요 설정 디폴트값&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-8054-b6e0-eeba4a769aab&quot; class=&quot;&quot;&gt;설정값 디폴트(Default) 설명&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;table id=&quot;300d70fa-a068-80b9-86b7-c48b948ef3f2&quot; class=&quot;simple-table&quot;&gt;&lt;thead class=&quot;simple-table-header&quot;&gt;&lt;tr id=&quot;300d70fa-a068-80d3-957e-ce844311df14&quot;&gt;&lt;th id=&quot;o_vS&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;limitForPeriod&lt;/th&gt;&lt;th id=&quot;TFm~&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;50&lt;/th&gt;&lt;th id=&quot;jh:q&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;한 주기(Period) 동안 허용할 요청 수(토큰 수).&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr id=&quot;300d70fa-a068-801d-bfb4-e97c3dae17bc&quot;&gt;&lt;td id=&quot;o_vS&quot; class=&quot;&quot;&gt;limitRefreshPeriod&lt;/td&gt;&lt;td id=&quot;TFm~&quot; class=&quot;&quot;&gt;&lt;strong&gt;500 (ns)&lt;/strong&gt;&lt;/td&gt;&lt;td id=&quot;jh:q&quot; class=&quot;&quot;&gt;&lt;strong&gt;[주의]&lt;/strong&gt; 기본값은 500 나노초(0.0005ms)입니다. limitForPeriod가 50이라면, 사실상 &lt;strong&gt;초당 1억 건&lt;/strong&gt;을 허용하는 &quot;무제한&quot; 설정과 같습니다. 반드시 &lt;strong&gt;1s(초) 이상&lt;/strong&gt;으로 변경해서 쓰세요.&lt;/td&gt;&lt;/tr&gt;&lt;tr id=&quot;300d70fa-a068-8099-9973-ec959d08fc83&quot;&gt;&lt;td id=&quot;o_vS&quot; class=&quot;&quot;&gt;timeoutDuration&lt;/td&gt;&lt;td id=&quot;TFm~&quot; class=&quot;&quot;&gt;5000 (ms)&lt;/td&gt;&lt;td id=&quot;jh:q&quot; class=&quot;&quot;&gt;토큰이 없을 때 대기할 최대 시간.&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;hr id=&quot;300d70fa-a068-80bf-85e7-fc66b9376b0c&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-80c9-afa0-e155394cf3cd&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h2 id=&quot;300d70fa-a068-80d2-b19f-c231d272177f&quot; class=&quot;&quot;&gt;5. Bulkhead&lt;/h2&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-80ec-a26f-ead7173f412b&quot; class=&quot;&quot;&gt;시스템 리소스를 격리하여 장애 전파를 막습니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-809b-ae05-ec8312d8f6fd&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;300d70fa-a068-80ce-9160-df3a40c8c093&quot; class=&quot;&quot;&gt;5-1. 두 가지 구현체&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ol type=&quot;1&quot; id=&quot;300d70fa-a068-80a2-ae6e-fd1f56d2715d&quot; class=&quot;numbered-list&quot; start=&quot;1&quot;&gt;&lt;li&gt;&lt;strong&gt;SemaphoreBulkhead&lt;/strong&gt;: 세마포어로 동시 실행 스레드 수만 제한. (가볍다. 호출자 스레드 차단 가능성 있음).&lt;/li&gt;&lt;/ol&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ol type=&quot;1&quot; id=&quot;300d70fa-a068-806b-bb1e-dbd5b988fc4b&quot; class=&quot;numbered-list&quot; start=&quot;2&quot;&gt;&lt;li&gt;&lt;strong&gt;ThreadPoolBulkhead&lt;/strong&gt;: 별도의 스레드 풀과 큐를 생성하여 완벽 격리. (가장 안전하지만 무겁다).&lt;/li&gt;&lt;/ol&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-80d8-93a4-d38d6396cddf&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;300d70fa-a068-80e8-bf9d-ce46aba546c3&quot; class=&quot;&quot;&gt;5-2. 주요 설정 디폴트값&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-80bf-9f4f-e1b4e3375e92&quot; class=&quot;&quot;&gt;&lt;strong&gt;SemaphoreBulkhead&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-8009-ada6-e91ae5816e31&quot; class=&quot;&quot;&gt;설정값 디폴트 설명&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;table id=&quot;300d70fa-a068-8094-bf97-e8fcd92b16d8&quot; class=&quot;simple-table&quot;&gt;&lt;thead class=&quot;simple-table-header&quot;&gt;&lt;tr id=&quot;300d70fa-a068-80a8-bc9c-c7fc0c81d093&quot;&gt;&lt;th id=&quot;fmz}&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;maxConcurrentCalls&lt;/th&gt;&lt;th id=&quot;&amp;lt;~`e&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;25&lt;/th&gt;&lt;th id=&quot;LEME&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;동시 실행 최대 수.&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-80ef-a9fa-f3659a2b78f1&quot; class=&quot;&quot;&gt;&lt;strong&gt;ThreadPoolBulkhead&lt;/strong&gt;&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-8078-ae31-d29181613b8d&quot; class=&quot;&quot;&gt;설정값 디폴트 설명&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;ltr&quot;&gt;&lt;/div&gt;&lt;table id=&quot;300d70fa-a068-8060-84bf-e9d4c92d41c5&quot; class=&quot;simple-table&quot;&gt;&lt;thead class=&quot;simple-table-header&quot;&gt;&lt;tr id=&quot;300d70fa-a068-802e-b4c1-ca60131d0444&quot;&gt;&lt;th id=&quot;MIQb&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;maxThreadPoolSize&lt;/th&gt;&lt;th id=&quot;sfgt&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;CPU 코어 수&lt;/th&gt;&lt;th id=&quot;G[IH&quot; class=&quot;simple-table-header-color simple-table-header&quot;&gt;스레드 풀 문 닫을 때 크기.&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr id=&quot;300d70fa-a068-80a0-a920-f756cc013947&quot;&gt;&lt;td id=&quot;MIQb&quot; class=&quot;&quot;&gt;queueCapacity&lt;/td&gt;&lt;td id=&quot;sfgt&quot; class=&quot;&quot;&gt;100&lt;/td&gt;&lt;td id=&quot;G[IH&quot; class=&quot;&quot;&gt;대기 큐 크기.&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;hr id=&quot;300d70fa-a068-8059-9c97-d53aa114fa13&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-807a-9f18-c8e6317301f2&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h2 id=&quot;300d70fa-a068-80d9-8ae9-eca6c47892ea&quot; class=&quot;&quot;&gt;6. Time Limiter&lt;/h2&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-80e7-a0c2-c03461f5e9c1&quot; class=&quot;&quot;&gt;작업의 실행 시간을 제한하며, 주로 비동기 작업(CompletableFuture)과 함께 사용됩니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-803a-a3d1-fb8341e88d4f&quot; class=&quot;&quot;&gt;&lt;strong&gt;Time Limiter&lt;/strong&gt;는 작업 실행과 타이머를 경주시시키는 방식으로 동작합니다. 타이머가 먼저 끝나면 &lt;code&gt;TimeoutException&lt;/code&gt;을 던지고 작업을 cancel 합니다. 단, 이 과정은 &lt;strong&gt;자바의 인터럽트 메커니즘&lt;/strong&gt;에 의존하므로, 작업 코드가 인터럽트를 무시하거나 블로킹되어 있다면 실제로 멈추지 않을 수 있다는 점에 주의해야 합니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-809e-b131-deca9096441f&quot; class=&quot;&quot;&gt;&lt;strong&gt;디폴트값&lt;/strong&gt;: timeoutDuration = 1000ms (1초).&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;hr id=&quot;300d70fa-a068-8092-9a79-f84cbd57ab61&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-80ee-b261-c17d72be1a8d&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h2 id=&quot;300d70fa-a068-8035-98da-e2dc19b84d6e&quot; class=&quot;&quot;&gt;7. 심화: 동작 원리와 동시성&lt;/h2&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-808f-9989-f4672b938016&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;300d70fa-a068-8019-a6a9-e3089336641b&quot; class=&quot;&quot;&gt;7-1. 어노테이션 우선순위&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-802b-a4ad-e856ef879453&quot; class=&quot;&quot;&gt;Spring Boot에서 여러 어노테이션을 동시에 붙였을 때 실행 순서(바깥 -&amp;gt; 안쪽)는 다음과 같습니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ol type=&quot;1&quot; id=&quot;300d70fa-a068-807b-93fb-ecca09202192&quot; class=&quot;numbered-list&quot; start=&quot;1&quot;&gt;&lt;li&gt;&lt;strong&gt;Retry&lt;/strong&gt; (제일 바깥. 서킷 에러도 잡아서 재시도)&lt;/li&gt;&lt;/ol&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ol type=&quot;1&quot; id=&quot;300d70fa-a068-80d1-8771-e91ffbe23da8&quot; class=&quot;numbered-list&quot; start=&quot;2&quot;&gt;&lt;li&gt;&lt;strong&gt;CircuitBreaker&lt;/strong&gt;&lt;/li&gt;&lt;/ol&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ol type=&quot;1&quot; id=&quot;300d70fa-a068-802b-9186-cdcbadcd1c85&quot; class=&quot;numbered-list&quot; start=&quot;3&quot;&gt;&lt;li&gt;&lt;strong&gt;RateLimiter&lt;/strong&gt;&lt;/li&gt;&lt;/ol&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ol type=&quot;1&quot; id=&quot;300d70fa-a068-80dc-a3e5-e066521596c6&quot; class=&quot;numbered-list&quot; start=&quot;4&quot;&gt;&lt;li&gt;&lt;strong&gt;TimeLimiter&lt;/strong&gt;&lt;/li&gt;&lt;/ol&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ol type=&quot;1&quot; id=&quot;300d70fa-a068-80ad-8a2b-c14882479a9f&quot; class=&quot;numbered-list&quot; start=&quot;5&quot;&gt;&lt;li&gt;&lt;strong&gt;Bulkhead&lt;/strong&gt; (제일 안쪽. 스레드 점유 직전)&lt;/li&gt;&lt;/ol&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;hr id=&quot;300d70fa-a068-80e1-9dfa-da9f281a4711&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-8007-93c3-cf9ff8261a58&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h2 id=&quot;300d70fa-a068-8052-ac07-d3f793514753&quot; class=&quot;&quot;&gt;8. 운영 모니터링&lt;/h2&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-8047-a3bb-e74c31ef08a1&quot; class=&quot;&quot;&gt;
&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;300d70fa-a068-80a7-aac1-e5dd41b54266&quot; class=&quot;&quot;&gt;8-1. Micrometer &amp;amp; Prometheus 연동 및 주의사항&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-8091-9086-d9e173cd479c&quot; class=&quot;&quot;&gt;resilience4j-micrometer 모듈을 사용하면 서킷, 리트라이 등의 상태를 메트릭으로 노출할 수 있습니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-8074-8afc-f07911328e09&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;&lt;strong&gt;필수 Grafana 패널&lt;/strong&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-80bb-a0d5-da762c14e986&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:circle&quot;&gt;&lt;strong&gt;Circuit State&lt;/strong&gt;: resilience4j_circuitbreaker_state (Value 0=CLOSED, 1=OPEN, 2=HALF_OPEN). StateChange 시점에 점 찍히도록 시계열 그래프 추천.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-80ef-acaf-e4d8485f760c&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:circle&quot;&gt;&lt;strong&gt;Failure Rate&lt;/strong&gt;: resilience4j_circuitbreaker_failure_rate.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-80cd-98aa-ef31316ad676&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:circle&quot;&gt;&lt;strong&gt;Slow Calls&lt;/strong&gt;: resilience4j_circuitbreaker_slow_calls.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-80d7-a273-d8a2897ced7e&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;&lt;strong&gt;[Troubleshooting] Slow Call 메트릭이 안 떠요!&lt;/strong&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-8006-aff3-c069866249e7&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:circle&quot;&gt;slowCallDurationThreshold만 설정하고 &lt;strong&gt;slowCallRateThreshold를 설정 안 하면&lt;/strong&gt; 집계조차 안 될 수 있습니다. (또는 호출 수가 minimumNumberOfCalls 미만일 때).&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-80a1-9682-c98e6aa5395e&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:circle&quot;&gt;반드시 두 설정을 세트로 맞춰주세요.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;300d70fa-a068-8074-86a4-f95dbbdb811b&quot; class=&quot;&quot;&gt;8-2. Spring Boot Actuator Health&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-80b6-b413-eee91cf6f3ae&quot; class=&quot;&quot;&gt;management.health.circuitbreakers.enabled=true 설정으로 헬스 체크에 포함시킬 수 있습니다.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-8014-ba51-c163e6d4bb37&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;&lt;strong&gt;주의&lt;/strong&gt;: 쿠버네티스 &lt;strong&gt;Liveness Probe&lt;/strong&gt;와 연동하지 마세요. 서킷 열렸다고 파드 재시작하면 안 됩니다.&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;h3 id=&quot;300d70fa-a068-8037-9a5c-fe48defbf1a2&quot; class=&quot;&quot;&gt;8-3. Event Consumer 활용&lt;/h3&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-80c7-a7a1-e291d0ca9838&quot; class=&quot;&quot;&gt;상태 변화 시점에 로그를 남기거나 알람을 보내려면 EventConsumer를 등록하세요.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js&quot; integrity=&quot;sha512-7Z9J3l1+EYfeaPKcGXu3MS/7T+w19WtKQY/n+xzmw4hZhJ9tyYmcUS+4QqAlzhicE5LAfMQSF3iFTK9bQdTxXg==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/script&gt;&lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css&quot; integrity=&quot;sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;pre id=&quot;300d70fa-a068-808a-ab8e-d2ad8aa522ef&quot; class=&quot;code code-wrap language-plain&quot; tabindex=&quot;0&quot;&gt;&lt;code class=&quot;Text language-plain&quot; style=&quot;white-space:pre-wrap;word-break:break-all&quot;&gt;// 예시: 서킷 상태가 변할 때마다 로그 남기기
circuitBreaker.getEventPublisher()
    .onStateTransition(event -&amp;gt; log.warn(&quot;CircuitBreaker State Change: {} -&amp;gt; {}&quot;,
        event.getStateTransition().getFromState(),
        event.getStateTransition().getToState()));&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;hr id=&quot;300d70fa-a068-80cf-a90d-c395706d07f0&quot;&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;p id=&quot;300d70fa-a068-8068-ad45-cc3fade1455f&quot; class=&quot;&quot;&gt;&lt;strong&gt;References&lt;/strong&gt;:&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-80d6-89ac-e182b93a1913&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;resilience4j 공식 깃허브: &lt;a href=&quot;https://github.com/resilience4j/resilience4j&quot;&gt;https://github.com/resilience4j/resilience4j&lt;/a&gt;&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div style=&quot;display:contents&quot; dir=&quot;auto&quot;&gt;&lt;ul id=&quot;300d70fa-a068-80f7-b434-d5d925dc5c86&quot; class=&quot;bulleted-list&quot;&gt;&lt;li style=&quot;list-style-type:disc&quot;&gt;resilience4j 공식 문서: &lt;a href=&quot;https://resilience4j.readme.io/docs/getting-started&quot;&gt;https://resilience4j.readme.io/docs/getting-started&lt;/a&gt;&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;/div&gt;
&lt;/div&gt;</description>
      <category>스프링</category>
      <author>순원이</author>
      <guid isPermaLink="true">https://jsw5913.tistory.com/310</guid>
      <comments>https://jsw5913.tistory.com/310#entry310comment</comments>
      <pubDate>Sun, 8 Feb 2026 01:55:28 +0900</pubDate>
    </item>
    <item>
      <title>@Qualifier를 붙였는데 왜 무시될까? (스프링 빈 선택 코드 뜯어보기)</title>
      <link>https://jsw5913.tistory.com/309</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;최근 프로젝트에서 &lt;b&gt;Spring Boot 3&lt;/b&gt;와 &lt;b&gt;Lombok&lt;/b&gt;을 조합해 사용하던 중, 의아한 문제를 겪었습니다. 분명히 &lt;code&gt;@Qualifier&lt;/code&gt;를 붙였는데, 엉뚱한 빈이 주입되는 현상이었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 생성자를 직접 만들어서 해결했다로 끝내기엔 찜찜했습니다. 도대체 &lt;b&gt;왜 이런 일이 벌어지는지&lt;/b&gt;, 스프링 프레임워크 내부 코드를 뜯어보며 그 원리를 꼬리에 꼬리를 무는 질문으로 파헤쳐 보았습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;상황: 16MB짜리 WebClient가 필요해&lt;/b&gt;&lt;/h2&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Configuration
public class WebClientConfig {

    @Bean
    @Primary
    public WebClient webClient() {
        // 일반적인 요청용 (기본 설정)
        return WebClient.builder()
                .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                .build();
    }

    @Bean
    public WebClient aladinWebClient() {
        // 알라딘 크롤링용 - 큰 응답을 처리하기 위해 버퍼 사이즈 증가 (16MB)
        ExchangeStrategies exchangeStrategies = ExchangeStrategies.builder()
                .codecs(configurer -&amp;gt; configurer.defaultCodecs().maxInMemorySize(16 * 1024 * 1024))
                .build();

        return WebClient.builder()
                .exchangeStrategies(exchangeStrategies)
                .build();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 개의 &lt;code&gt;WebClient&lt;/code&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;code&gt;webClient&lt;/code&gt;: &lt;code&gt;@Primary&lt;/code&gt;가 붙은 기본 빈.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;aladinWebClient&lt;/code&gt;: 크롤링을 위해 버퍼를 늘린 특수 목적 빈.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor
public class AladinCrawlerService {

    @Qualifier(&quot;aladinWebClient&quot;) // 분명히 얘를 달라고 했다!
    private final WebClient aladinWebClient;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제 의도는 &lt;code&gt;aladinWebClient&lt;/code&gt;를 주입받는 것이었지만, 실제로는 &lt;b&gt;버퍼 제한(256KB)이 걸린 &lt;code&gt;@Primary&lt;/code&gt; 빈(webClient)&lt;/b&gt;이 주입되어 에러가 났습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;Q1. Lombok이 생성자를 만들어주니까 당연히 되는 거 아닌가요?&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결론부터 말하면, 롬복은 어노테이션을 복사하지 않습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Lombok의 &lt;code&gt;@RequiredArgsConstructor&lt;/code&gt;는 컴파일 시점에 자바의 AST(추상 구문 트리)를 조작해 생성자 코드를 삽입합니다. 하지만 Lombok 입장에서 필드에 붙은 &lt;code&gt;@Qualifier&lt;/code&gt;가 생성자 파라미터에도 필요한 정보인지는 알 길이 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Lombok은 &lt;b&gt;필드에 붙은 어노테이션을 생성자 파라미터로 복제하지 않고&lt;/b&gt;, 오직 타입과 변수명만 가지고 생성자를 만듭니다.&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;// Lombok이 실제로 만든 코드 (우리의 눈엔 안 보이지만)
public AladinCrawlerService(WebClient aladinWebClient) { 
    // 파라미터 앞에 @Qualifier가 없음!
    this.aladinWebClient = aladinWebClient;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스프링이 주입을 위해 생성자를 들여다봤을 때, 정작 주입 지점(파라미터)에는 &lt;b&gt;&lt;code&gt;@Qualifier&lt;/code&gt;가 글자 한 자 없이 깨끗하게 비어 있게 됩니다.&lt;/b&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;Q2. 그럼 이름(aladinWebClient)이 똑같은데 왜 매칭이 안 되죠?&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Qualifier는 날아갔다고 쳐요. 하지만 변수명이 &lt;code&gt;aladinWebClient&lt;/code&gt;잖아요? 스프링은 변수명으로 빈을 찾지 않나요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;잠시, 스프링이 변수명을 알아내는 방법부터 짚고 넘어갑시다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Spring Boot 2 vs 3&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;과거 (Spring Boot 2)&lt;/b&gt;: 자바 리플렉션은 원래 파라미터 이름을 모릅니다(&lt;code&gt;arg0&lt;/code&gt;, &lt;code&gt;arg1&lt;/code&gt;). 그래서 스프링은 &lt;code&gt;ASM&lt;/code&gt;이라는 라이브러리로 바이트코드를 뜯어 변수명을 알아냈습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;현재 (Spring Boot 3)&lt;/b&gt;: 성능과 표준화를 위해 ASM을 포기했습니다. 대신 &lt;code&gt;-parameters&lt;/code&gt; 컴파일 옵션을 기본으로 사용하여 표준 리플렉션(&lt;code&gt;Parameter.getName()&lt;/code&gt;)으로 이름을 알아냅니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 스프링은 제 변수명이 &lt;code&gt;aladinWebClient&lt;/code&gt;라는 걸 알고 있었습니다&lt;b&gt;.&lt;/b&gt; 그런데도 왜 엉뚱한 빈을 줬을까요?&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;범인은 빈 선택 알고리즘의 우선순위&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스프링 컨테이너의 핵심인 &lt;code&gt;DefaultListableBeanFactory&lt;/code&gt; 코드를 열어보면 그 답이 있습니다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// spring-beans/.../DefaultListableBeanFactory.java

protected @Nullable String determineAutowireCandidate(Map&amp;lt;String, Object&amp;gt; candidates, DependencyDescriptor descriptor) {
    // Step 1: @Primary 확인 (그리고 숨겨진 유일한 일반 빈 체크)
    String primaryCandidate = determinePrimaryCandidate(candidates, requiredType);
    if (primaryCandidate != null) {
        return primaryCandidate; // 찾으면 바로 리턴! (뒤는 보지도 않음)
    }

    // Step 2: 이름 매칭 (Name Matching)
    String dependencyName = descriptor.getDependencyName();
    if (dependencyName != null) {
        for (String beanName : candidates.keySet()) {
            if (matchesBeanName(beanName, dependencyName)) {
                return beanName;
            }
        }
    }

    // ... (Priority, Fallback 확인 등)
}&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;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;@Qualifier 확인&lt;/b&gt;: (롬복 때문에 증발해서 0순위 탈락)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;@Primary 확인&lt;/b&gt;: (기본 빈에 &lt;code&gt;@Primary&lt;/code&gt;가 붙어 있네? &lt;b&gt;당첨!&lt;/b&gt;)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;이름 매칭 (Name Matching)&lt;/b&gt;: (&lt;b&gt;이미 2단계에서 결정됐으므로 여기까지 오지도 않음 &lt;/b&gt;)&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 &lt;code&gt;-parameters&lt;/code&gt; 옵션이 있어도, &lt;b&gt;&lt;code&gt;@Primary&lt;/code&gt;가 이름 매칭보다 우선순위가 높기 때문에&lt;/b&gt; 우리는 16MB짜리 빈을 만날 수 없었던 것입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;심층 분석: @Fallback은 언제 쓰일까?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최신 스프링(6.2+)에는 &lt;code&gt;@Fallback&lt;/code&gt;이라는 기능도 있습니다. 이건 맨 마지막에 쓰이겠지?라고 생각하기 쉬운데, 소스 코드를 보면 아니였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;determinePrimaryCandidate&lt;/code&gt; 메서드 내부를 보면&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;protected @Nullable String determinePrimaryCandidate(...) {
    // 1. @Primary가 있으면 즉시 반환
    // ...

    // 2. @Primary가 없다면? 유일한 Non-Fallback 빈 찾기
    if (primaryBeanName == null) {
        for (String candidateBeanName : candidates.keySet()) {
            if (!isFallback(candidateBeanName)) { // @Fallback이 아닌 빈
               // ...
            }
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;놀랍게도 &lt;b&gt;&lt;code&gt;@Fallback&lt;/code&gt; 여부는 Step 1에서 체크&lt;/b&gt;합니다.&lt;br /&gt;즉, &lt;b&gt;이름 매칭(Step 2)보다 일반 빈 여부(Step 1)가 더 강력합니다.&lt;/b&gt; 만약 &lt;code&gt;aladinWebClient&lt;/code&gt; 빈에 실수로 &lt;code&gt;@Fallback&lt;/code&gt;을 붙였다면, 이름을 아무리 똑같이 맞춰도 절대 주입받을 수 없었을 겁니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;최종 우선순위 정리&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;@Qualifier&lt;/b&gt; (조건 필터링)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;@Primary&lt;/b&gt; (최강자)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;!@Fallback&lt;/b&gt; (유일한 일반 빈은 이름 매칭보다 강하다)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;이름 매칭&lt;/b&gt; (변수명 = 빈 이름)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;@Priority&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;@Fallback&lt;/b&gt; (최후의 보루)&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;결론 및 해결 방법&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 권장 방법: 직접 생성자 사용&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 확실한 방법은 롬복에 의존하지 않고 직접 생성자를 작성하여 &lt;code&gt;@Qualifier&lt;/code&gt;를 파라미터에 박아주는 것입니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;public AladinCrawlerService(@Qualifier(&quot;aladinWebClient&quot;) WebClient aladinWebClient) {
    this.aladinWebClient = aladinWebClient;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이건 롬복이 복사를 해주길 기다리는 게 아니라, &lt;b&gt;처음부터 정보를 그 자리에 박아넣은 것&lt;/b&gt;입니다. 스프링은 생성자를 보자마자 &lt;code&gt;@Qualifier&lt;/code&gt;를 발견하고, &lt;code&gt;@Primary&lt;/code&gt;고 뭐고 다 무시한 채 의도한 빈을 정확히 주입합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. 대안: lombok.config 설정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;롬복이 어노테이션을 복사하도록 강제할 수도 있습니다. 프로젝트 루트에 &lt;code&gt;lombok.config&lt;/code&gt; 파일을 만들고 다음을 추가하시면 됩니다.&lt;/p&gt;
&lt;pre class=&quot;crystal&quot;&gt;&lt;code&gt;lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier&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;기술의 편리함(Lombok) 뒤에 숨겨진 &lt;b&gt;컴파일러, Annotation Processing, 그리고 Spring의 빈 선택 알고리즘&lt;/b&gt;을 이해하는 좋은 기회였습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;References&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/spring-projects/spring-framework/pull/29531#issuecomment-1321797236&quot;&gt;Github: Deprecate &lt;code&gt;LocalVariableTableParameterNameDiscoverer&lt;/code&gt;&lt;/a&gt;&lt;a href=&quot;https://github.com/spring-projects/spring-framework/pull/29531#issuecomment-1321797236&quot;&gt;&lt;code&gt;&lt;/code&gt;&lt;/a&gt;&lt;code&gt;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://tech.kakaopay.com/post/martin-dev-honey-tip-2/&quot;&gt;카카오페이 기술 블로그: @Qualifier vs @Primary 누가 이길까?&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>스프링</category>
      <category>BeanInjection</category>
      <category>lombok</category>
      <category>Primary</category>
      <category>Qualifier</category>
      <category>RequiredArgsConstructor</category>
      <author>순원이</author>
      <guid isPermaLink="true">https://jsw5913.tistory.com/309</guid>
      <comments>https://jsw5913.tistory.com/309#entry309comment</comments>
      <pubDate>Sat, 3 Jan 2026 22:56:18 +0900</pubDate>
    </item>
    <item>
      <title>Spring Batch로 187초 &amp;rarr; 6.65초: 28배 성능 개선 전과정</title>
      <link>https://jsw5913.tistory.com/307</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;들어가며&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;20만 건의 경매 데이터를 처리하는 과정에서 &lt;b&gt;187초에서 6.65초로 약 28배의 성능 개선&lt;/b&gt;을 이뤄낸 경험을 바탕으로, Reader/Writer 선택부터 멀티스레드를 적용한 과정 사이에 겪은 트러블 슈팅이나 공유할만한 인사이트를 적어보겠습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. Reader 선택&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.1 Cursor vs Paging: 동작 방식의 차이&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배치 처리에서 가장 먼저 마주한 선택은 &lt;b&gt;어떤 방식으로 데이터를 읽을 것인가&lt;/b&gt;입니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;CursorItemReader&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;DB와 &lt;b&gt;하나의 커넥션을 유지&lt;/b&gt;하며 커서를 열어두고 Fetch 단위로 데이터를 스트리밍&lt;/li&gt;
&lt;li&gt;메모리 효율적: 한 건씩 처리하므로 힙 메모리 사용량이 일정하게 유지&lt;/li&gt;
&lt;li&gt;&lt;b&gt;단일 커넥션 사용으로 멀티스레드 불가&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;PagingItemReader&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;페이지 단위로 DB 커넥션을 맺고 끊으며 데이터를 가져옴&lt;/li&gt;
&lt;li&gt;각 페이지마다 새로운 쿼리 실행&lt;/li&gt;
&lt;li&gt;Lock으로 인해 병렬 처리는 불가하지만, &lt;b&gt;멀티스레드 환경에서 사용 가능&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.2 PagingItemReader 사용 시 필수 주의사항&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PagingReader를 사용할 때 가장 중요한 것은 &lt;b&gt;데이터 순서 보장&lt;/b&gt;입니다. 페이징 처리 시 각 쿼리에 Offset과 Limit를 지정해야 하는데, Spring Batch는 PageSize를 지정하면 자동으로 Offset과 Limit를 계산해줍니다. 하지만 여기서 문제가 발생할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;페이징 처리를 할 때마다 새로운 쿼리를 실행하기 때문에, ORDER BY를 명시하지 않으면 매 쿼리마다 데이터 순서가 달라질 수 있습니다. 첫 번째 페이지에서 읽은 데이터가 두 번째 페이지에서 다시 나타나거나, 반대로 어떤 데이터는 영영 읽히지 않을 수도 있습니다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// 잘못된 예시: Order By 없음
reader.setQueryString(&quot;SELECT a FROM Auction a WHERE a.status = :status&quot;);

// 올바른 예시: 고유하고 정렬된 키 사용
reader.setQueryString(
    &quot;SELECT a FROM Auction a WHERE a.status = :status ORDER BY a.id ASC&quot;
);&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;ORDER BY 없이 실행하면 어떻게 될까?&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터베이스는 ORDER BY가 없으면 가장 효율적으로 탐색할 수 있는 순서대로 데이터를 반환합니다. 이 방식은 예측 불가능하며 여러 요인에 따라 매번 달라질 수 있습니다. 먼저 물리적 저장 순서에 영향을 받는데, InnoDB는 Clustered Index 방식이므로 PK 순서로 저장되지만, 반드시 보장되는 건 아닙니다. 또한 쿼리 옵티마이저가 선택한 인덱스에 따라 스캔 순서가 결정되며, 통계 정보 업데이트나 데이터 변경 등으로 실행 계획이 바뀌면 반환 순서도 함께 바뀝니다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;-- 첫 번째 실행: PK 인덱스 스캔 &amp;rarr; id 순으로 반환
SELECT * FROM auction WHERE status = 'ACTIVE' LIMIT 1000 OFFSET 0;

-- 두 번째 실행: 다른 인덱스 선택 &amp;rarr; 다른 순서로 반환!
SELECT * FROM auction WHERE status = 'ACTIVE' LIMIT 1000 OFFSET 1000;
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;정렬 기준은 무엇을 사용해야 할까?&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;필수 조건: 정렬 컬럼은 고유하고 순서가 있어야 합니다.&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// 좋은 예시 1: Primary Key
ORDER BY a.id ASC

// 좋은 예시 2: Unique Index + 생성 시간 (조합으로 고유성 보장)
ORDER BY a.createdAt ASC, a.id ASC

// 주의가 필요한 예시: created_at만 사용
ORDER BY a.createdAt ASC
// 문제: 동일한 시간에 생성된 데이터가 여러 개 있으면 순서가 불안정!&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;created_at이나 updated_at 같은 timestamp 컬럼만으로 정렬하면 &lt;b&gt;동일한 값이 여러 개 존재할 때 순서가 보장되지 않습니다.&lt;/b&gt; 예를 들어 같은 초에 100건의 데이터가 생성되었다면, 이 100건 사이의 순서는 여전히 불안정합니다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;-- 위험: created_at이 같은 데이터들의 순서는 보장 안 됨
SELECT * FROM auction WHERE status = 'ACTIVE' 
ORDER BY created_at LIMIT 1000 OFFSET 0;

-- 안전: 고유 키를 추가로 정렬
SELECT * FROM auction WHERE status = 'ACTIVE' 
ORDER BY created_at ASC, id ASC LIMIT 1000 OFFSET 0;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 반드시 &lt;b&gt;고유성이 보장되는 컬럼(PK, Unique Index)을 정렬 기준에 포함&lt;/b&gt;해야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.3 CursorItemReader 사용 시 유념&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPACursorItemReader는 사용하기 가장 편한 선택지입니다. 그러나, &lt;b&gt;JPACursorItemReader는 한 번의 쿼리로 모든 데이터를 가져온 후 애플리케이션 단에서 하나씩 내어주는 방식이기 때문에 OOM(Out Of Memory) 위험을 내포&lt;/b&gt;하고 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 수십만 건 이상의 데이터를 처리해야 한다면 JPACursorItemReader는 사용하지 않는 것이 좋습니다. 대신 &lt;b&gt;JdbcCursorItemReader를 사용하면 JDBC 드라이버 레벨에서 스트리밍 방식으로 데이터를 가져오기 때문에 메모리 효율적입니다.&lt;/b&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.4 상태 저장(SaveState)&lt;/h3&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;SaveState를 켜두면 배치 작업이 실패했을 때 어디서부터 재시작할지 저장합니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;구분&lt;/td&gt;
&lt;td&gt;Cursor (순서 기반)&lt;/td&gt;
&lt;td&gt;&amp;nbsp;Paging (Key 기반)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;저장 상태&lt;/td&gt;
&lt;td&gt;마지막 읽은 아이템 순번(Index)&lt;/td&gt;
&lt;td&gt;마지막 처리된 아이템 고유 ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;복구 방식&lt;/td&gt;
&lt;td&gt;처음부터 순번만큼 Skip&lt;/td&gt;
&lt;td&gt;ID 이후부터 쿼리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;데이터 변경 영향&lt;/td&gt;
&lt;td&gt;취약 (순번이 틀어질 수 있음)&lt;/td&gt;
&lt;td&gt;강함 (고유 키 불변)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;PagingReader + saveState(true)&lt;/b&gt; 조합은 Key 기반의 안전한 복구를 제공하여 Index 기반보다 훨씬 안정적입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1.5 JPA Reader의 한계&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPA Reader는 근본적인 한계가 있습니다. &lt;b&gt;영속성 컨텍스트를 거치므로 오버헤드가 발생&lt;/b&gt;합니다. 또한 엔티티 전체를 로드하여 &lt;b&gt;불필요한 데이터까지 메모리에 적재&lt;/b&gt;합니다. 페이지 단위로 영속성 컨텍스트를 clear하기 때문에 &lt;b&gt;엔티티가 Detached 상태&lt;/b&gt;가 되며, 이로 인해 Lazy Loading이 불가능해져 FetchJoin을 필수적으로 사용해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위와 같은 단점들을 타파하기 위해&lt;b&gt; Projection으로 필요한 속성만 가져오고 JDBC Reader를 사용&lt;/b&gt;하는 것을 추천드립니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. JPAPagingReader의 함정&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;그럼에도 불구하고 간편함을 위해 JPAPagingReader를 쓰실 수 있으니, 주의점을 남겨보겠습니다.&lt;/b&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.1 OFFSET 실수&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPAPagingReader를 사용할 때 가장 많이 마주치는 문제가 바로 &lt;b&gt;데이터 건너뛰기 현상&lt;/b&gt;입니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;문제 상황&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;20만 건의 데이터를 처리했는데 12만 건만 처리되는 이상한 현상이 발생했습니다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;// Reader에서 조회
SELECT * FROM auction WHERE status = 'ACTIVE' ORDER BY id LIMIT 1000 OFFSET 0

// Writer에서 상태 변경
UPDATE auction SET status = 'ENDED' WHERE id IN (...)

// 다음 Reader 조회 시
SELECT * FROM auction WHERE status = 'ACTIVE' ORDER BY id LIMIT 1000 OFFSET 1000
// 문제: 앞의 1000건이 조건에서 제외되면서 결과셋이 당겨짐
// OFFSET 1000은 이제 원래 2001~3000번째 데이터를 가리킴
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;원인 분석&lt;/h4&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Page 1(Offset 0)을 읽고 상태를 ACTIVE &amp;rarr; ENDED로 변경&lt;/li&gt;
&lt;li&gt;변경된 데이터는 다음 쿼리의 WHERE 조건에서 제외됨&lt;/li&gt;
&lt;li&gt;&lt;b&gt;DB 결과셋 자체가 줄어들어 앞으로 당겨짐&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;Reader는 이를 모르고 Page 2(Offset 1000)를 요청&lt;/li&gt;
&lt;li&gt;결과적으로 1001~2000번 데이터를 건너뛰고 2001~3000번 데이터를 읽음&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.2 해결 방법&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Page 0 고정 방식을 추천합니다. 처리된 데이터는 WHERE 조건에서 사라지므로, 항상 첫 페이지를 읽으면 미처리 데이터를 순차적으로 가져올 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;@Bean
public JpaPagingItemReader&amp;lt;Auction&amp;gt; reader() {
    JpaPagingItemReader&amp;lt;Auction&amp;gt; reader = new JpaPagingItemReader&amp;lt;&amp;gt;() {
        @Override
        public int getPage() {
            return 0;  // 항상 첫 페이지만 읽기
        }
    };
    
    reader.setQueryString(
        &quot;SELECT a FROM Auction a &quot; +
        &quot;WHERE a.status = :status &quot; +
        &quot;ORDER BY a.id ASC&quot;  // 정렬 필수
    );
    
    return reader;
}
&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;JdbcItemPagingReader는 No offset 방식&lt;/b&gt;이기 때문에 위와 같은 상황은 발생하지 않습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;3. Writer 선택&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.1 Processor는 단건 처리&lt;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// 비효율적: 단건 처리
public class AuctionProcessor implements ItemProcessor&amp;lt;Auction, Auction&amp;gt; {
    @Override
    public Auction process(Auction auction) {
        auction.setStatus(AuctionStatus.ENDED);
        return auction;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Processor는 아이템 하나씩 처리하므로 비효율적&lt;/b&gt;입니다. 그래서 외부 API 호출이나, 외부와 네트워크 통신을 해야 한다면 &lt;b&gt;Chunk 단위로 동작하는 Writer&lt;/b&gt;를 활용하는 것이 유리합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.2 JPA Writer의 한계&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPA Writer는 성능상 불리한 지점이 많습니다. &lt;b&gt;건마다 개별 쿼리를 실행&lt;/b&gt;하기 때문에 대량 처리 시 데이터베이스 왕복이 과도하게 발생합니다. 또한 Dirty Checking 메커니즘으로 인한 오버헤드가 추가됩니다. 앞서 언급했듯이 PagingReader는 페이지 단위로 영속성 컨텍스트를 clear하기 때문에 엔티티가 Detached 상태가 되는데, 이런 엔티티는 &lt;b&gt;merge가 아닌 persist를 사용&lt;/b&gt;해야 한다는 점도 복잡도를 높입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.3 JDBC Writer 좋다..&lt;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;        @Bean
        @StepScope
        public JdbcBatchItemWriter&amp;lt;AuctionEndDto&amp;gt; auctionWriter() {
                log.info(&quot;========== Writer 설정: JDBC 배치 업데이트 ==========&quot;);

                return new JdbcBatchItemWriterBuilder&amp;lt;AuctionEndDto&amp;gt;()
                                .dataSource(dataSource)
                                .sql(&quot;UPDATE auctions SET status = 'ENDED' WHERE id = :id&quot;)
                                .beanMapped()
                                .build();
        }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JDBC Writer는 Chunk 단위로 배치 처리가 가능하기 때문에 훨씬 효율적입니다. 영속성 컨텍스트를 거치지 않으므로 Dirty Checking 오버헤드가 없으며, Projection 기반으로 필요한 데이터만 다루기 때문에 경량 처리가 가능합니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;3.4 성능 결과&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;20만건 업데이트 기준으로&amp;nbsp;&amp;nbsp;&lt;br /&gt;JpaPaingItemReader &amp;amp; JpaItemWriter를 사용했을 때는 &lt;b&gt;187초&lt;/b&gt;가 걸렸고&lt;br /&gt;JdbcPagingItemReader &amp;amp; JdbcBatchItemWriter를 사용했을 때는 &lt;b&gt;14초&lt;/b&gt;가 걸렸습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 멀티스레드 최적화&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4.1 동작 방식&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Bean
public Step multiThreadStep() {
    return stepBuilderFactory.get(&quot;multiThreadStep&quot;)
        .&amp;lt;AuctionDto, AuctionDto&amp;gt;chunk(1000)
        .reader(jdbcReader())
        .writer(jdbcWriter())
        .taskExecutor(taskExecutor())  // 멀티스레드 활성화
        .build();
}

@Bean
public TaskExecutor taskExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(10);
    executor.setMaxPoolSize(10);
    executor.setQueueCapacity(10);
    executor.setThreadNamePrefix(&quot;batch-thread-&quot;);
    return executor;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;멀티스레드 방식에서는 Reader &amp;rarr; Processor &amp;rarr; Writer가 청크 단위로 스레드 병렬 실행됩니다. 하지만 여기서 주의할 점이 있습니다. Reader는 Lock이 걸려 있어 순차적으로 처리됩니다. 따라서 스레드 수만큼 성능이 비례해서 향상되지는 않으며, Reader가 병목 지점이 됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4.2 주의사항: saveState(false)&lt;/h3&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;@Bean
public JdbcPagingItemReader&amp;lt;AuctionDto&amp;gt; reader() {
    JdbcPagingItemReader&amp;lt;AuctionDto&amp;gt; reader = new JdbcPagingItemReader&amp;lt;&amp;gt;();
    reader.setSaveState(false);  // 필수!
    return reader;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;멀티스레드 환경에서는 여러 스레드가 동시에 다른 범위를 처리합니다. 스레드 A가 101-110번 항목을 처리하는 동안 스레드 B는 111-120번 항목을 처리할 수 있습니다. 만약 B가 먼저 끝나고 A가 실패하면, Spring Batch는 &quot;어디서부터 재시작할까?&quot;를 결정할 수 없습니다. 순차적인 단일 지점을 정의할 수 없기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 멀티스레드 환경에서는 saveState(false)로 설정하여 상태 저장을 비활성화해야 합니다. 이렇게 하면 재시작 시 처음부터 시작하게 되므로, 쿼리는 멱등성을 보장해야 합니다. 즉, 같은 데이터를 여러 번 처리해도 문제가 없도록 설계해야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4.3 성능 결과&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;단일 스레드 JDBC: 14.06초 (14,228 건/초)
멀티스레드 (10개): 6.65초 (30,061 건/초) &amp;rarr; 약 2.1배 향상
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 파티셔닝 vs 멀티스레드, 언제 무엇을 사용할까?&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5.1 선택 기준&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파티셔닝과 멀티스레드 중 어떤 방식을 선택해야 할지는 상황에 따라 다릅니다. &lt;b&gt;Reader가 Thread-safe하지 않는데 병렬 처리를 원한다면 파티셔닝을 사용&lt;/b&gt;해야 합니다.&lt;b&gt; 데이터 분할이 명확한 경우&lt;/b&gt;, 예를 들어 날짜별로 나눌 수 있는 경우에도 파티셔닝이 적합합니다. 또한 파티션별로 처리 상황을 추적해야 한다면 파티셔닝이 유리합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 Reader가 Thread-safe하다면 멀티스레드 방식을 사용할 수 있습니다. 데이터 분할이 불명확한 경우나 설정을 단순하게 유지하고 싶을 때도 멀티스레드가 더 나은 선택입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5.2 파티셔닝의 함정, 데이터 불균등 문제&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ID Range 방식으로 파티션을 나누면 심각한 문제가 발생할 수 있습니다. 예를 들어 10개 파티션으로 분할했을 때, Partition 1은 ID 1-20000 범위에서 실제 데이터 100건만 처리하고, Partition 2는 ID 20001-40000 범위에서 5000건을 처리하며, Partition 3은 ID 40001-60000 범위에서 50건만 처리하는 상황이 발생합니다. 이렇게 되면 Partition 2가 전체 처리 시간을 결정하는 Load Imbalance 현상이 나타납니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 개선하기 위해 Row Count 방식을 사용할 수 있습니다. 실제 데이터 수를 기반으로 균등하게 분할하는 것입니다. 먼저 조건에 맞는 모든 ID를 조회한 후, 이를 파티션 개수로 균등하게 나누어 각 파티션에 할당합니다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;// 실제 데이터 수 기반 균등 분할
SELECT id FROM auction WHERE status = 'ACTIVE' ORDER BY id;
// 결과를 10개로 균등 분할하여 파티션 생성
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 이 방식도 문제가 있습니다. 모든 대상 ID를 메모리에 로드해야 하므로 메모리 사용량이 증가하고, 사전 조회 쿼리를 실행해야 하므로 데이터베이스 부하가 추가로 발생하며, Partitioner 구현 및 각 파티션별 Step 설정 등으로 코드 복잡도가 상승합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5.3 실험 결과 비교&lt;/h3&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;멀티스레드 (Paging):              6.65초 (30,061 건/초)
파티셔닝 ID Range (Paging):       14.98초 (13,355 건/초)
파티셔닝 Row Count (Cursor):      7.73초 (25,876 건/초)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결론:&lt;/b&gt; 데이터가 불균등할 때는 멀티스레드 방식이 유리합니다. JdbcPagingItemReader가 페이지 단위로 동적 분배하므로 자동으로 부하가 분산됩니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스프링 배치 최적화의 핵심은 기술 자체보다도 &lt;b&gt;데이터의 특성과 처리 요구사항을 얼마나 정확하게 이해하고 있나&lt;/b&gt;에 달려 있습니다. 먼저 병렬 처리가 반드시 필요한 상황인지 판단하는 것이 중요합니다. 병렬 처리가 필요하지 않다면, 가장 빠르고 단순하며 커넥션 점유만 관리하시면 되는 &lt;b&gt;JdbcCursorItemReader&lt;/b&gt;가 최선의 선택이 될 수 있습니다.&lt;/p&gt;
&lt;p data-end=&quot;622&quot; data-start=&quot;278&quot; data-ke-size=&quot;size16&quot;&gt;반대로 병렬 처리가 필요한 경우라면 다음 질문은 &lt;b&gt;데이터를 균등하게 분할할 수 있는 명확한 기준이 존재하는가&lt;/b&gt;입니다. 예를 들어 1부터 1억까지 결측 없이 연속된 ID처럼 균등하게 나눌 수 있는 키가 있다면, &lt;b&gt;JdbcCursorItemReader를 파티셔닝과 결합한 방식&lt;/b&gt;이 이론적으로 가장 높은 처리량을 제공합니다. 그러나 실제 데이터는 중간 ID가 비어 있거나 특정 날짜나 구간에 데이터가 극단적으로 몰려 있는 등 분포가 고르지 않은 경우가 많습니다. 이런 상황에서는 자동으로 부하를 분산할 수 있는 &lt;b&gt;JdbcPagingItemReader + 멀티 스레드 방식&lt;/b&gt;이 더 안정적이고 실용적인 선택이 됩니다.&lt;/p&gt;
&lt;p data-end=&quot;939&quot; data-start=&quot;624&quot; data-ke-size=&quot;size16&quot;&gt;이번 실험을 통해 이러한 의사결정 과정을 실제로 적용해본 결과, 리더와 라이터 변경으로도 &lt;b&gt;JPA 187초 &amp;rarr; JDBC 14초(약 13배 향상)&lt;/b&gt;라는 큰 성능 개선을 얻을 수 있었습니다. 여기에 병렬 처리를 더하니 &lt;b&gt;14초 &amp;rarr; 6.65초(약 2.1배 추가 개선)&lt;/b&gt;까지 줄어들며 배치 처리 시간이 눈에 띄게 단축되었습니다. 스프링 배치 최적화는 특정 기법을 무조건 적용하는 일이 아니라, &lt;b&gt;데이터 분포와 쿼리 특성, 그리고 업무 요구사항&lt;/b&gt;을 종합적으로 고려해 가장 적합한 전략을 선택하는 과정임을 다시 한 번 확인할 수 있는 경험이었습니다.&lt;/p&gt;</description>
      <category>스프링</category>
      <category>스프링 배치 최적화</category>
      <author>순원이</author>
      <guid isPermaLink="true">https://jsw5913.tistory.com/307</guid>
      <comments>https://jsw5913.tistory.com/307#entry307comment</comments>
      <pubDate>Thu, 4 Dec 2025 15:23:30 +0900</pubDate>
    </item>
    <item>
      <title>실시간 랭킹 MySQL로 버티다가, 결국 Redis ZSET으로 갈아탄 이야기</title>
      <link>https://jsw5913.tistory.com/306</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;독서 습관 형성 서비스에서 랭킹은 유저 참여도를 높이는 핵심 기능입니다. 유저들은 자신의 순위를 확인하고, 상위권 사용자들과 비교하며, 순위를 높이기 위해 다음 행동을 이어갑니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 서비스는 100만 개 순위에서 평균 동시 접속자 100명, 피크 타임에는 500명이 접속하는 수준을 가정하고 로직을 작성하였고, RDB만으로&amp;nbsp; 50ms 응답속도를 확보하였고 Range scan이라는 한계상 피크 타임 때 처리량이 한계가 보여서 ZSET을 도입하여, 처리량을 높였습니다.&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;초기 설계: 점수별 인원 집계 테이블&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;설계 의도&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 랭킹 시스템을 설계할 때, MySQL을 기반으로 구현하였습니다. 랭킹 화면에는 Top 100과 내 순위가 함께 표시되는데, Top 100은 인덱스가 정렬되어 있어 빠르게 조회할 수 있습니다. 하지만 &lt;b&gt;내 순위가 하위권인 경우&lt;/b&gt; 문제가 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어, 100만 명 중 100만 등이라면 인덱스를 100만 행 스캔해야 순위를 알 수 있습니다. 많은 유저가 동시에 자신의 순위를 조회하면 Index Range Scan 비용이 누적되어 부하가 발생할 것으로 예상했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 해결하기 위해 별도의 캐시 레이어 없이도 성능을 확보할 수 있는 점수별 인원 집계 테이블을 만들었습니다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Entity
@Table(name = &quot;score_counts&quot;, 
    uniqueConstraints = {
        @UniqueConstraint(name = &quot;uk_score_shard&quot;, 
                         columnNames = { &quot;score&quot;, &quot;shardIdx&quot; })
    },
    indexes = {
        @Index(name = &quot;idx_ranking_cover&quot;, 
              columnList = &quot;score, userCount&quot;)
    })
public class ScoreCounts {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false)
    private Long score;
    
    @Column(nullable = false)
    private Integer shardIdx;
    
    @Column(nullable = false)
    private Long userCount;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반적으로 랭킹을 조회할 때 RANK() 윈도우 함수를 사용하면 전체 테이블을 스캔해야 합니다. Top 100 조회는 ranking_year, ranking_week, score로 구성된 복합 인덱스를 타면서 WHERE 조건 필터링과 ORDER BY 정렬을 인덱스 레벨에서 처리하여 빠르게 가져올 수 있지만,&amp;nbsp;&lt;b&gt;하위권 유저의 순위를 구하려면 수십만~수백만 행을 스캔&lt;/b&gt;해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하기 위해 점수별로 사용자 수를 집계한 ScoreCounts 테이블을 두고, 해당 사용자의 점수보다 높은 점수의 userCount를 누적 합산하는 방식으로 순위를 계산했습니다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;-- 내 순위 조회 (ScoreCounts 집계 테이블)
-- idx_ranking_cover 커버링 인덱스 사용
SELECT SUM(userCount) 
FROM score_counts 
WHERE score &amp;gt; ?;&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; RankingHistory 테이블에서 수십만 행을 스캔하는 것이 아닌, ScoreCounts에 커버링 인덱스(score, userCount)로 자기 점수(최대 2000점)만큼만 Row를 읽을 수 있도록 최적화했습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;샤딩 전략&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;점수 업데이트 시 특정 점수 레코드에 인원 수를 변경을 위한 락 경합이 발생하는 것을 방지&lt;/b&gt;하기 위해, shardIdx를 도입해 같은 점수라도 10개의 레코드로 분산 저장했습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Transactional
public void updateScore(Long memberId, boolean isCorrect, LocalDate date) {
    RankingYearWeek currentYearWeek = RankingYearWeek.of(date.getYear(), week);
    
    RankingHistory ranking = rankingHistoryRepository
        .findByMemberIdAndRankingYearWeek(memberId, currentYearWeek)
        .orElseGet(() -&amp;gt; RankingHistory.create(memberId, currentYearWeek));
    
    Long oldScore = ranking.getScore();
    ranking.updateScore(isCorrect, date);
    Long newScore = ranking.getScore();
    
    // 점수가 변경되었을 때만 ScoreCounts 업데이트
    if (!oldScore.equals(newScore)) {
        // 랜덤 샤드 선택으로 분산
        int oldShard = ThreadLocalRandom.current().nextInt(1, 11);
        scoreCountsRepository.decrementCount(oldScore, oldShard);
        
        int newShard = ThreadLocalRandom.current().nextInt(1, 11);
        scoreCountsRepository.incrementCount(newScore, newShard);
    }
    
    rankingHistoryRepository.save(ranking);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;점수가 업데이트될 때마다 랜덤하게 1~10 사이의 샤드를 선택합니다. 이렇게 하면 내 순위 조회 시 읽어야 할 ROW가 샤딩 개수 만큼 배로 늘어나긴 하지만, 동일한 점수를 가진 여러 유저가 동시에 업데이트하더라도, Row Lock이 10개로 분산되어 경합이 줄어듭니다.&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;성능 측정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;100만개 순위에서 동시 접속자 100명까지는 평균 응답 속도가 11ms 정도로 준수했습니다. 하지만 500명으로 부하 테스트를 돌리자, &lt;b&gt;p99 응답 속도가 720ms&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;1826&quot; data-origin-height=&quot;400&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bkCVTC/dJMcabWWtir/TZqJd1GQJkYwUUGRxMf0Q1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bkCVTC/dJMcabWWtir/TZqJd1GQJkYwUUGRxMf0Q1/img.png&quot; data-alt=&quot;동시 사용자 100, 내 순위 조회&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bkCVTC/dJMcabWWtir/TZqJd1GQJkYwUUGRxMf0Q1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbkCVTC%2FdJMcabWWtir%2FTZqJd1GQJkYwUUGRxMf0Q1%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;1826&quot; height=&quot;400&quot; data-origin-width=&quot;1826&quot; data-origin-height=&quot;400&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;동시 사용자 100, 내 순위 조회&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1778&quot; data-origin-height=&quot;242&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/FQciI/dJMcafkJ04F/5Dkssa9mA1cr1lXpgsgLJK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/FQciI/dJMcafkJ04F/5Dkssa9mA1cr1lXpgsgLJK/img.png&quot; data-alt=&quot;DB 동시 사용자 500, 내 순위 조회&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/FQciI/dJMcafkJ04F/5Dkssa9mA1cr1lXpgsgLJK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FFQciI%2FdJMcafkJ04F%2F5Dkssa9mA1cr1lXpgsgLJK%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;1778&quot; height=&quot;242&quot; data-origin-width=&quot;1778&quot; data-origin-height=&quot;242&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;DB 동시 사용자 500, 내 순위 조회&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ScoreCounts 테이블은 샤딩(10) &amp;times; 점수 범위(최대 2000점)로 구성되어, 최악의 경우 약 2만 row를 스캔합니다. 커버링 인덱스 덕분에 쿼리 자체는 매우 빠르지만, 문제는 &lt;b&gt;데이터베이스 커넥션 풀&lt;/b&gt;에 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;(전제: 컨텍스트 스위칭 비용을 고려하여 DB가 처리할 수 있는 최적의 커넥션 풀 개수에 맞추어 &lt;span style=&quot;text-align: start;&quot;&gt;커넥션 풀을 150개로 설정했었습니다)&lt;br /&gt;&lt;/span&gt;&lt;/span&gt;&lt;br /&gt;동시 접속자 500명이 랭킹을 조회하면, 커넥션 대기 시간이 누적되면서 전체 응답 속도가 느려졌습니다. 쿼리는 빠른데, 커넥션을 얻기까지 기다리는 시간이 병목이었던 것입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Redis 도입 결정&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Master-Slave 구조 검토&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동시 접속자가 늘어날 것을 대비해, 근본적인 해결책을 찾아야 했습니다. 처음에는 읽기 처리량을 늘리기 위해 MySQL Master-Slave 구조를 고려했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;검토한 구성: Master(1) + Slave(2)&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;Master는 쓰기 전용&lt;/li&gt;
&lt;li&gt;Slave 2대로 읽기 부하 분산&lt;/li&gt;
&lt;li&gt;이론적으로 읽기 처리량 2배 증가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 다음과 같은 이유로 보류했습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Replication Lag&lt;/b&gt;: Master와 Slave 간 데이터 동기화 지연 발생 가능. 랭킹처럼 실시간성이 중요한 경우 문제가 될 수 있음&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Failover 처리&lt;/b&gt;: Slave 장애 시 자동 전환 로직 필요&lt;/li&gt;
&lt;li&gt;&lt;b&gt;인프라 복잡도&lt;/b&gt;: DB 인스턴스 3대 관리 및 모니터링 비용 증가&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Redis 선택&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 이미 캐싱 용도로 Redis를 사용하고 있었습니다. 추가 인프라 없이 기존 Redis를 활용하는 것이 관리 비용 측면에서 더 유리하다고 판단했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;Redis는 싱글 스레드 기반이지만 메모리 연산이므로 처리량이 훨씬 높습니다. 또한 Sorted Set(ZSET)은 내부적으로 Skip List와 Hash Table을 함께 사용해, 이미 정렬된 상태를 유지합니다. &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;메모리 기반으로 빠른 처리량과 RDB를 사용할 때보다 더 빠른 시간복잡도로 처리할 수 있기 때문에 레디스 Zset 사용이 적합하다고 생각했습니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;MySQL: Index Scan
Redis: 메모리 기반 O(log N) 순위 조회&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;캐싱 전략: Write-Through&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;데이터 영속성 vs 성능&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis를 도입하고 캐싱 전략을 정하는 과정에서 고민이 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Write-Back (비동기 쓰기)&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;Redis에만 먼저 쓰고, 나중에 배치로 DB에 반영&lt;/li&gt;
&lt;li&gt;성능은 가장 좋지만, Redis 장애 시 데이터 유실 가능성 발생하고, 실시간 랭킹 보여줄 수 없음&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Write-Through (동기 쓰기)&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;DB 트랜잭션 커밋 후 Redis 업데이트&lt;/li&gt;
&lt;li&gt;쓸 때마다 매번 디비에 요청이 가지만, 데이터 확보가 가능하기 때문에, 레디스 장애 시에도 실시간 랭킹 조회 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;습관 형성 서비스이기 때문에, 유저가 열심히 쌓은 점수인 랭킹데이터가 유실되면 신뢰를 잃고 이탈로 이어질 수 있다고 생각하여 &lt;b&gt;Write-Through&lt;/b&gt;를 선택했습니다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;    public void updateRanking(Long memberId, boolean isCorrect, LocalDate date) {
        rankingShardingService.updateScore(memberId, isCorrect, date);

        try {
            rankingRedisRepository.updateRankingAtomic(memberId, isCorrect, date);
        } catch (Exception e) {
            log.error(&quot;Redis 업데이트 실패 (DB는 성공): {}&quot;, e.getMessage());
        }
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB 커밋이 실패하면 Redis는 갱신되지 않습니다. 또한, 레디스가 장애시에도 랭킹 시스템은 동작해야 하기 때문에, 레디스가 장애나더라도 디비 저장은 가능하도록 만들었습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Fallback 메커니즘&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Write-Through를 선택했기 때문에, Redis 장애 시 MySQL로도 실시간 랭킹을 보여주는 Fallback 로직을 구현할 수 있었습니다.&lt;/p&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;public List&amp;lt;RankingDto&amp;gt; getTopRanking(RankingYearWeek yearWeek) {
    try {
        return redisRankingService.getTop100(yearWeek);
    } catch (RedisConnectionException e) {
        log.warn(&quot;Redis unavailable, falling back to MySQL&quot;);
        return mysqlRankingService.getTop100(yearWeek);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis가 다운되더라도 서비스는 계속되며, 유저들은 조금 느린 응답 속도를 경험하지만 서비스 중단은 없습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Redis 구현: Lua Script의 선택&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;저장 구조&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis에 랭킹을 구현하기 위해 세 가지 자료구조를 사용합니다.&lt;/p&gt;
&lt;pre class=&quot;markdown&quot;&gt;&lt;code&gt;1. ZSET: ranking:{year}:{week}
   - 점수와 memberId 저장
   - 정렬된 순위 조회에 사용

2. Hash: ranking:{year}:{week}:{memberId}:stats
   - days, solved, correct 저장
   - 점수 계산에 필요한 세부 데이터

3. Set: ranking:{year}:{week}:{memberId}:attendance
   - 출석 날짜 저장 (YYYY-MM-DD)
   - 중복 출석 방지
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;원자성 보장: Lua Script vs Transaction&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;점수 업데이트는 다음 과정을 거칩니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Hash에서 solved 증가&lt;/li&gt;
&lt;li&gt;정답이면 correct 증가&lt;/li&gt;
&lt;li&gt;오늘 날짜가 Set에 없으면 days 증가&lt;/li&gt;
&lt;li&gt;days, solved, correct로 점수 계산&lt;/li&gt;
&lt;li&gt;ZSET에 점수 업데이트&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 과정을 원자적으로 처리해야 했습니다. Redis Transaction(MULTI/EXEC)도 고려했지만, &lt;b&gt;중간 값(오늘 날짜가 set에 있는지)을 읽어서 값 업데이트에 사용&lt;/b&gt;해야 하므로 적합하지 않았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis Transaction은 명령어들을 큐잉만 할 뿐, 중간 결과를 읽어서 다음 명령에 사용할 수 없습니다. 반면 &lt;b&gt;Lua Script는 서버 측에서 원자적으로 실행&lt;/b&gt;되므로, 중간 계산 결과를 활용할 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;local memberId = ARGV[1]
local isCorrect = ARGV[2] == 'true'
local today = ARGV[3]
local scoreKey = KEYS[1]
local statsKey = KEYS[2]
local attendanceKey = KEYS[3]

-- 1. Increment Solved
redis.call('HINCRBY', statsKey, 'solved', 1)

-- 2. Increment Correct
if isCorrect then
    redis.call('HINCRBY', statsKey, 'correct', 1)
end

-- 3. Update Attendance
local added = redis.call('SADD', attendanceKey, today)
if added == 1 then
    redis.call('HINCRBY', statsKey, 'days', 1)
end

-- 4. Calculate Score (중간 값 활용)
local days = tonumber(redis.call('HGET', statsKey, 'days') or 0)
local solved = tonumber(redis.call('HGET', statsKey, 'solved') or 0)
local correct = tonumber(redis.call('HGET', statsKey, 'correct') or 0)

-- Formula: (days * 10) + (solved * 1) + (correct * 5)
local score = (days * 10) + (solved * 1) + (correct * 5)

-- 5. Update ZSET
redis.call('ZADD', scoreKey, score, memberId)

-- 6. Set TTL (2 weeks)
redis.call('EXPIRE', statsKey, 1209600)
redis.call('EXPIRE', attendanceKey, 1209600)
redis.call('EXPIRE', scoreKey, 1209600)

return score&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Lua Script 덕분에 Hash 조회, 점수 계산, ZSET 업데이트가 단일 원자 연산으로 처리됩니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;조회 성능 최적화: Pipelining&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;네트워크 RTT 문제&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Top 100 memberIds를 조회 후 memberId별로 정보를 가져올 때 Redis 명령을 여러 번 호출하고 있었습니다. 각 명령마다 네트워크 왕복이 발생하니, 1ms &amp;times; 100명 = 100ms가 네트워크 비용만으로 소모될 수 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Redis Pipelining 도입&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis Pipelining은 여러 명령을 한 번에 전송하고, 응답을 일괄로 받도록 하였습니다.&lt;/p&gt;
&lt;pre class=&quot;dart&quot;&gt;&lt;code&gt;@SuppressWarnings(&quot;unchecked&quot;)
public Map&amp;lt;Long, RankingStats&amp;gt; getRankingStats(List&amp;lt;Long&amp;gt; memberIds, LocalDate date) {
    List&amp;lt;Object&amp;gt; results = redisTemplate.executePipelined(
        new SessionCallback&amp;lt;Object&amp;gt;() {
            @Override
            public &amp;lt;K, V&amp;gt; Object execute(RedisOperations&amp;lt;K, V&amp;gt; operations) 
                throws DataAccessException {
                RedisOperations&amp;lt;String, String&amp;gt; ops = 
                    (RedisOperations&amp;lt;String, String&amp;gt;) operations;
                HashOperations&amp;lt;String, String, String&amp;gt; hashOps = ops.opsForHash();
                
                // 모든 조회 명령을 파이프라인에 추가
                for (Long memberId : memberIds) {
                    String key = getStatsKey(memberId, date);
                    hashOps.multiGet(key, Arrays.asList(&quot;days&quot;, &quot;solved&quot;, &quot;correct&quot;));
                }
                return null;
            }
        });
    
    // 결과는 일괄 수신
    Map&amp;lt;Long, RankingStats&amp;gt; statsMap = new HashMap&amp;lt;&amp;gt;();
    for (int i = 0; i &amp;lt; memberIds.size(); i++) {
        Object result = results.get(i);
        if (result instanceof List) {
            statsMap.put(memberIds.get(i), parseStatsFromList((List&amp;lt;String&amp;gt;) result));
        }
    }
    return statsMap;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 명령을 하나의 네트워크 패킷으로 묶어서 전송했습니다. 100명의 stats를 조회하더라도 네트워크 RTT는 1회로 줄어들었고, &lt;b&gt;평균 응답 속도는 36ms&lt;/b&gt;로 개선됐습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결과&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종적으로 &lt;b&gt;내 순위 조회 API 결과&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;1728&quot; data-origin-height=&quot;392&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bHaFlS/dJMcaacE8yW/9UUrXQnALKOMs4og8af2C1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bHaFlS/dJMcaacE8yW/9UUrXQnALKOMs4og8af2C1/img.png&quot; data-alt=&quot;레디스 동시 사용자 100명, 내순위 조회&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bHaFlS/dJMcaacE8yW/9UUrXQnALKOMs4og8af2C1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbHaFlS%2FdJMcaacE8yW%2F9UUrXQnALKOMs4og8af2C1%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;1728&quot; height=&quot;392&quot; data-origin-width=&quot;1728&quot; data-origin-height=&quot;392&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;레디스 동시 사용자 100명, 내순위 조회&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1730&quot; data-origin-height=&quot;220&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/JGq4r/dJMcabJp3OX/BOpQJgrkuLOwrE8b0m5Ml0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/JGq4r/dJMcabJp3OX/BOpQJgrkuLOwrE8b0m5Ml0/img.png&quot; data-alt=&quot;레디스 동시 사용자 500명, 내순위 조회&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/JGq4r/dJMcabJp3OX/BOpQJgrkuLOwrE8b0m5Ml0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FJGq4r%2FdJMcabJp3OX%2FBOpQJgrkuLOwrE8b0m5Ml0%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;1730&quot; height=&quot;220&quot; data-origin-width=&quot;1730&quot; data-origin-height=&quot;220&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;레디스 동시 사용자 500명, 내순위 조회&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동시 사용자 500일 때, 지표 Before (MySQL) After (Redis) 개선율&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;평균 응답 속도&lt;/td&gt;
&lt;td&gt;410ms&lt;/td&gt;
&lt;td&gt;32ms&lt;/td&gt;
&lt;td&gt;92.2% &amp;darr;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p95 응답 속도&lt;/td&gt;
&lt;td&gt;600ms&lt;/td&gt;
&lt;td&gt;163ms&lt;/td&gt;
&lt;td&gt;72.8% &amp;darr;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p99 응답 속도&lt;/td&gt;
&lt;td&gt;720ms&lt;/td&gt;
&lt;td&gt;203ms&lt;/td&gt;
&lt;td&gt;71.8% &amp;darr;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동시 접속자가 500명 이상으로 늘어나도 200ms 안팍의 응답속도로 반환할 수 있었습니다.&lt;/p&gt;</description>
      <category>스프링</category>
      <category>db최적화</category>
      <category>luascript</category>
      <category>MySQL</category>
      <category>redis</category>
      <category>WriteThrough</category>
      <category>zset</category>
      <category>대용량트래픽</category>
      <category>백엔드개발</category>
      <category>성능개선기</category>
      <category>실시간랭킹</category>
      <author>순원이</author>
      <guid isPermaLink="true">https://jsw5913.tistory.com/306</guid>
      <comments>https://jsw5913.tistory.com/306#entry306comment</comments>
      <pubDate>Fri, 28 Nov 2025 16:08:31 +0900</pubDate>
    </item>
    <item>
      <title>왜 내 readOnly는 슬레이브로 가지 않는가?</title>
      <link>https://jsw5913.tistory.com/305</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;개요&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@Transactional(readOnly=true) 어노테이션을 사용해 마스터/슬레이브 DB로 자동 라우팅하는 기능이 LazyConnectionDataSourceProxy 없이 제대로 동작하지 않는 이유와 해결책을 정리한 내용입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;문제 상황(readOnly=true여도 마스터 DB로 접속)&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AbstractRoutingDataSource를 구현해 TransactionSynchronizationManager.isCurrentTransactionReadOnly() 값으로 DB를 분기 처리해도, readOnly=true로 설정된 트랜잭션이 슬레이브 DB가 아닌 마스터 DB로 잘못 연결되고 있었습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;왜 readonly가 주입이 안되는가? (Spring 트랜잭션의 동작 순서)&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제의 원인은 &lt;b&gt;Spring의 트랜잭션 처리 순서가 커넥션 획득 이후 트랜잭션 정보 동기화로 설계&lt;/b&gt;되어 있기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AbstractRoutingDataSource를 상속받아 마스터/슬레이브 라우팅을 구현하는 일반적인 코드는 다음과 같습니다.&lt;/p&gt;
&lt;pre class=&quot;scala&quot;&gt;&lt;code&gt;public class ReplicationRoutingDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        // 이 시점에 isCurrentTransactionReadOnly()를 호출하면...
        return TransactionSynchronizationManager.isCurrentTransactionReadOnly() ? &quot;slave&quot; : &quot;master&quot;;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;determineCurrentLookupKey()는 DataSource.getConnection()이 호출될 때 실행됩니다. 문제는 &lt;b&gt;이 메서드가 호출되는 시점에, readOnly 정보가 아직 ThreadLocal에 반영되지 않았다&lt;/b&gt;는 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 Spring Framework의 트랜잭션 시작 코드인 AbstractPlatformTransactionManager.startTransaction()를 살펴보겠습니다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// AbstractPlatformTransactionManager.startTransaction()

private TransactionStatus startTransaction(TransactionDefinition definition, Object transaction,
        boolean nested, boolean debugEnabled, @Nullable SuspendedResourcesHolder suspendedResources) {
    // ... (생략)
    try {
        doBegin(transaction, definition);  // &amp;larr; 1. 여기서 커넥션 획득!
    }
    catch (RuntimeException | Error ex) {
        // ... (생략)
    }
    prepareSynchronization(status, definition);  // &amp;larr; 2. 여기서 readOnly를 ThreadLocal에 저장
    // ... (생략)
    return status;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보시다시피 doBegin()(트랜잭션 시작 및 커넥션 획득)이 prepareSynchronization()(트랜잭션 정보 동기화)보다 &lt;b&gt;먼저&lt;/b&gt; 호출됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;readOnly 정보는 prepareSynchronization() 메서드 내부에서 ThreadLocal에 저장됩니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// AbstractPlatformTransactionManager.prepareSynchronization()

protected void prepareSynchronization(DefaultTransactionStatus status, TransactionDefinition definition) {
    if (status.isNewSynchronization()) {
        // ... (생략)
        TransactionSynchronizationManager.setCurrentTransactionReadOnly(definition.isReadOnly());  // &amp;larr; 2-1. 바로 여기!
        // ... (생략)
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면, 커넥션 획득은 doBegin() 내부에서 일어납니다. DataSourceTransactionManager의 doBegin()을 보면 다음과 같습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// DataSourceTransactionManager.doBegin()

@Override
protected void doBegin(Object transaction, TransactionDefinition definition) {
    // ... (생략)
    try {
        if (!txObject.hasConnectionHolder() ||
                txObject.getConnectionHolder().isSynchronizedWithTransaction()) {
            Connection newCon = obtainDataSource().getConnection();  // &amp;larr; 1-1. 여기서 커넥션 획득!
            // ... (생략)
            txObject.setConnectionHolder(new ConnectionHolder(newCon), true);
        }
        // ... (생략)
    }
    // ... (생략)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;obtainDataSource().getConnection()이 호출되는 시점에 AbstractRoutingDataSource의 determineCurrentLookupKey()가 실행됩니다. 하지만 이 시점은 prepareSynchronization()이 호출되기 전이므로, TransactionSynchronizationManager.isCurrentTransactionReadOnly()는 항상 false (정확히는 null을 확인하므로)를 반환합니다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;// TransactionSynchronizationManager.isCurrentTransactionReadOnly()

public static boolean isCurrentTransactionReadOnly() {
    // 이 ThreadLocal 변수는 prepareSynchronization()에서만 설정됨
    return (currentTransactionReadOnly.get() != null);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 &lt;b&gt;커넥션을 획득하는 시점(doBegin)에는 readOnly 정보가 없고, readOnly 정보가 저장되는 시점(prepareSynchronization)에는 이미 커넥션 획득이 끝났기 때문&lt;/b&gt;에 readOnly=true임에도 불구하고 항상 마스터(기본) DataSource로 연결되는 문제가 발생합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;해결책 1: TransactionExecutionListener 활용 (Spring 6.0+)&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring은 6.0부터 TransactionExecutionListener로 beforeBegin에서 미리 저장하는 기능을 제공했습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Override
public void beforeBegin(TransactionExecution transaction) {
    TransactionSynchronizationManager.setCurrentTransactionReadOnly(transaction.isReadOnly());
    TransactionSynchronizationManager.setActualTransactionActive(true);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 doBegin() 시점에 이미 readOnly 정보가 ThreadLocal에 저장되어 라우팅이 올바르게 동작합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;해결책 2: LazyConnectionDataSourceProxy 사용 (권장)&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LazyConnectionDataSourceProxy는 실제 커넥션 획득을 지연시켜서 해결합니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;// LazyConnectionDataSourceProxy.getConnection()

@Override
public Connection getConnection() throws SQLException {
    checkDefaultConnectionProperties();
    return (Connection) Proxy.newProxyInstance(
            ConnectionProxy.class.getClassLoader(),
            new Class&amp;lt;?&amp;gt;[] {ConnectionProxy.class},
            new LazyConnectionInvocationHandler());  // &amp;larr; Proxy 반환 (실제 커넥션 X)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 LazyConnectionInvocationHandler가 실제 SQL이 실행될 때 getTargetConnection()을 호출하는 것입니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;// LazyConnectionInvocationHandler.getTargetConnection()

private Connection getTargetConnection(Method operation) throws SQLException {
    if (this.target == null) {
        // ... (생략)
        // Fetch physical Connection from DataSource.
        DataSource dataSource = getDataSourceToUse();  // &amp;larr; SQL 실행 시점에 DataSource 선택!
        this.target = (this.username != null ?
            dataSource.getConnection(this.username, this.password) :
            dataSource.getConnection());
        // ... (생략)
    }
    return this.target;
}

private DataSource getDataSourceToUse() {
    return (this.readOnly &amp;amp;&amp;amp; readOnlyDataSource != null ?
        readOnlyDataSource : obtainTargetDataSource());  // &amp;larr; readOnly 상태 확인
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LazyConnectionDataSourceProxy를 사용하면:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;doBegin()에서는 Proxy 객체만 반환됩니다. (실제 커넥션 X)&lt;/li&gt;
&lt;li&gt;prepareSynchronization()이 실행되어 readOnly 정보가 ThreadLocal에 저장됩니다.&lt;/li&gt;
&lt;li&gt;이후 실제 SQL 실행 시점(예: Statement 생성 시)에 getTargetConnection()이 호출됩니다.&lt;/li&gt;
&lt;li&gt;이때는 이미 readOnly 정보가 ThreadLocal에 있으므로 isCurrentTransactionReadOnly()가 올바르게 동작하여 정확한 DataSource(master/slave)로 라우팅됩니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;마무리하며: 그럼 항상 Proxy를 쓰는 것이 좋을까?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 분석을 마치며 이런 의문이 들었습니다. 트랜잭션 내에서 쿼리 실행 후 외부 API를 호출하느라 커넥션을 불필요하게 오래 점유하는 문제도 이 프록시로 간단히 해결될 수 있을 것 같은데? 평소에도 LazyConnectionDataSourceProxy를 기본으로 사용하면 어떨까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론은 평상시에 사용하지 않기로 결론내렸습니다. LazyConnectionDataSourceProxy라는 편리한 안전장치만 믿고 개발자가 커넥션 관리의 복잡성을 인지하지 못하게 될 수 있고, 결국 커넥션 점유 시간을 신경 쓰지 않게 되어, 커넥션을 길게 물고 가는 코드가 탄생할 위험이 있기 때문입니다. 일반적인 상황에서의 커넥션 점유 문제는 개발자가 서비스 로직을 명확히 이해하고, 코드 레벨에서 트랜잭션을 명시적으로 분리하는 것으로 해결해야 한다고 생각합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프레임워크가 제공하는 편리함에 기대기보다, 명시적으로 개발자가 관리하는 것이 더 견고한 애플리케이션을 만드는 길이라고 다시 한번 생각하게 되었습니다.&lt;/p&gt;</description>
      <category>스프링</category>
      <category>datasource</category>
      <category>lazyconnectiondatasourceproxy</category>
      <category>MasterSlave</category>
      <category>springboot</category>
      <category>transactional</category>
      <category>넌왜마스터로가니</category>
      <category>타이밍이웬수</category>
      <author>순원이</author>
      <guid isPermaLink="true">https://jsw5913.tistory.com/305</guid>
      <comments>https://jsw5913.tistory.com/305#entry305comment</comments>
      <pubDate>Wed, 12 Nov 2025 17:29:36 +0900</pubDate>
    </item>
    <item>
      <title>[디프만, 밥토리] Hibernate 벡터, &amp;quot;묻고 double[]로 가!&amp;quot; 가 아니라.. float[]로 가!</title>
      <link>https://jsw5913.tistory.com/304</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; font-size: 1.44em;&quot;&gt;[개요]&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디프만 프로젝트인 혼밥 식당 추천 서비스 밥토리에서 PostgreSQL의 pgvector 확장을 사용해 유사 상점 추천 기능을 구현했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기 프로토타이핑은 네이티브 쿼리(@Query(..., nativeQuery = true))로 진행했고, 엔티티 필드를 double[]로 사용했음에도 쿼리는 잘 작동했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 이 쿼리를 컴파일 시점의 안정성을 확보하기 위해 QueryDSL로 리팩토링하는 순간 발생했습니다. 잘 되던 double[] 타입이 갑자기 FunctionArgumentException을 일으켰습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 이 FunctionArgumentException의 원인을 추적하고, 네이티브 쿼리와 HQL(QueryDSL)의 동작 방식 차이를 이해하며 문제를 해결한 과정을 담은 트러블 슈팅 기록입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2466&quot; data-origin-height=&quot;1414&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/xbpTX/dJMcacVIl4O/llRtOriUHMxbK2PP1IN2qk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/xbpTX/dJMcacVIl4O/llRtOriUHMxbK2PP1IN2qk/img.png&quot; data-alt=&quot;하이버네이트는 float과 double 배열을 벡터로 지원한다는 내용&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/xbpTX/dJMcacVIl4O/llRtOriUHMxbK2PP1IN2qk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FxbpTX%2FdJMcacVIl4O%2FllRtOriUHMxbK2PP1IN2qk%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;2466&quot; height=&quot;1414&quot; data-origin-width=&quot;2466&quot; data-origin-height=&quot;1414&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;하이버네이트는 float과 double 배열을 벡터로 지원한다는 내용&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;[요약]&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제 현상&lt;/b&gt;&lt;/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;double[]&lt;/b&gt;&amp;nbsp;엔티티 필드 + 네이티브 쿼리 = 성공&lt;/li&gt;
&lt;li&gt;&lt;b&gt;double[]&lt;/b&gt;&amp;nbsp;엔티티 필드 + QueryDSL/HQL = 실패 (&lt;b&gt;FunctionArgumentException: ... requires a vector type, but argument is of type 'double[]'&lt;/b&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 원인&lt;/b&gt;&lt;/p&gt;
&lt;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;: Hibernate가 SQL을 파싱하거나 검증하지 않습니다.&amp;nbsp;&lt;b&gt;double[]&lt;/b&gt;&amp;nbsp;파라미터를 그대로 JDBC 드라이버에 전달하고, PostgreSQL이&amp;nbsp;&lt;b&gt;double precision[]&lt;/b&gt;&amp;nbsp;&amp;rarr;&amp;nbsp;&lt;b&gt;vector&lt;/b&gt;로 암묵적 형변환(손실 발생)하여 쿼리가 성공합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;QueryDSL/HQL (실패 이유)&lt;/b&gt;: Hibernate가 HQL을 파싱하고 검증(&lt;b&gt;VectorArgumentValidator&lt;/b&gt;)합니다. 이 검증기는 pgvector 표준에 따라 vector를 &lt;b&gt;float[]&lt;/b&gt;&amp;nbsp;기준으로 구현했기 때문에,&amp;nbsp;&lt;b&gt;double[]&lt;/b&gt; 타입은 알 수 없는 벡터 타입으로 간주하여 예외를 발생시킵니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결 방법&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;네이티브 쿼리의 성공에 속지 말고, Hibernate의 표준 구현인 float[]로 필드 타입을 변경합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;[해결 과정]&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. 문제의 초기 코드&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 임베딩 벡터가 고정밀도일 필요는 없다고 생각했지만,&amp;nbsp; float[] 말고 double[]로 선언하면, 후에 타입 변경 없이 더 폭 넓은 vector 값을 담을 수 있어 double[]을 사용했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1762277698115&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Entity
public class StoreEmbedding {
    // ...

    @Column
    @JdbcTypeCode(SqlTypes.VECTOR)
    @Array(length = 1024) // 1024차원
    private double[] embedding; //   문제의 시작
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1762277719903&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Q-class는 ArrayPath&amp;lt;double[], Double&amp;gt;로 정상 생성됨
QStoreEmbedding se = new QStoreEmbedding(&quot;se&quot;);
QStoreEmbedding targetSe = new QStoreEmbedding(&quot;targetSe&quot;);

// &quot;의미적 거리&quot; (0에 가까울수록 유사함)
NumberExpression&amp;lt;Float&amp;gt; semanticDistance = Expressions.numberTemplate(
        Double.class, 
        &quot;cosine_distance({0}, {1})&quot;,
        se.embedding,       //   double[] 타입
        targetSe.embedding  //   double[] 타입
);

// ...
.orderBy(semanticDistance.asc()) //   여기서 예외 발생
// ...&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;에러 발생: &lt;code&gt;FunctionArgumentException&lt;/code&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쿼리를 실행하자마자 에러 메시지가 출력되었습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;...requires a vector type, but argument is of type 'double[]'&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;cosine_distance&lt;/code&gt;라는 함수는 알지만, 인자로 &lt;code&gt;double[]&lt;/code&gt;이 들어오는 것을 허용하지 않는다는 의미였습니다. 아니,, float[]이랑 double[] 지원한다며,,,&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;b&gt;[가설 1: 버전 문제인가? (기각)]&lt;/b&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;네이티브 지원이 제대로 안 되나?라는 생각에 스택을 점검했습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #333333; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Spring Boot:&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;3.3.0&lt;span&gt;&amp;nbsp;&lt;/span&gt;(확인)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Hibernate:&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;6.5.2&lt;span&gt;&amp;nbsp;&lt;/span&gt;(확인)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Hibernate-Vector:&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;6.5.2(확인)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;PostgreSQL JDBC:&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;42.7.3&lt;span&gt;&amp;nbsp;&lt;/span&gt;(vector&lt;span&gt;&amp;nbsp;&lt;/span&gt;지원을 위해 42.7.0 이상 필요)&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;스택 자체는 하이버네이트&amp;nbsp;vector&lt;span&gt;&amp;nbsp;&lt;/span&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;style5&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;[가설 2: Q-Class가 벡터가 아닌 배열을 반환해서? (기각)]&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;네이티브 쿼리는 단순히 SQL 문자열이지만, QueryDSL은 엔티티의 메타모델(Q-Class)을 기반으로 쿼리를 생성합니다. 그렇다면&amp;nbsp;&lt;span data-token-index=&quot;1&quot;&gt;Q-Class 생성 단계에서 문제가 발생했을 수도 있겠다&lt;/span&gt;는 가설을 세웠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에러 메시지 ... requires a vector type, but argument is of type 'double[]를 보고 가장 먼저 든 생각은 QueryDSL Q-Class 생성 문제였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;엔티티에 double[]을 선언하니, QueryDSL은 ArrayPath&amp;lt;double[], Double&amp;gt;라는 Q-Class를 생성했습니다. 혹시 Hibernate는 &lt;b&gt;VectorPath&lt;/b&gt; 같은 특별한 타입을 기대하는데, QueryDSL이 이를 벡터로 인식하지 못하고 단순 배열(ArrayPath)로 반환해서 문제가 생긴 것 아닐까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, double[]을 감싸는 모종의 벡터 래퍼 객체가 필요하고, Q-Class가 그 래퍼 객체 타입으로 생성되어야 한다고 추측했습니다.&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;@JdbcTypeCode(SqlTypes.VECTOR)는 Hibernate 6에서 도입된 &lt;b&gt;런타임 JDBC 타입 힌트&lt;/b&gt;로, Hibernate가 실제로 SQL을 실행할 때 어떤 JDBC 타입으로 바인딩할지 결정하는 데 사용됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 QueryDSL의 Q-Class 생성은&amp;nbsp;&lt;b&gt;컴파일 타임&lt;/b&gt;에 일어나며, 이 시점에는:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;@JdbcTypeCode&lt;/b&gt;&amp;nbsp;애노테이션이 붙어 있어도, annotation processor는 이를 읽지 않습니다.&lt;/li&gt;
&lt;li&gt;단순히 Java 필드 타입(&lt;b&gt;double[]&lt;/b&gt;)만 보고 ArrayPath&amp;lt;double[], Double&amp;gt;를 생성합니다.&lt;/li&gt;
&lt;li&gt;Hibernate가 런타임에&amp;nbsp;&lt;b&gt;SqlTypes.VECTOR&lt;/b&gt;로 변환한다는 정보는 Q-Class 생성에 반영되지 않습니다.&lt;/li&gt;
&lt;/ul&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;QueryDSL은 Hibernate의 커스텀 타입 메타정보(&lt;b&gt;@JdbcTypeCode&lt;/b&gt;,&amp;nbsp;&lt;b&gt;UserType&lt;/b&gt;&amp;nbsp;등)를 인식할 수 없기 때문입니다. QueryDSL은 JPA 표준만 따르는 범용 프레임워크입니다. Hibernate 6의&amp;nbsp;&lt;b&gt;@JdbcTypeCode&lt;/b&gt;는 Hibernate 특화 기능이며, JPA 표준이 아닙니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 QueryDSL은 double[]을 그대로 &lt;b&gt;ArrayPath&lt;/b&gt;로 생성하고, 이것이 PostgreSQL의&amp;nbsp;&lt;b&gt;vector&lt;/b&gt;&amp;nbsp;타입으로 매핑된다는 사실을 알 수 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제는 Q-Class 생성이 아니라, Q-Class가 전달한&amp;nbsp;double[]&amp;nbsp;타입을 Hibernate가 거부하고 있다는 것이었습니다.&lt;/b&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;[✅ 가설 3: 네이티브 쿼리와 QueryDSL의 동작 방식 차이]&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가설 2가 기각되니 새로운 의문이 생겼습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;double[]&amp;nbsp;타입이 Q-Class에 정상적으로 반영되고 있는데, 왜 네이티브 쿼리에서는 작동하고 QueryDSL에서는 작동하지 않을까?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동일한 double[] 필드를 사용하는데 쿼리 방식에 따라 결과가 다르니, 둘의 동작 방식을 살펴볼 필요가 있었습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;QueryDSL (HQL) 플로우&lt;/b&gt;&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;QueryDSL 코드(개발자) &amp;rarr; &lt;/span&gt;&lt;b&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;컴파일 타임 타입/문법 검증(Java 컴파일러 + Q-Class) &amp;rarr; JPQL/HQL 문자열 생성 + 쿼리 구조 검증(QueryDSL 라이브러리) &amp;rarr; HQL 파싱/검증/SQL 번역(Hibernate)&lt;/span&gt;&lt;/b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;rarr; SQL 실행 요청(Hibernate) &amp;rarr; DB 통신(JDBC) &amp;rarr; SQL 실행 -&amp;gt; (DB) 결과 반환(JDBC) &amp;rarr; 객체 매핑(Hibernate)&lt;/span&gt;&lt;b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;네이티브 쿼리 (Native SQL) 플로우&lt;br /&gt;&lt;/b&gt;&lt;/span&gt;&lt;/b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;Native SQL 문자열(개발자) &amp;rarr;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span style=&quot;color: #9d9d9d;&quot;&gt;[HQL 파싱/검증/SQL 번역 SKIP]&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;rarr; SQL 실행 요청(Hibernate) &amp;rarr; DB 통신(JDBC) &amp;rarr; SQL 실행(DB) &amp;rarr; 결과 반환(JDBC) &amp;rarr; 객체 매핑(Hibernate)&lt;/span&gt;&lt;/span&gt;&lt;b&gt;&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;가설2에서 QueryDSL 쪽을 살펴봤으니 하이버네이트 HQL 파싱 로직을 살펴보겠습니다.&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;Hibernate의&amp;nbsp;&lt;b&gt;VectorArgumentValidator&lt;/b&gt;&amp;nbsp;클래스를 확인해보니, HQL 함수(&lt;b&gt;cosine_distance&lt;/b&gt;,&amp;nbsp;&lt;b&gt;euclidean_distance&lt;/b&gt;&amp;nbsp;등)의 인자로&amp;nbsp;&lt;b&gt;float[]만 허용&lt;/b&gt;하도록 구현되어 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;왜&amp;nbsp;float[]만 허용할까요?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;pgvector 깃허브와 스택오버플로우를 뒤져가며 조사해봤습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;pgvector의 &lt;b&gt;vector&lt;/b&gt;&amp;nbsp;타입은 내부적으로 &lt;b&gt;32비트 float(single-precision)만 지원&lt;/b&gt;합니다. 64비트 double vector 타입은 아직 추가되지 않았습니다. double[]로 벡터 연산 시 손실이 발생합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;Hibernate 개발팀은 pgvector 표준을 따라, vector 타입의 네이티브 지원 및 HQL 함수를 float[]을 기준으로 구현했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #666666;&quot;&gt;+) OpenAI 등 대부분의 임베딩 모델은 32비트 float을 표준으로 사용합니다.(Oracle 23AI만 double 지원)&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;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;[Native Query vs HQL/QueryDSL: 동작 방식]&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Native Query (성공)&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;Hibernate가 SQL을 파싱하거나 검증하지 않습니다. &lt;/b&gt;(nativeQuery = true)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;VectorArgumentValidator가 실행되지 않습니다.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;double[]&lt;/b&gt;&amp;nbsp;파라미터를 그대로 JDBC 드라이버에 전달합니다.&lt;/li&gt;
&lt;li&gt;PostgreSQL JDBC 드라이버는 이를&amp;nbsp;&lt;b&gt;double precision[]&lt;/b&gt;&amp;nbsp;(PostgreSQL의 double 배열 타입)으로 DB에 전송합니다.&lt;/li&gt;
&lt;li&gt;PostgreSQL 서버가 cosine_distance(double precision[], double precision[])를 만나면, &lt;b&gt;cosine_distance&lt;/b&gt;&amp;nbsp;함수가&amp;nbsp;&lt;b&gt;vector&lt;/b&gt;&amp;nbsp;타입을 요구하므로 &lt;b&gt;double precision[]&lt;/b&gt;&amp;nbsp;&amp;rarr;&amp;nbsp;&lt;b&gt;vector&lt;/b&gt;로 암묵적 형변환을 시도합니다.&lt;b&gt;(손실 발생)&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;형변환이 성공하고 쿼리가 정상 실행됩니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre id=&quot;code_1762497508729&quot; class=&quot;sql&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;   String sql = &quot;&quot;&quot;
                SELECT s.*
                FROM store s
                INNER JOIN store_embedding se ON s.id = se.store_id
                INNER JOIN store_embedding target_se ON target_se.store_id = :storeId
                WHERE s.id IN (:candidateIds)
                  AND se.embedding IS NOT NULL
                  AND target_se.embedding IS NOT NULL
                  AND se.embedding_status = :embeddingStatus
                  AND target_se.embedding_status = :embeddingStatus
                ORDER BY se.embedding &amp;lt;=&amp;gt; target_se.embedding
                LIMIT :limit
                &quot;&quot;&quot;;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;HQL/QueryDSL (실패)&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;Hibernate가 HQL을 파싱하고&amp;nbsp;&lt;b&gt;VectorArgumentValidator로 함수 인자를 검증&lt;/b&gt;합니다.&lt;/li&gt;
&lt;li&gt;이 검증기는 HQL 함수 인자로&amp;nbsp;&lt;b&gt;float[]만 허용&lt;/b&gt;하도록 등록되어 있습니다. &amp;larr; &lt;b&gt;double[]로 벡터 변환 시 손실 발생 방지&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;double[]이 들어오자, &quot;등록되지 않은 벡터 타입&quot;으로 간주하고 &lt;b&gt;FunctionArgumentException&lt;/b&gt;을 발생시킵니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&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;code&gt;double[]&lt;/code&gt; -&amp;gt; &lt;code&gt;float[]&lt;/code&gt;)&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;@Entity
public class StoreEmbedding {
    // ...

    @Column(columnDefinition = &quot;vector(1024)&quot;)
    private float[] embedding; //   float[]로 변경
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&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;엔티티 필드를 double[]에서 float[]로 변경하고 관련 코드를 수정한 뒤, QueryDSL 쿼리가 정상적으로 동작하는 것을 확인했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 트러블 슈팅의 가장 큰 성과는, cosine_distance 같은 HQL 함수 호출이 막혔을 때, 포기하지 않고&amp;nbsp;&lt;b&gt;네이티브 쿼리로 우회하지 않았다&lt;/b&gt;는 점입니다. 만약 네이티브 쿼리를 사용했다면, double[]을 벡터를 계속 사용했을 것이고, 후에 손실이 발생하는 추천 결과를 반환했을 수도 있었을 겁니다.&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;[배운점]&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;네이티브 쿼리는 실행을 JDBC 드라이버와 DB에게 바로 위임하지만, QueryDSL(HQL)은 Hibernate라는 추상화 계층을 통과하며 검증 단계를 한 번 더 거친다는 차이를 알게되었습니다.&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;이번 기회에 그동안 트러블 슈팅을 해결하는 과정에서 기술의 동작방식을 명확히 규정하지 않고, 눈에 보이는 차이점부터 주먹구구식으로 가설을 세웠구나라는 생각이 들었습니다 . 처음부터 두 방식의 동작 원리를 명확히 구분했다면, Hibernate 예외를 QueryDSL 예외로 오해하는 일 없이 더 빨리 근본 원인을 찾았을 것입니다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 경험을 바탕으로, 앞으로는 문제가 발생했을 때 눈앞의 현상에 매몰되지 않고, 각 기술 스택의 플로우에 기반한 체계적인 가설을 세우고 검증하는 개발자로 성장하겠습니다!&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;참고&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://docs.spring.io/spring-data/jpa/reference/4.0/repositories/vector-search.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://docs.spring.io/spring-data/jpa/reference/4.0/repositories/vector-search.html&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1762278198539&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;Vector Search :: Spring Data JPA&quot; data-og-description=&quot;With the rise of Generative AI, Vector databases have gained strong traction in the world of databases. These databases enable efficient storage and querying of high-dimensional vectors, making them well-suited for tasks such as semantic search, recommenda&quot; data-og-host=&quot;docs.spring.io&quot; data-og-source-url=&quot;https://docs.spring.io/spring-data/jpa/reference/4.0/repositories/vector-search.html&quot; data-og-url=&quot;https://docs.spring.io/spring-data/jpa/reference/4.0/repositories/vector-search.html&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://docs.spring.io/spring-data/jpa/reference/4.0/repositories/vector-search.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://docs.spring.io/spring-data/jpa/reference/4.0/repositories/vector-search.html&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&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;Vector Search :: Spring Data JPA&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;With the rise of Generative AI, Vector databases have gained strong traction in the world of databases. These databases enable efficient storage and querying of high-dimensional vectors, making them well-suited for tasks such as semantic search, recommenda&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;docs.spring.io&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;a href=&quot;https://discourse.hibernate.org/t/how-to-use-criteria-api-to-build-query-with-vector-operation-such-as-euclidean-distance/11789&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://discourse.hibernate.org/t/how-to-use-criteria-api-to-build-query-with-vector-operation-such-as-euclidean-distance/11789&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1762278724709&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;How to use criteria Api to build query with vector operation such as euclidean distance&quot; data-og-description=&quot;Hi everyone, I am posting my issue here but please let me know if this is not the appropriate place to do so. I am trying to write a criteria query that order the results based on a vector distance. My entity field holding the vector (an embedding coming f&quot; data-og-host=&quot;discourse.hibernate.org&quot; data-og-source-url=&quot;https://discourse.hibernate.org/t/how-to-use-criteria-api-to-build-query-with-vector-operation-such-as-euclidean-distance/11789&quot; data-og-url=&quot;https://discourse.hibernate.org/t/how-to-use-criteria-api-to-build-query-with-vector-operation-such-as-euclidean-distance/11789&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/dG6FP7/hyZMQD936h/uDQ3IeSQLa2iHmdpReoh11/img.png?width=48&amp;amp;height=48&amp;amp;face=0_0_48_48,https://scrap.kakaocdn.net/dn/oIVx0/hyZMMuXb04/N0OOJ7ELYcSv37gQE6QkD0/img.png?width=48&amp;amp;height=48&amp;amp;face=0_0_48_48&quot;&gt;&lt;a href=&quot;https://discourse.hibernate.org/t/how-to-use-criteria-api-to-build-query-with-vector-operation-such-as-euclidean-distance/11789&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://discourse.hibernate.org/t/how-to-use-criteria-api-to-build-query-with-vector-operation-such-as-euclidean-distance/11789&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/dG6FP7/hyZMQD936h/uDQ3IeSQLa2iHmdpReoh11/img.png?width=48&amp;amp;height=48&amp;amp;face=0_0_48_48,https://scrap.kakaocdn.net/dn/oIVx0/hyZMMuXb04/N0OOJ7ELYcSv37gQE6QkD0/img.png?width=48&amp;amp;height=48&amp;amp;face=0_0_48_48');&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;How to use criteria Api to build query with vector operation such as euclidean distance&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Hi everyone, I am posting my issue here but please let me know if this is not the appropriate place to do so. I am trying to write a criteria query that order the results based on a vector distance. My entity field holding the vector (an embedding coming f&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;discourse.hibernate.org&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;a href=&quot;https://docs.hibernate.org/orm/7.1/userguide/html_single/#vector-module&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://docs.hibernate.org/orm/7.1/userguide/html_single/#vector-module&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1762284711345&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;Hibernate ORM User Guide&quot; data-og-description=&quot;Starting in 6.0, Hibernate allows to configure the default semantics of List without @OrderColumn via the hibernate.mapping.default_list_semantics setting. To switch to the more natural LIST semantics with an implicit order-column, set the setting to LIST.&quot; data-og-host=&quot;docs.hibernate.org&quot; data-og-source-url=&quot;https://docs.hibernate.org/orm/7.1/userguide/html_single/#vector-module&quot; data-og-url=&quot;https://docs.hibernate.org/orm/7.1/userguide/html_single/#vector-module&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://docs.hibernate.org/orm/7.1/userguide/html_single/#vector-module&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://docs.hibernate.org/orm/7.1/userguide/html_single/#vector-module&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&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;Hibernate ORM User Guide&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Starting in 6.0, Hibernate allows to configure the default semantics of List without @OrderColumn via the hibernate.mapping.default_list_semantics setting. To switch to the more natural LIST semantics with an implicit order-column, set the setting to LIST.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;docs.hibernate.org&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/pgvector/pgvector/tree/master/src&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/pgvector/pgvector/tree/master/src&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1762286917444&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;pgvector/src at master &amp;middot; pgvector/pgvector&quot; data-og-description=&quot;Open-source vector similarity search for Postgres. Contribute to pgvector/pgvector development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/pgvector/pgvector/tree/master/src&quot; data-og-url=&quot;https://github.com/pgvector/pgvector/tree/master/src&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/IcH4Y/hyZMBMyBhc/K03boC4u6RX1mzwDTZpAi0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/fIz7A/hyZNfh1kn0/G1rmzP1FpUFBx7A0jiN9JK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/pgvector/pgvector/tree/master/src&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/pgvector/pgvector/tree/master/src&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/IcH4Y/hyZMBMyBhc/K03boC4u6RX1mzwDTZpAi0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/fIz7A/hyZNfh1kn0/G1rmzP1FpUFBx7A0jiN9JK/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;pgvector/src at master &amp;middot; pgvector/pgvector&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Open-source vector similarity search for Postgres. Contribute to pgvector/pgvector 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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/hibernate/hibernate-orm/blob/7.1/hibernate-vector/src/main/java/org/hibernate/vector/PGVectorTypeContributor.java&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/hibernate/hibernate-orm/blob/7.1/hibernate-vector/src/main/java/org/hibernate/vector/PGVectorTypeContributor.java&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1762287623182&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;hibernate-orm/hibernate-vector/src/main/java/org/hibernate/vector/PGVectorTypeContributor.java at 7.1 &amp;middot; hibernate/hibernate-orm&quot; data-og-description=&quot;Idiomatic persistence for Java and relational databases - hibernate/hibernate-orm&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/hibernate/hibernate-orm/blob/7.1/hibernate-vector/src/main/java/org/hibernate/vector/PGVectorTypeContributor.java&quot; data-og-url=&quot;https://github.com/hibernate/hibernate-orm/blob/7.1/hibernate-vector/src/main/java/org/hibernate/vector/PGVectorTypeContributor.java&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/xp4TG/hyZMXwvm3y/9sc8KoiKOPdNPpng9YtLv1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/4emPo/hyZM1KShkq/NZlKeNJa60j7iNKMnkYTik/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/hibernate/hibernate-orm/blob/7.1/hibernate-vector/src/main/java/org/hibernate/vector/PGVectorTypeContributor.java&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/hibernate/hibernate-orm/blob/7.1/hibernate-vector/src/main/java/org/hibernate/vector/PGVectorTypeContributor.java&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/xp4TG/hyZMXwvm3y/9sc8KoiKOPdNPpng9YtLv1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/4emPo/hyZM1KShkq/NZlKeNJa60j7iNKMnkYTik/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;hibernate-orm/hibernate-vector/src/main/java/org/hibernate/vector/PGVectorTypeContributor.java at 7.1 &amp;middot; hibernate/hibernate-orm&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Idiomatic persistence for Java and relational databases - hibernate/hibernate-orm&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>스프링</category>
      <category>Hibernate 6.5</category>
      <category>JPA</category>
      <category>pgvector</category>
      <category>QueryDSL</category>
      <category>Spring Boot 3.3</category>
      <category>트러블 슈팅</category>
      <author>순원이</author>
      <guid isPermaLink="true">https://jsw5913.tistory.com/304</guid>
      <comments>https://jsw5913.tistory.com/304#entry304comment</comments>
      <pubDate>Fri, 7 Nov 2025 15:59:07 +0900</pubDate>
    </item>
    <item>
      <title>100명 동시 요청 25분 &amp;rarr; 1분, Gemini API 병목 96% 개선기 (feat. Virtual Thread의 함정)</title>
      <link>https://jsw5913.tistory.com/303</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;[개요]&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI 기반 퀴즈 생성 서비스에서 Google Gemini API를 활용하는 기능을 개발했습니다. 사용자가 챌린지를 생성하면, 백엔드 서버가 Gemini API를 호출하여 퀴즈를 생성하고 사용자에게 제공하는 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 Gemini API가 퀴즈를 생성하는 데 &lt;b&gt;약 1분의 긴 I/O 대기 시간을 소요&lt;/b&gt;한다는 점이었습니다. 이로 인해 동시 사용자 요청이 몰릴 경우, 시스템 전체의 처리량이 심각하게 저하되고 사용자 대기 시간이 기하급수적으로 증가하는 병목 현상이 발생했습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;[요약]&lt;/b&gt;&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;핵심 문제:&lt;/b&gt; AI 모델의 응답 시간(약 1분) 동안 &lt;code&gt;@Async&lt;/code&gt;로 할당된 플랫폼 스레드가 블로킹되어, 한정된 스레드 풀(CorePoolSize: 4)이 금방 소진되어 병목 현상.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;분석 과정:&lt;/b&gt; Java 21 Virtual Thread(VT) 도입을 검토했으나, Google SDK(OkHttp) 내부의 &lt;code&gt;synchronized&lt;/code&gt; 블록으로 인한 스레드 피닝(Pinning)을 식별하여 VT 도입 효과가 무력화될 것을 예측했습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;최종 해결:&lt;/b&gt; SDK가 제공하는 논블로킹 API를 채택하여, I/O 대기 시간 동안 플랫폼 스레드를 점유하지 않도록 변경했습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;결과:&lt;/b&gt; 100명 동시 요청 시 &lt;b&gt;최대 대기 시간을 25분에서 1분으로 96% 단축&lt;/b&gt;하여, I/O 대기 시간과 무관하게 안정적인 동시성을 확보했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;[문제 상황 1:&amp;nbsp; API의 긴 I/O 대기 시간과 동기 방식의 한계]&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기 구현은 사용자의 HTTP 요청을 받아 즉시 Gemini API를 호출하는 동기 방식이었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Gemini API는 모델 추론 특성상 응답을 받기까지 평균 1분의 시간이 소요되었습니다. 웹 애플리케이션의 일반적인 스레드(Tomcat)에서 이 요청을 직접 처리할 경우, 해당 스레드는 1분 동안 블로킹되어 다른 어떤 요청도 처리할 수 없게 되고 서비스 장애로 이어질 수 있었습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;[해결 방안 1: @Async를 통한 비동기 처리 및 화면 분리]&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우선 사용자 요청 스레드가 1분 동안 블로킹되는 것을 막아야 했습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;화면 분리:&lt;/b&gt; 사용자가 퀴즈 생성을 요청하면, 서버는 즉시 &lt;code&gt;202 Accepted&lt;/code&gt; 응답을 반환하고 사용자는 퀴즈가 생성되는 동안, 다른 활동을 진행할 수 있습니다. 최대한 빨리 퀴즈를 생성하고 사용자에게 퀴즈가 생성되었다고 알림을 보냅니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;비동기 실행:&lt;/b&gt; 실제 API 호출 및 퀴즈 생성 로직은 &lt;code&gt;@Async&lt;/code&gt; 어노테이션을 통해 별도의 스레드 풀에서 비동기적으로 실행하도록 분리했습니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 조치로 사용자는 즉각적인 응답을 받을 수 있게 되었고, 웹 서버 스레드가 1분 동안 대기하는 치명적인 상황은 피할 수 있었습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;[문제 상황 2: @Async의 한계와 플랫폼 스레드 풀 병목]&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1차 해결책은 사용자의 인식 상의 대기 시간을 줄였을 뿐, 서버가 감당할 수 있는 총처리량의 문제는 해결하지 못했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저희는 비동기 스레드 풀의 최대 크기를 &lt;code&gt;corePoolSize: 4&lt;/code&gt;로 설정해서 &lt;b&gt;동시에 4개의 퀴즈만 생성&lt;/b&gt;할 수 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;부하 테스트를 통해 100명의 사용자가 동시에 퀴즈 생성을 요청하는 시나리오를 시뮬레이션했습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2048&quot; data-origin-height=&quot;333&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/HOxm4/dJMcahQgqeo/lEFihKT7m1K3AZZPX4aRYK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/HOxm4/dJMcahQgqeo/lEFihKT7m1K3AZZPX4aRYK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/HOxm4/dJMcahQgqeo/lEFihKT7m1K3AZZPX4aRYK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FHOxm4%2FdJMcahQgqeo%2FlEFihKT7m1K3AZZPX4aRYK%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;2048&quot; height=&quot;333&quot; data-origin-width=&quot;2048&quot; data-origin-height=&quot;333&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2048&quot; data-origin-height=&quot;470&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/DjCur/dJMcacIbJqv/JhLppYLRldkfDTuQFlf3qk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/DjCur/dJMcacIbJqv/JhLppYLRldkfDTuQFlf3qk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/DjCur/dJMcacIbJqv/JhLppYLRldkfDTuQFlf3qk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FDjCur%2FdJMcacIbJqv%2FJhLppYLRldkfDTuQFlf3qk%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;2048&quot; height=&quot;470&quot; data-origin-width=&quot;2048&quot; data-origin-height=&quot;470&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;1 API 호출 = 1분&lt;/li&gt;
&lt;li&gt;동시 처리 가능 개수 = 4개 (스레드 풀 크기)&lt;/li&gt;
&lt;li&gt;서버의 시간당 처리량 = 4 요청 / 1분&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;100명의 요청을 처리하기 위한 총 시간은 (100 / 4) * 1분 = 25분이었습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 100번째 사용자는 퀴즈가 생성되기 시작하기까지 24분을 기다려야 하며, 퀴즈를 받기까지 &lt;b&gt;최대 25분의 대기 시간&lt;/b&gt;이 발생합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제의 본질은 &lt;b&gt;&lt;code&gt;@Async&lt;/code&gt;가 I/O 대기를 해결해주지 않는다&lt;/b&gt;는 점입니다. 단지 블로킹이 발생하는 위치를 웹 스레드에서 비동기 스레드로 옮겼을 뿐, 비싼 플랫폼 스레드가 1분 동안 아무 일도 하지 않고 I/O를 기다리는 자원 낭비는 동일했습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;[해결 방안 2: I/O 병목 근본적 해결을 위한 탐색]&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 I/O 대기 시 스레드 점유였습니다. 이 문제를 해결하기 위해 Java 21에서 도입된 가상 스레드(Virtual Threads)를 최우선으로 검토했습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;(1차 검토) Java 21 가상 스레드(Virtual Threads) 도입&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가상 스레드는 I/O 대기 시(예: API 호출) 플랫폼 스레드에서 분리(unmount)되어 플랫폼 스레드를 반납합니다. 100만 개의 가상 스레드가 동시에 I/O 대기 상태여도, 실제 플랫폼 스레드는 소수만으로 감당 가능합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;하지만, 자바 21의 가상스레드는 함정이 있습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가상 스레드는 코드 블록이 &lt;code&gt;synchronized&lt;/code&gt; 키워드로 감싸져 있거나 &lt;code&gt;native&lt;/code&gt; 메서드를 호출할 때 플랫폼 스레드에서 분리되지 못하고 고정(Pinning)됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Google Gemini SDK의 내부 구현을 분석한 결과, SDK가 의존하는 &lt;code&gt;OkHttp&lt;/code&gt; 라이브러리 내부 여러 곳에 synchronized 블록이 존재하는 것을 확인했습니다. &lt;span style=&quot;color: #9d9d9d;&quot;&gt;(깃허브 이슈를 뒤져봤는데, OKHttp도 pinning 병목을 없애기 위해서 대대적인 리팩토링 작업을 하려다 24부터 Synchronized&amp;nbsp; pinning 문제가 해결되어서 리팩토링을 하지 않았다고 합니다.)&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이는 만약 synchronized 블록 내부에서 I/O 대기가 발생할 경우, 가상 스레드가 플랫폼 스레드에 고정되어 1분 동안 스레드를 반납하지 못함을 의미합니다. &lt;b&gt;결과적으로 가상 스레드를 도입하더라도 플랫폼 스레드 풀(기본 ForkJoinPool)이 1분 동안 블로킹되어, 동일한 병목 현상이 발생할 것이라 판단했습니다.&lt;/b&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;synchronized로 인한 피닝 문제는 Java 24에서 해결되었으나, 현재(Java 21)로서는 한계였습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;(2차 검토) 기술적 Trade-off 분석&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SDK + 가상 스레드의 한계에서 다음과 같은 대안들을 검토했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;[대안 1 - 채택x] 가상 스레드 + REST API 직접 호출&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Google SDK를 포기하고, WebClient를 사용해 Gemini REST API를 직접 호출합니다.가상 스레드의 이점을 100% 활용할 수 있지만, API 명세 변경, 직렬화/역직렬화, 오류 처리를 모두 직접 구현하고 관리해야 합니다. SDK가 제공하는 편의성을 모두 포기해야 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;[대안 2 - 채택x] Java 25 마이그레이션&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;피닝 문제가 해결된 Java 25로 마이그레이션 합니다. SDK를 유지하면서 가상스레드의 이점을 사용할 수 있지만, 안정적인 마이그레이션을 위해서는 의존성 체크, 카나리 배포 등 비용이 높습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;[대안 3 - 채택o] SDK의 논블로킹(비동기) API 사용&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SDK 문서를 재검토하여, I/O 대기 시 스레드를 점유하지 않는 비동기(Non-Blocking) 호출 방식이 있는지 확인했습니다. 다행히 Google SDK는 &lt;code&gt;generateContent()&lt;/code&gt;(동기) 외에 &lt;code&gt;generateContentAsync()&lt;/code&gt;(비동기) 메서드를 제공하고 있었습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;[최종 해결] 논블로킹(비동기) SDK 호출&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종적으로 &lt;b&gt;&lt;code&gt;@Async&lt;/code&gt; + &lt;code&gt;generateContentAsync()&lt;/code&gt;&lt;/b&gt; 조합을 선택했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;generateContentAsync()&lt;/code&gt; 메서드는 호출 즉시 &lt;code&gt;ListenableFuture&lt;/code&gt; 객체를 반환하며, 실제 I/O 작업은 SDK 내부의 I/O 스레드 풀(OkHttp의 Dispatcher)에서 수행됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;@Async&lt;/code&gt; 스레드 풀(MaxPoolSize: 4)은 1분간의 I/O 대기를 수행하는 것이 아니라, 100ms 미만의 작업 등록하고 즉시, 또 다른 요청의 AI 요청을 보냅니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;[결과 / 성능 테스트]&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2048&quot; data-origin-height=&quot;789&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/CV1OS/dJMcaawPWzT/9BylyOnvihCSdZ8Kxtq8F0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/CV1OS/dJMcaawPWzT/9BylyOnvihCSdZ8Kxtq8F0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/CV1OS/dJMcaawPWzT/9BylyOnvihCSdZ8Kxtq8F0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FCV1OS%2FdJMcaawPWzT%2F9BylyOnvihCSdZ8Kxtq8F0%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;2048&quot; height=&quot;789&quot; data-origin-width=&quot;2048&quot; data-origin-height=&quot;789&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동일하게 100명 동시 요청 부하 테스트를 진행했습니다.&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;[Before] Blocking SDK:&lt;/b&gt; 100번째 사용자는 &lt;b&gt;약 25분&lt;/b&gt; 후에 퀴즈를 받았습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;[After] Non-Blocking SDK:&lt;/b&gt; 100번째 사용자도 &lt;b&gt;약 1분&lt;/b&gt; 후에 퀴즈를 받기 시작했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과적으로 100명 동시 요청 시 &lt;b&gt;최대 대기 시간을 25분에서 1분으로 96% 단축&lt;/b&gt;했습니다. 스레드 풀 크기와 관계없이 I/O 병목이 해소되었으며, 동시 요청도 안정적으로 처리할 수 있는 확장성을 확보했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;I/O병목에는 가상 스레드가 정답...이지만, 만능은 아니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가상 스레드는 I/O 바운드 작업의 성능을 올려줄 좋은 기술임은 분명합니다. 하지만 가상스레드를 분석하지 않고 맹목적으로 도입했다면, synchronized로 인한 피닝(Pinning) 함정에 빠져 아무런 성능 향상을 얻지 못한 채 복잡성만 증가시켰을 것입니다. 새로운 기술을 도입할 때는 반드시 내부 구현과 한계점을 명확히 분석해야 함을 다시 한 번 느꼈습니다.&lt;/p&gt;</description>
      <category>스프링</category>
      <category>geminiapi</category>
      <category>Java</category>
      <category>okhttp</category>
      <category>springboot</category>
      <category>VirtualThreads</category>
      <category>가상스레드</category>
      <category>성능개선</category>
      <author>순원이</author>
      <guid isPermaLink="true">https://jsw5913.tistory.com/303</guid>
      <comments>https://jsw5913.tistory.com/303#entry303comment</comments>
      <pubDate>Thu, 6 Nov 2025 19:50:02 +0900</pubDate>
    </item>
    <item>
      <title>@Transactional에서 try-catch를 썼는데 500 에러?</title>
      <link>https://jsw5913.tistory.com/302</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;[문제 발견: catch로 잡았는데 왜 500 에러가 발생할까?]&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋아요 중복 요청 시 요구사항은 다음과 같았습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;중복 요청 시 데이터베이스 트랜잭션은 롤백되어야 한다.&lt;/li&gt;
&lt;li&gt;이벤트 발행 등 후속 로직이 실행되지 않아야 한다.&lt;/li&gt;
&lt;li&gt;클라이언트에게는 200 OK를 응답해야 한다. (멱등성 보장)&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기 코드는 단순한 try-catch 구조였습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Transactional
public void createDiaryLike(Long memberId, Long diaryId) {
    final Member member = memberRepository.findByIdOrElseThrow(memberId);
    final ReadingDiary readingDiary = readingDiaryRepository.findByIdOrElseThrow(diaryId);
    try {
        readingDiaryLikeRepository.save(new ReadingDiaryLike(member, readingDiary));
        eventPublisher.publishEvent(new LikeEvent(...));
    } catch (DataIntegrityViolationException e) {
        log.warn(&quot;좋아요 중복 저장 시도 발생 (정상 처리)&quot;);
    }
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 테스트 결과, 클라이언트는 200 OK가 아닌 500 에러를 받았고, 로그에는 다음과 같은 예외가 찍혔습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;분명히 예외를 catch로 잡았는데도, Spring은 트랜잭션이 비정상적으로 롤백되었다고 판단했습니다. 이 문제를 해결하기 위해 Spring AOP의 트랜잭션 메커니즘을 깊이 파고들기 시작했습니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;[가설 수립]&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제의 흐름은 다음과 같았습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;save() 호출 시 유니크 제약 조건 위반으로 DataIntegrityViolationException ( RuntimeException의 하위 클래스) 발생&lt;/li&gt;
&lt;li&gt;Spring의 트랜잭션 매니저는 RuntimeException이 발생하면, &lt;b&gt;트랜잭션을 즉시 globalRollbackOnly 상태로 마킹&lt;/b&gt;합니다.&lt;/li&gt;
&lt;li&gt;catch 블록이 예외를 잡았기 때문에, createDiaryLike 메서드 자체는 &lt;b&gt;정상 종료&lt;/b&gt;됩니다.&lt;/li&gt;
&lt;li&gt;메서드가 정상 종료되자, Spring AOP는 트랜잭션 commit을 시도합니다.&lt;/li&gt;
&lt;li&gt;commit 시점에 트랜잭션 매니저는 &quot;메서드는 정상 종료됐는데, 트랜잭션은 globalRollbackOnly 상태네?&quot;라고 판단합니다.&lt;/li&gt;
&lt;li&gt;이 불일치 상황을 개발자에게 알리기 위해 UnexpectedRollbackException을 발생시킵니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 가설을 검증하기 위해 Spring의 AbstractPlatformTransactionManager.commit() 소스 코드를 확인했습니다.&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;// AbstractPlatformTransactionManager.commit()
public final void commit(TransactionStatus status) throws TransactionException {
    DefaultTransactionStatus defStatus = (DefaultTransactionStatus)status;

    if (defStatus.isLocalRollbackOnly()) {
        // 1. 개발자가 명시적으로 setRollbackOnly() 호출한 경우
        if (defStatus.isDebug()) {
            this.logger.debug(&quot;Transactional code has requested rollback&quot;);
        }
        this.processRollback(defStatus, false); // 조용히 롤백 (예외 없음)

    } else if (!this.shouldCommitOnGlobalRollbackOnly() &amp;amp;&amp;amp; defStatus.isGlobalRollbackOnly()) {
        // 2. 예외로 인한 자동 rollback-only 마킹인 경우 (우리의 케이스)
        if (defStatus.isDebug()) {
            this.logger.debug(&quot;Global transaction is marked as rollback-only but transactional code requested commit&quot;);
        }
        this.processRollback(defStatus, true); // 여기서 UnexpectedRollbackException 발생!

    } else {
        this.processCommit(defStatus);
    }
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 Spring이 &lt;b&gt;두 가지 rollback-only 플래그를 구분하여 처리&lt;/b&gt;한다는 것이었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;플래그 의미 설정 방법 Spring의 반응&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;localRollbackOnly&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;개발자의 명시적 롤백 요청&lt;/td&gt;
&lt;td&gt;TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()&lt;/td&gt;
&lt;td&gt;processRollback(defStatus, false) 호출. 조용히 롤백 후 정상 종료.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;globalRollbackOnly&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;예외로 인한 자동 마킹&lt;/td&gt;
&lt;td&gt;RuntimeException 발생&lt;/td&gt;
&lt;td&gt;processRollback(defStatus, true) 호출. UnexpectedRollbackException 발생.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리의 초기 코드는 globalRollbackOnly 플래그만 true로 만들었고, commit 시점에 두 번째 else if 문에 걸려 예외가 발생했던 것입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;[해결 방안 도출 및 비교]&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;방안 1(채택안함): REQUIRES_NEW로 트랜잭션 분리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋아요 저장 로직을 서비스와 REQUIRES_NEW 전파 속성을 가진 새 트랜잭션으로 분리합니다. save()가 실패하면 내부 트랜잭션만 롤백되고, 예외는 외부로 전파됩니다. 외부 트랜잭션은 rollback-only로 마킹되지 않기 때문에, catch 블록에서 예외를 잡고 정상 커밋(혹은 롤백)할 수 있습니다. 그러나, 이 문제를 해결하기 위해 클래스를 새로 분리하는 것이 조금 무겁게 느껴졌고, 빈번한 '좋아요' 요청마다 기존의 2배의 DB 커넥션을 사용해야 한다는 점이 단점입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;방안 2(채택): setRollbackOnly()로 명시적 의도 전달&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;catch 블록에서 localRollbackOnly 플래그를 true로 설정하여, Spring이 첫 번째 if문에 진입하도록 유도합니다. 이는 &quot;예외가 발생했지만, 이건 내가 의도한 롤백이니 조용히 처리해 줘&quot;라고 Spring에게 명시적으로 알려주는 것입니다. 결국 setRollbackOnly 방식을 채택했습니다. 좋아요는 빈번한 작업이므로, 트랜잭션과 커넥션을 1개만 사용하는 것이 오버헤드 최소화에 유리하고, 만약 이 메서드에 다른 비즈니스 로직이 추가되어 실패 격리가 필요해진다면, &lt;b&gt;그때 리팩토링&lt;/b&gt;하는 것이 더 실용적일 거라고 판단했기 때문입니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;[최종 구현]&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;완성된 코드는 다음과 같습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Transactional
public void createDiaryLike(Long memberId, Long diaryId) {
    final Member member = memberRepository.findByIdOrElseThrow(memberId);
    final ReadingDiary readingDiary = readingDiaryRepository.findByIdOrElseThrow(diaryId);
    try {
        readingDiaryLikeRepository.save(new ReadingDiaryLike(member, readingDiary));

        // 예외 발생 시 이 라인은 실행되지 않음
        eventPublisher.publishEvent(new LikeEvent(...));

    } catch (DataIntegrityViolationException e) {
        // Spring에게 개발자가 의도한 롤백임을 명시적으로 전달
        TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
        log.warn(&quot;좋아요 중복 저장 시도 발생 (정상 처리)&quot;);
    }
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;동작 검증:&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;중복 save() 호출 &amp;rarr; DataIntegrityViolationException 발생&lt;/li&gt;
&lt;li&gt;catch 블록 진입 &amp;rarr; setRollbackOnly() 호출 (localRollbackOnly = true 설정)&lt;/li&gt;
&lt;li&gt;eventPublisher 라인에 도달하지 않아 이벤트 발행 방지&lt;/li&gt;
&lt;li&gt;메서드 정상 종료&lt;/li&gt;
&lt;li&gt;Spring commit() 메서드에서 isLocalRollbackOnly()가 true이므로 processRollback(defStatus, false) 호출&lt;/li&gt;
&lt;li&gt;트랜잭션은 조용히 롤백되고, 예외는 발생하지 않음&lt;/li&gt;
&lt;li&gt;클라이언트에게 &lt;b&gt;200 OK&lt;/b&gt; 응답 (멱등성 보장)&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;[결론 및 인사이트]&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경험을 통해 단순한 좋아요 기능 멱등성 확보와 더불어, Spring 트랜잭션의 깊은 내부 동작을 학습할 수 있었습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;local vs global RollbackOnly:&lt;/b&gt; Spring이 개발자의 명시적 롤백과 예외로 인한 자동 롤백을 구분하여 처리하는 설계 의도를 이해했습니다.&lt;/li&gt;
&lt;li&gt;예외를 비즈니스 흐름 제어에 사용하더라도, 트랜잭션의 상태 전이는 개발자가 해 명확히 의도를 지정해야 함을 깨달았습니다.&lt;/li&gt;
&lt;li&gt;현재 문제에 가장 적합하고 단순한 해결책을 선택(**YAGNI)**하고, 필요할 때 리팩토링하는 것이 실용적인 접근 방식임을 다시 한번 확인했습니다.&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>스프링</category>
      <category>Spring AOP</category>
      <category>멱등성</category>
      <category>트랜잭션</category>
      <author>순원이</author>
      <guid isPermaLink="true">https://jsw5913.tistory.com/302</guid>
      <comments>https://jsw5913.tistory.com/302#entry302comment</comments>
      <pubDate>Thu, 6 Nov 2025 00:17:50 +0900</pubDate>
    </item>
    <item>
      <title>Spring 이벤트, 혹시 이렇게 쓰고 계신가요? (Fat Event 피하기)</title>
      <link>https://jsw5913.tistory.com/301</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;개요&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋아요 기능을 추가했을 뿐인데, &lt;code&gt;NotificationService&lt;/code&gt;와 &lt;code&gt;StatisticService&lt;/code&gt;까지 수정해야 했습니다. OCP(개방-폐쇄 원칙)가 훼손되는 문제를 해결하기 위해 Spring의 이벤트 기반 아키텍처로 리팩토링했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서비스 계층이 특정 구현체에 직접 의존하는 강한 결합 구조에서 Spring ApplicationEvent 기반의 비동기 이벤트 구조로 전환하는 것이 목표였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 과정 속에서 Fat Event라는 두 번째 문제를 만났고, 이 문제를 해결하기 위해 &lt;b&gt;최소 ID 원칙&lt;/b&gt;을 도입했습니다. 최종적으로는 원칙의 순수성과 시스템 성능 사이에서 실용적인 트레이드오프를 선택하며 해결하였습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;문제 정의: 왜 리팩토링이 필요했나 (강한 결합과 OCP 위반)&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기능 요구사항은 간단했습니다. &quot;사용자가 '좋아요'를 누르면, (1) 데이터를 저장하고, (2) 통계를 갱신하고, (3) 작성자에게 알림을 보낸다.&quot;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기 코드는 다음과 같았습니다.&lt;/p&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;// 초기 구조 - 직접 의존
@Service
public class ReadingDiaryLikeService {

    private final StatisticService statisticService;
    private final NotificationService notificationService; // 기능 추가 시마다 의존성 증가

    public void createDiaryLike(Long memberId, Long diaryId) {
        // ... DB 저장 로직 ...

        // 1. 통계 서비스 직접 호출
        statisticService.increaseLikeCount(diaryId); 

        // 2. 알림 서비스 직접 호출
        notificationService.send(...); // 책임 과다
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조는 심각한 문제들을 안고 있었습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;강한 결합&lt;/b&gt;: &lt;code&gt;LikeService&lt;/code&gt;가 &lt;code&gt;StatisticService&lt;/code&gt;, &lt;code&gt;NotificationService&lt;/code&gt; 등 구체적인 클래스에 직접 의존합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;OCP 위반&lt;/b&gt;: 만약 &quot;좋아요 시 로깅&quot;이나 &quot;인기 피드 점수 반영&quot; 같은 새 기능이 추가되면, &lt;code&gt;LikeService&lt;/code&gt;의 코드를 &lt;b&gt;직접 수정&lt;/b&gt;해야 합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;책임 과다 (SRP 위반)&lt;/b&gt;: &lt;code&gt;LikeService&lt;/code&gt;는 '좋아요 생성'이라는 핵심 책임 외에 통계, 알림 등 수많은 부가 처리를 직접 담당하고 있었습니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1차 리팩토링: Spring Event와 'Fat Event'의 함정&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하기 위해 Spring의 &lt;code&gt;@EventListener&lt;/code&gt;를 도입했습니다. &lt;code&gt;LikeService&lt;/code&gt;는 오직 자신의 책임(DB 저장)만 다하고, 이벤트만 발행하도록 변경했습니다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// 1. 서비스는 이벤트만 발행
@Service
public class ReadingDiaryLikeService {
    // ...
    public void createDiaryLike(Long memberId, Long diaryId) {
        // ... DB 저장 ...

        // 이벤트 발행 후 자신의 책임 종료
        eventPublisher.publishEvent(new LikeEvent(
            diaryId, 
            1, // value
            diary.getMember().getId(), // diaryOwnerId
            liker.getNickname() // likerNickname
        ));
    }
}

// 2. 이벤트 객체
public record LikeEvent(
    Long diaryId, 
    int value, 
    Long diaryOwnerId, 
    String likerNickname
) {}

// 3. 통계 리스너
@EventListener
public void onLike(LikeEvent event) { 
    /* 통계 처리: diaryId, value, diaryOwnerId 사용 */ 
}

// 4. 알림 리스너
@EventListener
public void onLike(LikeEvent event) { 
    /* 알림 처리: diaryOwnerId, likerNickname 사용 */ 
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 &lt;code&gt;LikeService&lt;/code&gt; 코드를 수정하지 않고도 새로운 리스너를 추가할 수 있게 되었습니다. OCP를 준수하게 된 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;하지만&lt;/b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;'Fat Event'라는 새로운&amp;nbsp;&lt;/b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;문제를 발견했습니다. 통계 리스너는 &lt;/span&gt;&lt;code style=&quot;letter-spacing: 0px;&quot;&gt;likerNickname&lt;/code&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;이 필요 없는데, 알림 리스너 때문에 어쩔 수 없이 &lt;/span&gt;&lt;code style=&quot;letter-spacing: 0px;&quot;&gt;LikeEvent&lt;/code&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;가 이 데이터를 갖게 되었습니다.&lt;/span&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;code&gt;diaryTitle&lt;/code&gt;이 추가로 필요해진다면? &lt;code&gt;LikeEvent&lt;/code&gt;에 &lt;code&gt;diaryTitle&lt;/code&gt;이 추가되고, 이 데이터가 전혀 필요 없는 통계 리스너까지 알게 됩니다. 이로 인해 불필요한 정보가 개발자가 헷갈려 할 수 있습니다. 예를 들면, LikeEvent를 열어봤을 때 diaryTitle이 보입니다. &quot;이 diaryTitle은 뭐지? 통계에 써야 하나?&quot;라고 고민하게 만듭니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;해결을 위한 고민: 핸들러 패턴 vs 최소 ID 원칙&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;'Fat Event' 문제를 해결하기 위해 두 가지 방안을 고민했습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;❌ [방안 1] 중간 핸들러 패턴 (채택 안 함)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;LikeService&lt;/code&gt;가 범용 이벤트( &lt;code&gt;LikeChangedEvent&lt;/code&gt;)를 발행하면, 중간 핸들러가 이를 받아 각 리스너에 맞는 전용 이벤트( &lt;code&gt;LikeStatisticEvent&lt;/code&gt;, &lt;code&gt;LikeNotificationEvent&lt;/code&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;: 흐름 추적이 너무 복잡해집니다. (이벤트 발행 &amp;rarr; 핸들러 &amp;rarr; 재발행 &amp;rarr; 리스너) 이벤트와 클래스가 과도하게 증가하여 더 큰 복잡성을 만듭니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;✅ [방안 2] 최소 ID 원칙 (채택)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;필요하면, 필요한 쪽에서 직접 조회하도록 하면 최소한의 정보만 담을 수 있습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;이벤트는 '행위의 주체와 대상'을 나타내는 &lt;b&gt;최소한의 ID&lt;/b&gt;만 담는다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&quot;누가( &lt;code&gt;likerId&lt;/code&gt;), 무엇을( &lt;code&gt;diaryId&lt;/code&gt;), 어떻게( &lt;code&gt;value&lt;/code&gt;) 했다&quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;모든 리스너는 이 단일 이벤트를 구독한다.&lt;/li&gt;
&lt;li&gt;데이터가 더 필요한 리스너는, 받은 ID로 &lt;b&gt;직접 DB에서 조회&lt;/b&gt;한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;!-- end list --&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// 1. 순수 '최소 ID 원칙'을 따른 이벤트
public record LikeEvent(Long diaryId, Long likerId, int value) {}

// 2. 알림 리스너 - 필요한 데이터 직접 조회
@Async // (중요) 비동기 처리
@TransactionalEventListener
public void handleLikeNotification(LikeEvent event) {
    // 필요한 데이터를 이 시점에 직접 조회!
    ReadingDiary diary = readingDiaryRepository.findByIdOrElseThrow(event.diaryId());
    Member liker = memberRepository.findByIdOrElseThrow(event.likerId());

    NotificationMessage message = buildLikeNotificationMessage(diary.getMember(), liker);
    notificationSender.send(message);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 &quot;리스너에서 DB 조회를 해도 괜찮을까?&quot;라는 성능 우려가 생길 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리스너가 &lt;code&gt;@Async&lt;/code&gt;로 동작하기 때문에, DB 조회로 인한 약간의 지연이 발생하더라도 &lt;b&gt;사용자 요청 스레드를 막지 않습니다.&lt;/b&gt; 아키텍처의 단순성과 유연성이 주는 이점이 약간의 처리 지연보다 훨씬 크다고 판단했습니다.&lt;/p&gt;
&lt;hr 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;'최소 ID 원칙'을 적용하려던 순간, &lt;b&gt;고민&lt;/b&gt;이 생겼습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좋아요처럼 &lt;b&gt;빈번하게 발생하는 이벤트&lt;/b&gt;에서, 통계 리스너가 매번 통계 처리를 위해 &lt;code&gt;diaryOwnerId&lt;/code&gt;나 &lt;code&gt;bookId&lt;/code&gt;를 조회하는 것은 DB에 부담이 된다고 판단했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 원칙을 100% 맹신하는 대신, 성능을 위한 &lt;b&gt;실용적 트레이드오프&lt;/b&gt;를 적용했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;가장 빈번하게 사용되는 ID는 예외적으로 이벤트에 포함시키기록 결정하였습니다.&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;zephir&quot;&gt;&lt;code&gt;/**
 * '좋아요' 상태 변경 이벤트 (최종)
 *
 * @param diaryId 좋아요가 달린 독서일지 ID
 * @param diaryOwnerId 독서일지 작성자 ID (성능 최적화를 위해 추가)
 * @param bookId 독서일지가 속한 도서 ID (성능 최적화를 위해 추가)
 * @param likerId 좋아요를 누른 사용자 ID
 * @param likeIncrement 좋아요 생성: 1, 좋아요 취소: -1
 */
public record LikeEvent(
    Long diaryId,
    Long diaryOwnerId,  // 순수 원칙에서는 불필요하지만 성능을 위해 추가
    Long bookId,        // 순수 원칙에서는 불필요하지만 성능을 위해 추가
    Long likerId,
    int likeIncrement
) {}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 트레이드오프가 각 리스너에 미친 영향은 다음과 같습니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. 통계 리스너 (성능 이득)&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;통계 리스너는 &lt;code&gt;diaryOwnerId&lt;/code&gt;와 &lt;code&gt;bookId&lt;/code&gt;가 반드시 필요했습니다. 이 ID들을 이벤트에서 직접 전달받아 DB 조회 없이 즉시 통계 처리가 가능해졌습니다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;// 통계 리스너 - 성능 이득
@Async
@TransactionalEventListener
public void handleLikeEvent(LikeEvent event) {
    if (event.likeIncrement() &amp;gt; 0) {
        readingDiaryStatisticService.incrementCount(event.diaryId(), CountType.LIKE);

        // bookId와 diaryOwnerId를 이벤트에서 직접 받아 DB 조회 없이 처리
        popularDiaryFeedManager.increaseScore(
            event.diaryOwnerId(),
            event.bookId(),
            event.diaryId()
        );
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. 알림 리스너 (유연성 유지)&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;알림 리스너는 &lt;code&gt;bookId&lt;/code&gt;는 필요 없었지만, 이벤트에 포함되어 있으니 어쩔 수 없이 받게 되었지만, 여전히 유지보수성은 좋습니다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// 알림 리스너 - 원칙 유지
@Async
@TransactionalEventListener
public void handleLikeNotification(LikeEvent event) {
    // bookId, diaryOwnerId는 받지만 사용하지 않음 (사소한 손해)

    // 알림에 필요한 데이터는 원칙대로 직접 조회
    ReadingDiary diary = readingDiaryRepository.findByIdOrElseThrow(event.diaryId());
    Member liker = memberRepository.findByIdOrElseThrow(event.likerId());

    NotificationMessage message = buildLikeNotificationMessage(diary.getMember(), liker.getNickname);
    notificationSender.send(message);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 &quot;알림 메시지에 작성자 닉네임 대신 실명을 표시해달라&quot;는 요구사항이 와도, &lt;code&gt;LikeEvent&lt;/code&gt;는 건드릴 필요가 없습니다. &lt;code&gt;handleLikeNotification&lt;/code&gt; 메서드 내부의 조회 로직만 수정하면 됩니다. Fat Event 방식이었다면 의존성 오염과 개발자 인지적 부하를 초래했을 겁니다.&lt;/p&gt;
&lt;hr 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;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;OCP 준수&lt;/b&gt;: 도메인 간 결합도를 제거하여 새 기능(리스너) 추가 시 기존 코드(&lt;code&gt;LikeService&lt;/code&gt;) 수정이 불필요해졌습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;유지보수성 향상&lt;/b&gt;: 이벤트 구조가 단순화되고 리스너별 책임이 명확해졌습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;성능과 원칙의 균형&lt;/b&gt;: 빈번한 통계 처리는 최적화하고, 알림 등 부가 기능은 유연성을 유지했습니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 리팩토링을 통해 &quot;코드에 '완벽한 정답'은 없다&quot;는 것을 다시 한번 경험했습니다. 원칙(최소 ID 원칙)을 이해하되 맹신하지 않고, 시스템의 특성과 요구사항(빈번한 통계 처리)에 맞춰 원칙의 순수성과 실용성 사이에서 최선의 트레이드오프를 찾아내는 것이 더 나은 개발자로 성장하는 과정임을 깨달았습니다.&lt;/p&gt;</description>
      <category>스프링</category>
      <category>Spring Fat Event</category>
      <category>Spring 강한 결합</category>
      <category>Spring 이벤트</category>
      <category>Spring 이벤트 리스너 결합</category>
      <category>Spring 이벤트 트레이드오프</category>
      <category>스프링 OCP</category>
      <category>스프링 리팩토링</category>
      <category>이벤트 기반 아키텍처</category>
      <category>최소 ID 원칙</category>
      <author>순원이</author>
      <guid isPermaLink="true">https://jsw5913.tistory.com/301</guid>
      <comments>https://jsw5913.tistory.com/301#entry301comment</comments>
      <pubDate>Thu, 23 Oct 2025 18:47:06 +0900</pubDate>
    </item>
  </channel>
</rss>