<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>kimfishes 님의 블로그</title>
    <link>https://kimfishes.tistory.com/</link>
    <description>kimfishes 님의 블로그 입니다.</description>
    <language>ko</language>
    <pubDate>Thu, 9 Apr 2026 01:41:36 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>kimfishes</managingEditor>
    <image>
      <title>kimfishes 님의 블로그</title>
      <url>https://tistory1.daumcdn.net/tistory/8229048/attach/05fabcfe35fb44b6b6491d05c41da8c2</url>
      <link>https://kimfishes.tistory.com</link>
    </image>
    <item>
      <title>PGVector Window 설치 방법</title>
      <link>https://kimfishes.tistory.com/18</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://kimfishes.tistory.com/17&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://kimfishes.tistory.com/17&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1775537988275&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;LLM (Ollama) RAG 구현 전 pgvector VS Qdrant 중 무엇이 좋을까&quot; data-og-description=&quot;https://kimfishes.tistory.com/3 SpringAI 사용해서 LLM (Ollama) 연결 + Resilience4j 도입 (Spring Boot)고객센터에 사용자가 문의 게시물을 작성하면 관리자에게 디스코드로 알림이 가고, 관리자가 답변을 달아주기&quot; data-og-host=&quot;kimfishes.tistory.com&quot; data-og-source-url=&quot;https://kimfishes.tistory.com/17&quot; data-og-url=&quot;https://kimfishes.tistory.com/17&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/dl9axI/dJMb83SjAt7/ko10pe9fst2Up1j6qVS021/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/eVvdn/dJMb85vPJyD/PosS25IOr8RxBx3idGZONk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800&quot;&gt;&lt;a href=&quot;https://kimfishes.tistory.com/17&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://kimfishes.tistory.com/17&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/dl9axI/dJMb83SjAt7/ko10pe9fst2Up1j6qVS021/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/eVvdn/dJMb85vPJyD/PosS25IOr8RxBx3idGZONk/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800');&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;LLM (Ollama) RAG 구현 전 pgvector VS Qdrant 중 무엇이 좋을까&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;https://kimfishes.tistory.com/3 SpringAI 사용해서 LLM (Ollama) 연결 + Resilience4j 도입 (Spring Boot)고객센터에 사용자가 문의 게시물을 작성하면 관리자에게 디스코드로 알림이 가고, 관리자가 답변을 달아주기&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;kimfishes.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;구현하려는 전체 구조&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;[사용자] &amp;rarr; [백엔드 (Spring)] &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;darr; &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;[Embedding&amp;nbsp;생성&amp;nbsp;(Ollama)] &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;darr; &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;[PostgreSQL&amp;nbsp;+&amp;nbsp;pgvector] &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;darr; &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;[유사도&amp;nbsp;검색] &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;darr; &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;[응답 생성]&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;PGVector 설치 (Window 설치법)&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- PostgreSQL은 이미 설치되어 있으므로 PGVvector의 추가 설치법만 작성합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 현재 설치법은 Window OS 설치법이므로 Linux는 git에 접속하여 설치법 대로 따라하시면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/pgvector/pgvector&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/pgvector/pgvector&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1775544362395&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - pgvector/pgvector: Open-source vector similarity search for Postgres&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&quot; data-og-url=&quot;https://github.com/pgvector/pgvector&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/dhET1Q/dJMb82eNMMc/o55TGxi2KlzdK9OLbywUh0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/btu77N/dJMb9jgx1jU/s5TbS5tvxBPnVfpfbIj3A1/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&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/pgvector/pgvector&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/dhET1Q/dJMb82eNMMc/o55TGxi2KlzdK9OLbywUh0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/btu77N/dJMb9jgx1jU/s5TbS5tvxBPnVfpfbIj3A1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - pgvector/pgvector: Open-source vector similarity search for Postgres&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;&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;- 윈도우 검색창에 x64 Native Tools Command Prompt for VS 2022 검색&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;770&quot; data-origin-height=&quot;477&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/B0cpC/dJMcadBuLa3/WWVFB2rmIFdmwm8fw6J37k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/B0cpC/dJMcadBuLa3/WWVFB2rmIFdmwm8fw6J37k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/B0cpC/dJMcadBuLa3/WWVFB2rmIFdmwm8fw6J37k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FB0cpC%2FdJMcadBuLa3%2FWWVFB2rmIFdmwm8fw6J37k%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;770&quot; height=&quot;477&quot; data-origin-width=&quot;770&quot; data-origin-height=&quot;477&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- nmake /? 이라고 명령을 입력했을 때 이러한 결과처럼 나온다면 C++ 설치 필요&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;597&quot; data-origin-height=&quot;69&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/oSIn7/dJMcajobf9f/0Bf0IWndCmFe44ZikHkWo0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/oSIn7/dJMcajobf9f/0Bf0IWndCmFe44ZikHkWo0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/oSIn7/dJMcajobf9f/0Bf0IWndCmFe44ZikHkWo0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FoSIn7%2FdJMcajobf9f%2F0Bf0IWndCmFe44ZikHkWo0%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;597&quot; height=&quot;69&quot; data-origin-width=&quot;597&quot; data-origin-height=&quot;69&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 이번에도 윈도우에서 Visual Studio Installer를 검색 후&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;415&quot; data-start=&quot;400&quot; data-ke-size=&quot;size16&quot;&gt;Installer가 열리면&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;517&quot; data-start=&quot;417&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;474&quot; data-start=&quot;417&quot; data-section-id=&quot;1cwvxbi&quot;&gt;설치된 &lt;b&gt;Visual Studio 2022 Community&lt;/b&gt; 옆에 수정(Modify) 클릭&lt;/li&gt;
&lt;li data-end=&quot;512&quot; data-start=&quot;475&quot; data-section-id=&quot;s74zio&quot;&gt;&lt;b&gt;Desktop development with C++&lt;/b&gt; 체크&lt;/li&gt;
&lt;li data-end=&quot;517&quot; data-start=&quot;513&quot; data-section-id=&quot;yiln4k&quot;&gt;설치&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;773&quot; data-origin-height=&quot;430&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nv84l/dJMcabDLzHz/2t1gk7AJSPQvKX0IjSGGk0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nv84l/dJMcabDLzHz/2t1gk7AJSPQvKX0IjSGGk0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nv84l/dJMcabDLzHz/2t1gk7AJSPQvKX0IjSGGk0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fnv84l%2FdJMcabDLzHz%2F2t1gk7AJSPQvKX0IjSGGk0%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;773&quot; height=&quot;430&quot; data-origin-width=&quot;773&quot; data-origin-height=&quot;430&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;863&quot; data-origin-height=&quot;391&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cHiu8Y/dJMcag58X4s/xV35GgEJ6Ch4gNajTqOSF1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cHiu8Y/dJMcag58X4s/xV35GgEJ6Ch4gNajTqOSF1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cHiu8Y/dJMcag58X4s/xV35GgEJ6Ch4gNajTqOSF1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcHiu8Y%2FdJMcag58X4s%2FxV35GgEJ6Ch4gNajTqOSF1%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;629&quot; height=&quot;285&quot; data-origin-width=&quot;863&quot; data-origin-height=&quot;391&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 설치 후 nmake /? 이러한 화면이 나오면 성공&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;631&quot; data-origin-height=&quot;422&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/woc6z/dJMcacim5zd/gfToezq4eoKz45LAxDZcr0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/woc6z/dJMcacim5zd/gfToezq4eoKz45LAxDZcr0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/woc6z/dJMcacim5zd/gfToezq4eoKz45LAxDZcr0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fwoc6z%2FdJMcacim5zd%2FgfToezq4eoKz45LAxDZcr0%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;631&quot; height=&quot;422&quot; data-origin-width=&quot;631&quot; data-origin-height=&quot;422&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- git에 쓰여진 대로 입력해주면 설치 끝&lt;/p&gt;
&lt;pre id=&quot;code_1775544281078&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;set &quot;PGROOT=C:\Program Files\PostgreSQL\18&quot;
cd %TEMP%
git clone --branch v0.8.2 https://github.com/pgvector/pgvector.git
cd pgvector
nmake /F Makefile.win
nmake /F Makefile.win install&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PostgreSQL에 터미널로 접속하거나 pgAdmin을 사용해 PostgreSQL에 &quot;벡터 기능&quot; 추가&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- CREATE&amp;nbsp;EXTENSION&amp;nbsp;vector;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;616&quot; data-origin-height=&quot;445&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c0eRqh/dJMcacim58Z/Pkr2DWhko7o7Mvqtd67B61/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c0eRqh/dJMcacim58Z/Pkr2DWhko7o7Mvqtd67B61/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c0eRqh/dJMcacim58Z/Pkr2DWhko7o7Mvqtd67B61/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc0eRqh%2FdJMcacim58Z%2FPkr2DWhko7o7Mvqtd67B61%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;616&quot; height=&quot;445&quot; data-origin-width=&quot;616&quot; data-origin-height=&quot;445&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;388&quot; data-origin-height=&quot;620&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mvLei/dJMcajhrLyx/irZNe2XSgOh8YBYDcvS6Ak/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mvLei/dJMcajhrLyx/irZNe2XSgOh8YBYDcvS6Ak/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mvLei/dJMcajhrLyx/irZNe2XSgOh8YBYDcvS6Ak/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmvLei%2FdJMcajhrLyx%2FirZNe2XSgOh8YBYDcvS6Ak%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;388&quot; height=&quot;620&quot; data-origin-width=&quot;388&quot; data-origin-height=&quot;620&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;</description>
      <category>Spring Boot/LLM</category>
      <category>pgvector</category>
      <category>PostgreSQL</category>
      <author>kimfishes</author>
      <guid isPermaLink="true">https://kimfishes.tistory.com/18</guid>
      <comments>https://kimfishes.tistory.com/18#entry18comment</comments>
      <pubDate>Tue, 7 Apr 2026 16:01:06 +0900</pubDate>
    </item>
    <item>
      <title>RAG 구현 전 Vector DB 선택 (PGVector VS Qdrant 중 무엇이 좋을까?)</title>
      <link>https://kimfishes.tistory.com/17</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://kimfishes.tistory.com/3&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://kimfishes.tistory.com/3&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1771840984387&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;SpringAI 사용해서 LLM (Ollama) 연결 + Resilience4j 도입  (Spring Boot)&quot; data-og-description=&quot;고객센터에 사용자가 문의 게시물을 작성하면 관리자에게 디스코드로 알림이 가고, 관리자가 답변을 달아주기 전 AI가 먼저 문의글에 맞게 답변을 달아주는 기능을 구현하려고 한다.디스코드(Di&quot; data-og-host=&quot;kimfishes.tistory.com&quot; data-og-source-url=&quot;https://kimfishes.tistory.com/3&quot; data-og-url=&quot;https://kimfishes.tistory.com/3&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/dtVVCF/dJMb86OYIDX/4mBQiikchazLWTuEYNKYZK/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/b3gR9i/dJMb82eJWtm/yDIYHWjhqWmzOz2kOf0lG1/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/ceDr69/dJMb85WQbbw/f0gUK3lhuWPnWpFQfRZ5s0/img.png?width=1370&amp;amp;height=668&amp;amp;face=0_0_1370_668&quot;&gt;&lt;a href=&quot;https://kimfishes.tistory.com/3&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://kimfishes.tistory.com/3&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/dtVVCF/dJMb86OYIDX/4mBQiikchazLWTuEYNKYZK/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/b3gR9i/dJMb82eJWtm/yDIYHWjhqWmzOz2kOf0lG1/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/ceDr69/dJMb85WQbbw/f0gUK3lhuWPnWpFQfRZ5s0/img.png?width=1370&amp;amp;height=668&amp;amp;face=0_0_1370_668');&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;SpringAI 사용해서 LLM (Ollama) 연결 + Resilience4j 도입 (Spring Boot)&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;고객센터에 사용자가 문의 게시물을 작성하면 관리자에게 디스코드로 알림이 가고, 관리자가 답변을 달아주기 전 AI가 먼저 문의글에 맞게 답변을 달아주는 기능을 구현하려고 한다.디스코드(Di&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;kimfishes.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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-end=&quot;249&quot; data-start=&quot;182&quot; data-ke-size=&quot;size16&quot;&gt;기존 Ollama로 LLM을 사용하던 상황에서도 기본적인 대화는 가능하다.&lt;br /&gt;하지만&amp;nbsp;예를 들어:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;317&quot; data-start=&quot;259&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;279&quot; data-start=&quot;259&quot;&gt;&amp;ldquo;고객센터 몇 시에 종료되나요?&amp;rdquo;&lt;/li&gt;
&lt;li data-end=&quot;298&quot; data-start=&quot;280&quot;&gt;&amp;ldquo;환불은 언제까지 가능하죠?&amp;rdquo;&lt;/li&gt;
&lt;li data-end=&quot;317&quot; data-start=&quot;299&quot;&gt;&amp;ldquo;구독 해지는 바로 되나요?&amp;rdquo;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;349&quot; data-start=&quot;319&quot; data-ke-size=&quot;size16&quot;&gt;이 질문들은 &lt;b&gt;정확한 정책/운영 정보&lt;/b&gt;를 요구한다.&lt;/p&gt;
&lt;p data-end=&quot;356&quot; data-start=&quot;351&quot; data-ke-size=&quot;size16&quot;&gt;LLM은:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;436&quot; data-start=&quot;357&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;374&quot; data-start=&quot;357&quot;&gt;일반적인 지식은 잘 답하지만&lt;/li&gt;
&lt;li data-end=&quot;394&quot; data-start=&quot;375&quot;&gt;우리 회사의 최신 정책은 모른다&lt;/li&gt;
&lt;li data-end=&quot;436&quot; data-start=&quot;395&quot;&gt;잘못된 정보를 그럴듯하게 만들어낼 수 있다 (hallucination)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;468&quot; data-start=&quot;438&quot; data-ke-size=&quot;size16&quot;&gt;즉, 단순히 &amp;ldquo;학습시킨다&amp;rdquo;는 개념으로 해결할 수 없다.&lt;/p&gt;
&lt;p data-end=&quot;495&quot; data-start=&quot;470&quot; data-ke-size=&quot;size16&quot;&gt;그래서 등장하는 개념이 바로 &lt;b&gt;RAG&lt;/b&gt;다.&lt;/p&gt;
&lt;p data-end=&quot;495&quot; data-start=&quot;470&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;495&quot; data-start=&quot;470&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;495&quot; data-start=&quot;470&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;495&quot; data-start=&quot;470&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-end=&quot;519&quot; data-start=&quot;502&quot; data-ke-size=&quot;size26&quot;&gt;RAG란 무엇인가&lt;/h2&gt;
&lt;p data-end=&quot;557&quot; data-start=&quot;521&quot; data-ke-size=&quot;size16&quot;&gt;RAG(Retrieval-Augmented Generation)는&lt;/p&gt;
&lt;blockquote data-end=&quot;605&quot; data-start=&quot;559&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;605&quot; data-start=&quot;561&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;LLM이 답을 만들기 전에, 먼저 관련 정보를 검색해서 참고하게 만드는 구조&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-end=&quot;620&quot; data-start=&quot;611&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;729&quot; data-start=&quot;622&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;635&quot; data-start=&quot;622&quot;&gt;사용자가 질문한다.&lt;/li&gt;
&lt;li data-end=&quot;652&quot; data-start=&quot;636&quot;&gt;질문을 벡터로 변환한다.&lt;/li&gt;
&lt;li data-end=&quot;673&quot; data-start=&quot;653&quot;&gt;의미가 비슷한 문서를 검색한다.&lt;/li&gt;
&lt;li data-end=&quot;699&quot; data-start=&quot;674&quot;&gt;검색된 문서를 LLM 프롬프트에 넣는다.&lt;/li&gt;
&lt;li data-end=&quot;729&quot; data-start=&quot;700&quot;&gt;LLM이 해당 문서를 기반으로 답변을 생성한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-end=&quot;733&quot; data-start=&quot;731&quot; data-ke-size=&quot;size16&quot;&gt;즉,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;795&quot; data-start=&quot;735&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;749&quot; data-start=&quot;735&quot;&gt;LLM = 답변 생성기&lt;/li&gt;
&lt;li data-end=&quot;775&quot; data-start=&quot;750&quot;&gt;Vector DB = 의미 기반 검색 엔진&lt;/li&gt;
&lt;li data-end=&quot;795&quot; data-start=&quot;776&quot;&gt;DB/파일 = 정답 원문 저장소&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;814&quot; data-start=&quot;797&quot; data-ke-size=&quot;size16&quot;&gt;이 세 가지가 조합되는 구조다.&lt;/p&gt;
&lt;h1 data-end=&quot;842&quot; data-start=&quot;821&quot;&gt;&amp;nbsp;&lt;/h1&gt;
&lt;h2 data-end=&quot;842&quot; data-start=&quot;821&quot; data-ke-size=&quot;size26&quot;&gt;왜 Vector DB가 필요한가&lt;/h2&gt;
&lt;p data-end=&quot;863&quot; data-start=&quot;844&quot; data-ke-size=&quot;size16&quot;&gt;기존 LIKE 검색으로는 부족하다.&lt;/p&gt;
&lt;p data-end=&quot;885&quot; data-start=&quot;865&quot; data-ke-size=&quot;size16&quot;&gt;FAQ에 이런 문장이 있다고 해보자.&lt;/p&gt;
&lt;blockquote data-end=&quot;932&quot; data-start=&quot;887&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;932&quot; data-start=&quot;889&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;고객센터 게시물에 계좌번호와 성함을 남기시면 2~3일 내 환불 처리됩니다.&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-end=&quot;941&quot; data-start=&quot;934&quot; data-ke-size=&quot;size16&quot;&gt;사용자 질문:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1004&quot; data-start=&quot;943&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;959&quot; data-start=&quot;943&quot;&gt;&amp;ldquo;환불은 어떻게 하나요?&amp;rdquo;&lt;/li&gt;
&lt;li data-end=&quot;983&quot; data-start=&quot;960&quot;&gt;&amp;ldquo;돈 돌려받으려면 어디에 써야 돼요?&amp;rdquo;&lt;/li&gt;
&lt;li data-end=&quot;1004&quot; data-start=&quot;984&quot;&gt;&amp;ldquo;결제 취소하면 언제 입금돼요?&amp;rdquo;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1031&quot; data-start=&quot;1006&quot; data-ke-size=&quot;size16&quot;&gt;키워드 검색은 표현이 조금만 달라져도 놓친다.&lt;/p&gt;
&lt;p data-end=&quot;1043&quot; data-start=&quot;1033&quot; data-ke-size=&quot;size16&quot;&gt;반면 벡터 검색은:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1072&quot; data-start=&quot;1045&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1051&quot; data-start=&quot;1045&quot;&gt;&amp;ldquo;환불&amp;rdquo;&lt;/li&gt;
&lt;li data-end=&quot;1062&quot; data-start=&quot;1052&quot;&gt;&amp;ldquo;돈 돌려받다&amp;rdquo;&lt;/li&gt;
&lt;li data-end=&quot;1072&quot; data-start=&quot;1063&quot;&gt;&amp;ldquo;결제 취소&amp;rdquo;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1095&quot; data-start=&quot;1074&quot; data-ke-size=&quot;size16&quot;&gt;를 &lt;b&gt;의미적으로 가깝게 인식&lt;/b&gt;한다.&lt;/p&gt;
&lt;p data-end=&quot;1144&quot; data-start=&quot;1097&quot; data-ke-size=&quot;size16&quot;&gt;문장을 숫자 벡터로 변환하고, 코사인 유사도 기반으로 가장 가까운 문서를 찾는다.&lt;/p&gt;
&lt;p data-end=&quot;1183&quot; data-start=&quot;1146&quot; data-ke-size=&quot;size16&quot;&gt;Vector DB는 이 벡터 저장/검색을 빠르게 처리해 주는 DB다.&lt;/p&gt;
&lt;h4 data-end=&quot;1218&quot; data-start=&quot;1190&quot; data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-end=&quot;1218&quot; data-start=&quot;1190&quot; data-ke-size=&quot;size26&quot;&gt;이때 답변 데이터는 어디에 저장해야 하는가?&lt;/h2&gt;
&lt;p data-end=&quot;1283&quot; data-start=&quot;1240&quot; data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;color: #000000; background-color: #f3c000;&quot;&gt;&lt;u&gt;&lt;b&gt;정답이 절대 틀리면 안 되는 정책은 반드시 정형 DB에 저장해야 한다.&lt;/b&gt;&lt;/u&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;1287&quot; data-start=&quot;1285&quot; data-ke-size=&quot;size16&quot;&gt;예:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1336&quot; data-start=&quot;1289&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1299&quot; data-start=&quot;1289&quot;&gt;환불 가능 기간&lt;/li&gt;
&lt;li data-end=&quot;1307&quot; data-start=&quot;1300&quot;&gt;운영 시간&lt;/li&gt;
&lt;li data-end=&quot;1312&quot; data-start=&quot;1308&quot;&gt;약관&lt;/li&gt;
&lt;li data-end=&quot;1324&quot; data-start=&quot;1313&quot;&gt;적용 시작/종료일&lt;/li&gt;
&lt;li data-end=&quot;1336&quot; data-start=&quot;1325&quot;&gt;회원 등급별 정책&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;&lt;b&gt;DB&lt;/b&gt; = 정책 원문 + 메타데이터 (정답 SSOT)&lt;/span&gt;&lt;br /&gt;&lt;span&gt;&lt;b&gt;Vector DB&lt;/b&gt; = 검색 인덱스 (임베딩) 사용&lt;/span&gt;&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;1445&quot; data-start=&quot;1434&quot; 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-end=&quot;1553&quot; data-start=&quot;1447&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;1460&quot; data-start=&quot;1447&quot;&gt;정책을 DB에 저장&lt;/li&gt;
&lt;li data-end=&quot;1478&quot; data-start=&quot;1461&quot;&gt;정책 텍스트를 임베딩 생성&lt;/li&gt;
&lt;li data-end=&quot;1499&quot; data-start=&quot;1479&quot;&gt;벡터를 Vector DB에 저장&lt;/li&gt;
&lt;li data-end=&quot;1527&quot; data-start=&quot;1500&quot;&gt;질문이 오면 Vector DB에서 후보 검색&lt;/li&gt;
&lt;li data-end=&quot;1553&quot; data-start=&quot;1528&quot;&gt;최종 출력은 DB의 최신 활성 정책 사용&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-end=&quot;1597&quot; data-start=&quot;1555&quot; data-ke-size=&quot;size16&quot;&gt;즉, &lt;b&gt;Vector DB는 검색용 인덱스일 뿐, 정답 저장소가 아니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;1597&quot; data-start=&quot;1555&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1597&quot; data-start=&quot;1555&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1597&quot; data-start=&quot;1555&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-end=&quot;1632&quot; data-start=&quot;1604&quot; data-ke-size=&quot;size26&quot;&gt;pgvector vs Qdrant&lt;/h2&gt;
&lt;p data-end=&quot;1651&quot; data-start=&quot;1634&quot; data-ke-size=&quot;size16&quot;&gt;Vector DB를 위해 이 둘을 선택하는 과정에서 고민이 발생했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1698&quot; data-start=&quot;1653&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1674&quot; data-start=&quot;1653&quot;&gt;Postgres + pgvector&lt;/li&gt;
&lt;li data-end=&quot;1698&quot; data-start=&quot;1675&quot;&gt;Qdrant (전용 Vector DB)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1717&quot; data-start=&quot;1700&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1717&quot; data-start=&quot;1700&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1717&quot; data-start=&quot;1700&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-end=&quot;1754&quot; data-start=&quot;1724&quot; data-ke-size=&quot;size26&quot;&gt;벤치마크&amp;nbsp;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.tigerdata.com/blog/pgvector-vs-qdrant&quot;&gt;https://www.tigerdata.com/blog/pgvector-vs-qdrant&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1792&quot; data-start=&quot;1756&quot; data-ke-size=&quot;size16&quot;&gt;이 블로그의 벤치마크를 보면 결론은 의외로 단순하다.&lt;/p&gt;
&lt;h3 data-end=&quot;1811&quot; data-start=&quot;1794&quot; data-ke-size=&quot;size23&quot;&gt;사용 패턴에 따라 달라지지만 둘 사이에 성능 차이는 크지 않다.&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1923&quot; data-start=&quot;1813&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1844&quot; data-start=&quot;1813&quot;&gt;Qdrant는 단일 쿼리 latency 측면에서 강점&lt;/li&gt;
&lt;li data-end=&quot;1891&quot; data-start=&quot;1845&quot;&gt;pgvector(+pgvectorscale)는 throughput 측면에서 강점&lt;/li&gt;
&lt;li data-end=&quot;1923&quot; data-start=&quot;1892&quot;&gt;대규모(수천만 벡터) 환경에서도 둘 다 충분히 고성능&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-end=&quot;1986&quot; data-start=&quot;1929&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;1986&quot; data-start=&quot;1931&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;Qdrant가 압도적으로 빠르다&amp;rdquo;도 아니고&lt;br /&gt;&amp;ldquo;pgvector가 압도적으로 낫다&amp;rdquo;도 아니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-end=&quot;2007&quot; data-start=&quot;1988&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-end=&quot;2012&quot; data-start=&quot;2009&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h1 data-end=&quot;2040&quot; data-start=&quot;2014&quot;&gt;만약 나의 상황이 MySQL만 사용 중이라면?&lt;/h1&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2131&quot; data-start=&quot;2051&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2068&quot; data-start=&quot;2051&quot;&gt;현재 메인 DB는 MySQL&lt;/li&gt;
&lt;li data-end=&quot;2104&quot; data-start=&quot;2069&quot;&gt;&lt;u&gt;&lt;b&gt;pgvector를 쓰려면 Postgres를 새로 띄워야 한다&lt;/b&gt;&lt;/u&gt;&lt;/li&gt;
&lt;li data-end=&quot;2131&quot; data-start=&quot;2105&quot;&gt;&lt;u&gt;&lt;b&gt;Qdrant를 써도 서버를 새로 띄워야 한다&lt;/b&gt;&lt;/u&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;2136&quot; data-start=&quot;2133&quot; data-ke-size=&quot;size16&quot;&gt;결론:&lt;/p&gt;
&lt;blockquote data-end=&quot;2156&quot; data-start=&quot;2138&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;2156&quot; data-start=&quot;2140&quot; data-ke-size=&quot;size16&quot;&gt;어차피 서버 하나는 추가된다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-end=&quot;2201&quot; data-start=&quot;2158&quot; data-ke-size=&quot;size16&quot;&gt;그렇다면 선택 기준은 &amp;ldquo;성능&amp;rdquo;이 아니라 &lt;b&gt;역할 분리와 단순성&lt;/b&gt;이 된다.&lt;/p&gt;
&lt;p data-end=&quot;2201&quot; data-start=&quot;2158&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2201&quot; data-start=&quot;2158&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2201&quot; data-start=&quot;2158&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2201&quot; data-start=&quot;2158&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h1 data-end=&quot;2231&quot; data-start=&quot;2208&quot;&gt;Qdrant를 선택하는 경우 장점은?&lt;/h1&gt;
&lt;h3 data-end=&quot;2254&quot; data-start=&quot;2233&quot; data-ke-size=&quot;size23&quot;&gt;1) 벡터 검색에만 집중된 구조&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2292&quot; data-start=&quot;2266&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2273&quot; data-start=&quot;2266&quot;&gt;벡터 저장&lt;/li&gt;
&lt;li data-end=&quot;2282&quot; data-start=&quot;2274&quot;&gt;유사도 검색&lt;/li&gt;
&lt;li data-end=&quot;2292&quot; data-start=&quot;2283&quot;&gt;ANN 인덱싱&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;2309&quot; data-start=&quot;2294&quot; data-ke-size=&quot;size16&quot;&gt;이러한 목적에 최적화된 DB로 불필요한 SQL 레이어가 없다.&lt;/p&gt;
&lt;p data-end=&quot;2309&quot; data-start=&quot;2294&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2309&quot; data-start=&quot;2294&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;2360&quot; data-start=&quot;2335&quot; data-ke-size=&quot;size23&quot;&gt;2) MySQL과 역할이 명확히 분리됨&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2406&quot; data-start=&quot;2362&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2388&quot; data-start=&quot;2362&quot;&gt;MySQL = 정책 원문 + 비즈니스 데이터&lt;/li&gt;
&lt;li data-end=&quot;2406&quot; data-start=&quot;2389&quot;&gt;Qdrant = 검색 인덱스&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;2421&quot; data-start=&quot;2408&quot; data-ke-size=&quot;size16&quot;&gt;도메인 분리가 명확하다.&lt;/p&gt;
&lt;p data-end=&quot;2421&quot; data-start=&quot;2408&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2421&quot; data-start=&quot;2408&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2421&quot; data-start=&quot;2408&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;2452&quot; data-start=&quot;2428&quot; data-ke-size=&quot;size23&quot;&gt;3) RAG에 바로 맞는 API 구조&lt;/h3&gt;
&lt;p data-end=&quot;2499&quot; data-start=&quot;2454&quot; data-ke-size=&quot;size16&quot;&gt;REST 기반으로 바로 연동 가능하고, Collection 개념도 직관적이다.&lt;/p&gt;
&lt;p data-end=&quot;2499&quot; data-start=&quot;2454&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2499&quot; data-start=&quot;2454&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2499&quot; data-start=&quot;2454&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;2529&quot; data-start=&quot;2506&quot; data-ke-size=&quot;size23&quot;&gt;4) 운영 부담이 크게 다르지 않음&lt;/h3&gt;
&lt;p data-end=&quot;2563&quot; data-start=&quot;2531&quot; data-ke-size=&quot;size16&quot;&gt;pgvector를 쓰려면 Postgres를 도입해야 한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2592&quot; data-start=&quot;2565&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2569&quot; data-start=&quot;2565&quot;&gt;백업&lt;/li&gt;
&lt;li data-end=&quot;2576&quot; data-start=&quot;2570&quot;&gt;모니터링&lt;/li&gt;
&lt;li data-end=&quot;2584&quot; data-start=&quot;2577&quot;&gt;권한 관리&lt;/li&gt;
&lt;li data-end=&quot;2592&quot; data-start=&quot;2585&quot;&gt;운영 전략&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;2647&quot; data-start=&quot;2594&quot; data-ke-size=&quot;size16&quot;&gt;어차피 새로운 DB를 관리해야 한다면, 벡터 전용 DB를 두는 것이 구조적으로 더 명확하다라고도 볼 수 있음&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div id=&quot;code-block-viewer&quot;&gt;
&lt;div&gt;
&lt;div&gt;&lt;span&gt;Spring Boot&lt;/span&gt;&lt;br /&gt;&lt;span&gt; ├─ MySQL (정책/정답 저장)&lt;/span&gt;&lt;br /&gt;&lt;span&gt; ├─ Qdrant (벡터 검색 인덱스)&lt;/span&gt;&lt;br /&gt;&lt;span&gt; └─ Ollama (LLM)&lt;/span&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-end=&quot;2754&quot; data-start=&quot;2751&quot; data-ke-size=&quot;size16&quot;&gt;흐름:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;2860&quot; data-start=&quot;2756&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;2772&quot; data-start=&quot;2756&quot;&gt;정책은 MySQL에 저장&lt;/li&gt;
&lt;li data-end=&quot;2795&quot; data-start=&quot;2773&quot;&gt;임베딩 생성 후 Qdrant에 저장&lt;/li&gt;
&lt;li data-end=&quot;2820&quot; data-start=&quot;2796&quot;&gt;질문 들어오면 Qdrant로 후보 검색&lt;/li&gt;
&lt;li data-end=&quot;2843&quot; data-start=&quot;2821&quot;&gt;MySQL에서 최신 활성 정책 조회&lt;/li&gt;
&lt;li data-end=&quot;2860&quot; data-start=&quot;2844&quot;&gt;Ollama가 답변 생성&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Spring Boot/LLM</category>
      <category>LLM</category>
      <category>ollama</category>
      <category>pgvector</category>
      <category>Qdrant</category>
      <category>rag</category>
      <author>kimfishes</author>
      <guid isPermaLink="true">https://kimfishes.tistory.com/17</guid>
      <comments>https://kimfishes.tistory.com/17#entry17comment</comments>
      <pubDate>Tue, 7 Apr 2026 13:57:32 +0900</pubDate>
    </item>
    <item>
      <title>Spring Boot 4에서 외부 API 호출 무엇을 선택해야 할까?</title>
      <link>https://kimfishes.tistory.com/16</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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;Spring Boot에서 외부 API를 호출해야 하는 경우 RestClient vs WebClient vs Feign Client vs WebFlux 중 무엇을 선택해야 좋을지 궁금해서 끄적여 보았습니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;1. RestTemplate은 왜 고려하지 않는지?&lt;/h3&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;과거에는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;RestTemplate&lt;/b&gt;이 기본 선택이었습니다. 하지만 현재는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;유지보수 모드&lt;/b&gt;이며, Spring 공식 문서에서도 신규 프로젝트에서는 사용을 권장하지 않음&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;따라서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Boot 4 기준&lt;/b&gt;에서는:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;RestTemplate &amp;rarr; 구버전&lt;/li&gt;
&lt;li&gt;RestClient / WebClient / Feign Client / WebFlux &amp;rarr; 사용 권장&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;2. RestClient란?&lt;/h3&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;RestClient&lt;/b&gt;는 Spring Framework 6.1부터 도입된 최신&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;동기 HTTP 클라이언트&lt;/b&gt;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;특징&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;동기 방식 (Blocking)&lt;/li&gt;
&lt;li&gt;코드가 간결하고 직관적&lt;/li&gt;
&lt;li&gt;Spring MVC와 궁합이 좋음&lt;/li&gt;
&lt;li&gt;Boot 4 공식 권장 방식&lt;/li&gt;
&lt;li&gt;빌더 패턴으로 가독성 우수&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;예시 코드&lt;/h4&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;RestClient restClient = RestClient.create();

KakaoUserInfo response = restClient.get()
        .uri(&quot;https://api.kakao.com/v2/user/me&quot;)
        .header(&quot;Authorization&quot;, &quot;Bearer &quot; + token)
        .retrieve()
        .body(KakaoUserInfo.class);&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;기존 RestTemplate보다 훨씬 가독성이 좋고 체이닝 방식이라 깔끔함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;3. WebClient란?&lt;/h3&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;WebClient&lt;/b&gt;는 Spring의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;리액티브(비동기, 논블로킹) HTTP 클라이언트&lt;/b&gt;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;특징&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;비동기(Non-blocking)&lt;/li&gt;
&lt;li&gt;대규모 트래픽에 유리&lt;/li&gt;
&lt;li&gt;병렬 API 호출 가능&lt;/li&gt;
&lt;li&gt;리액티브 아키텍처에 적합&lt;/li&gt;
&lt;li&gt;Reactor 기반 Mono/Flux 반환&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;예시 코드&lt;/h4&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;WebClient webClient = WebClient.create();

KakaoUserInfo response = webClient.get()
        .uri(&quot;https://api.kakao.com/v2/user/me&quot;)
        .header(&quot;Authorization&quot;, &quot;Bearer &quot; + token)
        .retrieve()
        .bodyToMono(KakaoUserInfo.class)
        .block();  // MVC에서는 block() 호출 필요&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;주의:&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;MVC 환경에서는 결국&lt;span&gt;&amp;nbsp;&lt;/span&gt;.block()을 호출해야 하므로 비동기의 장점이 줄어드는 경우가 많음&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;4. WebFlux란?&lt;/h3&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;WebFlux&lt;/b&gt;는 Spring이 제공하는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;완전한 리액티브 웹 프레임워크&lt;/b&gt;입니다. 단순한 HTTP 클라이언트가 아니라&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;서버 프레임워크&lt;/b&gt;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;특징&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;u&gt;&lt;b&gt;완전한 논블로킹/비동기 아키텍처&lt;/b&gt;&lt;/u&gt;&lt;/li&gt;
&lt;li&gt;Netty를 기반으로 한 리액티브 서버&lt;/li&gt;
&lt;li&gt;Mono/Flux로 스트림 처리&lt;/li&gt;
&lt;li&gt;MVC를 완전히 대체하는 선택지&lt;/li&gt;
&lt;li&gt;고성능, 저리소스 사용&lt;/li&gt;
&lt;li&gt;함수형 프로그래밍 지원&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;MVC vs WebFlux 아키텍처 비교&lt;/h4&gt;
&lt;table style=&quot;color: #000000; text-align: start; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;서버&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;Tomcat (Servlet 기반)&lt;/td&gt;
&lt;td&gt;Netty (리액티브 기반)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;스레드 모델&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;스레드 풀 (요청당 1 스레드)&lt;/td&gt;
&lt;td&gt;이벤트 루프 (스레드 수 최소화)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;동작 방식&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;블로킹&lt;/td&gt;
&lt;td&gt;논블로킹&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;메모리 사용&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;높음 (스레드당 메모리)&lt;/td&gt;
&lt;td&gt;낮음 (스레드 수 적음)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;동시 연결&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;제한적&lt;/td&gt;
&lt;td&gt;매우 많음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;학습곡선&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;낮음&lt;/td&gt;
&lt;td&gt;높음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;WebFlux 예시 코드 (서버)&lt;/h4&gt;
&lt;pre class=&quot;kotlin&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;@RestController
@RequestMapping(&quot;/api&quot;)
public class UserController {
    
    @Autowired
    private UserService userService;
    
    // Mono: 단일 값 반환
    @GetMapping(&quot;/users/{id}&quot;)
    public Mono&amp;lt;User&amp;gt; getUser(@PathVariable Long id) {
        return userService.findById(id);
    }
    
    // Flux: 여러 값 스트림 반환
    @GetMapping(&quot;/users&quot;)
    public Flux&amp;lt;User&amp;gt; getAllUsers() {
        return userService.findAll();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;WebFlux 예시 코드 (서비스)&lt;/h4&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;@Service
public class UserService {
    
    @Autowired
    private UserRepository userRepository;
    
    // 단일 값
    public Mono&amp;lt;User&amp;gt; findById(Long id) {
        return Mono.fromCallable(() -&amp;gt; userRepository.findById(id))
                .filterWhen(opt -&amp;gt; Mono.just(opt.isPresent()))
                .map(opt -&amp;gt; opt.get());
    }
    
    // 스트림 값
    public Flux&amp;lt;User&amp;gt; findAll() {
        return Flux.fromIterable(userRepository.findAll());
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;WebFlux + WebClient 조합 (병렬 처리)&lt;/h4&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;@Service
public class OrderService {
    
    private final WebClient webClient = WebClient.create();
    
    // Mono 반환 - 리액티브 완성
    public Mono&amp;lt;Order&amp;gt; createOrder(OrderRequest req) {
        Mono&amp;lt;Payment&amp;gt; paymentMono = webClient.post()
                .uri(&quot;http://payment-service/pay&quot;)
                .bodyValue(req.getPayment())
                .retrieve()
                .bodyToMono(Payment.class);
        
        Mono&amp;lt;Stock&amp;gt; stockMono = webClient.get()
                .uri(&quot;http://stock-service/check/{id}&quot;, req.getProductId())
                .retrieve()
                .bodyToMono(Stock.class);
        
        Mono&amp;lt;Shipping&amp;gt; shippingMono = webClient.get()
                .uri(&quot;http://shipping-service/quote&quot;)
                .retrieve()
                .bodyToMono(Shipping.class);
        
        // 세 개 API를 병렬로 호출 - 완전한 비동기 (block() 없음!)
        return Mono.zip(paymentMono, stockMono, shippingMono)
                .map(tuple -&amp;gt; new Order(tuple.getT1(), tuple.getT2(), tuple.getT3()));
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;주목:&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;WebFlux에서는&lt;span&gt;&amp;nbsp;&lt;/span&gt;.block()을 호출하지 않습니다. 완전한 논블로킹 구조를 유지&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;5. Feign Client란?&lt;/h3&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Feign Client&lt;/b&gt;는 Netflix가 개발한&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;선언적 HTTP 클라이언트&lt;/b&gt;로, Spring Cloud에서 제공&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;특징&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;인터페이스 기반 선언적 방식&lt;/li&gt;
&lt;li&gt;어노테이션으로 HTTP 요청 정의&lt;/li&gt;
&lt;li&gt;동기 방식 (Blocking)&lt;/li&gt;
&lt;li&gt;MSA 환경에서 표준처럼 사용&lt;/li&gt;
&lt;li&gt;자동 로드밸런싱 지원 (Eureka 연동 시)&lt;/li&gt;
&lt;li&gt;에러 처리 및 재시도 로직 자동화 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;의존성&lt;/h4&gt;
&lt;pre class=&quot;xml&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;&amp;lt;dependency&amp;gt;
    &amp;lt;groupId&amp;gt;org.springframework.cloud&amp;lt;/groupId&amp;gt;
    &amp;lt;artifactId&amp;gt;spring-cloud-starter-openfeign&amp;lt;/artifactId&amp;gt;
&amp;lt;/dependency&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;기본 예시 코드&lt;/h4&gt;
&lt;pre class=&quot;less&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;@FeignClient(name = &quot;kakao-api&quot;, url = &quot;https://api.kakao.com&quot;)
public interface KakaoFeignClient {
    
    @GetMapping(&quot;/v2/user/me&quot;)
    KakaoUserInfo getUserInfo(
        @RequestHeader(&quot;Authorization&quot;) String token
    );
}

// 사용
@Service
public class OAuthService {
    
    @Autowired
    private KakaoFeignClient kakaoClient;
    
    public KakaoUserInfo getKakaoUser(String token) {
        return kakaoClient.getUserInfo(&quot;Bearer &quot; + token);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;Feign의 강력한 기능&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 자동 로드밸런싱 (Eureka 연동)&lt;/p&gt;
&lt;pre class=&quot;less&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;@FeignClient(name = &quot;user-service&quot;)  // Eureka에서 자동 조회
public interface UserFeignClient {
    @GetMapping(&quot;/users/{id}&quot;)
    User getUser(@PathVariable Long id);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 재시도 로직 자동화&lt;/p&gt;
&lt;pre class=&quot;less&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;@FeignClient(name = &quot;payment-service&quot;, 
             configuration = FeignConfig.class)
public interface PaymentFeignClient {
    @GetMapping(&quot;/payment/{id}&quot;)
    Payment getPayment(@PathVariable Long id);
}

@Configuration
public class FeignConfig {
    @Bean
    public Retryer retryer() {
        return new Retryer.Default(100, 1000, 3);  // 3회 재시도
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 타임아웃 설정&lt;/p&gt;
&lt;pre class=&quot;java&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;@Configuration
public class FeignConfig {
    @Bean
    public Request.Options options() {
        return new Request.Options(
            5, TimeUnit.SECONDS,   // 연결 타임아웃
            5, TimeUnit.SECONDS,   // 읽기 타임아웃
            true                   // 리다이렉트 허용
        );
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. 에러 처리&lt;/p&gt;
&lt;pre class=&quot;haxe&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;@Component
public class FeignErrorDecoder implements ErrorDecoder {
    @Override
    public Exception decode(String methodKey, Response response) {
        if (response.status() == 404) {
            return new UserNotFoundException(&quot;User not found&quot;);
        }
        if (response.status() == 500) {
            return new ServerException(&quot;Server error&quot;);
        }
        return new FeignException(&quot;API call failed&quot;);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5. 완전한 설정 예시&lt;/p&gt;
&lt;pre class=&quot;less&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;@FeignClient(
    name = &quot;payment-service&quot;,
    url = &quot;http://payment-service:8080&quot;,
    configuration = PaymentFeignConfig.class
)
public interface PaymentFeignClient {
    
    @PostMapping(&quot;/payments&quot;)
    PaymentResponse processPayment(@RequestBody PaymentRequest request);
    
    @GetMapping(&quot;/payments/{id}&quot;)
    PaymentResponse getPayment(@PathVariable Long id);
}

@Configuration
public class PaymentFeignConfig {
    
    // 재시도 로직
    @Bean
    public Retryer retryer() {
        return new Retryer.Default(100, 1000, 3);
    }
    
    // 타임아웃
    @Bean
    public Request.Options options() {
        return new Request.Options(5, TimeUnit.SECONDS, 5, TimeUnit.SECONDS, true);
    }
    
    // 에러 처리
    @Bean
    public ErrorDecoder errorDecoder() {
        return new PaymentErrorDecoder();
    }
    
    // 로깅
    @Bean
    public Logger.Level feignLoggerLevel() {
        return Logger.Level.FULL;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;6. 무엇을 선택해야 할까?&lt;/h3&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;RestClient를 선택해야 하는 경우&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Spring MVC 기반 프로젝트&lt;/li&gt;
&lt;li&gt;OAuth 토큰 발급 / 유저 조회 같은 단순 API 호출&lt;/li&gt;
&lt;li&gt;외부 API 연동 (카카오, 구글, 네이버 등)&lt;/li&gt;
&lt;li&gt;복잡한 병렬 처리 필요 없음&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 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;WebClient를 선택해야 하는 경우&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;WebFlux 기반 리액티브 서버&lt;/li&gt;
&lt;li&gt;동시에 여러 외부 API 호출해야 함&lt;/li&gt;
&lt;li&gt;고트래픽 마이크로서비스&lt;/li&gt;
&lt;li&gt;논블로킹 구조가 필요한 경우&lt;/li&gt;
&lt;li&gt;대규모 배치 작업&lt;/li&gt;
&lt;li&gt;&lt;b&gt;MSA + 고성능 환경&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;WebFlux를 선택해야 하는 경우&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;완전한 리액티브 아키텍처 필요&lt;/li&gt;
&lt;li&gt;실시간 스트리밍 데이터 처리&lt;/li&gt;
&lt;li&gt;매우 높은 동시 연결 수 (10,000+)&lt;/li&gt;
&lt;li&gt;마이크로서비스 환경에서 응답 시간이 매우 중요&lt;/li&gt;
&lt;li&gt;함수형 프로그래밍 스타일 선호&lt;/li&gt;
&lt;li&gt;&lt;b&gt;고성능, 저리소스 마이크로서비스&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;학습곡선이 높을 수 있을 수도?&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;Feign Client를 선택해야 하는 경우&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;마이크로서비스 아키텍처 (MSA)&lt;/li&gt;
&lt;li&gt;서비스 간 통신이 주 목적&lt;/li&gt;
&lt;li&gt;Eureka 같은 서비스 디스커버리 사용&lt;/li&gt;
&lt;li&gt;선언적인 코드 스타일 선호&lt;/li&gt;
&lt;li&gt;재시도 로직이나 로드밸런싱 자동화 필요&lt;/li&gt;
&lt;li&gt;여러 개의 내부 API 호출&lt;/li&gt;
&lt;li&gt;&lt;b&gt;모놀리식 구조보다는 분산 환경&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;7. 실전 예시: 상황별 선택&lt;/h3&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;시나리오 1: 카카오 로그인 구현&lt;/h4&gt;
&lt;pre class=&quot;bash&quot; style=&quot;color: #000000; text-align: start;&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;// RestClient 사용
@Service
public class KakaoOAuthService {
    
    private final RestClient restClient = RestClient.create();
    
    public KakaoUserInfo getUserInfo(String token) {
        return restClient.get()
                .uri(&quot;https://api.kakao.com/v2/user/me&quot;)
                .header(&quot;Authorization&quot;, &quot;Bearer &quot; + token)
                .retrieve()
                .body(KakaoUserInfo.class);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이유:&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;단순 외부 API 호출, MVC 환경, 코드 간결성 필요&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;시나리오 2: 주문 시 동시에 결제 + 재고 확인 + 배송 조회&lt;/h4&gt;
&lt;pre class=&quot;bash&quot; style=&quot;color: #000000; text-align: start;&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;// WebClient 사용
@Service
public class OrderService {
    
    private final WebClient webClient = WebClient.create();
    
    public Order createOrder(OrderRequest req) {
        Mono&amp;lt;Payment&amp;gt; paymentMono = webClient.post()
                .uri(&quot;http://payment-service/pay&quot;)
                .bodyValue(req.getPayment())
                .retrieve()
                .bodyToMono(Payment.class);
        
        Mono&amp;lt;Stock&amp;gt; stockMono = webClient.get()
                .uri(&quot;http://stock-service/check/{id}&quot;, req.getProductId())
                .retrieve()
                .bodyToMono(Stock.class);
        
        Mono&amp;lt;Shipping&amp;gt; shippingMono = webClient.get()
                .uri(&quot;http://shipping-service/quote&quot;)
                .retrieve()
                .bodyToMono(Shipping.class);
        
        // 세 개 API를 병렬로 호출하고 모두 완료 대기
        return Mono.zip(paymentMono, stockMono, shippingMono)
                .map(tuple -&amp;gt; new Order(tuple.getT1(), tuple.getT2(), tuple.getT3()))
                .block();  // MVC에서 동기 반환
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이유:&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;병렬 API 호출, 대기 시간 최소화, 높은 동시성&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;시나리오 3: 실시간 주문 알림 시스템 (고동시성)&lt;/h4&gt;
&lt;pre class=&quot;bash&quot; style=&quot;color: #000000; text-align: start;&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;// WebFlux + WebClient 사용
@RestController
@RequestMapping(&quot;/api/orders&quot;)
public class OrderController {
    
    @Autowired
    private OrderService orderService;
    
    // Server-Sent Events (SSE) - 실시간 스트림
    @GetMapping(value = &quot;/stream&quot;, produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux&amp;lt;Order&amp;gt; getOrderStream() {
        return orderService.getOrderStream();
    }
    
    // WebSocket도 가능
    @GetMapping(&quot;/subscribe&quot;)
    public Mono&amp;lt;Order&amp;gt; subscribeOrder(@PathVariable Long id) {
        return orderService.findById(id);
    }
}

@Service
public class OrderService {
    
    private final WebClient webClient = WebClient.create();
    
    // Flux로 계속 데이터 스트림
    public Flux&amp;lt;Order&amp;gt; getOrderStream() {
        return Flux.interval(Duration.ofSeconds(1))  // 1초마다 실행
                .flatMap(i -&amp;gt; webClient.get()
                        .uri(&quot;http://order-service/latest&quot;)
                        .retrieve()
                        .bodyToMono(Order.class)
                )
                .retry(3);  // 실패 시 3회 재시도
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이유:&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;완전한 논블로킹, 실시간 스트리밍, 매우 높은 동시성 (10,000+ 연결)&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;시나리오 4: MSA 환경에서 주문 서비스가 결제 서비스 호출&lt;/h4&gt;
&lt;pre class=&quot;bash&quot; style=&quot;color: #000000; text-align: start;&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;// Feign Client 사용
@FeignClient(
    name = &quot;payment-service&quot;,
    url = &quot;http://payment-service:8080&quot;,
    configuration = PaymentFeignConfig.class
)
public interface PaymentFeignClient {
    
    @PostMapping(&quot;/payments&quot;)
    PaymentResponse processPayment(@RequestBody PaymentRequest request);
    
    @GetMapping(&quot;/payments/{id}&quot;)
    PaymentResponse getPayment(@PathVariable Long id);
}

@Configuration
public class PaymentFeignConfig {
    
    // 재시도 로직: 3회 시도, 100ms ~ 1000ms 간격
    @Bean
    public Retryer retryer() {
        return new Retryer.Default(100, 1000, 3);
    }
    
    // 타임아웃
    @Bean
    public Request.Options options() {
        return new Request.Options(5, TimeUnit.SECONDS, 5, TimeUnit.SECONDS, true);
    }
    
    // 에러 디코딩
    @Bean
    public ErrorDecoder errorDecoder() {
        return new PaymentErrorDecoder();
    }
}

// 사용
@Service
public class OrderService {
    
    @Autowired
    private PaymentFeignClient paymentClient;
    
    public Order createOrder(OrderRequest req) {
        PaymentResponse payment = paymentClient.processPayment(
            new PaymentRequest(req.getAmount(), req.getOrderId())
        );
        
        if (payment.isSuccess()) {
            return new Order(req, payment);
        }
        throw new PaymentFailedException(&quot;결제 실패&quot;);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이유:&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서비스 간 통신&lt;/li&gt;
&lt;li&gt;Eureka와 함께 사용하면 로드밸런싱 자동화&lt;/li&gt;
&lt;li&gt;재시도/타임아웃/에러 처리 자동화&lt;/li&gt;
&lt;li&gt;선언적이고 깔끔한 코드&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;8. 선택 플로우차트&lt;/h3&gt;
&lt;pre class=&quot;bash&quot; style=&quot;color: #000000; text-align: start;&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;HTTP 클라이언트가 필요한가?
│
├─ 웹 서버 자체를 리액티브하게 구성하고 싶은가?
│  └─ YES &amp;rarr; WebFlux
│
├─ 외부 API 호출인가? (카카오, 구글, 네이버 등)
│  └─ YES &amp;rarr; RestClient
│
├─ 마이크로서비스 간 통신인가?
│  └─ YES &amp;rarr; Feign Client
│
└─ 동시에 여러 API를 병렬로 호출해야 하는가?
   └─ YES &amp;rarr; WebClient
   └─ NO &amp;rarr; RestClient 또는 Feign Client 중 선택&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;10. 성능 비교&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시나리오처리량 (req/s)응답시간 (ms)메모리 (MB)&lt;/p&gt;
&lt;table style=&quot;color: #000000; text-align: start; 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;MVC + RestClient (1,000 동시)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;5,000&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;MVC + WebClient (1,000 동시)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;8,000&lt;/td&gt;
&lt;td&gt;125&lt;/td&gt;
&lt;td&gt;300&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;WebFlux + WebClient (10,000 동시)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;50,000&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;MSA + Feign (1,000 동시)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;6,000&lt;/td&gt;
&lt;td&gt;167&lt;/td&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;주목:&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;동시 연결이 1,000을 넘어가면 WebFlux의 장점이 극대화&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;결론&lt;/h3&gt;
&lt;table style=&quot;color: #000000; text-align: start; border-collapse: collapse; width: 100%; height: 84px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;일반 웹 애플리케이션 + 외부 API 호출&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;RestClient&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;간단하고 Spring MVC와 완벽 호환&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;고성능 필요 + 병렬 처리 많음&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;WebClient&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;논블로킹으로 동시성 극대화&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;완전한 리액티브 아키텍처 + 실시간 스트림&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;WebFlux&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;고동시성, 저리소스, 스트리밍 처리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;MSA 환경 + 서비스 간 통신&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;&lt;b&gt;Feign Client&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px;&quot;&gt;선언적이고 자동화 기능 우수&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Spring Boot 4 시대에는 더 이상 RestTemplate을 선택할 이유가 없음&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Spring Boot</category>
      <category>Feign Client</category>
      <category>RestClient</category>
      <category>Spring boot</category>
      <category>WebClient</category>
      <category>WebFlux</category>
      <author>kimfishes</author>
      <guid isPermaLink="true">https://kimfishes.tistory.com/16</guid>
      <comments>https://kimfishes.tistory.com/16#entry16comment</comments>
      <pubDate>Fri, 27 Mar 2026 22:13:17 +0900</pubDate>
    </item>
    <item>
      <title>왜 UUID v7을 선택했는가? (Snowflake와 비교까지)</title>
      <link>https://kimfishes.tistory.com/15</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;199&quot; data-start=&quot;168&quot; data-ke-size=&quot;size16&quot;&gt;대부분의 프로젝트는 기본 키로 다음 중 하나를 사용한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;268&quot; data-start=&quot;201&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;224&quot; data-start=&quot;201&quot;&gt;AUTO_INCREMENT (Long)&lt;/li&gt;
&lt;li data-end=&quot;239&quot; data-start=&quot;225&quot;&gt;UUID v4 (랜덤)&lt;/li&gt;
&lt;li data-end=&quot;268&quot; data-start=&quot;240&quot;&gt;Snowflake (시간 기반 64bit ID)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;325&quot; data-start=&quot;270&quot; data-ke-size=&quot;size16&quot;&gt;이번 프로젝트에서는 Postgres + Hibernate 환경에서 &lt;b&gt;UUID v7&lt;/b&gt;을 선택했다.&lt;/p&gt;
&lt;p data-end=&quot;373&quot; data-start=&quot;327&quot; data-ke-size=&quot;size16&quot;&gt;왜 굳이 v7을 선택했는지, 그리고 Snowflake와는 무엇이 다른지 정리해본다.&lt;/p&gt;
&lt;p data-end=&quot;373&quot; data-start=&quot;327&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;373&quot; data-start=&quot;327&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;373&quot; data-start=&quot;327&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;373&quot; data-start=&quot;327&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;373&quot; data-start=&quot;327&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt; 기존 UUID v4의 문제점 &lt;/b&gt;&lt;/h3&gt;
&lt;p data-end=&quot;373&quot; data-start=&quot;327&quot; data-ke-size=&quot;size16&quot;&gt;UUID v4는 완전 랜덤이다.&lt;/p&gt;
&lt;pre id=&quot;code_1772091805283&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;550e8400-e29b-41d4-a716-446655440000&lt;/code&gt;&lt;/pre&gt;
&lt;p data-end=&quot;477&quot; data-start=&quot;471&quot; data-ke-size=&quot;size18&quot;&gt;장점&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;516&quot; data-start=&quot;478&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;491&quot; data-start=&quot;478&quot;&gt;충돌 확률 거의 없음&lt;/li&gt;
&lt;li data-end=&quot;504&quot; data-start=&quot;492&quot;&gt;분산 환경에서 안전&lt;/li&gt;
&lt;li data-end=&quot;516&quot; data-start=&quot;505&quot;&gt;중앙 서버 불필요&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;525&quot; data-start=&quot;518&quot; data-ke-size=&quot;size18&quot;&gt;단점&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;608&quot; data-start=&quot;526&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;550&quot; data-start=&quot;526&quot;&gt;완전 랜덤 &amp;rarr; B-Tree 인덱스에 불리&lt;/li&gt;
&lt;li data-end=&quot;575&quot; data-start=&quot;551&quot;&gt;insert 시 page split 빈번&lt;/li&gt;
&lt;li data-end=&quot;588&quot; data-start=&quot;576&quot;&gt;인덱스 단편화 발생&lt;/li&gt;
&lt;li data-end=&quot;608&quot; data-start=&quot;589&quot;&gt;대량 트래픽 환경에서 성능 저하&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;636&quot; data-start=&quot;610&quot; data-ke-size=&quot;size16&quot;&gt;Postgres나 MySql 기본 인덱스는 B-Tree라 &lt;b&gt;순차 증가 값에 최적화&lt;/b&gt;되어 있다.&lt;/p&gt;
&lt;p data-end=&quot;708&quot; data-start=&quot;670&quot; data-ke-size=&quot;size16&quot;&gt;그러나 랜덤 값(v4)은 매번 트리 중간에 삽입되기 때문에 성능상 불리하다.&lt;/p&gt;
&lt;p data-end=&quot;708&quot; data-start=&quot;670&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;708&quot; data-start=&quot;670&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;708&quot; data-start=&quot;670&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;708&quot; data-start=&quot;670&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;708&quot; data-start=&quot;670&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt; UUID v7란?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-end=&quot;708&quot; data-start=&quot;670&quot; data-ke-size=&quot;size16&quot;&gt;UUID v7은 2024년 RFC 9562에서 공식화된 최신 UUID 버전&lt;/p&gt;
&lt;p data-end=&quot;708&quot; data-start=&quot;670&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1216&quot; data-start=&quot;1203&quot;&gt;앞부분 = 시간 기반&lt;/li&gt;
&lt;li data-end=&quot;1227&quot; data-start=&quot;1217&quot;&gt;뒷부분 = 랜덤&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1772091996860&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# UUID V7 구조
48bit Unix epoch milliseconds + 랜덤 비트&lt;/code&gt;&lt;/pre&gt;
&lt;p data-end=&quot;708&quot; data-start=&quot;670&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1257&quot; data-start=&quot;1245&quot;&gt;시간 순 정렬 가능&lt;/li&gt;
&lt;li data-end=&quot;1267&quot; data-start=&quot;1258&quot;&gt;인덱스 친화적&lt;/li&gt;
&lt;li data-end=&quot;1281&quot; data-start=&quot;1268&quot;&gt;충돌 확률 매우 낮음&lt;/li&gt;
&lt;li data-end=&quot;1315&quot; data-start=&quot;1282&quot;&gt;Snowflake처럼 동작하지만 표준 UUID 형식 유지&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;708&quot; data-start=&quot;670&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;708&quot; data-start=&quot;670&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;708&quot; data-start=&quot;670&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;373&quot; data-start=&quot;327&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt; Snowflake란&lt;/b&gt;&lt;/h3&gt;
&lt;p data-end=&quot;373&quot; data-start=&quot;327&quot; data-ke-size=&quot;size16&quot;&gt;Snowflake ID는 Twitter에서 만든 분산 ID 생성 전략&lt;/p&gt;
&lt;pre id=&quot;code_1772091901529&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# SnowFlake 구조
timestamp (41bit)
workerId (10bit)
sequence (12bit)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-end=&quot;867&quot; data-start=&quot;861&quot; data-ke-size=&quot;size18&quot;&gt;특징&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;927&quot; data-start=&quot;869&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;881&quot; data-start=&quot;869&quot;&gt;시간 순 정렬 가능&lt;/li&gt;
&lt;li data-end=&quot;892&quot; data-start=&quot;882&quot;&gt;분산 환경 지원&lt;/li&gt;
&lt;li data-end=&quot;909&quot; data-start=&quot;893&quot;&gt;중앙 DB 없이 ID 생성&lt;/li&gt;
&lt;li data-end=&quot;927&quot; data-start=&quot;910&quot;&gt;Long 타입 (64bit)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;935&quot; data-start=&quot;929&quot; data-ke-size=&quot;size18&quot;&gt;장점&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;977&quot; data-start=&quot;937&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;946&quot; data-start=&quot;937&quot;&gt;인덱스 친화적&lt;/li&gt;
&lt;li data-end=&quot;969&quot; data-start=&quot;947&quot;&gt;AUTO_INCREMENT처럼 정렬됨&lt;/li&gt;
&lt;li data-end=&quot;977&quot; data-start=&quot;970&quot;&gt;높은 성능&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;985&quot; data-start=&quot;979&quot; data-ke-size=&quot;size18&quot;&gt;단점&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1069&quot; data-start=&quot;987&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1017&quot; data-start=&quot;987&quot;&gt;중앙 시계 의존 (Clock rollback 문제)&lt;/li&gt;
&lt;li data-end=&quot;1035&quot; data-start=&quot;1018&quot;&gt;worker id 관리 필요&lt;/li&gt;
&lt;li data-end=&quot;1047&quot; data-start=&quot;1036&quot;&gt;구현 복잡도 존재&lt;/li&gt;
&lt;li data-end=&quot;1069&quot; data-start=&quot;1048&quot;&gt;DB 독립적이지만 시스템 설계 필요&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;왜 Snowflake 대신 UUID v7을 선택했는가?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-end=&quot;2017&quot; data-start=&quot;2004&quot; data-ke-size=&quot;size16&quot;&gt;1️⃣ 표준 기반&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2040&quot; data-start=&quot;2019&quot;&gt;UUID v7은 RFC 공식 표준이지만 Snowflake는 구현 전략일 뿐 표준이 아니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;2040&quot; data-start=&quot;2019&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2040&quot; data-start=&quot;2019&quot; data-ke-size=&quot;size16&quot;&gt;2️⃣ 운영 복잡도 감소&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2107&quot; data-start=&quot;2096&quot;&gt;Snowflake는 worker id 관리, 시계 동기화 문제, 인프라 고려 필요로 즉 &lt;u&gt;&lt;b&gt;ID 생성이 시스템 설계 일부가 된다. &lt;/b&gt;&lt;/u&gt;&lt;/li&gt;
&lt;li data-end=&quot;2107&quot; data-start=&quot;2096&quot;&gt;그러나 UUID v7은 그냥 생성하면 끝&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3️⃣ 만드려는 시스템이 Snowflake를 &quot;관리할 가치가 있는가?&quot;판단 필요&lt;/p&gt;
&lt;p data-end=&quot;582&quot; data-start=&quot;560&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- Snowflake는 이런 경우에 빛난다&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;660&quot; data-start=&quot;584&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;591&quot; data-start=&quot;584&quot;&gt;초고트래픽&lt;/li&gt;
&lt;li data-end=&quot;602&quot; data-start=&quot;592&quot;&gt;수십~수백 노드&lt;/li&gt;
&lt;li data-end=&quot;622&quot; data-start=&quot;603&quot;&gt;ID를 long으로 유지해야 함&lt;/li&gt;
&lt;li data-end=&quot;650&quot; data-start=&quot;623&quot;&gt;Kafka key 등에서 8byte 이점 중요&lt;/li&gt;
&lt;li data-end=&quot;660&quot; data-start=&quot;651&quot;&gt;글로벌 스케일&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;682&quot; data-start=&quot;667&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 반면, 이런 경우라면?&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;750&quot; data-start=&quot;684&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;690&quot; data-start=&quot;684&quot;&gt;모놀리식&lt;/li&gt;
&lt;li data-end=&quot;703&quot; data-start=&quot;691&quot;&gt;혹은 소규모 MSA&lt;/li&gt;
&lt;li data-end=&quot;750&quot; data-start=&quot;734&quot;&gt;운영 복잡도 최소화가 목표&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2107&quot; data-start=&quot;2096&quot; data-ke-size=&quot;size16&quot;&gt;4️⃣ Hibernate와 쉽게 통합 가능하여 구현의 간단함&lt;/p&gt;
&lt;p data-end=&quot;2107&quot; data-start=&quot;2096&quot; data-ke-size=&quot;size16&quot;&gt;- 의존성 주입&lt;/p&gt;
&lt;pre id=&quot;code_1772092344538&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    implementation(&quot;com.github.f4b6a3:uuid-creator:5.3.3&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-end=&quot;2107&quot; data-start=&quot;2096&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2107&quot; data-start=&quot;2096&quot; data-ke-size=&quot;size16&quot;&gt;- 엔티티에 사용 코드&lt;/p&gt;
&lt;pre id=&quot;code_1772092321580&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User extends BaseEntity {

    @Id
    @Column(nullable = false, updatable = false)
    private UUID id;


    @PrePersist
    void init() {
        if (id == null) {
            id = UuidCreator.getTimeOrderedEpoch(); // = UUID v7
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-end=&quot;2107&quot; data-start=&quot;2096&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2107&quot; data-start=&quot;2096&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2107&quot; data-start=&quot;2096&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2107&quot; data-start=&quot;2096&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;2107&quot; data-start=&quot;2096&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt; UUID v7의 단점은 없는가? &lt;/b&gt;&lt;/h3&gt;
&lt;p data-end=&quot;2107&quot; data-start=&quot;2096&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2507&quot; data-start=&quot;2475&quot;&gt;128bit라 Snowflake(64bit)보다 크다.&lt;/li&gt;
&lt;li data-end=&quot;2521&quot; data-start=&quot;2508&quot;&gt;사람이 보기 어렵다.&lt;/li&gt;
&lt;li data-end=&quot;2539&quot; data-start=&quot;2522&quot;&gt;로그 추적 시 가독성 낮음&lt;/li&gt;
&lt;/ul&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span style=&quot;background-color: #c1bef9;&quot;&gt;&lt;b&gt;항목&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span style=&quot;background-color: #c1bef9;&quot;&gt;&lt;b&gt; Snowflake &lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span style=&quot;background-color: #c1bef9;&quot;&gt;&lt;b&gt; UUID v7 &lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;타입&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;bigint&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;uuid&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;길이&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;64bit&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;128bit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;시간 정렬&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;O&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;O&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;분산 지원&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;O&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;O&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;worker 관리&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;필요&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;불필요&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;표준&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;비표준&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;RFC 표준&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;DB 독립성&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;O&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;O&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;인덱스 성능&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;좋음&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;좋음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;크기 문제 해결 방법&lt;/b&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;128bit라는 크기를 해결하기 위해 MySQL에서는 UUID를 저장하기 위한 방식이 3가지 존재한다.&lt;/li&gt;
&lt;li&gt;MySQL에는 PostgreSQL처럼 전용 UUID 타입이 없기 때문&lt;/li&gt;
&lt;/ul&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span style=&quot;background-color: #c1bef9;&quot;&gt;&lt;b&gt;방식&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span style=&quot;background-color: #c1bef9;&quot;&gt;&lt;b&gt;타입&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span style=&quot;background-color: #c1bef9;&quot;&gt;&lt;b&gt;크기&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;&lt;span style=&quot;background-color: #c1bef9;&quot;&gt;&lt;b&gt;특징&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;문자열 저장&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;CHAR(36)&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;36 bytes&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;가독성 좋음, 느림&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;압축 저장&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;BINARY(16)&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;16 bytes&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;빠름, 공간 절약&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;함수 기반&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;UUID_TO_BIN()&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;16 bytes&lt;/td&gt;
&lt;td style=&quot;text-align: center;&quot;&gt;정렬 최적화 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-end=&quot;671&quot; data-start=&quot;654&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;671&quot; data-start=&quot;654&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;671&quot; data-start=&quot;654&quot; data-ke-size=&quot;size18&quot;&gt;BINARY(16) 장점&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;713&quot; data-start=&quot;673&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;684&quot; data-start=&quot;673&quot;&gt;16바이트로 저장&lt;/li&gt;
&lt;li data-end=&quot;694&quot; data-start=&quot;685&quot;&gt;인덱스 작아짐&lt;/li&gt;
&lt;li data-end=&quot;702&quot; data-start=&quot;695&quot;&gt;비교 빠름&lt;/li&gt;
&lt;li data-end=&quot;713&quot; data-start=&quot;703&quot;&gt;캐시 효율 증가&lt;/li&gt;
&lt;li data-end=&quot;713&quot; data-start=&quot;703&quot;&gt;이러한 이유들로 MySQL에선 BINARY(16)이 사실상 정석&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1772092474388&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;id BINARY(16) NOT NULL PRIMARY KEY&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000; font-size: 1.44em; letter-spacing: -1px;&quot;&gt;그렇다면&lt;/span&gt;&lt;span style=&quot;color: #000000; font-size: 1.44em; letter-spacing: -1px;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;color: #000000; font-size: 1.44em; letter-spacing: -1px;&quot;&gt;PostgreSQL에서 크기 해결법은?&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;u&gt;&lt;b&gt; Postgres에는 전용 uuid 타입이 있어 내부적으로 16바이트로 저장함&lt;/b&gt;&lt;/u&gt;&lt;/li&gt;
&lt;li&gt;인덱스 최적화&lt;/li&gt;
&lt;li&gt;함수 내장&lt;/li&gt;
&lt;li&gt;타입 안정성 보장&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;결론&lt;/h3&gt;
&lt;blockquote data-end=&quot;940&quot; data-start=&quot;903&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;940&quot; data-start=&quot;905&quot; data-ke-size=&quot;size16&quot;&gt;&lt;u&gt; Snowflake는 더 강력하지만, ID 생성 전략이 인프라 설계의 일부가 된다.&lt;/u&gt;&lt;br /&gt;UUID v7은 Snowflake의 시간 정렬 장점을 유지하면서도, 표준 기반이며 운영 부담이 없기 때문에&lt;br /&gt;이번 프로젝트는 운영 단순성과 표준 기반 설계를 우선했다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Spring Boot</category>
      <category>snowflake</category>
      <category>Spring boot</category>
      <category>UUID</category>
      <category>UUID v7</category>
      <author>kimfishes</author>
      <guid isPermaLink="true">https://kimfishes.tistory.com/15</guid>
      <comments>https://kimfishes.tistory.com/15#entry15comment</comments>
      <pubDate>Thu, 26 Feb 2026 17:07:28 +0900</pubDate>
    </item>
    <item>
      <title>Lettuce VS Redisson 분산 락</title>
      <link>https://kimfishes.tistory.com/14</link>
      <description>&lt;h3 style=&quot;color: #000000;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;a href=&quot;https://kimfishes.tistory.com/13&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://kimfishes.tistory.com/13&lt;/a&gt;&lt;/h3&gt;
&lt;figure id=&quot;og_1770963832159&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;  허브 간 최단 경로 탐색 캐시 스탬피드 (Cache Stampede) 발생+해결&quot; data-og-description=&quot;이전 문제 상황출발지와 목적지 사이 허브 간 경로를 다익스트라 알고리즘으로 선정 후, Kakao Api를 호출해 허브 간 소요 시간 산출이때 Kakao Api 응답을 기다리는 방식의 경우 16 ~ 20 초의 많은 시&quot; data-og-host=&quot;kimfishes.tistory.com&quot; data-og-source-url=&quot;https://kimfishes.tistory.com/13&quot; data-og-url=&quot;https://kimfishes.tistory.com/13&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/coyUk6/dJMb83SeXNI/aAoZe9KwFxsuYP24yCgIg1/img.png?width=800&amp;amp;height=416&amp;amp;face=0_0_800_416,https://scrap.kakaocdn.net/dn/PEaVD/dJMb9aKBe3d/T32c7VUqkpWGzMRH9gjzr1/img.png?width=800&amp;amp;height=416&amp;amp;face=0_0_800_416,https://scrap.kakaocdn.net/dn/clg1CN/dJMb83SeXNJ/iT9FsIIMQhYVDXxHt61JwK/img.png?width=1752&amp;amp;height=677&amp;amp;face=0_0_1752_677&quot;&gt;&lt;a href=&quot;https://kimfishes.tistory.com/13&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://kimfishes.tistory.com/13&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/coyUk6/dJMb83SeXNI/aAoZe9KwFxsuYP24yCgIg1/img.png?width=800&amp;amp;height=416&amp;amp;face=0_0_800_416,https://scrap.kakaocdn.net/dn/PEaVD/dJMb9aKBe3d/T32c7VUqkpWGzMRH9gjzr1/img.png?width=800&amp;amp;height=416&amp;amp;face=0_0_800_416,https://scrap.kakaocdn.net/dn/clg1CN/dJMb83SeXNJ/iT9FsIIMQhYVDXxHt61JwK/img.png?width=1752&amp;amp;height=677&amp;amp;face=0_0_1752_677');&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;  허브 간 최단 경로 탐색 캐시 스탬피드 (Cache Stampede) 발생+해결&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;이전 문제 상황출발지와 목적지 사이 허브 간 경로를 다익스트라 알고리즘으로 선정 후, Kakao Api를 호출해 허브 간 소요 시간 산출이때 Kakao Api 응답을 기다리는 방식의 경우 16 ~ 20 초의 많은 시&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;kimfishes.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #1d1c1d; text-align: left;&quot;&gt;&lt;b&gt;나의 상황&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-start=&quot;1577&quot; data-end=&quot;1611&quot;&gt;1명만 refresh lock &amp;rarr; Kakao Api를 호출하여 값 갱신 시도&lt;/li&gt;
&lt;li data-start=&quot;1612&quot; data-end=&quot;1643&quot;&gt;나머지는 Redis 재조회 2~3회 후 없으면 TTL 시간이 지난 값이라도 반환&lt;/li&gt;
&lt;li data-start=&quot;1644&quot; data-end=&quot;1672&quot;&gt;성공 시 DB upsert + Redis set&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #1d1c1d; text-align: left;&quot;&gt;- 허브 간 소요시간 저장 시 Redis에 분산락을 사용하여 하나의 요청만 Kakao에 요청하여 값이 갱신되도록 해야 함&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #1d1c1d; text-align: left;&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #1d1c1d; text-align: left;&quot;&gt;-&lt;span&gt; 동일 키에 대해 갱신자 1명만 선출하면 되고&lt;/span&gt;&lt;b&gt;&lt;span&gt;, &lt;u&gt;나머지는 &lt;/u&gt;&lt;/span&gt;&lt;u&gt;&lt;span&gt;락 대기 없이 값을 랜덤하게 2 ~ 3회 기다리다가 그래도 값을 받지 못하면 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;TTL 시간이 지난 값이라도 반환&lt;/span&gt;&amp;nbsp;&lt;/span&gt;&lt;/u&gt;&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Lettuce이란&lt;/b&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-start=&quot;246&quot; data-end=&quot;288&quot;&gt;&lt;span style=&quot;background-color: #fff4e6; color: #000000; text-align: start;&quot;&gt;&lt;b&gt;Lettuce는 Redis와 비동기로 통신할 수 있는 자바 클라이언&lt;/b&gt;트&lt;/span&gt;&lt;/li&gt;
&lt;li data-start=&quot;246&quot; data-end=&quot;288&quot;&gt;Redis 서버에 명령을 안전하고 효율적으로 전달&lt;/li&gt;
&lt;li&gt;비동기 / 논블로킹 기반&lt;/li&gt;
&lt;li&gt;Redis polling + jitter 구조에 안정적&lt;/li&gt;
&lt;li&gt;분산락을 &amp;ldquo;자동으로&amp;rdquo; 제공하진 않음&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;&amp;ldquo;락 자체&amp;rdquo;를 제공하지 않는다 &amp;rarr; 직접 설계/검증 필요&lt;/li&gt;
&lt;li&gt;재시도/폴링 설계를 잘못하면 결국 &amp;ldquo;스핀락&amp;rdquo;이 된다&lt;/li&gt;
&lt;li&gt;TTL 만료 시간에 Lock 획득으로 &lt;b&gt;&lt;u&gt;동시 갱신자 가능성&lt;/u&gt;&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1370&quot; data-start=&quot;1352&quot;&gt;A가 갱신 중인데 TTL 만료 -&amp;gt; B가 락 획득 &amp;rarr; Kakao API를 또 호출하여 결과적으로 &amp;ldquo;&lt;b&gt;중복 호출&lt;/b&gt;&amp;rdquo; 발생&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Redisson ( 고수준 라이브러리 분산락 ) 이란&lt;/b&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Redis 위에&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;&amp;ldquo;분산 객체&amp;rdquo;를 제공하는 라이브러리&lt;/b&gt;&lt;/li&gt;
&lt;li data-start=&quot;1680&quot; data-end=&quot;1725&quot;&gt;RLock, RMap, RSemaphore 같은 고수준 API 제공&lt;/li&gt;
&lt;li data-start=&quot;1726&quot; data-end=&quot;1760&quot;&gt;내부적으로 Lua + watchdog + TTL 자동 연장&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&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 data-start=&quot;1950&quot; data-end=&quot;1973&quot;&gt;락이 무거움 (watchdog 스레드)&lt;/li&gt;
&lt;li data-start=&quot;1974&quot; data-end=&quot;2008&quot;&gt;&amp;ldquo;외부 API 1번만 호출&amp;rdquo; 같은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;정밀 제어엔 과함&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Lettuce 선택 이유&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-start=&quot;374&quot; data-end=&quot;402&quot;&gt;Redis의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;Lettuce&lt;span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;락은 Lock를 얻지 못한 스레드가 Lock 획득 시도를 반복하는 스핀락(spin lock) 문제가 있으므로 무조건 사용하면 안된다라는 블로그들에 글이 꽤 있다.&lt;/li&gt;
&lt;li data-start=&quot;374&quot; data-end=&quot;402&quot;&gt;하지만 지금의 경우&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Lock 획득하기 위해 싸우는게 목적이 아닌 &quot;갱신자 한 명을 선출하는 게 목적&quot;&lt;/b&gt;&lt;/li&gt;
&lt;li data-start=&quot;374&quot; data-end=&quot;402&quot;&gt;즉&lt;b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;락을 기다리지 않는 single-flight 캐시 갱신 정책을 사용하므로&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/b&gt;최소 기능(SET NX PX + 토큰 기반 해제)만으로 투명하고 가볍게 구현 가능한 Lettuce 방식을 선택&lt;/li&gt;
&lt;li data-start=&quot;374&quot; data-end=&quot;402&quot;&gt;혹여나 중복 호출이 발생하더라도 허브 간 소요 시간 결과에 악 영향을 끼치지 않음&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Lettuce을 언제 사용하는지&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;638&quot; data-start=&quot;607&quot;&gt;락 보유 시간이 &lt;b&gt;짧다&lt;/b&gt; (수백 ms ~ 수 초)&lt;/li&gt;
&lt;li data-end=&quot;661&quot; data-start=&quot;639&quot;&gt;&amp;ldquo;대기&amp;rdquo;가 아니라 &lt;b&gt;선출&lt;/b&gt;이 목적&lt;/li&gt;
&lt;li data-end=&quot;699&quot; data-start=&quot;662&quot;&gt;실패해도 치명적이지 않다 (503/캐시미스 허용, 재시도 가능)&lt;/li&gt;
&lt;li data-end=&quot;738&quot; data-start=&quot;700&quot;&gt;구현을 투명하게 유지하고 싶다 (키/TTL/해제 조건 직접 제어)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;언제 Redisson을 사용하는지&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-start=&quot;1509&quot; data-end=&quot;1541&quot;&gt;락을 잡고 하는 작업이&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;수십 초~분 단위인 경우&lt;/b&gt;&lt;/li&gt;
&lt;li data-start=&quot;1542&quot; data-end=&quot;1573&quot;&gt;락이 중간에 풀리면&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;치명적인 중복 실행&lt;/b&gt;이 되는 경우 ( 금전/정산/재고 오류 )&lt;/li&gt;
&lt;li data-start=&quot;507&quot; data-end=&quot;541&quot;&gt;동일 자원(좌석, 쿠폰, 포인트) &lt;b&gt;&lt;u&gt;업데이트를 순서대로 처리가 필요한 경우&lt;/u&gt;&lt;/b&gt;&lt;/li&gt;
&lt;li data-start=&quot;542&quot; data-end=&quot;578&quot;&gt;임계구역 작업이 꼭 성공해야 해서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;대기 후 획득&lt;/b&gt;이 필요한 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;예시 코드&lt;/b&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1770965517857&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Component
@RequiredArgsConstructor
public class RedisHubEdgeCache {

    private final StringRedisTemplate redisTemplate;
    private final ObjectMapper om;

    public Optional&amp;lt;CacheHit&amp;lt;HubInfo&amp;gt;&amp;gt; get(String key){
        try {
            String value = redisTemplate.opsForValue().get(key);
            if(value == null) { return Optional.empty(); }

            long sec = redisTemplate.getExpire(key);
            Duration ttl = sec &amp;lt; 0 ? Duration.ZERO : Duration.ofSeconds(sec);

            return Optional.of(new CacheHit&amp;lt;&amp;gt;(om.readValue(value, HubInfo.class), ttl));

        } catch (Exception e) {
            // 역직렬화/redis 오류면 캐시 제거 후 miss 처리
            redisTemplate.delete(key);

            return Optional.empty();
        }
    }

    public void add(String key, HubInfo hubInfo, Duration ttl){
        try {
            redisTemplate.opsForValue().set(key, om.writeValueAsString(hubInfo), ttl);
        } catch (Exception ignored) {
            // 캐시 저장 실패는 복구 불가능 오류가 아니므로 실패를 시키지 않음
        }
    }

    // DB 조회/갱신은 1명만 가능하도록 Lock
    public boolean tryLock(String key, Duration ttl){
        Boolean result = redisTemplate.opsForValue().setIfAbsent(key, &quot;1&quot;, ttl);
        return Boolean.TRUE.equals(result);
    }

    public void unlock(String key){
        try { redisTemplate.delete(key); } catch (Exception ignored) {}
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;결론&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 이번 문제는 &amp;ldquo;분산락으로 모든 요청을 직렬화&amp;rdquo;하는 문제가 아니라,&lt;br /&gt;&lt;b&gt;캐시 갱신자 1명을 선출해 외부 API 호출을 1회로 제한하는 single-flight 문제&lt;/b&gt;다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 따라서 무거운 락 추상화보다 &lt;b&gt;SET NX PX + 토큰 기반 해제&lt;/b&gt;로 충분하며,&lt;br /&gt;운영 중 갱신 시간이 길어질 수 있는 경우에만 Redisson(Watchdog)로 확장하는 전략이 합리적이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Spring Boot</category>
      <category>lettuce</category>
      <category>Redisson</category>
      <category>분산 락</category>
      <author>kimfishes</author>
      <guid isPermaLink="true">https://kimfishes.tistory.com/14</guid>
      <comments>https://kimfishes.tistory.com/14#entry14comment</comments>
      <pubDate>Fri, 13 Feb 2026 15:54:51 +0900</pubDate>
    </item>
    <item>
      <title>  허브 간 최단 경로 탐색 캐시 스탬피드 (Cache Stampede) 발생+해결</title>
      <link>https://kimfishes.tistory.com/13</link>
      <description>&lt;blockquote data-ke-size=&quot;size23&quot; data-ke-style=&quot;style1&quot;&gt;&lt;b&gt;이전 문제 상황&lt;/b&gt;&lt;/blockquote&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;출발지와 목적지 사이 허브 간 경로를 다익스트라 알고리즘으로 선정 후, Kakao Api를 호출해 허브 간 소요 시간 산출&lt;/li&gt;
&lt;li&gt;&lt;u style=&quot;color: #1d1c1d; letter-spacing: 0px;&quot;&gt;&lt;b&gt;이때 Kakao Api 응답을 기다리는 방식의 경우 16 ~ 20 초의 많은 시간이 걸림&lt;/b&gt;&lt;/u&gt;&lt;u style=&quot;color: #1d1c1d; letter-spacing: 0px;&quot;&gt;&lt;b&gt;&lt;/b&gt;&lt;/u&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;첫 번째 해결&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-&lt;b&gt; Redis를 도입하여 TTL 5분이 지나지 않은 값은 Redis에 값을 꺼내 사용 (Redis TTL 5분, DB TTL 10분)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Redis TTL 5분이 지난 경우 DB 조회 후 Redis에 값 갱신 (이때 우선 Redis에 갱신 후 Kakao Api를 호출하여 최신 값으로 갱신)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Redis와 DB 모두 값이 없는 Cold Start의 경우 Kakao Api 호출 후 값 갱신&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;RedisHubEdgeCache&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1768797240599&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Component
@RequiredArgsConstructor
public class RedisHubEdgeCache {

    private final StringRedisTemplate redisTemplate;
    private final ObjectMapper om;

    public Optional&amp;lt;HubInfo&amp;gt; get(String key){
        String value = redisTemplate.opsForValue().get(key);
        if(value == null){
            return Optional.empty();
        }
        try {
            return Optional.of(om.readValue(value, HubInfo.class));
        } catch (Exception e) {
            redisTemplate.delete(key);
            return Optional.empty();
        }
    }

    public void add(String key, HubInfo hubInfo, Duration ttl){
        try {
            redisTemplate.opsForValue().set(key, om.writeValueAsString(hubInfo), ttl);
        } catch (Exception ignored) {
            // 캐시 저장 실패는 복구 불가능 오류가 아니므로 실패를 시키지 않음
        }
    }

}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1768796994160&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
@Slf4j
@RequiredArgsConstructor
public class HubEdgeWeightService implements HubEdgeWeightProvider {

    private final Duration redisTtl = Duration.ofMinutes(5);

    private final HubInfoRepository hubInfoRepository;
    private final HubRepository hubRepository;
    private final KakaoMapClient kakaoMapClient;
    private final RedisHubEdgeCache redisHubEdgeCache;
    private final CacheStats cacheStats;

    @Override
    @Transactional
    public EdgeWeight getWeight(UUID startHubId, UUID endHubId) {

        // 1) redis 조회 (5분인 상태)
        String keys = HubEdgeCacheKeys.edgeKeys(String.valueOf(startHubId), String.valueOf(endHubId));
        Optional&amp;lt;HubInfo&amp;gt; optionalHubInfo = redisHubEdgeCache.get(keys);
        if (optionalHubInfo.isPresent()) {
            cacheStats.hit();
            log.info(&quot;redis Cache hit&quot;);
            HubInfo hubInfo = optionalHubInfo.get();
            return new EdgeWeight(startHubId, endHubId, hubInfo.getDeliveryDuration(), hubInfo.getDistance());
        }
        cacheStats.miss();

        // 2) DB TTL인 10분 이내의 캐시값이 있으면 기존 값 그대로 사용
        HubInfo hubInfo = hubInfoRepository.findByStartHubIdAndEndHubId(startHubId, endHubId)
            .orElseThrow(()-&amp;gt; new BusinessException(ErrorCode.HUB_INFO_NOT_FOUND));
        LocalDateTime now = LocalDateTime.now();

        if (hubInfo.getDeliveryDuration() != null &amp;amp;&amp;amp; hubInfo.getDistance() != null &amp;amp;&amp;amp; !hubInfo.checkUpdateTime(now)) {

            redisHubEdgeCache.add(keys, hubInfo, redisTtl);
            log.info(&quot;[HubEdgeWeight] cache hit: {} -&amp;gt; {} ({}h {}m, {} km)&quot;,
                startHubId, endHubId, hubInfo.getDeliveryDuration()/3600, hubInfo.getDeliveryDuration() % 3600 / 60, hubInfo.getDistance());

            return new EdgeWeight(startHubId, endHubId, hubInfo.getDeliveryDuration(), hubInfo.getDistance());
        }

        // 3) DB에도 없다면 Kakao API로 실제 거리/시간 계산
        // 허브 존재 여부 확인
        Hub startHub = hubRepository.findById(hubInfo.getStartHubId())
            .orElseThrow(() -&amp;gt; new BusinessException(ErrorCode.HUB_NOT_FOUND));
        Hub endHub = hubRepository.findById(hubInfo.getEndHubId())
            .orElseThrow(() -&amp;gt; new BusinessException(ErrorCode.HUB_NOT_FOUND));

        // 해당 허브들에 위도, 경도값 추출
        String origin = startHub.getLongitude() + &quot;,&quot; + startHub.getLatitude();
        String destination = endHub.getLongitude() + &quot;,&quot; + endHub.getLatitude();

        DirectionInfoResponseV1 direction = kakaoMapClient.getDirection(
            origin,
            destination,
            2,          // carType
            &quot;DIESEL&quot;,   // carFuel
            true        // carHipass
        );

        // Kakao 응답은 초 단위
        int durationSec = (int) direction.duration();
        BigDecimal distanceKm = BigDecimal
            .valueOf(direction.distance() / 1000.0)
            .setScale(3, RoundingMode.HALF_UP);

        hubInfo.updateDeliveryInfo(durationSec, distanceKm);
        // redis의 값은 after commit
        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                redisHubEdgeCache.add(keys, hubInfo, redisTtl);
            }
        });

        log.info(&quot;[HubEdgeWeight] cache hit: {} -&amp;gt; {} ({}h {}m, {} km)&quot;,
            startHubId, endHubId, hubInfo.getDeliveryDuration() / 3600, hubInfo.getDeliveryDuration() % 3600 / 60, hubInfo.getDistance());

        return new EdgeWeight(startHubId, endHubId, durationSec, distanceKm);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 부하 테스트 걸기 전 캐시 miss와 hit 갯수를 알아보기 위해 &lt;b&gt;CacheStats&lt;/b&gt; 작성&lt;/p&gt;
&lt;pre id=&quot;code_1768797351182&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Component
public class CacheStats {
    private final LongAdder hit = new LongAdder();
    private final LongAdder miss = new LongAdder();

    public void hit() { hit.increment(); }
    public void miss() { miss.increment(); }

    public long hits() { return hit.sum(); }
    public long misses() { return miss.sum(); }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;CacheStatsEndPointConfig&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 캐시가 제대로 효율을 내고 있는지 운영 중에 확인하기 위해 사용&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;implementation 'org.springframework.boot:spring-boot-starter-actuator'&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 엔드포인트 설정&lt;/p&gt;
&lt;pre id=&quot;code_1768797787074&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;management:
  endpoints:
    web:
      exposure:
        include: cache-stats&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1768797431683&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Component
@Endpoint(id = &quot;cache-stats&quot;)
@RequiredArgsConstructor
public class CacheStatsEndPointConfig {

    private final CacheStats cacheStats;

    @ReadOperation
    public Map&amp;lt;String, Object&amp;gt; stats() {
        long hit = cacheStats.hits();
        long miss = cacheStats.misses();
        long total = hit+miss;

        double hitRate = (total == 0) ? 0.0 : (hit * 100.0 / total);

        return Map.of(&quot;hits&quot;, hit, &quot;misses&quot;, miss, &quot;total&quot;, total, &quot;hitRate&quot;, hitRate);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Jemeter 부하 테스트&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;- 동시 사용자 200명 &amp;times; 사용자당 100회 요청 (총 20,000 requests)의 예시&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;- 결과 : &lt;b&gt;&lt;span&gt;p95: 850ms, p99: 13.4s, 평균 448ms &lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;- 기존 16초 걸리던 상황보다 &lt;/span&gt;&lt;b&gt;16,000ms &amp;rarr; 488ms &lt;/b&gt;  &lt;b&gt;약 32배 개선&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1085&quot; data-origin-height=&quot;107&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/JBG9J/dJMcafk33lQ/PC3Q28sdklK5FIWkLwkuKK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/JBG9J/dJMcafk33lQ/PC3Q28sdklK5FIWkLwkuKK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/JBG9J/dJMcafk33lQ/PC3Q28sdklK5FIWkLwkuKK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FJBG9J%2FdJMcafk33lQ%2FPC3Q28sdklK5FIWkLwkuKK%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;1085&quot; height=&quot;107&quot; data-origin-width=&quot;1085&quot; data-origin-height=&quot;107&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러나 Redis &lt;span&gt;TTL&amp;nbsp; 5분 만료 시점에 miss가 동시 다발적으로 발생하여 지연 시간이 스파이크 모양으로 튀는 모습&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1752&quot; data-origin-height=&quot;677&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bDESWR/dJMb99ZscU3/5UHhhjPXmVNb3ejVk5eBG0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bDESWR/dJMb99ZscU3/5UHhhjPXmVNb3ejVk5eBG0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bDESWR/dJMb99ZscU3/5UHhhjPXmVNb3ejVk5eBG0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbDESWR%2FdJMb99ZscU3%2F5UHhhjPXmVNb3ejVk5eBG0%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;1752&quot; height=&quot;677&quot; data-origin-width=&quot;1752&quot; data-origin-height=&quot;677&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;http://localhost:19093/actuator/cache-stats에 접속 시 miss와 hit 개수 &amp;amp; 퍼센트를 알 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;507&quot; data-origin-height=&quot;205&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bS2Dxp/dJMcabCXm8O/BkRlcMRIN00CLermxyWL11/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bS2Dxp/dJMcabCXm8O/BkRlcMRIN00CLermxyWL11/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bS2Dxp/dJMcabCXm8O/BkRlcMRIN00CLermxyWL11/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbS2Dxp%2FdJMcabCXm8O%2FBkRlcMRIN00CLermxyWL11%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;507&quot; height=&quot;205&quot; data-origin-width=&quot;507&quot; data-origin-height=&quot;205&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;u&gt;&lt;b&gt;이 코드의 문제&lt;/b&gt;&lt;/u&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;Redis TTL 끝나는 타이밍에 트래픽이 몰리면, 들어온 모든 요청이 DB에 한번에 몰려 &lt;br /&gt;캐시 스탬피드(cache stampede) 발생&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; color: #1d1c1d; text-align: left;&quot;&gt;[ &lt;/span&gt;&lt;/b&gt;&lt;b&gt;캐시 스탬피드 (Cache Stampede) &lt;/b&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; color: #1d1c1d; text-align: left;&quot;&gt;]&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;u&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; color: #1d1c1d; text-align: left;&quot;&gt;여러 요청이 동시에 캐시 미스가 나면서, 원본&amp;nbsp;데이터(DB&amp;middot;외부&amp;nbsp;API)에&amp;nbsp;한꺼번에&amp;nbsp;몰려가는&amp;nbsp;현상&lt;/span&gt;&lt;/b&gt;&lt;/u&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #1d1c1d; text-align: left;&quot;&gt;200명이 동시 요청으로 몰릴 시 Redis에 값이 있지만 TTL이 만료 된 경우 lock으로 한 명의 사용자만 DB에서 값을 가져오거나&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #1d1c1d; text-align: left;&quot;&gt;DB 값도 TTL이 만료 되었다면 외부 Api(Kakao Api)를 호출해야 함 &lt;br /&gt;-&amp;gt; 나머지 199명에게는 만료된 값을 주거나 일정 시간 &amp;amp; 일정 횟수만큼 재시도 처리 후 실패 처리 등 처리 필요&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 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 data-ke-size=&quot;size16&quot;&gt;- &lt;u&gt;&lt;b&gt;라즈베리파이나 프리티어 AWS로 서버를 돌릴 경우 낮은 서버 사양과 Kakao Api를 보내는 하루 &amp;amp; 월 사용 제한이 있으므로 스케줄링을 사용하여 항상 최신의 응답을 캐시에 넣을 시 많은 비용이 발생&lt;/b&gt;&lt;/u&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-&lt;b&gt; &lt;span style=&quot;color: #ee2323;&quot;&gt;비용 절감을 위해 스케줄링 사용 없이 사용자의 요청이 있을 때만 외부 Api를 전송하여 최신의 값을 제공해야 함&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;저장소의 TTL 분리&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 TTL (10분)&lt;/li&gt;
&lt;li style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;DB TTL (15분)&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 data-end=&quot;341&quot; data-start=&quot;308&quot;&gt;&lt;b&gt;Redis가 있으면 무조건 Redis 먼저 사용&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;507&quot; data-start=&quot;342&quot;&gt;&lt;b&gt;Redis hit이면 항상 즉시 응답&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;507&quot; data-start=&quot;375&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;507&quot; data-start=&quot;375&quot;&gt;&lt;b&gt;단&lt;/b&gt;, &lt;u&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;Redis TTL이 5분 이하로 남아있으면&lt;/b&gt;&lt;/span&gt;&lt;/u&gt;&lt;br /&gt;&amp;rarr; &lt;b&gt;&lt;u&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;응답은 그대로 주되, refreshLock(single-flight) 성공한 1명만 외부 API로 백그라운드 갱신(refresh-ahead)&lt;/span&gt;&lt;/u&gt;&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;533&quot; data-start=&quot;508&quot;&gt;&lt;b&gt;Redis miss이면 DB 확인&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;593&quot; data-start=&quot;534&quot;&gt;&lt;b&gt;DB도 없거나 DB TTL 초과(15분 초과)면 외부 API 필요 (single-flight)&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;663&quot; data-start=&quot;594&quot;&gt;&lt;b&gt;Redis 장애면 DB fallback + DB bulkhead(동시성 제한)로 &amp;ldquo;느리게라도&amp;rdquo; 응답, DB 보호&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt; 시나리오별 동작 &lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;color: #006dd7;&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; text-align: left;&quot;&gt;1. Redis와 DB 모두 값이 없는 초기 상황 (Cord start)&amp;nbsp;&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;949&quot; data-start=&quot;722&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;734&quot; data-start=&quot;722&quot;&gt;Redis miss&lt;/li&gt;
&lt;li data-end=&quot;744&quot; data-start=&quot;735&quot;&gt;DB miss&lt;/li&gt;
&lt;li data-end=&quot;802&quot; data-start=&quot;745&quot;&gt;refreshLock(single-flight) : &lt;b&gt;200명 중 1명만&lt;/b&gt; 외부 API 호출&lt;/li&gt;
&lt;li data-end=&quot;888&quot; data-start=&quot;803&quot;&gt;나머지 199명:
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;888&quot; data-start=&quot;817&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;857&quot; data-start=&quot;817&quot;&gt;&lt;b&gt;jitter 재조회 2~3회&lt;/b&gt;로 Redis에 값이 생겼는지 확인&lt;/li&gt;
&lt;li data-end=&quot;888&quot; data-start=&quot;860&quot;&gt;3회에도 Redis에 값이 없으면 &amp;rarr; DB 조회 -&amp;gt; 그래도 없다면 503 에러 발생&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;949&quot; data-start=&quot;889&quot;&gt;외부 API 성공 시:
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;949&quot; data-start=&quot;906&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;921&quot; data-start=&quot;906&quot;&gt;&lt;b&gt;DB upsert&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;949&quot; data-start=&quot;924&quot;&gt;&lt;b&gt;Redis set (TTL 10분)&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-end=&quot;995&quot; data-start=&quot;951&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;995&quot; data-start=&quot;953&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심: Cold Start는 이전에 만료된 값이 없으니 Redis -&amp;gt; DB 순차적으로 조회&lt;br /&gt;그래도 없다면 503 에러&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;color: #006dd7;&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; text-align: left;&quot;&gt;2. Redis&amp;nbsp;hit&amp;nbsp;+&amp;nbsp;TTL&amp;nbsp;&amp;gt;&amp;nbsp;5분&amp;nbsp;(Redis&amp;nbsp;Fresh)&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Redis hit으로 즉시&amp;nbsp;응답&amp;nbsp;후&amp;nbsp;종료&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;color: #006dd7;&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; text-align: left;&quot;&gt;3. &lt;span style=&quot;background-color: #ffffff; text-align: left;&quot;&gt;Redis가 사망 후 복구되어 값이 없거나 값이 있어도 TTL이 만료된 상황이지만, DB에선 값도 있으며 TTL도 유효한 상황&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(Redis가 죽었다가 복구했거나, Redis가 만료돼서 비어있는 상황)&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1845&quot; data-start=&quot;1542&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1554&quot; data-start=&quot;1542&quot;&gt;Redis miss&lt;/li&gt;
&lt;li data-end=&quot;1575&quot; data-start=&quot;1555&quot;&gt;DB hit + DB TTL 유효&lt;/li&gt;
&lt;li data-end=&quot;1730&quot; data-start=&quot;1576&quot;&gt;warmupLock(single-flight) : &lt;b&gt;1명만 DB 조회&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1730&quot; data-start=&quot;1624&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1669&quot; data-start=&quot;1624&quot;&gt;DB 값을 Redis에 &lt;b&gt;즉시 warm-up (TTL 10분)&lt;/b&gt; 하고 응답&lt;/li&gt;
&lt;li data-end=&quot;1730&quot; data-start=&quot;1672&quot;&gt;동시에 refreshLock을 잡은 1명이 &lt;b&gt;백그라운드 외부 API 갱신&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;1845&quot; data-start=&quot;1731&quot;&gt;나머지 199명:
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1845&quot; data-start=&quot;1745&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1779&quot; data-start=&quot;1745&quot;&gt;&lt;b&gt;jitter 재조회 2~3회로 Redis가 채워졌는지 확인&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;1798&quot; data-start=&quot;1782&quot;&gt;&lt;b&gt;채워지면 Redis로 응답&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;1845&quot; data-start=&quot;1801&quot;&gt;3회 재조회 실패 시 DB 값을 fallback으로 응답&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;핵심: Redis miss 시 DB로 한 명만 내려가서 Redis를 채우고, 나머지는 Redis에서 응답을 받음 &lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;( 나머지 199명 -&amp;gt; jitter 재조회 2~3회로 Redis 값 받아서 반환 )&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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;size18&quot;&gt;&lt;span style=&quot;color: #006dd7;&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; text-align: left;&quot;&gt;4. &lt;span style=&quot;background-color: #ffffff; text-align: left;&quot;&gt;Redis TTL 만료이고, DB에 값이 있지만 TTL이 초과인 상황&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #1d1c1d; text-align: left;&quot;&gt;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #1d1c1d; text-align: left;&quot;&gt;Redis&amp;nbsp;miss&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;background-color: #ffffff; color: #1d1c1d; text-align: left;&quot;&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #1d1c1d; text-align: left;&quot;&gt;DB 값은 있으나 TTL 초과 &amp;rarr; 신선하지 않음&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;background-color: #ffffff; color: #1d1c1d; text-align: left;&quot;&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #1d1c1d; text-align: left;&quot;&gt;refreshLock(single-flight) : 1명만 외부 API 호출&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;background-color: #ffffff; color: #1d1c1d; text-align: left;&quot;&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #1d1c1d; text-align: left;&quot;&gt;나머지 199명:&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #1d1c1d; text-align: left;&quot;&gt;jitter 재조회 2~3회로 Redis 값 생성 기다림&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #1d1c1d; text-align: left;&quot;&gt;199명에게 DB에 만료된 값이라도 준다면 지연 시간을 줄일 수 있지만 너무 과거의 값을 주게 된다면 신뢰성을 잃음&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff; color: #1d1c1d; text-align: left;&quot;&gt;그러나 3회 재시도에도 Redis에 값이 없으면 : &lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #1d1c1d; text-align: left;&quot;&gt;DB stale 값으로 응답(Degraded) - 일단 신선하지 않은 결과라도 전달&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;2241&quot; data-start=&quot;2229&quot;&gt;외부 API 성공 시:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2259&quot; data-start=&quot;2244&quot;&gt;&lt;b&gt;DB upsert&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;2287&quot; data-start=&quot;2262&quot;&gt;&lt;b&gt;Redis set (TTL 10분)&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;핵심:&amp;nbsp;외부&amp;nbsp;API가&amp;nbsp;필요하지만,&amp;nbsp;실패&amp;nbsp;시에도&amp;nbsp;서비스&amp;nbsp;연속성을&amp;nbsp;위해&amp;nbsp;DB&amp;nbsp;stale을&amp;nbsp;최후&amp;nbsp;fallback으로&amp;nbsp;둔다.&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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;size18&quot;&gt;&lt;span style=&quot;color: #006dd7;&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; text-align: left;&quot;&gt;5. Redis나 Kakao Map Api에 문제가 생기면?&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-start=&quot;587&quot; data-end=&quot;614&quot;&gt;&lt;b&gt;Redis 장애면 &lt;u&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;DB fallback + DB bulkhead&lt;/span&gt;&lt;/u&gt;&lt;/b&gt;&lt;/li&gt;
&lt;li data-start=&quot;587&quot; data-end=&quot;614&quot;&gt;속도를 느리게라도 DB에 값을 응답으로 보내줘야 함&lt;/li&gt;
&lt;li data-start=&quot;587&quot; data-end=&quot;614&quot;&gt;현재 로직에선 &lt;u&gt;&lt;b&gt;주문 후 배송이 이뤄지는 비동기 로직으로 구현되어 있어 사용자에게 따로 알림을 전송하진 않음&lt;/b&gt;&lt;/u&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; color: #1d1c1d; text-align: left;&quot;&gt;구현&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #1d1c1d; text-align: left;&quot;&gt;- Redis에 분산락을 사용하여 하나의 요청만 Kakao에 갱신되도록 해야 함&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #1d1c1d; text-align: left;&quot;&gt;- Lock는 2개로 나뉨&amp;nbsp; &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #1d1c1d; text-align: left;&quot;&gt;&amp;nbsp; - &lt;span style=&quot;background-color: #ffffff; color: #1d1c1d; text-align: left;&quot;&gt;Redis에 값이 없을 시 DB로 내려가 Redis를 채움&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #1d1c1d; text-align: left;&quot;&gt;&amp;nbsp; - Cold Start 상황, &lt;span style=&quot;background-color: #ffffff; color: #1d1c1d; text-align: left;&quot;&gt;Redis TTL이 5분 이하이거나 DB값의 TTL도 만료인 경우 외부 Api(Kakao Api) 호출하는 용&lt;/span&gt; &lt;/span&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;1611&quot; data-start=&quot;1577&quot;&gt;1명만 refresh lock &amp;rarr; Kakao 갱신 시도&lt;/li&gt;
&lt;li data-end=&quot;1611&quot; data-start=&quot;1577&quot;&gt;Kakao에게 응답을 성공적으로 받을 시 DB upsert + Redis set&lt;/li&gt;
&lt;li data-end=&quot;1643&quot; data-start=&quot;1612&quot;&gt;나머지 유저들은 리더가 응답을 받아올 때까지 Redis 3회 재조회 -&amp;gt;그래도 없으면 503&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Redis 클라이언트 중 Lettuce 사용&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-start=&quot;246&quot; data-end=&quot;288&quot;&gt;Redis 서버에 명령을 안전하고 효율적으로 전달&lt;/li&gt;
&lt;li&gt;비동기 / 논블로킹 기반&lt;/li&gt;
&lt;li&gt;Redis polling + jitter 구조에 안정적&lt;/li&gt;
&lt;li&gt;분산락을 &amp;ldquo;자동으로&amp;rdquo; 제공하진 않음&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Lettuce 선택 이유&amp;nbsp;&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-start=&quot;374&quot; data-end=&quot;402&quot;&gt;Redis의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;Lettuce&lt;span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;락은 Lock를 얻지 못한 스레드가 Lock 획득 시도를 반복하는 스핀락(spin lock) 문제가 있으므로 무조건 사용하면 안 된다라는 블로그들 글이 있다.&lt;/li&gt;
&lt;li data-start=&quot;374&quot; data-end=&quot;402&quot;&gt;하지만 지금의 경우&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Lock 획득하기 위해 싸우는게 목적이 아닌 &quot;갱신자 한 명을 선출 후 나머지는 Lock를 대기하지 않음&quot;&lt;/b&gt;&lt;/li&gt;
&lt;li data-start=&quot;374&quot; data-end=&quot;402&quot;&gt;즉&lt;b&gt;&lt;span&gt; L&lt;/span&gt;ock을 기다리지 않는 single-flight 캐시 갱신 정책을 사용하므로&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;u&gt;최소 기능(SET NX PX + 토큰 기반 해제)만으로 투명하고 가볍게 구현 가능한 Lettuce 방식을 선택&lt;/u&gt;&lt;/span&gt;&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1768800388171&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Component
@RequiredArgsConstructor
public class RedisHubEdgeCache {

    private final StringRedisTemplate redisTemplate;
    private final ObjectMapper om;

    public Optional&amp;lt;CacheHit&amp;lt;HubInfo&amp;gt;&amp;gt; get(String key){
        try {
            String value = redisTemplate.opsForValue().get(key);
            if(value == null) { return Optional.empty(); }

            long sec = redisTemplate.getExpire(key);
            Duration ttl = sec &amp;lt; 0 ? Duration.ZERO : Duration.ofSeconds(sec);

            return Optional.of(new CacheHit&amp;lt;&amp;gt;(om.readValue(value, HubInfo.class), ttl));

        } catch (Exception e) {
            // 역직렬화/redis 오류면 캐시 제거 후 miss 처리
            redisTemplate.delete(key);

            return Optional.empty();
        }
    }

    public void add(String key, HubInfo hubInfo, Duration ttl){
        try {
            redisTemplate.opsForValue().set(key, om.writeValueAsString(hubInfo), ttl);
        } catch (Exception ignored) {
            // 캐시 저장 실패는 복구 불가능 오류가 아니므로 실패를 시키지 않음
        }
    }

    // DB 조회/갱신은 1명만 가능하도록 Lock
    public boolean tryLock(String key, Duration ttl){
        Boolean result = redisTemplate.opsForValue().setIfAbsent(key, &quot;1&quot;, ttl);
        return Boolean.TRUE.equals(result);
    }

    public void unlock(String key){
        try { redisTemplate.delete(key); } catch (Exception ignored) {}
    }
}&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;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;HubEdgeWeightService&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;@Service
@Slf4j
@RequiredArgsConstructor
public class HubEdgeWeightService implements HubEdgeWeightProvider {

     private static final Duration REDIS_TTL = Duration.ofMinutes(6);

    // single-flight lock TTL (너무 길게 잡지 말고 &amp;ldquo;짧게&amp;rdquo;)
    private static final Duration LOCK_TTL = Duration.ofSeconds(10);

    // DB bulkhead: Redis 장애 시 DB 동시 접근 제한
    private final Semaphore dbBulkhead = new Semaphore(10);

    // jitter 재조회(50 -&amp;gt; 100 -&amp;gt; 200ms)
    private static final long[] JITTER_MS = {50, 150, 250};

    // Redis가 죽으면 Redis lock을 못 쓰니, JVM 내부 single-flight로 Kakao 폭주 방지 (같은 키에 대해 1명만 진입시키는 락 사용)
    private final ConcurrentHashMap&amp;lt;String, ReentrantLock&amp;gt; localLocks = new ConcurrentHashMap&amp;lt;&amp;gt;();

    private final HubInfoRepository hubInfoRepository;
    private final HubRepository hubRepository;
    private final KakaoMapClient kakaoMapClient;
    private final RedisHubEdgeCache redisHubEdgeCache;
    private final CacheStats cacheStats;
    private final TransactionTemplate transactionTemplate;

    @Override
    @Transactional
    public EdgeWeight getWeight(UUID startHubId, UUID endHubId) {

        String redisKey = HubEdgeCacheKeys.edgeKeys(String.valueOf(startHubId), String.valueOf(endHubId));
        final String refreshLockKey = redisKey + &quot;:refreshLock&quot;;
        final String warmupLockKey = redisKey + &quot;:warmupLock&quot;;
        boolean redisDown = false;

        LocalDateTime now = LocalDateTime.now();

        /** 1) Redis hit (항상 즉시 응답) */
        try {
            Optional&amp;lt;CacheHit&amp;lt;HubInfo&amp;gt;&amp;gt; redisHit = redisHubEdgeCache.get(redisKey);
            if (redisHit.isPresent()) {

                HubInfo hubInfo = redisHit.get().value();
                Duration ttlTime = redisHit.get().ttlTime();

                cacheStats.hit();
                log.info(&quot;redis Cache hit&quot;);

                // Redis TTL이 5분 이하면 값은 반환해서 주지만 Kakao API를 호출하여 시간 갱신
                if (ttlTime.compareTo(Duration.ofMinutes(5)) &amp;lt;= 0) {

                    // 동시에 refresh-ahead 트리거(1명만)
                    triggerRefreshAheadAsync(refreshLockKey, startHubId, endHubId, redisKey);
                }

                return new EdgeWeight(startHubId, endHubId, hubInfo.getDeliveryDuration(), hubInfo.getDistance());
            }
            cacheStats.miss();
        } catch (DataAccessException e) {

            // Redis 장애: 아래에서 DB fallback + bulkhead
            redisDown = true;
            log.warn(&quot;Redis down -&amp;gt; fallback to DB with bulkhead key={}&quot;, redisKey, e);
        }

        /** 2) Redis가 완전히 죽은 경우: DB로 &quot;느리게라도 무조건&quot; 반환 */
        if (redisDown) {
            return handleRedisDownMustReturnDb(startHubId, endHubId, redisKey, now);
        }

        /** 3) Redis miss (정상 Redis 동작) */
        return handleRedisMissNormal(startHubId, endHubId, warmupLockKey, refreshLockKey, redisKey, now);
    }

    /**
     * Redis가 완전히 죽었을 때 정책: - DB bulkhead는 &quot;대기&quot;해서라도 들어가는 방향으로 - DB에 시간/거리 값이 있으면 TTL 상관없이 무조건 반환
     * (단 리더는 무조건 최신값을 받게 구현 (DB TTL 만료 시 사용자가 한명만 접근하더라도 최신의 값을 주어야 되기 때문))

     * 예시 1 : Redis에 값이 없는 상황에서 DB에 값이 없는 상황 (cold start)
     * -&amp;gt; 로컬 single-flight로 1명만 Kakao Api 호출 -&amp;gt; bulkhead로 10명씩만 처리하기 때문에 많은 지연이 발생할 수 있음

     * 예시 2 : Redis가 없는 상황에서 DB에 값은 있지만 TTL이 만료된 상황 -&amp;gt; DB에서 값을 읽고 TTL이 만료된 값이라도 일단 반환 (bulkhead 적용) -&amp;gt;
     * 만료된 값을 받고 있다가 1명이 백그라운드 갱신 성공 시 최신의 값을 받게 됨 (여전히 bulkhead 적용)

     * - DB에 값이 없으면 cold-start처럼 외부 API 필요 (JVM single-flight로 Kakao api 폭주 방지)
     */
    private EdgeWeight handleRedisDownMustReturnDb(UUID startHubId, UUID endHubId, String redisKey, LocalDateTime now) {

        // 느리게라도 대기해서 bulkhead 진입
        dbBulkhead.acquireUninterruptibly();

        try {
            HubInfo hubInfo = hubInfoRepository.findByStartHubIdAndEndHubId(startHubId, endHubId)
                .orElseThrow(() -&amp;gt; new BusinessException(ErrorCode.HUB_NOT_FOUND));

            boolean hasValue = hubInfo.hasDeliveryInfo();

            // Redis 사망 + DB에 값도 없는 ColdStart 상황
            if (!hasValue) {
                return coldStartWithLocalSingleFlightSync(startHubId, endHubId, redisKey);
            }

            // DB에 값은 있지만 TTL이 만료 된 상황으로 우선 만료된 값을 주며 1명만 백그라운드 갱신
            if (hubInfo.checkUpdateTime(now)) {
                triggerRefreshWhenRedisDownAsync(startHubId, endHubId, redisKey);
            }

            return new EdgeWeight(startHubId, endHubId, hubInfo.getDeliveryDuration(), hubInfo.getDistance());
        } finally {
            dbBulkhead.release();
        }
    }

    /**
     * Redis down + DB TTL 만료(stale)여도 일단 반환하고, 1명만 백그라운드로 최신 갱신 시도
     * (중요: Thread에서 JPA 쓰려면 트랜잭션 필요 -&amp;gt; TransactionTemplate 사용)
     */
    private void triggerRefreshWhenRedisDownAsync(UUID startHubId, UUID endHubId, String redisKey) {

        ReentrantLock lock = localLocks.computeIfAbsent(redisKey, k -&amp;gt; new ReentrantLock());

        // reader가 아니라면 그냥 return
        if (!lock.tryLock()) {
            return;
        }

        new Thread(() -&amp;gt; {
            try {
                transactionTemplate.execute(status -&amp;gt; {
                HubInfo hubInfo = hubInfoRepository.findByStartHubIdAndEndHubId(startHubId, endHubId)
                    .orElseThrow(() -&amp;gt; new BusinessException(ErrorCode.HUB_NOT_FOUND));

                // Redis는 죽었으니 DB만 갱신
                DirectionInfoResponseV1 directionInfoResponse = getKakaoMap(hubInfo);
                dbSet(directionInfoResponse.duration(), directionInfoResponse.distance(), hubInfo);

                // TransactionTemplate.execute()는 반드시 return 값이 필요하지만 지금은 필요가 없으므로 null 반환
                return null;
                });
            } catch (Exception e) {
                log.error(&quot;[RedisDownRefresh] failed {} -&amp;gt; {}&quot;, startHubId, endHubId, e);
            }finally {
                lock.unlock();
            }
        }).start();
    }

    /** Redis down + DB에도 값이 없는 cold-start
     * 리더 한명만 kakao Api를 호출하며 나머지는 DB 값이 채워질 때까지 짧게 재조회 시도
    */
    private EdgeWeight coldStartWithLocalSingleFlightSync(UUID startHubId, UUID endHubId, String redisKey) {

        ReentrantLock lock = localLocks.computeIfAbsent(redisKey, k -&amp;gt; new ReentrantLock());

        boolean tryLock = lock.tryLock();
        if (tryLock) {
            try {
                HubInfo hubInfo = hubInfoRepository.findByStartHubIdAndEndHubId(startHubId, endHubId)
                    .orElseThrow(() -&amp;gt; new BusinessException(ErrorCode.HUB_NOT_FOUND));

                // 혹시 그 사이에 다른 트랜잭션에서 값이 채워졌으면 그대로 반환
                if (hubInfo.hasDeliveryInfo()){
                    return new EdgeWeight(startHubId, endHubId, hubInfo.getDeliveryDuration(), hubInfo.getDistance());
                }

                // DB만 갱신
                DirectionInfoResponseV1 directionInfoResponse = getKakaoMap(hubInfo);
                dbSet(directionInfoResponse.duration(), directionInfoResponse.distance(), hubInfo);

                return new EdgeWeight(startHubId, endHubId, hubInfo.getDeliveryDuration(), hubInfo.getDistance());
            }finally {
                lock.unlock();
            }
        }

        for(long ms : JITTER_MS){
            sleep(ms);
            HubInfo hubInfo = hubInfoRepository.findByStartHubIdAndEndHubId(startHubId, endHubId)
                .orElseThrow(() -&amp;gt; new BusinessException(ErrorCode.HUB_NOT_FOUND));

            // 값이 DB에 갱신이 되었으면 반환
            if (hubInfo.hasDeliveryInfo()){
                return new EdgeWeight(startHubId, endHubId, hubInfo.getDeliveryDuration(), hubInfo.getDistance());
            }
        }
        throw new BusinessException(ErrorCode.TEMPORARILY_UNAVAILABLE);
    }

    /**
     * Redis 서버는 정상인데 TTL 만료인 경우: warmupLeader 1명만 DB 내려가서 Redis 채움 -&amp;gt; 나머지는 jitter로 Redis 재조회
     * DB에 값도 TTL 만료 상태라면 refreshLock으로 1명만 Kakao 호출 (리더는 최신값으로 응답하도록 구성)
     * 예시 (Redis 만료 + DB도 TTL 만료 상황에서 200명 접근 시 리더 1명만 최신 값을 받고 나머지 199명은 redis에 재처리 3회를 하면서 리더가 갱신한 값을 받게 됨)
     * 3회 처리에도 받지 못한다면 에러를 띄움
     */
    private EdgeWeight handleRedisMissNormal(UUID startHubId, UUID endHubId, String warmupLockKey,
        String refreshLockKey, String redisKey, LocalDateTime now) {

        // DB에 값이 있다면 한명만 갱신 수행
        boolean warmupLeader = redisHubEdgeCache.tryLock(warmupLockKey, LOCK_TTL);

        if (warmupLeader) {
            try {
                HubInfo hubInfo = hubInfoRepository.findByStartHubIdAndEndHubId(startHubId, endHubId)
                    .orElseThrow(() -&amp;gt; new BusinessException(ErrorCode.HUB_NOT_FOUND));

                //DB에도 값이 없으면 cold-start (1명만 Kakao 동기 호출해서 생성 후 응답)
                if (! hubInfo.hasDeliveryInfo()) {
                    return coldStartFetchWithRedisLock(startHubId, endHubId, refreshLockKey, redisKey, hubInfo);
                }
                // DB의 값이 존재하고 TTL도 살아 있으면 Redis에 warm-up 후 즉시 응답
                if (!hubInfo.checkUpdateTime(now)) { // false = fresh
                    redisHubEdgeCache.add(redisKey, hubInfo, REDIS_TTL);
                    return new EdgeWeight(startHubId, endHubId, hubInfo.getDeliveryDuration(), hubInfo.getDistance());
                }

                // Redis 서버는 살아 있고 DB에 값도 있지만 DB 값의 TTL이 만료 된 상황이라면 예전 값이라도 주는 동시에 1명은 값 갱신
                boolean leader = redisHubEdgeCache.tryLock(refreshLockKey, LOCK_TTL);
                if (leader) {
                    try {
                        // 리더는 항상 최신값을 받아서 응답
                        DirectionInfoResponseV1 directionInfoResponse = getKakaoMap(hubInfo);
                        RedisAndDbSet(directionInfoResponse.duration(), directionInfoResponse.distance(), hubInfo, redisKey);
                        return new EdgeWeight(startHubId, endHubId, hubInfo.getDeliveryDuration(), hubInfo.getDistance());
                    } finally {
                        redisHubEdgeCache.unlock(refreshLockKey);
                    }
                }
                // warmupLeader가 되지 못한 나머지 요청들은 우선 db에 값을 응답받고 jitter로 Redis에 값 생성을 기다리게 된다
            } finally {
                redisHubEdgeCache.unlock(warmupLockKey);
            }
        }

        return jitterWaitRedis(startHubId, endHubId, redisKey)
            .orElseThrow(() -&amp;gt; new BusinessException(ErrorCode.TEMPORARILY_UNAVAILABLE));
    }

    private EdgeWeight coldStartFetchWithRedisLock(UUID startHubId, UUID endHubId, String refreshLockKey, String redisKey, HubInfo hubInfo) {
        boolean leader = redisHubEdgeCache.tryLock(refreshLockKey, LOCK_TTL);

        if (leader) {
            try {
                DirectionInfoResponseV1 directionInfoResponse = getKakaoMap(hubInfo);
                RedisAndDbSet(directionInfoResponse.duration(), directionInfoResponse.distance(), hubInfo, redisKey);

                // 리더는 최신값으로 즉시 응답
                return new EdgeWeight(startHubId, endHubId, hubInfo.getDeliveryDuration(), hubInfo.getDistance());
            } finally {
                redisHubEdgeCache.unlock(refreshLockKey);
            }
        }

        return jitterWaitRedis(startHubId, endHubId, redisKey)
            .orElseThrow(() -&amp;gt; new BusinessException(ErrorCode.TEMPORARILY_UNAVAILABLE));
    }

    /**
     * Redis hit TTL 임박 시 한명만 값 갱신
     */
    private void triggerRefreshAheadAsync(String refreshLockKey, UUID startHubId, UUID endHubId, String redisKey) {
        boolean leader = redisHubEdgeCache.tryLock(refreshLockKey, LOCK_TTL);

        // 리더로 선정되지 못하면 반환
        if (!leader)
            return;

        // 선정된 리더만 비동기로 외부 API(Kakao Map)를 호출해 Redis + DB 갱신
        new Thread(() -&amp;gt; {
            try {
                transactionTemplate.execute(status -&amp;gt; {
                HubInfo hubInfo = hubInfoRepository.findByStartHubIdAndEndHubId(startHubId, endHubId)
                    .orElseThrow(() -&amp;gt; new BusinessException(ErrorCode.HUB_NOT_FOUND));

                // Kakao api 호출
                DirectionInfoResponseV1 directionInfoResponse = getKakaoMap(hubInfo);
                // 결과를 Redis와 DB에 갱신
                RedisAndDbSet(directionInfoResponse.duration(), directionInfoResponse.distance(), hubInfo, redisKey);
                return null;
                });
            } catch (Exception e) {
                log.error(&quot;[RefreshAhead] failed {} -&amp;gt; {}&quot;, startHubId, endHubId, e);
            } finally {
                redisHubEdgeCache.unlock(refreshLockKey); // 실패해도 락은 반드시 해제
            }
        }).start();
    }

    private DirectionInfoResponseV1 getKakaoMap(HubInfo hubInfo) {

        // 허브 존재 여부 확인
        Hub startHub = hubRepository.findById(hubInfo.getStartHubId())
            .orElseThrow(() -&amp;gt; new BusinessException(ErrorCode.HUB_NOT_FOUND));
        Hub endHub = hubRepository.findById(hubInfo.getEndHubId())
            .orElseThrow(() -&amp;gt; new BusinessException(ErrorCode.HUB_NOT_FOUND));

        // 해당 허브들에 위도, 경도값 추출
        String origin = startHub.getLongitude() + &quot;,&quot; + startHub.getLatitude();
        String destination = endHub.getLongitude() + &quot;,&quot; + endHub.getLatitude();

        // Kakao Api 호출
        return kakaoMapClient.getDirection(
            origin,
            destination,
            2,          // carType
            &quot;DIESEL&quot;,          // carFuel
            true               // carHipass
        );
    }

    private void RedisAndDbSet(long duration, long distance, HubInfo hubInfo, String redisKey) {
        // Kakao 응답은 초 단위
        int durationSec = (int) duration;

        BigDecimal distanceKm = BigDecimal
            .valueOf(distance / 1000.0)
            .setScale(3, RoundingMode.HALF_UP);

        hubInfo.updateDeliveryInfo(durationSec, distanceKm);

        // redis의 값은 after commit
        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                redisHubEdgeCache.add(redisKey, hubInfo, REDIS_TTL);
            }
        });
    }

    private void dbSet(long duration, long distance, HubInfo hubInfo) {
        // Kakao 응답은 초 단위
        int durationSec = (int) duration;

        BigDecimal distanceKm = BigDecimal
            .valueOf(distance / 1000.0)
            .setScale(3, RoundingMode.HALF_UP);

        hubInfo.updateDeliveryInfo(durationSec, distanceKm);
    }

    /** reader로 선출되지 못하고 Redis에 값 갱신을 기다리는 애들을 위한 jitter 재조회 */
    private Optional&amp;lt;EdgeWeight&amp;gt; jitterWaitRedis(UUID startHubId, UUID endHubId, String redisKey){
        for (long ms : JITTER_MS) {
            sleep(ms);

            try {
                Optional&amp;lt;CacheHit&amp;lt;HubInfo&amp;gt;&amp;gt; hubInfoCacheHit = redisHubEdgeCache.get(redisKey);
                if (hubInfoCacheHit.isPresent()) {
                    HubInfo hubInfo = hubInfoCacheHit.get().value();
                    return Optional.of(new EdgeWeight(startHubId, endHubId, hubInfo.getDeliveryDuration(), hubInfo.getDistance()));
                }
            } catch (DataAccessException e) {
                return Optional.empty();
            }
        }
            return Optional.empty();
    }

    /** 이 스레드를 잠깐 재우지만 다른 스레드가 interrupt(중단 신호)로 깨우면 신호를 받고 중지
     (대기중 상황에서 타임아웃 발생 &amp;amp; 클라이언트가 연결을 끊음 &amp;amp; 서버 종료 등) */
    private void sleep(long ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException ignored) {
            Thread.currentThread().interrupt();
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;중요 내용&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Redis에 값이 있을 경우 사용자에게 반환하고 Redis&amp;nbsp;hit&amp;nbsp;TTL&amp;nbsp;임박&amp;nbsp;시&amp;nbsp;한 명만&amp;nbsp;값&amp;nbsp;갱신&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Redis가 완전히 죽은 경우 세마포어에서 설정한 만큼씩만 DB에 접근하여&amp;nbsp; &quot;느리게라도 무조건&quot; 반환&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Redis&amp;nbsp;서버는&amp;nbsp;정상인데&amp;nbsp;TTL&amp;nbsp;만료인&amp;nbsp;경우:&amp;nbsp;warmupLeader&amp;nbsp;1명만&amp;nbsp;DB&amp;nbsp;내려가서&amp;nbsp;Redis&amp;nbsp;채움&amp;nbsp;-&amp;gt;&amp;nbsp;나머지는&amp;nbsp;jitter로&amp;nbsp;Redis&amp;nbsp;재조회&lt;/p&gt;
&lt;pre id=&quot;code_1768800849946&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;        /** 1) Redis hit (항상 즉시 응답) */
        try {
            Optional&amp;lt;CacheHit&amp;lt;HubInfo&amp;gt;&amp;gt; redisHit = redisHubEdgeCache.get(redisKey);
            if (redisHit.isPresent()) {

                HubInfo hubInfo = redisHit.get().value();
                Duration ttlTime = redisHit.get().ttlTime();

                cacheStats.hit();
                log.info(&quot;redis Cache hit&quot;);

                // Redis TTL이 5분 이하면 값은 반환해서 주지만 Kakao API를 호출하여 시간 갱신
                if (ttlTime.compareTo(Duration.ofMinutes(5)) &amp;lt;= 0) {

                    // 동시에 refresh-ahead 트리거(1명만)
                    triggerRefreshAheadAsync(refreshLockKey, startHubId, endHubId, redisKey);
                }

                return new EdgeWeight(startHubId, endHubId, hubInfo.getDeliveryDuration(), hubInfo.getDistance());
            }
            cacheStats.miss();
        } catch (DataAccessException e) {

            // Redis 장애: 아래에서 DB fallback + bulkhead
            redisDown = true;
            log.warn(&quot;Redis down -&amp;gt; fallback to DB with bulkhead key={}&quot;, redisKey, e);
        }

        /** 2) Redis가 완전히 죽은 경우: DB로 &quot;느리게라도 무조건&quot; 반환 */
        if (redisDown) {
            return handleRedisDownMustReturnDb(startHubId, endHubId, redisKey, now);
        }

        /** 3) Redis miss (정상 Redis 동작) */
        return handleRedisMissNormal(startHubId, endHubId, warmupLockKey, refreshLockKey, redisKey, now);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Refactoring 후 Jemeter 부하 테스트&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;- 동시 사용자 200명 &amp;times; 사용자당 100회 요청 (총 20,000 requests)의 예시&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;- 결과 :&lt;b&gt;&lt;span&gt; &lt;span&gt;p95: 1.13s, p99: 2.57s, 평균 336ms&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;- 기존 16초 걸리던 상황보다&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;16,000ms &amp;rarr; 366ms&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/b&gt; &lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;약 48배 개선&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 리팩토링 전 488ms -&amp;gt; 366ms  &amp;nbsp;&lt;b&gt;약 1.45배 개선&lt;/b&gt; &lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1076&quot; data-origin-height=&quot;96&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/I6UBS/dJMcai26Eku/qKIC1zCC3BZkIkTxilfKAk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/I6UBS/dJMcai26Eku/qKIC1zCC3BZkIkTxilfKAk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/I6UBS/dJMcai26Eku/qKIC1zCC3BZkIkTxilfKAk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FI6UBS%2FdJMcai26Eku%2FqKIC1zCC3BZkIkTxilfKAk%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;1076&quot; height=&quot;96&quot; data-origin-width=&quot;1076&quot; data-origin-height=&quot;96&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;span&gt;캐시 miss가 특정 시점에 집중되어 p99 기준 10초 이상 튀던 지연 구간이 사라지고, 응답 시간이 전반적으로 고르게 분포&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1446&quot; data-origin-height=&quot;745&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bDvJfm/dJMcaiWl7ms/pNEocg8Tpac69jFaBNBwg0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bDvJfm/dJMcaiWl7ms/pNEocg8Tpac69jFaBNBwg0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bDvJfm/dJMcaiWl7ms/pNEocg8Tpac69jFaBNBwg0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbDvJfm%2FdJMcaiWl7ms%2FpNEocg8Tpac69jFaBNBwg0%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;1446&quot; height=&quot;745&quot; data-origin-width=&quot;1446&quot; data-origin-height=&quot;745&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Cache Hit Rate 100% 유지, TTL 만료 구간에서도 성능 저하 없음&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;255&quot; data-origin-height=&quot;199&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/eEFu2g/dJMcaiWl7mW/Yb1LeKOxGv4UJmoZBUlJZK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/eEFu2g/dJMcaiWl7mW/Yb1LeKOxGv4UJmoZBUlJZK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/eEFu2g/dJMcaiWl7mW/Yb1LeKOxGv4UJmoZBUlJZK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FeEFu2g%2FdJMcaiWl7mW%2FYb1LeKOxGv4UJmoZBUlJZK%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;255&quot; height=&quot;199&quot; data-origin-width=&quot;255&quot; data-origin-height=&quot;199&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Spring Boot</category>
      <category>cache stampede</category>
      <category>DB</category>
      <category>Redis</category>
      <category>Spring</category>
      <category>Spring boot</category>
      <category>캐시 스탬피드</category>
      <author>kimfishes</author>
      <guid isPermaLink="true">https://kimfishes.tistory.com/13</guid>
      <comments>https://kimfishes.tistory.com/13#entry13comment</comments>
      <pubDate>Mon, 19 Jan 2026 15:25:58 +0900</pubDate>
    </item>
    <item>
      <title>라즈베리파이에 Loki + Grafana로 로그 수집 시스템 구축하기</title>
      <link>https://kimfishes.tistory.com/12</link>
      <description>&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;문제&lt;/b&gt;&lt;/h3&gt;
&lt;p data-end=&quot;443&quot; data-start=&quot;355&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;기존에는 EC2 환경에서 ELK 스택을 사용해 로그를 수집하고 있었다&lt;/b&gt;.&lt;br /&gt;하지만 개인 프로젝트와 홈 서버 성격의 서비스까지 포함되면서 다음 문제가 발생했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;514&quot; data-start=&quot;445&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;469&quot; data-start=&quot;445&quot;&gt;ELK는 &lt;b&gt;메모리 사용량이 너무 큼&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;514&quot; data-start=&quot;489&quot;&gt;라즈베리파이의 RAM 용량은 4~8GB이지만 ELK를 가볍게 띄워도 3~4GB가 소요됨&lt;/li&gt;
&lt;li data-end=&quot;514&quot; data-start=&quot;489&quot;&gt;라즈베리파이의 디스크 IO 성능 제한&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt; 지금 환경에서 ELK는 기능은 충분하지만, 너무 무거우므로 &amp;ldquo;가벼운 로그 수집 시스템&amp;rdquo;이 필요&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 data-end=&quot;939&quot; data-start=&quot;924&quot;&gt;서비스별 로그 흐름 파악&lt;/li&gt;
&lt;li data-end=&quot;958&quot; data-start=&quot;940&quot;&gt;장애 발생 시 빠른 원인 추적&lt;/li&gt;
&lt;li data-end=&quot;983&quot; data-start=&quot;959&quot;&gt;저사양 환경에서도 &lt;b&gt;안정적으로 동작&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;997&quot; data-start=&quot;984&quot;&gt;운영 부담이 적을 것&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Loki + Grafana + Promtail&amp;nbsp;조합&lt;/b&gt;&lt;/h3&gt;
&lt;p data-end=&quot;1280&quot; data-start=&quot;1260&quot; data-ke-size=&quot;size16&quot;&gt;기존 로그 시스템과 달리 로그 &lt;b&gt;본문을 인덱싱 하지 않고 &lt;/b&gt;로그에 붙은 &lt;b&gt;라벨(Label)만 인덱싱하는 구조&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 data-end=&quot;1369&quot; data-start=&quot;1356&quot;&gt;메모리 사용량이 낮고&lt;/li&gt;
&lt;li data-end=&quot;1386&quot; data-start=&quot;1370&quot;&gt;디스크 IO 부담이 적으며&lt;/li&gt;
&lt;li data-end=&quot;1411&quot; data-start=&quot;1387&quot;&gt;라즈베리파이 같은 저사양 환경에 적합하다&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;각 구성요소 역할&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Loki&lt;/b&gt;: Grafana Labs에서 만든 &lt;b&gt;로그 수집&amp;middot;저장 시스템&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Grafana&lt;/b&gt;: &lt;b&gt;시각화 &amp;amp; 검색 UI&lt;/b&gt; ( &amp;ldquo;관측성(Observability)을 시각화하는 플랫폼&amp;rdquo;으로 Grafana 자체는 데이터를 저장하지 않는다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Promtail :&lt;/b&gt;&lt;b&gt;&amp;nbsp;&lt;/b&gt; 로그 수집 Agent (&lt;b&gt;로그 파일을 읽어, 라벨을 붙인 뒤 Loki로 전달&lt;/b&gt;)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt; 기존 로그 시스템(ELK)의 방식 &lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Full Text Index 방식으로 로그 &lt;b&gt;내용 전체를 인덱싱 ( 모든 필드, 모든 텍스트를 검색 가능 )&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1766384873390&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;[ERROR] OrderService - orderId=1234 timeout occurred&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-end=&quot;422&quot; data-start=&quot;413&quot; data-ke-size=&quot;size23&quot;&gt;문제점&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;482&quot; data-start=&quot;423&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;433&quot; data-start=&quot;423&quot;&gt;인덱스 용량 큼&lt;/li&gt;
&lt;li data-end=&quot;445&quot; data-start=&quot;434&quot;&gt;메모리 사용량 큼&lt;/li&gt;
&lt;li data-end=&quot;456&quot; data-start=&quot;446&quot;&gt;디스크 IO 부담&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt; Loki의 접근 방식: 라벨(Label) 기반 &lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;❝ 로그의 내용을 인덱싱 하지 않으며, 대신 로그의 메타데이터(label)만 인덱싱 한다 ❞&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;라벨(Label)이란?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 라벨은 로그 한 줄 한 줄에 붙는 &lt;b&gt;검색용 메타데이터&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 예시 로그가 있다면&lt;/p&gt;
&lt;pre id=&quot;code_1766384951190&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;2025-12-19 11:36:29 ERROR OrderService orderId=1234 timeout&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Loki에 넣을 때 이런 방식으로 로그를 넣게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉&lt;b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;Loki는 오직 이 label만 인덱싱하여 로그 본문(message)은 인덱싱 되지 않는다&lt;/b&gt;.&lt;/p&gt;
&lt;pre id=&quot;code_1766385007104&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;labels:
  job: chill-logistics
  service: order-server
  level: ERROR
  instance: raspberrypi-01&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-start=&quot;413&quot; data-end=&quot;422&quot; data-ke-size=&quot;size23&quot;&gt;라벨 기반의 장점&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1428&quot; data-start=&quot;1414&quot;&gt;로그 본문은 사용하지 않으며 메타데이터(label)만 인덱싱 사용&lt;/li&gt;
&lt;li data-end=&quot;1428&quot; data-start=&quot;1414&quot;&gt;인덱싱 비용 거의 없음&lt;/li&gt;
&lt;li data-end=&quot;1442&quot; data-start=&quot;1429&quot;&gt;로그 쓰기 속도 빠름&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-start=&quot;1500&quot; data-end=&quot;1504&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote style=&quot;color: #666666;&quot; data-start=&quot;1505&quot; data-end=&quot;1525&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p style=&quot;color: #666666;&quot; data-start=&quot;1507&quot; data-end=&quot;1525&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo; ELK : 일단 다 넣고 나중에 검색하자&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-start=&quot;1527&quot; data-end=&quot;1532&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote style=&quot;color: #666666;&quot; data-start=&quot;1533&quot; data-end=&quot;1557&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p style=&quot;color: #666666;&quot; data-start=&quot;1535&quot; data-end=&quot;1557&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo; Loki : 어떤 기준으로 검색할지를 미리 정하자&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 59px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px; text-align: center;&quot;&gt;&lt;b&gt;항목&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px; text-align: center;&quot;&gt;&lt;b&gt;ELK&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px; text-align: center;&quot;&gt;&lt;b&gt;Loki&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px; text-align: center;&quot;&gt;&lt;u&gt;메모리 사용&lt;/u&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px; text-align: center;&quot;&gt;높음&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px; text-align: center;&quot;&gt;낮음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px; text-align: center;&quot;&gt;&lt;u&gt;인덱싱 방식&lt;/u&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px; text-align: center;&quot;&gt;Full Index&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px; text-align: center;&quot;&gt;Label 기반&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-end=&quot;1504&quot; data-start=&quot;1500&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1504&quot; data-start=&quot;1500&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Code&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. Loki + Grafana의 Docker Compose 파일로 저장 후 실행 시켜준다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예시 : docker&amp;nbsp;compose&amp;nbsp;-f&amp;nbsp;docker-compose-logging.yml&amp;nbsp;up&amp;nbsp;-d&lt;/p&gt;
&lt;pre id=&quot;code_1766386299692&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;services:
  loki:
    image: grafana/loki:2.9.8
    container_name: loki
    ports:
      - &quot;3100:3100&quot;
    command: -config.file=/etc/loki/local-config.yaml
    restart: always
    volumes:
      - loki_data:/loki

  grafana:
    image: grafana/grafana:10.4.5
    container_name: grafana
    ports:
      - &quot;3000:3000&quot;
    restart: always
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=admin
      # 필요하면 아래도 추가
      # - GF_SERVER_ROOT_URL=http://&amp;lt;GRAFANA_HOST&amp;gt;:3000
    depends_on:
      - loki
    volumes:
      - grafana_data:/var/lib/grafana

volumes:
  loki_data:
  grafana_data:&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 로그 저장 형태인 logback.xml 파일은 기존 ELK 형태의 방식과 같다&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;spring에서 resources 폴더 -&amp;gt; logback.xml 파일로 넣어주면 됩니다&lt;/p&gt;
&lt;pre id=&quot;code_1766386830132&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&amp;gt;
&amp;lt;configuration&amp;gt;

  &amp;lt;!-- 로그 경로 --&amp;gt;
  &amp;lt;property name=&quot;LOG_PATH&quot; value=&quot;/app/logs&quot;/&amp;gt;
  &amp;lt;property name=&quot;INFO_PATH&quot; value=&quot;${LOG_PATH}/info&quot; /&amp;gt;
  &amp;lt;property name=&quot;WARN_PATH&quot; value=&quot;${LOG_PATH}/warn&quot; /&amp;gt;
  &amp;lt;property name=&quot;ERROR_PATH&quot; value=&quot;${LOG_PATH}/error&quot; /&amp;gt;

  &amp;lt;!-- =============== 콘솔 =============== --&amp;gt;
  &amp;lt;appender name=&quot;CONSOLE&quot; class=&quot;ch.qos.logback.core.ConsoleAppender&quot;&amp;gt;
    &amp;lt;encoder&amp;gt;
      &amp;lt;pattern&amp;gt;%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n&amp;lt;/pattern&amp;gt;
    &amp;lt;/encoder&amp;gt;
  &amp;lt;/appender&amp;gt;

  &amp;lt;!-- =============== INFO 전용 JSON 로그 파일 =============== --&amp;gt;
  &amp;lt;appender name=&quot;INFO_FILE&quot; class=&quot;ch.qos.logback.core.rolling.RollingFileAppender&quot;&amp;gt;
    &amp;lt;file&amp;gt;${INFO_PATH}/application-info.log&amp;lt;/file&amp;gt;
    &amp;lt;rollingPolicy class=&quot;ch.qos.logback.core.rolling.TimeBasedRollingPolicy&quot;&amp;gt;
      &amp;lt;fileNamePattern&amp;gt;${INFO_PATH}/application-info-%d{yyyy-MM-dd}.log&amp;lt;/fileNamePattern&amp;gt;
      &amp;lt;maxHistory&amp;gt;7&amp;lt;/maxHistory&amp;gt; &amp;lt;!--  7일간 보관 --&amp;gt;
    &amp;lt;/rollingPolicy&amp;gt;
    &amp;lt;filter class=&quot;ch.qos.logback.classic.filter.LevelFilter&quot;&amp;gt;
      &amp;lt;level&amp;gt;INFO&amp;lt;/level&amp;gt;
      &amp;lt;onMatch&amp;gt;ACCEPT&amp;lt;/onMatch&amp;gt;
      &amp;lt;onMismatch&amp;gt;DENY&amp;lt;/onMismatch&amp;gt;
    &amp;lt;/filter&amp;gt;
    &amp;lt;encoder class=&quot;net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder&quot;&amp;gt;
      &amp;lt;providers&amp;gt;
        &amp;lt;timestamp/&amp;gt;
        &amp;lt;logLevel/&amp;gt;
        &amp;lt;threadName/&amp;gt;
        &amp;lt;loggerName/&amp;gt;
        &amp;lt;message/&amp;gt;
        &amp;lt;arguments/&amp;gt;
        &amp;lt;stackTrace/&amp;gt;
          &amp;lt;mdc&amp;gt;
            &amp;lt;includeMdcKeyName&amp;gt;traceId&amp;lt;/includeMdcKeyName&amp;gt;
          &amp;lt;/mdc&amp;gt;
      &amp;lt;/providers&amp;gt;
    &amp;lt;/encoder&amp;gt;
  &amp;lt;/appender&amp;gt;

  &amp;lt;!-- =============== WARN 전용 JSON 로그 =============== --&amp;gt;
  &amp;lt;appender name=&quot;WARN_FILE&quot; class=&quot;ch.qos.logback.core.rolling.RollingFileAppender&quot;&amp;gt;
    &amp;lt;file&amp;gt;${WARN_PATH}/application-warn.log&amp;lt;/file&amp;gt;
    &amp;lt;rollingPolicy class=&quot;ch.qos.logback.core.rolling.TimeBasedRollingPolicy&quot;&amp;gt;
      &amp;lt;fileNamePattern&amp;gt;${WARN_PATH}/application-warn-%d{yyyy-MM-dd}.log&amp;lt;/fileNamePattern&amp;gt;
      &amp;lt;maxHistory&amp;gt;7&amp;lt;/maxHistory&amp;gt;
    &amp;lt;/rollingPolicy&amp;gt;

    &amp;lt;!-- WARN만 --&amp;gt;
    &amp;lt;filter class=&quot;ch.qos.logback.classic.filter.LevelFilter&quot;&amp;gt;
      &amp;lt;level&amp;gt;WARN&amp;lt;/level&amp;gt;
      &amp;lt;onMatch&amp;gt;ACCEPT&amp;lt;/onMatch&amp;gt;
      &amp;lt;onMismatch&amp;gt;DENY&amp;lt;/onMismatch&amp;gt;
    &amp;lt;/filter&amp;gt;

    &amp;lt;encoder class=&quot;net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder&quot;&amp;gt;
      &amp;lt;providers&amp;gt;
        &amp;lt;timestamp/&amp;gt;
        &amp;lt;logLevel/&amp;gt;
        &amp;lt;threadName/&amp;gt;
        &amp;lt;loggerName/&amp;gt;
        &amp;lt;message/&amp;gt;
        &amp;lt;arguments/&amp;gt;
        &amp;lt;stackTrace/&amp;gt;
        &amp;lt;mdc&amp;gt;
          &amp;lt;includeMdcKeyName&amp;gt;traceId&amp;lt;/includeMdcKeyName&amp;gt;
        &amp;lt;/mdc&amp;gt;
      &amp;lt;/providers&amp;gt;
    &amp;lt;/encoder&amp;gt;
  &amp;lt;/appender&amp;gt;


  &amp;lt;!-- =============== ERROR 전용 JSON 로그 파일 =============== --&amp;gt;
  &amp;lt;appender name=&quot;ERROR_FILE&quot; class=&quot;ch.qos.logback.core.rolling.RollingFileAppender&quot;&amp;gt;
    &amp;lt;file&amp;gt;${ERROR_PATH}/application-error.log&amp;lt;/file&amp;gt;
    &amp;lt;rollingPolicy class=&quot;ch.qos.logback.core.rolling.TimeBasedRollingPolicy&quot;&amp;gt;
      &amp;lt;fileNamePattern&amp;gt;${ERROR_PATH}/application-error-%d{yyyy-MM-dd}.log&amp;lt;/fileNamePattern&amp;gt;
      &amp;lt;maxHistory&amp;gt;7&amp;lt;/maxHistory&amp;gt; &amp;lt;!-- 7일간 보관  --&amp;gt;
    &amp;lt;/rollingPolicy&amp;gt;
    &amp;lt;filter class=&quot;ch.qos.logback.classic.filter.LevelFilter&quot;&amp;gt;
      &amp;lt;level&amp;gt;ERROR&amp;lt;/level&amp;gt;
      &amp;lt;onMatch&amp;gt;ACCEPT&amp;lt;/onMatch&amp;gt;
      &amp;lt;onMismatch&amp;gt;DENY&amp;lt;/onMismatch&amp;gt;
    &amp;lt;/filter&amp;gt;
    &amp;lt;encoder class=&quot;net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder&quot;&amp;gt;
      &amp;lt;providers&amp;gt;
        &amp;lt;timestamp/&amp;gt;
        &amp;lt;logLevel/&amp;gt;
        &amp;lt;threadName/&amp;gt;
        &amp;lt;loggerName/&amp;gt;
        &amp;lt;message/&amp;gt;
        &amp;lt;arguments/&amp;gt;
        &amp;lt;stackTrace/&amp;gt;
        &amp;lt;mdc&amp;gt;
          &amp;lt;includeMdcKeyName&amp;gt;traceId&amp;lt;/includeMdcKeyName&amp;gt;
        &amp;lt;/mdc&amp;gt;
      &amp;lt;/providers&amp;gt;
    &amp;lt;/encoder&amp;gt;
  &amp;lt;/appender&amp;gt;

  &amp;lt;!-- =============== 루트 로거 =============== --&amp;gt;
  &amp;lt;!--  root logger의 레벨을 설정으로 INFO 이상 레벨(INFO, WARN, ERROR) 만 출력한다 --&amp;gt;
  &amp;lt;root level=&quot;INFO&quot;&amp;gt;
    &amp;lt;appender-ref ref=&quot;CONSOLE&quot;/&amp;gt;
    &amp;lt;appender-ref ref=&quot;INFO_FILE&quot;/&amp;gt;
    &amp;lt;appender-ref ref=&quot;WARN_FILE&quot;/&amp;gt;
    &amp;lt;appender-ref ref=&quot;ERROR_FILE&quot;/&amp;gt;
  &amp;lt;/root&amp;gt;

&amp;lt;/configuration&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. Loki + Grafana가 있는 서버로 보내기 위해 Promtail 사용&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주의 : 해당 경로에 config.yml 파일이 존재해야 합니다.&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;/c/app/promtail/config.yml&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- config.yml 파일 (/*.log 부분 때문에 주석으로 인식 중이므로 무시해도 됩니다.)&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1766386993055&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;server:
  http_listen_port: 9080
  grpc_listen_port: 0

positions:
  filename: /tmp/positions.yaml

clients:
  # 로깅 서버(Loki) 주소
  - url: http://221.xxx.xxx.xxx:3100/loki/api/v1/push

scrape_configs:
  - job_name: chill-logistics-info
    static_configs:
      - targets: [localhost]
        labels:
          job: chill-logistics
          service: business  # 어떤 서비스인지 예로 user-server, firm-server처럼 구체적으로 사용이 좋지만 로컬 개발용으로 한번에 하므로
          level: INFO
          __path__: /app/logs/info/*.log
    pipeline_stages:
      - json:
          expressions:
            timestamp: timestamp
            logLevel: logLevel
            threadName: threadName
            loggerName: loggerName
            message: message
            traceId: traceId
      - timestamp:
          source: timestamp
          format: RFC3339Nano

  - job_name: chill-logistics-warn
    static_configs:
      - targets: [localhost]
        labels:
          job: chill-logistics
          service: business
          level: WARN
          __path__: /app/logs/warn/*.log
    pipeline_stages:
      - json:
          expressions:
            timestamp: timestamp
            logLevel: logLevel
            threadName: threadName
            loggerName: loggerName
            message: message
            traceId: traceId
      - timestamp:
          source: timestamp
          format: RFC3339Nano

  - job_name: chill-logistics-error
    static_configs:
      - targets: [localhost]
        labels:
          job: chill-logistics
          service: business
          level: ERROR
          __path__: /app/logs/error/*.log
    pipeline_stages:
      - json:
          expressions:
            timestamp: timestamp
            logLevel: logLevel
            threadName: threadName
            loggerName: loggerName
            message: message
            traceId: traceId
      - timestamp:
          source: timestamp
          format: RFC3339Nano&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;b&gt;config.yml 파일을 경로에 넣고, Promtail Docker Compose 파일 생성&lt;/b&gt; (스프링 서버가 돌아가는 서버에서 실행 시켜준다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;예시 :&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;docker compose -f docker-compose-promtail.yml up -d&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;vim&quot;&gt;&lt;code&gt;services:
  promtail:
    image: grafana/promtail:2.9.8
    container_name: promtail
    restart: always
    command: -config.file=/etc/promtail/config.yml
    volumes:
      - /c/app/promtail/config.yml:/etc/promtail/config.yml:ro
      - /c/app/logs:/app/logs:ro
      - promtail_positions:/tmp
    ports:
      - &quot;${PROMTAIL_PORT}:${PROMTAIL_PORT}&quot;

volumes:
  promtail_positions:
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 모든 서버를 실행 후 로그가 발생한다면 Loki + Grafana가 실행 중인 서버에 3000번 포트로 들어가면 Grafana에 들어갈 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;- 예시 :&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;a href=&quot;http://221.xxx.xxx.xxx:3000&quot;&gt;http://221.xxx.xxx.xxx:3000&lt;/a&gt;&amp;nbsp; (만약 로그인 창이 뜬다면 초기 값은&amp;nbsp; &amp;nbsp;ID : admin&amp;nbsp; &amp;nbsp; &amp;nbsp;PW : admin 을 넣어주면 됩니다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1907&quot; data-origin-height=&quot;841&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cIHUBk/dJMcacaCRn7/yx4HhFFLNn0lIs71p6n96K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cIHUBk/dJMcacaCRn7/yx4HhFFLNn0lIs71p6n96K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cIHUBk/dJMcacaCRn7/yx4HhFFLNn0lIs71p6n96K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcIHUBk%2FdJMcacaCRn7%2Fyx4HhFFLNn0lIs71p6n96K%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;1907&quot; height=&quot;841&quot; data-origin-width=&quot;1907&quot; data-origin-height=&quot;841&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;결론&lt;/b&gt;&lt;/h3&gt;
&lt;p data-end=&quot;226&quot; data-start=&quot;153&quot; data-ke-size=&quot;size16&quot;&gt;1. 라즈베리파이와 같은 저사양 환경에서 &lt;b&gt;ELK 스택은&lt;/b&gt; 기능적으로는 충분하지만, 운영 비용과 리소스 측면에서 과한 선택이었다.&lt;/p&gt;
&lt;p data-end=&quot;346&quot; data-start=&quot;233&quot; data-ke-size=&quot;size16&quot;&gt;2. &lt;b&gt;Loki + Grafana + Promtail 조합은 로그 본문을 인덱싱 하지 않고 라벨 기반으로 접근하는 구조 덕분에 제한된 자원 환경에서도 안정적인 로그 수집과 조회를 가능&lt;/b&gt;하게 했다.&lt;/p&gt;
&lt;p data-end=&quot;346&quot; data-start=&quot;233&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;346&quot; data-start=&quot;233&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Infra/LogBack</category>
      <category>grafana</category>
      <category>Loki</category>
      <category>promtail</category>
      <category>Spring boot</category>
      <author>kimfishes</author>
      <guid isPermaLink="true">https://kimfishes.tistory.com/12</guid>
      <comments>https://kimfishes.tistory.com/12#entry12comment</comments>
      <pubDate>Mon, 22 Dec 2025 16:16:18 +0900</pubDate>
    </item>
    <item>
      <title>Cloudflare을 활용한 SSL 적용</title>
      <link>https://kimfishes.tistory.com/11</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot 프로젝트 서버 배포 시 HTTPS 적용은 더 이상 선택이 아닌 필수가 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AWS나 GCP 같은 클라우드 환경이 아닌, 라즈베리파이에 직접 백엔드 서버를 배포 후 간단한 방법으로 SSL 인증서를 적용하는 방법을 작성하려 한다.&lt;br /&gt;&lt;br /&gt;AWS를 사용하지 않고 라즈베리파이에서 운영 중인 서버에 Cloudflare를&amp;nbsp;활용해&amp;nbsp;SSL을&amp;nbsp;적용한&amp;nbsp;과정을&amp;nbsp;정리해본다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Cloudflare를&amp;nbsp;선택한&amp;nbsp;이유&lt;/b&gt; &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;-&amp;nbsp;DNS&amp;nbsp;+&amp;nbsp;SSL&amp;nbsp;+&amp;nbsp;Proxy &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;-&amp;nbsp;비용 &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;- 사용자는 HTTPS(443)로만 접근 &lt;br /&gt;&amp;nbsp; &amp;nbsp;- 라즈베리파이 서버는 기존 7777 포트 유지 &lt;br /&gt;&amp;nbsp; &amp;nbsp;- Nginx 및 인증서 관리 불필요 &lt;br /&gt;&amp;nbsp; &amp;nbsp;- 설정 단순 + 유지보수 용이&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;전체 구조&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;[&amp;nbsp;사용자&amp;nbsp;브라우저&amp;nbsp;] &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;| &lt;br /&gt;&amp;nbsp;HTTPS (443) &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;|&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;[ Cloudflare ]&lt;br /&gt;HTTP (7777) &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;| &lt;br /&gt;[&amp;nbsp;Raspberry&amp;nbsp;Pi&amp;nbsp;] &lt;br /&gt;Backend Server &lt;br /&gt;&amp;nbsp;(Port: 7777)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;481&quot; data-start=&quot;451&quot;&gt;사용자는 &lt;b&gt;항상 443 (HTTPS)&lt;/b&gt; 로 접근&lt;/li&gt;
&lt;li data-end=&quot;512&quot; data-start=&quot;482&quot;&gt;실제 라즈베리파이 서버는 &lt;b&gt;7777 포트 유지&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;553&quot; data-start=&quot;513&quot;&gt;포트 변환은 &lt;b&gt;Cloudflare Origin Rule&lt;/b&gt;에서 처리&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재는 간단하게 클라우드 플레어에서 7777로 포트를 변환 후 전송하지만 다른 방법으론 Ngnix 방식도 존재한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러나 이번 프로젝트에서는 설정을 최대한 단순하게 유지하기 위해 Nginx를 사용하지 않고, &lt;b&gt;Cloudflare의&amp;nbsp;Origin&amp;nbsp;Rule&amp;nbsp;기능을&amp;nbsp;사용&lt;/b&gt;하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Nginx 방식&lt;/b&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1681&quot; data-start=&quot;1660&quot;&gt;라즈베리파이에 Nginx 설치 필요&lt;/li&gt;
&lt;li data-end=&quot;1693&quot; data-start=&quot;1682&quot;&gt;SSL 설정 필요&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;443&amp;nbsp;(Nginx) &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;darr; &lt;br /&gt;7777&amp;nbsp;(Backend)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1.&amp;nbsp;가비아에서&amp;nbsp;도메인&amp;nbsp;구매하기&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.gabia.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.gabia.com/&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1766066792223&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;웹을 넘어 클라우드로. 가비아&quot; data-og-description=&quot;그룹웨어부터 멀티클라우드까지 하나의 클라우드 허브&quot; data-og-host=&quot;www.gabia.com&quot; data-og-source-url=&quot;https://www.gabia.com/&quot; data-og-url=&quot;https://www.gabia.com/&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/g3PVs/hyZPxjR6zA/s1yYWP02qZ8fym35AukYpk/img.jpg?width=1200&amp;amp;height=1000&amp;amp;face=0_0_1200_1000&quot;&gt;&lt;a href=&quot;https://www.gabia.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://www.gabia.com/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/g3PVs/hyZPxjR6zA/s1yYWP02qZ8fym35AukYpk/img.jpg?width=1200&amp;amp;height=1000&amp;amp;face=0_0_1200_1000');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;웹을 넘어 클라우드로. 가비아&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;그룹웨어부터 멀티클라우드까지 하나의 클라우드 허브&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;www.gabia.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;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 회원가입 후 원하는 도메인을 찾아 구매&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1195&quot; data-origin-height=&quot;826&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cNc4jX/dJMcagD5J3o/P1nlmK3uy94VtHtuCb5J3K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cNc4jX/dJMcagD5J3o/P1nlmK3uy94VtHtuCb5J3K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cNc4jX/dJMcagD5J3o/P1nlmK3uy94VtHtuCb5J3K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcNc4jX%2FdJMcagD5J3o%2FP1nlmK3uy94VtHtuCb5J3K%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;1195&quot; height=&quot;826&quot; data-origin-width=&quot;1195&quot; data-origin-height=&quot;826&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2. Cloudflare에 도메인 등록하기&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.cloudflare.com/ko-kr/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.cloudflare.com/ko-kr/&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1766066964252&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;모든 곳에서 연결하고, 보호하고, 구축하기&quot; data-og-description=&quot;복잡성과 비용을 줄이면서 직원, 애플리케이션, 네트워크를 어디에서든 더 빠르고 안전하게 만듭니다.&quot; data-og-host=&quot;www.cloudflare.com&quot; data-og-source-url=&quot;https://www.cloudflare.com/ko-kr/&quot; data-og-url=&quot;https://www.cloudflare.com/ko-kr/&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/0JMCZ/hyZPsiyCXd/r0TWTlqTlg0RlogAde3rF0/img.png?width=1920&amp;amp;height=560&amp;amp;face=0_0_1920_560&quot;&gt;&lt;a href=&quot;https://www.cloudflare.com/ko-kr/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://www.cloudflare.com/ko-kr/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/0JMCZ/hyZPsiyCXd/r0TWTlqTlg0RlogAde3rF0/img.png?width=1920&amp;amp;height=560&amp;amp;face=0_0_1920_560');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;모든 곳에서 연결하고, 보호하고, 구축하기&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;복잡성과 비용을 줄이면서 직원, 애플리케이션, 네트워크를 어디에서든 더 빠르고 안전하게 만듭니다.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;www.cloudflare.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;b&gt;- 클라우드 플레어에 회원가입 후 도메인 온보딩을 추가해준다&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;513&quot; data-origin-height=&quot;149&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/YkjCd/dJMcagjLHaD/23RnlRbDLNT98OERx8gTUk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/YkjCd/dJMcagjLHaD/23RnlRbDLNT98OERx8gTUk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/YkjCd/dJMcagjLHaD/23RnlRbDLNT98OERx8gTUk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FYkjCd%2FdJMcagjLHaD%2F23RnlRbDLNT98OERx8gTUk%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;513&quot; height=&quot;149&quot; data-origin-width=&quot;513&quot; data-origin-height=&quot;149&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 가비아에서 구매한 도메인 입력&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;868&quot; data-origin-height=&quot;750&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ARgDQ/dJMb9951Iro/mFHox2tDsTgAcMfAt7Npq1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ARgDQ/dJMb9951Iro/mFHox2tDsTgAcMfAt7Npq1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ARgDQ/dJMb9951Iro/mFHox2tDsTgAcMfAt7Npq1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FARgDQ%2FdJMb9951Iro%2FmFHox2tDsTgAcMfAt7Npq1%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;868&quot; height=&quot;750&quot; data-origin-width=&quot;868&quot; data-origin-height=&quot;750&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;b&gt;요금제 선택&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1423&quot; data-origin-height=&quot;759&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/SoH0U/dJMcabQiDbA/C7qXUc7URDc0l2BdMYXk2K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/SoH0U/dJMcabQiDbA/C7qXUc7URDc0l2BdMYXk2K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/SoH0U/dJMcabQiDbA/C7qXUc7URDc0l2BdMYXk2K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FSoH0U%2FdJMcabQiDbA%2FC7qXUc7URDc0l2BdMYXk2K%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;1423&quot; height=&quot;759&quot; data-origin-width=&quot;1423&quot; data-origin-height=&quot;759&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;b&gt;레코드를 설정해준다&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;A 레코드란?&lt;/b&gt;&lt;br /&gt;- 도메인을 IPv4 주소(서버 IP) 로 연결해주는 레코드&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 배포 한 서버의 공인 IP를 넣어주면 된다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt; CNAME (www)이란?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 구매한 DNS 이름 앞에 www. 이 붙어도, 붙지 않아도 같은 서버로 접근 가능&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1258&quot; data-origin-height=&quot;304&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/JiRPf/dJMcaa4Vq1V/sEUy6U0Z6KwdMT5WzPooRK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/JiRPf/dJMcaa4Vq1V/sEUy6U0Z6KwdMT5WzPooRK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/JiRPf/dJMcaa4Vq1V/sEUy6U0Z6KwdMT5WzPooRK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FJiRPf%2FdJMcaa4Vq1V%2FsEUy6U0Z6KwdMT5WzPooRK%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;1258&quot; height=&quot;304&quot; data-origin-width=&quot;1258&quot; data-origin-height=&quot;304&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;b&gt;레코드 설정 후 네임서버 2개의 값을 복사한다&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;1187&quot; data-origin-height=&quot;284&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b0ppbq/dJMcadAw4Qk/R8xQiEsHZO88OWQMIjDWzK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b0ppbq/dJMcadAw4Qk/R8xQiEsHZO88OWQMIjDWzK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b0ppbq/dJMcadAw4Qk/R8xQiEsHZO88OWQMIjDWzK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb0ppbq%2FdJMcadAw4Qk%2FR8xQiEsHZO88OWQMIjDWzK%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;1187&quot; height=&quot;284&quot; data-origin-width=&quot;1187&quot; data-origin-height=&quot;284&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3. 네임서버를 Cloudflare로 변경하기&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 가비아로 돌아가 도메인 관리 -&amp;gt; 본인이 구매한 도메인 설정을 들어간다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;2. 복사한 클라우드플레어에 네임서버를 넣어준다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1644&quot; data-origin-height=&quot;647&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bfJbbp/dJMcacVYprh/At5bZycCfwKOpux33ZU4U1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bfJbbp/dJMcacVYprh/At5bZycCfwKOpux33ZU4U1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bfJbbp/dJMcacVYprh/At5bZycCfwKOpux33ZU4U1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbfJbbp%2FdJMcacVYprh%2FAt5bZycCfwKOpux33ZU4U1%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;1644&quot; height=&quot;647&quot; data-origin-width=&quot;1644&quot; data-origin-height=&quot;647&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;4. Cloudflare SSL 설정&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;b&gt;SSL/TLS 개요로 진입&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;232&quot; data-origin-height=&quot;483&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/WONux/dJMcafkRIxv/0yvAMrxYxXkZkH6M17nkPK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/WONux/dJMcafkRIxv/0yvAMrxYxXkZkH6M17nkPK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/WONux/dJMcafkRIxv/0yvAMrxYxXkZkH6M17nkPK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FWONux%2FdJMcafkRIxv%2F0yvAMrxYxXkZkH6M17nkPK%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;232&quot; height=&quot;483&quot; data-origin-width=&quot;232&quot; data-origin-height=&quot;483&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;b&gt;구성으로 들어가 준다&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1320&quot; data-origin-height=&quot;716&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/GV1LX/dJMcaaYaeIv/W5Soc3KGYsw2y90ZYdA0Q1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/GV1LX/dJMcaaYaeIv/W5Soc3KGYsw2y90ZYdA0Q1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/GV1LX/dJMcaaYaeIv/W5Soc3KGYsw2y90ZYdA0Q1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FGV1LX%2FdJMcaaYaeIv%2FW5Soc3KGYsw2y90ZYdA0Q1%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;1320&quot; height=&quot;716&quot; data-origin-width=&quot;1320&quot; data-origin-height=&quot;716&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Cloudflare에서 SSL을 설정할 때 Cloudflare와 원본 서버(Raspberry Pi) 사이를 어떻게 암호화할 것인가 설정 가능&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 설정은&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;606&quot; data-start=&quot;577&quot;&gt;원본 서버에는 &lt;b&gt;암호화 없이 HTTP로 요청&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;633&quot; data-start=&quot;607&quot;&gt;원본 서버에 SSL 설정이 전혀 없어도 동작&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실제 서비스하는 서버라면 Full로 설정하여 전체(엄격)을 사용해야 한다&lt;/b&gt;. ( Cloudflare Origin Certificate 설치 필요)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 지금은 간단하게 설정 한 상황으로&amp;nbsp;Cloudflare와 원본 서버 간 통신은 HTTP로 이루어진다.&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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1378&quot; data-origin-height=&quot;796&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tVTZk/dJMcaaw5Xbj/vbdxDyhaqLnrG0Lplpf0tK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tVTZk/dJMcaaw5Xbj/vbdxDyhaqLnrG0Lplpf0tK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tVTZk/dJMcaaw5Xbj/vbdxDyhaqLnrG0Lplpf0tK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtVTZk%2FdJMcaaw5Xbj%2FvbdxDyhaqLnrG0Lplpf0tK%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;1378&quot; height=&quot;796&quot; data-origin-width=&quot;1378&quot; data-origin-height=&quot;796&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;br /&gt;&lt;b&gt;5. Origin Rule로 포트 변경 처리&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 규칙 -&amp;gt; 개요&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;240&quot; data-origin-height=&quot;315&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/k8kba/dJMcaaKCOHq/MPrqj5Tv0dET8HjxK3Hkg1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/k8kba/dJMcaaKCOHq/MPrqj5Tv0dET8HjxK3Hkg1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/k8kba/dJMcaaKCOHq/MPrqj5Tv0dET8HjxK3Hkg1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fk8kba%2FdJMcaaKCOHq%2FMPrqj5Tv0dET8HjxK3Hkg1%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;240&quot; height=&quot;315&quot; data-origin-width=&quot;240&quot; data-origin-height=&quot;315&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 규칙 생성 -&amp;gt; Origin Rule 클릭&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;1577&quot; data-origin-height=&quot;417&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bk92E3/dJMcabCLySJ/m6IutvTThES7Ikngg0K4c1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bk92E3/dJMcabCLySJ/m6IutvTThES7Ikngg0K4c1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bk92E3/dJMcabCLySJ/m6IutvTThES7Ikngg0K4c1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbk92E3%2FdJMcabCLySJ%2Fm6IutvTThES7Ikngg0K4c1%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;1577&quot; height=&quot;417&quot; data-origin-width=&quot;1577&quot; data-origin-height=&quot;417&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- 규칙 이름 입력 후 모든 수신 요청 클릭&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1017&quot; data-origin-height=&quot;806&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/1euHj/dJMcahCYx8d/4kmq3GrOGfdN8O3lh3OAw0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/1euHj/dJMcahCYx8d/4kmq3GrOGfdN8O3lh3OAw0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/1euHj/dJMcahCYx8d/4kmq3GrOGfdN8O3lh3OAw0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F1euHj%2FdJMcahCYx8d%2F4kmq3GrOGfdN8O3lh3OAw0%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;1017&quot; height=&quot;806&quot; data-origin-width=&quot;1017&quot; data-origin-height=&quot;806&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;b&gt;배포한 서버의 PORT 번호 입력 후 배포 클릭&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;1208&quot; data-origin-height=&quot;637&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mgq9c/dJMcaaYafbM/DTuqk9AhFuB3DlsaTkfY2k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mgq9c/dJMcaaYafbM/DTuqk9AhFuB3DlsaTkfY2k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mgq9c/dJMcaaYafbM/DTuqk9AhFuB3DlsaTkfY2k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fmgq9c%2FdJMcaaYafbM%2FDTuqk9AhFuB3DlsaTkfY2k%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;1208&quot; height=&quot;637&quot; data-origin-width=&quot;1208&quot; data-origin-height=&quot;637&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;- &lt;span style=&quot;color: #777777; text-align: center;&quot;&gt;HTTPS 통신 성공&lt;/span&gt; &lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;589&quot; data-origin-height=&quot;438&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/MkV24/dJMcaaqkfEV/jptEtqzzs4RxOn3A85b6OK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/MkV24/dJMcaaqkfEV/jptEtqzzs4RxOn3A85b6OK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/MkV24/dJMcaaqkfEV/jptEtqzzs4RxOn3A85b6OK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FMkV24%2FdJMcaaqkfEV%2FjptEtqzzs4RxOn3A85b6OK%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;589&quot; height=&quot;438&quot; data-origin-width=&quot;589&quot; data-origin-height=&quot;438&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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;size18&quot;&gt;&lt;b&gt;다시 작성하지만 현재 설정 방식은 실제 운영을 목적으로 하는 설정이 아니므로 설정의 주의가 필요합니다 !&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Cloudflare의 Flexible SSL(가변) 모드는 구성은&amp;nbsp;간단하지만, &lt;br /&gt;종단 간 암호화(End-to-End Encryption)를 제공하지 않기 때문에 보안&amp;nbsp;측면에서&amp;nbsp;주의가&amp;nbsp;필요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;354&quot; data-start=&quot;341&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;이 구조의 문제점&lt;/b&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;471&quot; data-start=&quot;356&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;390&quot; data-start=&quot;356&quot;&gt;Cloudflare &amp;harr; 서버 구간이 &lt;b&gt;평문(HTTP)&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;407&quot; data-start=&quot;391&quot;&gt;중간에서 패킷 스니핑 가능&lt;/li&gt;
&lt;li data-end=&quot;442&quot; data-start=&quot;408&quot;&gt;내부 네트워크가 아니라면 &lt;b&gt;MITM 공격 가능성 존재&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;471&quot; data-start=&quot;443&quot;&gt;&amp;ldquo;자물쇠&amp;rdquo;는 있지만 &lt;b&gt;종단 간 암호화 아님&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;497&quot; data-start=&quot;473&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;즉 보안적으로는 반쪽짜리 HTTPS&lt;/b&gt;&lt;/p&gt;
&lt;p data-end=&quot;497&quot; data-start=&quot;473&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;497&quot; data-start=&quot;473&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;497&quot; data-start=&quot;473&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;497&quot; data-start=&quot;473&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;497&quot; data-start=&quot;473&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;497&quot; data-start=&quot;473&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;497&quot; data-start=&quot;473&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt; 언제 이런 설정을 사용할 수 있을까? &lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 프론트엔드 개발팀과 협업하는 과정에서, 서로 다른 개발 서버 간 통신 시 HTTPS가&amp;nbsp;아니면&amp;nbsp;요청이&amp;nbsp;차단되는&amp;nbsp;문제가&amp;nbsp;발생하는&amp;nbsp;경우가&amp;nbsp;있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 이러한 상황에서 운영 목적이 아닌 개발&amp;middot;테스트 환경이라면 간단한&amp;nbsp;해결책으로&amp;nbsp;Flexible&amp;nbsp;SSL&amp;nbsp;방식을&amp;nbsp;가볍게&amp;nbsp;사용하는&amp;nbsp;것을&amp;nbsp;고려할&amp;nbsp;수&amp;nbsp;있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;현제 설정의(Flexible(가변)) 이득은?&lt;/b&gt;&lt;br /&gt;- 사용자 &amp;harr; Cloudflare 구간 보호하여 &lt;b&gt;공용 환경에서 쿠키 / 세션 / 요청 내용 탈취 방지 가능&lt;/b&gt; &lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;공용 와이파이&lt;/li&gt;
&lt;li&gt;회사 네트워크&lt;/li&gt;
&lt;li&gt;카페 환경&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;요약&lt;/b&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Flexible SSL은 HTTPS가 반드시 필요한 개발 환경&amp;rsquo;에서는 빠르게&amp;nbsp;적용할&amp;nbsp;수&amp;nbsp;있는&amp;nbsp;편의성&amp;nbsp;있는&amp;nbsp;선택지일&amp;nbsp;수&amp;nbsp;있다. &lt;/li&gt;
&lt;li&gt;하지만 실제 운영 환경에서는 Cloudflare와 원본 서버 간 통신까지 암호화되는 Full&amp;nbsp;또는&amp;nbsp;Full&amp;nbsp;(Strict)&amp;nbsp;모드를&amp;nbsp;사용하는&amp;nbsp;것이&amp;nbsp;바람직하다.&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Infra</category>
      <author>kimfishes</author>
      <guid isPermaLink="true">https://kimfishes.tistory.com/11</guid>
      <comments>https://kimfishes.tistory.com/11#entry11comment</comments>
      <pubDate>Thu, 18 Dec 2025 23:57:41 +0900</pubDate>
    </item>
    <item>
      <title>  허브 간 최단 경로 탐색 알고리즘 구현 + Kakao Map Api 연동 (Spring Boot)</title>
      <link>https://kimfishes.tistory.com/10</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-token-index=&quot;0&quot;&gt;물류 관리 및 배송 시스템을 위한 MSA 기반 플랫폼 개발 프로젝트로 &lt;span data-token-index=&quot;0&quot;&gt;물류 관리 및 배송 시스템을 만들게 되었다.&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-token-index=&quot;0&quot;&gt;&lt;span data-token-index=&quot;0&quot;&gt;그중 hub 파트를 맡게 되어 &lt;b&gt;사용자가 주문을 완료하여 배송이 시작되면 특정 허브에서 도착 허브까지 가장 빠르고 효율적인 경로를 찾아 응답해 주는&lt;/b&gt; 코드를 구현해야 한다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-start=&quot;722&quot; data-end=&quot;783&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 거리만 기준으로 정렬하는 단순 탐색을 고려했지만, 실제 배송에서는 다음 요소들을 모두 고려해야 했다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;즉&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;가중치 기반 최단 경로 문제&lt;/b&gt;였고, 자연스럽게&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;다익스트라(Dijkstra) 알고리즘&lt;/b&gt;을 채택하게 되었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot; data-start=&quot;785&quot; data-end=&quot;843&quot;&gt;
&lt;li data-start=&quot;785&quot; data-end=&quot;805&quot;&gt;이동 거리 (distance)&lt;/li&gt;
&lt;li data-start=&quot;806&quot; data-end=&quot;826&quot;&gt;소요 시간 (duration)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-token-index=&quot;0&quot;&gt;&lt;span data-token-index=&quot;0&quot;&gt;출발지의 위도 경도값, 도착지의 위도 경도값을 &lt;/span&gt;&lt;/span&gt;&lt;span data-token-index=&quot;0&quot;&gt;&lt;span data-token-index=&quot;0&quot;&gt;Kakao Map Api에 경로 탐색을 요청해 응답을 받아오며, &lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-token-index=&quot;0&quot;&gt;&lt;span data-token-index=&quot;0&quot;&gt;해당 결과가 있는 상태에서 동일 경로 탐색이 요청이 들어왔을 때 기존 결과와 시간차가 5분이 지나지 않은 상태이면 값을 재사용하여 응답 시간을 줄임&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-token-index=&quot;0&quot;&gt;&lt;span data-token-index=&quot;0&quot;&gt;허브들의 정보 &lt;b&gt;(p_hub)&lt;/b&gt;와 물류 허브 사이의 이동 데이터는&amp;nbsp;&lt;b&gt;p_hub_info&lt;/b&gt; 테이블에 UUID로 연관 지어 저장해두고 있으며 연결 정보제약사항은 다음과 같다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;제약사항&lt;/b&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;&amp;ldquo;연결된&amp;rdquo;&lt;/b&gt; 허브간 배송만 가능합니다. 예를 들어, 서울-부산 배송 시 서울-경기남부-대구-부산 순으로 배송해야 합니다.&lt;/li&gt;
&lt;li&gt;17개 센터는 아래와 같이 연결되어 있습니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;경기남부: 경기북부, 서울, 인천, 강원도, 경상북도, 대전, 대구&lt;/li&gt;
&lt;li&gt;대전: 충청남도, 충청북도, 세종, 전라북도, 광주, 전라남도, 경기남부, 대구&lt;/li&gt;
&lt;li&gt;대구: 경상북도, 경상남도, 부산, 울산, 경상북도, 경기남부, 대전&lt;/li&gt;
&lt;li&gt;경상북도: 경기남부, 대구&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;423&quot; data-origin-height=&quot;568&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bjBuXK/dJMcaaRjWzO/bS4fyI5EPuYPtUxATDPASk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bjBuXK/dJMcaaRjWzO/bS4fyI5EPuYPtUxATDPASk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bjBuXK/dJMcaaRjWzO/bS4fyI5EPuYPtUxATDPASk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbjBuXK%2FdJMcaaRjWzO%2FbS4fyI5EPuYPtUxATDPASk%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;423&quot; height=&quot;568&quot; data-origin-width=&quot;423&quot; data-origin-height=&quot;568&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;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;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span data-token-index=&quot;0&quot;&gt;&lt;span data-token-index=&quot;0&quot;&gt;Code&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;엔티티 테이블은 총 4개 존재&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Hub&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1764908473742&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public class Hub extends BaseEntity {

    @Id
    @GeneratedValue(generator = &quot;uuidv7&quot;)
    @GenericGenerator(
            name = &quot;uuidv7&quot;,
            strategy = &quot;lib.id.UUIDv7Generator&quot;
    )
    @Column(name = &quot;id&quot;, columnDefinition = &quot;BINARY(16)&quot;)
    private UUID id;

    @Column(name = &quot;name&quot;, nullable = false, length = 20)
    private String name;

    @Column(name = &quot;hub_manager_id&quot;, nullable = false, columnDefinition = &quot;BINARY(16)&quot;)
    private UUID hubManagerId;

    @Column(name = &quot;postal_code&quot;, nullable = false, length = 20)
    private String postalCode;

    @Column(name = &quot;country&quot;, nullable = false, length = 50)
    private String country;

    @Column(name = &quot;region&quot;, nullable = false, length = 50)
    private String region;

    @Column(name = &quot;city&quot;, nullable = false, length = 50)
    private String city;

    @Column(name = &quot;district&quot;, length = 50)
    private String district;

    @Column(name = &quot;road_name&quot;, nullable = false, length = 100)
    private String roadName;

    @Column(name = &quot;building_name&quot;, length = 50)
    private String buildingName;

    @Column(name = &quot;detail_address&quot;, length = 100)
    private String detailAddress;

    @Column(name = &quot;full_address&quot;, nullable = false, length = 100)
    private String fullAddress;

    @Column(name = &quot;latitude&quot;, nullable = false, precision = 10, scale = 7)
    private BigDecimal latitude;

    @Column(name = &quot;longitude&quot;, nullable = false, precision = 10, scale = 7)
    private BigDecimal longitude;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;HubInfo&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1764908528960&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
@Entity
@Table(name = &quot;p_hub_info&quot;)
public class HubInfo extends BaseEntity {

    @Id
    @GeneratedValue(generator = &quot;uuidv7&quot;)
    @GenericGenerator(
            name = &quot;uuidv7&quot;,
            strategy = &quot;lib.id.UUIDv7Generator&quot;
    )
    @Column(name = &quot;id&quot;, columnDefinition = &quot;BINARY(16)&quot;)
    private UUID id;

    @Column(name = &quot;start_hub_id&quot;, nullable = false, columnDefinition = &quot;BINARY(16)&quot;)
    private UUID startHubId;

    @Column(name = &quot;end_hub_id&quot;, nullable = false, columnDefinition = &quot;BINARY(16)&quot;)
    private UUID endHubId;

    @Column(name = &quot;delivery_duration&quot;)
    private Integer deliveryDuration;
    // 배송 예측 소요 시간

    @Column(name = &quot;distance&quot;, precision = 10, scale = 3)
    private BigDecimal distance;
    // 배송 거리 km 단위

    public static HubInfo create(UUID startHubId, UUID endHubId) {
        HubInfo hubInfo = new HubInfo();
        hubInfo.startHubId = startHubId;
        hubInfo.endHubId = endHubId;
        return hubInfo;
    }

    // 시간, 거리 넣기
    public void updateDeliveryInfo(Integer deliveryDuration, BigDecimal distance) {
        this.deliveryDuration = deliveryDuration;
        this.distance = distance;
    }


    // false면 기존 값 재사용
    public boolean checkUpdateTime(LocalDateTime updateTime) {
        LocalDateTime updatedAt = this.getUpdatedAt();
        if (updatedAt == null) {
            return true;
        }

        if (this.deliveryDuration == null || this.distance == null) {
           return true;
       }
        // 두 시간 차이를 분 단위로 계산
        long diffMinutes = Duration.between(updatedAt, updateTime).toMinutes();

        // 5분 이상 차이가 난다면 true
        return diffMinutes &amp;gt;= 5;
    }


    public void updateHubInfo(UUID startHubId, UUID endHubId) {
        if(startHubId != null) this.startHubId = startHubId;
        if(endHubId != null) this.endHubId = endHubId;
    }

}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;HubRouteLog (출발지와 목적지)&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1764908556890&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
@Entity
@Table(name = &quot;p_hub_route_log&quot;)
public class HubRouteLog extends BaseEntity {

    @Id
    @GeneratedValue(generator = &quot;uuidv7&quot;)
    @GenericGenerator(
            name = &quot;uuidv7&quot;,
            strategy = &quot;lib.id.UUIDv7Generator&quot;
    )
    @Column(name = &quot;id&quot;, columnDefinition = &quot;BINARY(16)&quot;)
    private UUID id;

    @Column(name = &quot;start_hub_id&quot;, nullable = false, columnDefinition = &quot;BINARY(16)&quot;)
    private UUID startHubId;

    @Column(name = &quot;end_hub_id&quot;, nullable = false, columnDefinition = &quot;BINARY(16)&quot;)
    private UUID endHubId;

    @Column(name = &quot;total_duration&quot;)
    private Integer totalDuration;
    //총 소요 시간

    @Column(name = &quot;total_distance&quot;, precision = 10, scale = 3)
    private BigDecimal totalDistance;
    // 총 거리 km

    public static HubRouteLog create(
        UUID startHubId,
        UUID endHubId,
        Integer totalDuration,
        BigDecimal totalDistance
    ){
        HubRouteLog log = new HubRouteLog();
        log.startHubId = startHubId;
        log.endHubId = endHubId;
        log.totalDuration = totalDuration;
        log.totalDistance = totalDistance;
        return log;
    }

}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;HubRouteLogStop&amp;nbsp; (허브 경로 정보)&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1764908621083&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Getter
@Entity
@Table(name = &quot;p_hub_route_log_stop&quot;)

public class HubRouteLogStop extends BaseEntity {

    @Id
    @GeneratedValue(generator = &quot;uuidv7&quot;)
    @GenericGenerator(
            name = &quot;uuidv7&quot;,
            strategy = &quot;lib.id.UUIDv7Generator&quot;
    )
    @Column(name = &quot;id&quot;, columnDefinition = &quot;BINARY(16)&quot;)
    private UUID id;

    @Column(name = &quot;hub_id&quot;, nullable = false, columnDefinition = &quot;BINARY(16)&quot;)
    private UUID hubId;
    // 경로 허브 id

    @Column(name = &quot;hub_route_log_id&quot;, nullable = false, columnDefinition = &quot;BINARY(16)&quot;)
    private UUID hubRouteLogId;
    // 출발허브 || 도착허브 정보

    @Column(name = &quot;sequence_num&quot;, nullable = false)
    private Integer sequenceNum;

    public static HubRouteLogStop create(UUID hubRouteLogId, UUID hubId, int sequenceNum) {
        HubRouteLogStop stop = new HubRouteLogStop();
        stop.hubRouteLogId = hubRouteLogId;
        stop.hubId = hubId;
        stop.sequenceNum = sequenceNum;
        return stop;
    }

}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;허브 간 Edge 가중치를 계산하는 서비스 &amp;ndash; HubEdgeWeightService&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 data-end=&quot;751&quot; data-start=&quot;698&quot;&gt;p_hub_info 테이블에서 &lt;b&gt;허브 간 연결 정보(HubInfo)&lt;/b&gt; 를 조회&lt;/li&gt;
&lt;li data-end=&quot;798&quot; data-start=&quot;752&quot;&gt;최근 5분 이내에 계산된 &lt;b&gt;캐시 된 거리/시간이 있으면 그대로 재사용&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;859&quot; data-start=&quot;799&quot;&gt;캐시가 없거나 오래되었으면 &lt;b&gt;Kakao Map API를 호출해서 실제 거리/시간을 다시 계산&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;923&quot; data-start=&quot;860&quot;&gt;새로 계산한 값은 HubInfo 엔티티에 업데이트해서 &lt;b&gt;DB 기반 캐시처럼 재사용&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;988&quot; data-start=&quot;924&quot;&gt;최종적으로 EdgeWeight VO로 (시작 허브, 도착 허브, 소요 시간, 거리)를 반환&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1764914233401&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
@Slf4j
@RequiredArgsConstructor
public class HubEdgeWeightService implements HubEdgeWeightProvider {

    private final HubInfoRepository hubInfoRepository;
    private final HubRepository hubRepository;
    private final KakaoMapClient kakaoMapClient;

    @Override
    public EdgeWeight getWeight(UUID startHubId, UUID endHubId) {

        HubInfo hubInfo = hubInfoRepository.findByStartHubIdAndEndHubId(startHubId, endHubId)
            .orElseThrow(()-&amp;gt; new BusinessException(ErrorCode.HUB_INFO_NOT_FOUND));

        LocalDateTime now = LocalDateTime.now();

        // 1) 5분 이내의 캐시가 있으면 기존 값 그대로 사용
        if (hubInfo.getDeliveryDuration() != null &amp;amp;&amp;amp; hubInfo.getDistance() != null &amp;amp;&amp;amp; !hubInfo.checkUpdateTime(now)) {

            log.info(&quot;[HubEdgeWeight] cache hit: {} -&amp;gt; {} ({}h {}m, {} km)&quot;,
                startHubId, endHubId, hubInfo.getDeliveryDuration()/3600, hubInfo.getDeliveryDuration() % 3600 / 60, hubInfo.getDistance());

            return new EdgeWeight(startHubId, endHubId, hubInfo.getDeliveryDuration(), hubInfo.getDistance());
        }

        // 2) 아니면 Kakao API로 실제 거리/시간 계산
        // 허브 존재 여부 확인
        Hub startHub = hubRepository.findById(hubInfo.getStartHubId())
            .orElseThrow(() -&amp;gt; new BusinessException(ErrorCode.HUB_NOT_FOUND));
        Hub endHub = hubRepository.findById(hubInfo.getEndHubId())
            .orElseThrow(() -&amp;gt; new BusinessException(ErrorCode.HUB_NOT_FOUND));

        // 해당 허브들에 위도, 경도값 추출
        String origin = startHub.getLongitude() + &quot;,&quot; + startHub.getLatitude();
        String destination = endHub.getLongitude() + &quot;,&quot; + endHub.getLatitude();

        DirectionInfoResponseV1 direction = kakaoMapClient.getDirection(
            origin,
            destination,
            2,          // carType
            &quot;DIESEL&quot;,   // carFuel
            true        // carHipass
        );

        // Kakao 응답은 초 단위
        int durationSec = (int) direction.duration();
        BigDecimal distanceKm = BigDecimal
            .valueOf(direction.distance() / 1000.0)
            .setScale(3, RoundingMode.HALF_UP);

        hubInfo.updateDeliveryInfo(durationSec, distanceKm);

        log.info(&quot;[HubEdgeWeight] cache hit: {} -&amp;gt; {} ({}h {}m, {} km)&quot;,
            startHubId, endHubId, hubInfo.getDeliveryDuration() / 3600, hubInfo.getDeliveryDuration() % 3600 / 60, hubInfo.getDistance());

        return new EdgeWeight(startHubId, endHubId, durationSec, distanceKm);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;최단&amp;nbsp;경로&amp;nbsp;탐색&amp;nbsp;+&amp;nbsp;로그&amp;nbsp;저장&amp;nbsp;&amp;ndash;&amp;nbsp;findFastestRouteAndLog&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; 경로 탐색(findFastestRoute)&lt;/b&gt;과 &lt;b&gt;로그 저장(logRoute&lt;/b&gt;)을 하나의 트랜잭션 안에서 처리&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1764915921593&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    /**
     * 최단 시간 기준 경로 탐색 + 로그 저장
     */
    @Transactional
    public HubRouteResult findFastestRouteAndLog(UUID startHubId, UUID endHubId) {

        HubRouteResult result = findFastestRoute(startHubId, endHubId);
        logRoute(result);
        
        return result;
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt; 최단 시간 기준 경로 탐색 &amp;ndash; findFastestRoute&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;HubInfo에 잇는 모든 정보를 findAll하여 가져오고&lt;/li&gt;
&lt;li&gt;Map &amp;lt;허브 ID, List &amp;lt;Edge&amp;gt;&amp;gt; 형태로 인접 리스트를 구성한다.&lt;/li&gt;
&lt;li&gt;허브 간 시간/거리는 HubEdgeWeightProvider(= HubEdgeWeightService)를 통해 가져옴&lt;/li&gt;
&lt;li&gt;다익스트라 알고리즘 호출&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1764916542886&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    /**
     * 최단 시간 기준 경로 탐색 (Dijkstra)
     */
    @Transactional(readOnly = true)
    public HubRouteResult findFastestRoute(UUID startHubId, UUID endHubId) {

        List&amp;lt;HubInfo&amp;gt; connections = hubInfoRepository.findAll();

        if (connections.isEmpty()) {
            log.warn(&quot;[HubRoute] no hub connections in DB&quot;);
            throw new BusinessException(ErrorCode.HUB_ROUTE_NOT_FOUND);
        }


        // 1. 인접 리스트 그래프 구성 (duration 기준 weight)
        Map&amp;lt;UUID, List&amp;lt;Edge&amp;gt;&amp;gt; graph = new HashMap&amp;lt;&amp;gt;();

        for (HubInfo info : connections) {
            UUID from = info.getStartHubId();
            UUID to = info.getEndHubId();

            // computeInfAbsent -&amp;gt; Map에서 key가 없을 때만 value를 생성해서 넣어주는 메서드
            graph.computeIfAbsent(from, k -&amp;gt; new ArrayList&amp;lt;&amp;gt;()).add(new Edge(to, info));

        }

        // 2. 다익스트라 실행
        return dijkstraByDuration(graph, startHubId, endHubId);
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt; 다익스트라 구현 &amp;ndash; dijkstraByDuration&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;가중치를 &amp;ldquo;시간(durationSec)&amp;rdquo;으로 고정&lt;/b&gt;해서 &amp;ldquo;최단 시간 경로&amp;rdquo;를 계산&lt;/li&gt;
&lt;li data-end=&quot;10250&quot; data-start=&quot;10208&quot;&gt;dist: 시작 허브로부터 각 허브까지의 최소 소요 시간을 저장&lt;/li&gt;
&lt;li data-end=&quot;10306&quot; data-start=&quot;10254&quot;&gt;prev: 나중에 실제 경로를 복원하기 위해 &amp;ldquo;이 허브로 오기 직전 허브&amp;rdquo;를 저장&lt;/li&gt;
&lt;li data-end=&quot;10380&quot; data-start=&quot;10310&quot;&gt;PriorityQueue &amp;lt;Node&amp;gt;: 현재까지의 누적 시간이 가장 짧은 허브부터 탐색하기 위해 우선순위 큐를 사용&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;u&gt; 주석을 자세히 보시면 편합니다. &lt;/u&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1764917002854&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    private HubRouteResult dijkstraByDuration(Map&amp;lt;UUID, List&amp;lt;Edge&amp;gt;&amp;gt; graph, UUID startHubId, UUID endHubId) {

        Map&amp;lt;UUID, Integer&amp;gt; dist = new HashMap&amp;lt;&amp;gt;();  // 최소 시간
        Map&amp;lt;UUID, UUID&amp;gt; prev = new HashMap&amp;lt;&amp;gt;();     // 경로 복원용

        // 우선순위 큐로 Node 객체를 비교할 때 n.time (노드까지의 누적 시간)을 기준으로 정렬해서 넣음
        PriorityQueue&amp;lt;Node&amp;gt; pq = new PriorityQueue&amp;lt;&amp;gt;(Comparator.comparingInt(n -&amp;gt; n.time));


        // 시작 허브의 거리를 0으로 두고, 나머지는 모두 무한대(최대값)로 초기화
        for (UUID hubId : graph.keySet()) {
            dist.put(hubId, Integer.MAX_VALUE);
        }

        // dist는 지금까지 발견된 최소 누적 시간으로 기록 저장소
        // 출발 시점은 0으로
        dist.put(startHubId, 0);

        // pq는 앞으로 탐색할 후보 노드 목록
        // 시작 허브를 시간 0으로 큐에 넣어서 다익스트라 탐색 시작
        pq.offer(new Node(startHubId, 0));


        // 가장 누적 시간이 짧은 허브부터 꺼내서, 허브에서 갈 수 있는 모든 인접 허브의 거리를 갱신
        while (!pq.isEmpty()) {
            Node cur = pq.poll();
            UUID curId = cur.hubId;


            // 최단 거리 알고리즘의 특성상, 목적지 허브가 PQ에서 꺼내졌다는 것은 그 시간 값이 최종 최소 시간이라는 의미
            if (curId.equals(endHubId)) {
                break;
            }

            // cur.time &amp;gt; dist.get(curId) 이면, 이미 더 짧은 경로가 존재하는 상태라서 스킵
            // 예: 더 긴 경로로 먼저 들어갔다가, 나중에 더 짧은 경로가 발견된 경우로
            // &amp;ldquo;현재 꺼낸 값이 dist 테이블에 저장된 최소 시간보다 크면 버림
            if (cur.time &amp;gt; dist.get(curId)) {
                continue;
            }


            // 현재 허브(curId)가 연결된 허브들의 목록을 가져온다. 없으면 빈 리스트 반환 (NPE 방지)
            // key: 현재 허브 ID,  value: 그 허브에서 갈 수 있는 인접 허브들로의 간선 목록
            List&amp;lt;Edge&amp;gt; edges = graph.getOrDefault(curId, List.of());

            // 현재 허브에서 직접 갈 수 있는 허브들만 순회
            for (Edge e : edges) {

                //인접 허브들까지 얼마나 걸리는지 시간 계산
                EdgeWeight weight = hubEdgeWeightProvider.getWeight(curId, e.toHubId);

                // cur.time = 출발 &amp;rarr; e. 현재 허브까지 걸린 누적 시간
                int nextTime = cur.time + weight.durationSec();

                // 아직 도달한 적 없는 허브라면 &amp;rarr; 현재 값 = &amp;infin; 이고 nextTime이 기존 값보다 더 빠르면 저장
                // 목적지에 처음 도달했다고 끝나는 게 아니라 더 빠른 경로가 발견될 때까지 반복문 동안 dist를 계속 비교&amp;middot;갱신
                if (nextTime &amp;lt; dist.getOrDefault(e.toHubId, Integer.MAX_VALUE)) {
                    dist.put(e.toHubId, nextTime);

                    //prev.put(e.toHubId, curId); 를 통해 경로 추적용 역방향 링크를 기록
                    prev.put(e.toHubId, curId);
                    pq.offer(new Node(e.toHubId, nextTime));
                }
            }
            // dist에는 반복적으로 더해진 시간 nextTime가 들어감
        }


        // 도착 허브가 여전히 무한대라면, 실제 경로가 없다고 판단하고 예외처리
        if (!dist.containsKey(endHubId) || dist.get(endHubId) == Integer.MAX_VALUE) {
            log.warn(&quot;[HubRoute] no path found: {} -&amp;gt; {}&quot;, startHubId, endHubId);
            throw new BusinessException(ErrorCode.HUB_ROUTE_NOT_FOUND);
        }


        // 3. 경로 복원
        // prev 맵을 이용해 endHubId &amp;rarr; ... &amp;rarr; startHubId 방향으로 역추적을 진행
        // 리스트에 담고 나서 Collections.reverse(path)로 순서를 뒤집어 최종적으로 start &amp;rarr; ... &amp;rarr; end 순서의 경로를 얻음
        List&amp;lt;UUID&amp;gt; path = new ArrayList&amp;lt;&amp;gt;();
        UUID cur = endHubId;
        while (cur != null) {
            path.add(cur);
            cur = prev.get(cur);
        }
        Collections.reverse(path);

        // 4. 총 거리 합산
        // path 리스트를 인접한 두 개씩 (from, to) 쌍으로 묶어서 순회
        BigDecimal totalDistance = BigDecimal.ZERO;

        for (int i = 0; i &amp;lt; path.size() - 1; i++) {
            UUID from = path.get(i);
            UUID to = path.get(i + 1);

            // 그래프에서 from 의 인접 간선 목록을 가져와 to 로 가는 간선을 찾음
            List&amp;lt;Edge&amp;gt; edges = graph.getOrDefault(from, List.of());
            Edge edge = edges.stream()
                .filter(e -&amp;gt; e.toHubId.equals(to))
                .findFirst()
                .orElseThrow(() -&amp;gt; new BusinessException(ErrorCode.HUB_EDGE_NOT_FOUND));


            // 거리 합산은 다시 weight를 조회해서 distanceKm만 사용했다.
            // hubEdgeWeightProvider 내부 캐시 덕분에 Kakao API는 필요 시에만 호출되었다.
            EdgeWeight weight = hubEdgeWeightProvider.getWeight(from, to);
            // 해당 간선의 distanceKm 를 모두 더해 총 이동 거리를 계산
            totalDistance = totalDistance.add(weight.distanceKm());
        }

        int totalDuration = dist.get(endHubId);

        return new HubRouteResult(
            startHubId,
            endHubId,
            path,
            totalDuration,
            totalDistance
        );
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt; 경로 로그 저장 &amp;ndash; logRoute &lt;/b&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;경로를 찾는 것에서 끝내지 않고, 실제로 어떤 경로가 선택되었는지 DB에 남김&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 헤더 로그 &amp;ndash; HubRouteLog&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot; data-start=&quot;13976&quot; data-end=&quot;14012&quot;&gt;
&lt;li data-start=&quot;13976&quot; data-end=&quot;13990&quot;&gt;출발 허브, 도착 허브&lt;/li&gt;
&lt;li data-start=&quot;13994&quot; data-end=&quot;14012&quot;&gt;총 소요 시간, 총 이동 거리&lt;b&gt;&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 스텝 로그 &amp;ndash; HubRouteLogStop&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;14139&quot; data-start=&quot;14051&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;14066&quot; data-start=&quot;14051&quot;&gt;경로 상의 각 허브 ID&lt;/li&gt;
&lt;li data-end=&quot;14139&quot; data-start=&quot;14070&quot;&gt;seq 값을 통해 순서를 기록&lt;br /&gt;이렇게 하면 나중에 조인해서 실제 경로를 그대로 재구성&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1764917961020&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    /**
     * 경로 로그 테이블에 저장
     */
    @Transactional
    public void logRoute(HubRouteResult result) {

        HubRouteLog logEntity = HubRouteLog.create(
            result.startHubId(),
            result.endHubId(),
            result.totalDurationSec(),
            result.totalDistanceKm()
        );
        hubRouteLogRepository.save(logEntity);

        int seq = 0;
        for (UUID hubId : result.pathHubIds()) {
            HubRouteLogStop stop = HubRouteLogStop.create(
                logEntity.getId(),
                hubId,
                seq++
            );
            hubRouteLogStopRepository.save(stop);
        }

        log.info(&quot;[HubRoute] Logged route {} -&amp;gt; {} : {} sec, {} km, {} hops&quot;,
            result.startHubId(), result.endHubId(),
            result.totalDurationSec(), result.totalDistanceKm(),
            result.pathHubIds().size());
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Edge&lt;/b&gt; - 그래프의 한 간선을 의미&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;14665&quot; data-start=&quot;14586&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;14609&quot; data-start=&quot;14586&quot;&gt;어떤 허브(toHubId)로 가는지&lt;/li&gt;
&lt;li data-end=&quot;14638&quot; data-start=&quot;14612&quot;&gt;그때 걸리는 시간(durationSec)&lt;/li&gt;
&lt;li data-end=&quot;14665&quot; data-start=&quot;14641&quot;&gt;그때 이동 거리(distanceKm)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Node&lt;/b&gt; - 우선순위 큐에서 사용할 &amp;ldquo;현재 위치 + 지금까지 누적된 시간&amp;rdquo;을 표현&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1764918085041&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    private static class Edge {
        private final UUID toHubId;
        private final HubInfo hubInfo;

        public Edge(UUID toHubId, HubInfo hubInfo) {
            this.toHubId = toHubId;
            this.hubInfo = hubInfo;
        }
    }

    private static class Node {
        private final UUID hubId;
        private final int time;

        private Node(UUID hubId, int time) {
            this.hubId = hubId;
            this.time = time;
        }
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;결과&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;915&quot; data-origin-height=&quot;595&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cVu2Nv/dJMcahXuq8G/FzjovyNxW9gBvLbInKyuhK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cVu2Nv/dJMcahXuq8G/FzjovyNxW9gBvLbInKyuhK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cVu2Nv/dJMcahXuq8G/FzjovyNxW9gBvLbInKyuhK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcVu2Nv%2FdJMcahXuq8G%2FFzjovyNxW9gBvLbInKyuhK%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;915&quot; height=&quot;595&quot; data-origin-width=&quot;915&quot; data-origin-height=&quot;595&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;367&quot; data-origin-height=&quot;66&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bmLj6n/dJMcafd1R5P/wx1zRnRDa50WQUiPEBj5Yk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bmLj6n/dJMcafd1R5P/wx1zRnRDa50WQUiPEBj5Yk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bmLj6n/dJMcafd1R5P/wx1zRnRDa50WQUiPEBj5Yk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbmLj6n%2FdJMcafd1R5P%2Fwx1zRnRDa50WQUiPEBj5Yk%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;367&quot; height=&quot;66&quot; data-origin-width=&quot;367&quot; data-origin-height=&quot;66&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요구사항에 맞춰 서울에서 부산 hub까지의 요청인 경우&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;&lt;b&gt;서울 -&amp;gt; 경기남부 -&amp;gt; 대구 -&amp;gt; 부산&lt;/b&gt; 순서로 올바르게 경로를 찾아 응답을 주고 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;첫 요청의 경우 연관 경로에 허브들끼리의 시간을 api를 보내 가져와야 하니 시간이 오래 걸리지만, 한번 결과를 가져온 상태에서 5분이 지나지 않은 경우 DB에서 가져와 빠른 응답을 주고 있다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서울 -&amp;gt; 부산으로 결과가 나온 이후 5분이 지나지 않은 상태에서 경기남부 -&amp;gt; 부산으로 조회 시 빠른 도착 시간 조회가 가능하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;구현이 완료 되었지만 Redis 캐시를 사용하고 있지 않다는 문제가 존재하여 다음 게시물에서 이를 보완하려 한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;&lt;a href=&quot;https://kimfishes.tistory.com/13?category=1262695&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://kimfishes.tistory.com/13?category=1262695&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1774876163207&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;  허브 간 최단 경로 탐색 캐시 스탬피드 (Cache Stampede) 발생+해결&quot; data-og-description=&quot;이전 문제 상황출발지와 목적지 사이 허브 간 경로를 다익스트라 알고리즘으로 선정 후, Kakao Api를 호출해 허브 간 소요 시간 산출이때 Kakao Api 응답을 기다리는 방식의 경우 16 ~ 20 초의 많은 시&quot; data-og-host=&quot;kimfishes.tistory.com&quot; data-og-source-url=&quot;https://kimfishes.tistory.com/13?category=1262695&quot; data-og-url=&quot;https://kimfishes.tistory.com/13&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/v0mkf/dJMb9jgxjF1/OvkYSnjMjJqTgRJrevmKc1/img.png?width=800&amp;amp;height=416&amp;amp;face=0_0_800_416,https://scrap.kakaocdn.net/dn/xQnFy/dJMb9jgxjF2/knYrkmbFaabL5WFPBO6Wfk/img.png?width=800&amp;amp;height=416&amp;amp;face=0_0_800_416,https://scrap.kakaocdn.net/dn/H28Av/dJMb8VNvsMR/wKnovgksydn9VgKkYtITX0/img.png?width=1752&amp;amp;height=677&amp;amp;face=0_0_1752_677&quot;&gt;&lt;a href=&quot;https://kimfishes.tistory.com/13?category=1262695&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://kimfishes.tistory.com/13?category=1262695&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/v0mkf/dJMb9jgxjF1/OvkYSnjMjJqTgRJrevmKc1/img.png?width=800&amp;amp;height=416&amp;amp;face=0_0_800_416,https://scrap.kakaocdn.net/dn/xQnFy/dJMb9jgxjF2/knYrkmbFaabL5WFPBO6Wfk/img.png?width=800&amp;amp;height=416&amp;amp;face=0_0_800_416,https://scrap.kakaocdn.net/dn/H28Av/dJMb8VNvsMR/wKnovgksydn9VgKkYtITX0/img.png?width=1752&amp;amp;height=677&amp;amp;face=0_0_1752_677');&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;  허브 간 최단 경로 탐색 캐시 스탬피드 (Cache Stampede) 발생+해결&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;이전 문제 상황출발지와 목적지 사이 허브 간 경로를 다익스트라 알고리즘으로 선정 후, Kakao Api를 호출해 허브 간 소요 시간 산출이때 Kakao Api 응답을 기다리는 방식의 경우 16 ~ 20 초의 많은 시&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;kimfishes.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Spring Boot</category>
      <category>Dijkstra</category>
      <category>Spring boot</category>
      <category>다익스트라</category>
      <author>kimfishes</author>
      <guid isPermaLink="true">https://kimfishes.tistory.com/10</guid>
      <comments>https://kimfishes.tistory.com/10#entry10comment</comments>
      <pubDate>Sat, 6 Dec 2025 16:28:41 +0900</pubDate>
    </item>
    <item>
      <title>DDD의 페이징 로직 (Spring Boot)</title>
      <link>https://kimfishes.tistory.com/9</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;문제 발생&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 헥사고날 아키텍처와 DDD 기반으로 허브 서버를 개발하면서, 페이징 로직을 어떻게 가져가야 할지에 대한 고민이 생겼다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;- Spring Data JPA가 제공하는 Pageable, PageRequest, Page 같은 페이징 타입들은 매우 강력하고 편리하지만, 도메인 계층에 노출시키기에는 기술 의존성이 크다는 문제가 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;b&gt;도메인 계층에 그대로 사용하기에는 기술 의존성이 크다. &lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;문제 예시&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 헥사고날 아키텍처에서 도메인은 비즈니스 규칙만 알고 있어야 하고, 프레임워크나 기술에 종속되면 안되지만 페이징을 구현하려다 보니 자연스럽게 아래와 같은 코드가 만들어졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Hub Repository 인터페이스에서 Page를 사용하게 된다면 도메인 부분이지만 Jpa에 의존하는 문제가 됨&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 도메인은 특정 프레임워크에 종속되지 않아야 한다는 점에서 구조가 무너지기 때문&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt; 최초로 작성했던 Hub Repository 인터페이스 ( 도메인이 JPA에 잠식)&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1764660225257&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public interface HubRepository {

    void save(Hub hub);

    Page&amp;lt;Hub&amp;gt; findAll(Pageable pageable);

    Page&amp;lt;Hub&amp;gt; findByNameOrFullAddressContaining(String nameOrFullAddress, Pageable pageable);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;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 style=&quot;width: 50%; text-align: center;&quot;&gt;&lt;b&gt;구분&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;&lt;b&gt;문제점&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;DDD 관점&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;도메인 계층이 프레임워크에 의존하게 됨&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;확장성&lt;/td&gt;
&lt;td style=&quot;width: 50%; text-align: center;&quot;&gt;JPA 기반 페이징 전략에 고정되어 버림&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;DDD 구조를 깨트리지 않기 위해 리팩토링&lt;/b&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1545&quot; data-start=&quot;1510&quot;&gt;Pageable / Page 같은 JPA 타입을 제거&lt;/li&gt;
&lt;li data-end=&quot;1582&quot; data-start=&quot;1546&quot;&gt;순수 타입(int page, int size)으로 대체&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도메인은 페이징이 &lt;b&gt;어떻게 구현되는지&lt;/b&gt; 모르며 페이징 전략이 무엇이든(Offset / Slice / Cursor 등) 도메인은 **그저 &amp;ldquo;몇 번째 페이지를 가져와라&amp;rdquo;**만 표현&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1764661367654&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;    void save(Hub hub);

    List&amp;lt;Hub&amp;gt; findAll(int page, int size);

    List&amp;lt;Hub&amp;gt; findByNameOrFullAddressContaining(String nameOrFullAddress, int page, int size);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;JpaRepsitory&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1764661923408&quot; class=&quot;routeros&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;public interface JpaHubRepository extends JpaRepository&amp;lt;Hub, UUID&amp;gt; {

    List&amp;lt;Hub&amp;gt; findByNameAndFullAddressContaining(String name, String address, Pageable pageable);

    List&amp;lt;Hub&amp;gt; findAllByDeletedAtIsNull(Pageable pageable);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Adapter (인프라스트럭처)&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1764661589914&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RequiredArgsConstructor
public class HubRepositoryAdapter implements HubRepository {

    private final JpaHubRepository jpaHubRepository;


    @Override
    public void save(Hub hub) {
        jpaHubRepository.save(hub);
    }

    @Override
    public List&amp;lt;Hub&amp;gt; findAll(int page, int size) {
        Pageable pageable = PageRequest.of(page, size, Sort.by(&quot;name&quot;).ascending());
        return jpaHubRepository.findAllByDeletedAtIsNull(pageable);
    }



    @Override
    public List&amp;lt;Hub&amp;gt; findByNameOrFullAddressContaining(String nameOrFullAddress, int page, int size) {
        Pageable pageable = PageRequest.of(page, size);
        return jpaHubRepository.findByNameAndFullAddressContainingAndDeletedAtIsNotNull(nameOrFullAddress, nameOrFullAddress, pageable);
    }



}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2024&quot; data-start=&quot;2007&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2024&quot; data-start=&quot;2007&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2024&quot; data-start=&quot;2007&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2024&quot; data-start=&quot;2007&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2024&quot; data-start=&quot;2007&quot; data-ke-size=&quot;size16&quot;&gt;여기서 한 가지 의문이 들었다.&lt;/p&gt;
&lt;blockquote data-end=&quot;2111&quot; data-start=&quot;2026&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-end=&quot;2111&quot; data-start=&quot;2028&quot; data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;실제로 Repository에 넘어가는 객체는 PageRequest인데, 그냥 Repository에서 PageRequest를 받으면 더 낫지 않나?&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre id=&quot;code_1764661863853&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;List&amp;lt;Hub&amp;gt; findAllByDeletedAtIsNull(PageRequest pageRequest);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2234&quot; data-start=&quot;2210&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;2234&quot; data-start=&quot;2210&quot; data-ke-size=&quot;size16&quot;&gt;하지만 이 구조는 더 큰 문제를 만들어냈다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;2397&quot; data-start=&quot;2236&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;2279&quot; data-start=&quot;2236&quot;&gt;Repository가 PageRequest라는 &lt;b&gt;특정 구현체에 고정됨&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;2341&quot; data-start=&quot;2280&quot;&gt;Slice, Keyset, Cursor 기반 페이징으로 변경될 경우 &lt;b&gt;메서드 시그니처부터 수정해야 함&lt;/b&gt;&lt;/li&gt;
&lt;li data-end=&quot;2397&quot; data-start=&quot;2342&quot;&gt;Controller에서 Pageable 자동 매핑을 사용할 경우 &lt;b&gt;타입 불일치로 호출 불가&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;2445&quot; data-start=&quot;2399&quot; data-ke-size=&quot;size16&quot;&gt;즉, &lt;b&gt;PageRequest를 파라미터로 쓰는 순간 확장성이 완전히 사라지므로&lt;/b&gt;&lt;span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;Repository가 PageRequest에 &amp;ldquo;고정&amp;rdquo;되므로 구현체는 내부에서 생성해서 넘긴다.&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. find(PageRequest request)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. find(Pageable pageable)&lt;/p&gt;
둘 다 지금은 똑같이 동작하지만 2번째&amp;nbsp;구조가&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;변화에 강한 구조&lt;/b&gt;다.&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt; Pageable vs PageRequest 차이&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 55px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 21px; text-align: center;&quot;&gt;개념&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 21px; text-align: center;&quot;&gt;역할&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 17px; text-align: center;&quot;&gt;Pageable&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 17px; text-align: center;&quot;&gt;페이징 정보를 표현하는 인터페이스&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 50%; height: 17px; text-align: center;&quot;&gt;PageRequest&lt;/td&gt;
&lt;td style=&quot;width: 50%; height: 17px; text-align: center;&quot;&gt;가장 널리 쓰이는 Pageable의 구현체&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Pageable은 추상 타입이고 PageRequest는 그 구현체&lt;/b&gt;이다.&lt;/p&gt;
&lt;pre id=&quot;code_1764659968636&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;List&amp;lt;Hub&amp;gt; findAllByDeletedAtIsNull(PageRequest pageRequest);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 코드상에서는 문제가 없었고, 실제로 동작도 문제 없지만 &lt;b&gt;PageRequest라는 특정 구현체에 종속됨&lt;/b&gt;으로써 확장성을 잃게된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 이를 해결하기 위해 &lt;b&gt;Repository는 Pageable, Adapter는 PageRequest.of(...)를 적용하는 방식 사용&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;결론&lt;/b&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Repository가 PageRequest를 받는다고 해서 성능이 좋아지거나 기능이 늘어나지 않는다.&lt;/li&gt;
&lt;li&gt;하지만 Repository가 Pageable을 받는 순간, PageRequest &amp;middot; unpaged &amp;middot; Slice &amp;middot; keyset &amp;middot; cursor &amp;middot; custom Pageable 등 어떤 페이징 전략을 적용하더라도 Repository는 변경되지 않는다.&lt;/li&gt;
&lt;li&gt;즉 지금은 PageRequest가 넘어가더라도, 파라미터 타입을 Pageable로 두는 것이 미래의 변경 비용을 극적으로 줄이는 선택이 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;어? 만들어보니 프론트가 남은 갯수를 List로 주면 못 보는데??? &lt;/b&gt;&amp;nbsp;커스텀을 해야 한다...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도메인 계층에서는 JPA의 Page를 쓰지 않기 위해 List&amp;lt;Hub&amp;gt;만 반환하도록 깔끔하게 정리했는데, 막상 화면에서 필요한 정보는 단순히 &amp;ldquo;현재 페이지의 목록&amp;rdquo;뿐만 아니라 전체 허브 개수가 몇 개인지, 마지막 페이지가 어디까지인지 또는 다음 페이지가 있는지 정보를 제공해줘야 한다.&lt;/p&gt;
&lt;p data-end=&quot;605&quot; data-start=&quot;592&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;750&quot; data-start=&quot;607&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;635&quot; data-start=&quot;607&quot;&gt;JPA의 Page를 그대로 노출하는 대신&lt;/li&gt;
&lt;li data-end=&quot;692&quot; data-start=&quot;636&quot;&gt;&lt;b&gt;도메인에 독립적인 커스텀 페이징 타입&lt;/b&gt;(예: PageResult&amp;lt;T&amp;gt; 같은)을 만들고&lt;/li&gt;
&lt;li data-end=&quot;750&quot; data-start=&quot;693&quot;&gt;인프라 계층에서 Page &amp;rarr; PageResult로 변환해 올려주는 구조&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;PageResult&lt;/b&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1765006245383&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import lombok.Getter;

import java.util.List;

@Getter
public class PageResult&amp;lt;T&amp;gt; {

    private final List&amp;lt;T&amp;gt; items;   // 현재 페이지의 데이터
    private final int page;        // 현재 페이지 번호 (0 기반)
    private final int size;        // 페이지 크기
    private final long totalCount; // 전체 데이터 개수 (Slice 기반일 경우 -1 허용)
    private final boolean hasNext; // 다음 페이지 존재 여부

    private PageResult(List&amp;lt;T&amp;gt; items, int page, int size, long totalCount, boolean hasNext) {
        this.items = items;
        this.page = page;
        this.size = size;
        this.totalCount = totalCount;
        this.hasNext = hasNext;
    }

    public static &amp;lt;T&amp;gt; PageResult&amp;lt;T&amp;gt; of(List&amp;lt;T&amp;gt; items, int page, int size, long totalCount) {
        boolean hasNext = (long) (page + 1) * size &amp;lt; totalCount;
        return new PageResult&amp;lt;&amp;gt;(items, page, size, totalCount, hasNext);
    }

    // slice 기반 페이징을 위한 팩토리 (totalCount 미알 수 있을 때)
    public static &amp;lt;T&amp;gt; PageResult&amp;lt;T&amp;gt; sliceOf(List&amp;lt;T&amp;gt; items, int page, int size, boolean hasNext) {
        return new PageResult&amp;lt;&amp;gt;(items, page, size, -1, hasNext);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;사용 예시&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1765006342943&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public PageResult&amp;lt;Hub&amp;gt; findAll(int page, int size) {
    Pageable pageable = PageRequest.of(page, size, Sort.by(&quot;name&quot;).ascending());
    Page&amp;lt;Hub&amp;gt; result = jpaHubRepository.findAllByDeletedAtIsNull(pageable);

    return PageResult.of(
        result.getContent(),
        result.getNumber(),
        result.getSize(),
        result.getTotalElements()
    );
}&lt;/code&gt;&lt;/pre&gt;</description>
      <category>Spring Boot</category>
      <category>paging</category>
      <category>Spring boot</category>
      <category>페이징</category>
      <author>kimfishes</author>
      <guid isPermaLink="true">https://kimfishes.tistory.com/9</guid>
      <comments>https://kimfishes.tistory.com/9#entry9comment</comments>
      <pubDate>Tue, 2 Dec 2025 17:06:57 +0900</pubDate>
    </item>
  </channel>
</rss>