<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>  무럭무럭 자라나는 개발 기록</title>
    <link>https://programmingiraffe.tistory.com/</link>
    <description>백엔드 개발자</description>
    <language>ko</language>
    <pubDate>Wed, 1 Jul 2026 07:55:02 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>giraffe_</managingEditor>
    <image>
      <title>  무럭무럭 자라나는 개발 기록</title>
      <url>https://tistory1.daumcdn.net/tistory/5299427/attach/520dada7870549ae8f580fd70e8e8a36</url>
      <link>https://programmingiraffe.tistory.com</link>
    </image>
    <item>
      <title>[Java/Spring] Heap 캐시 vs Offheap 캐시 - 캐시 히트인데 왜 느릴까?</title>
      <link>https://programmingiraffe.tistory.com/200</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;배경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;얼마 전 업무에서 캐시를 적용했는데도 성능이 나아지지 않는 이상한 상황을 겪었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캐시 히트는 정상이었다. 근데 느렸다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 8500개짜리 &lt;code&gt;Map&lt;/code&gt;을 캐시에서 꺼내는 로직에 있었다. &lt;code&gt;getDataMap()&lt;/code&gt;은 분명 캐시에서 응답하고 있었는데, 요청이 몰릴 때마다 성능 저하가 반복됐다.&lt;/p&gt;
&lt;pre id=&quot;code_1772805295270&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Cacheable(value = &quot;dataCache&quot;, key = &quot;'dataMap'&quot;)
public Map&amp;lt;String, String&amp;gt; getDataMap() {
    return dataRepository.findAll();
}&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;code&gt;&amp;lt;offheap&amp;gt;&lt;/code&gt;을 쓰고 있었다는 걸 알게 됐다.&lt;/p&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;cache alias=&quot;dataCache&quot;&amp;gt;
    &amp;lt;resources&amp;gt;
        &amp;lt;offheap unit=&quot;MB&quot;&amp;gt;50&amp;lt;/offheap&amp;gt;
    &amp;lt;/resources&amp;gt;
&amp;lt;/cache&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 그때 처음으로 제대로 이해했다. &lt;b&gt;Heap 캐시와 Offheap 캐시는 동작 방식 자체가 다르다는 걸.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 그 과정에서 정리한 내용이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Heap과 Offheap의 차이&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;JVM 메모리 구조&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 캐시를 이해하려면 JVM의 메모리 구조를 먼저 봐야 한다.&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 widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1310&quot; data-origin-height=&quot;1002&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/MqL5E/dJMcaaFTpbb/lPuDCU7kMySdeE0cRYlVk1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/MqL5E/dJMcaaFTpbb/lPuDCU7kMySdeE0cRYlVk1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/MqL5E/dJMcaaFTpbb/lPuDCU7kMySdeE0cRYlVk1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FMqL5E%2FdJMcaaFTpbb%2FlPuDCU7kMySdeE0cRYlVk1%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;1310&quot; height=&quot;1002&quot; data-origin-width=&quot;1310&quot; data-origin-height=&quot;1002&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Heap&lt;/b&gt;: &lt;code&gt;new HashMap()&lt;/code&gt; 같이 Java가 생성하는 모든 객체가 올라가는 공간. GC가 관리한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Offheap&lt;/b&gt;: JVM 외부의 네이티브 메모리. GC의 관리 범위 밖이다.&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;Heap 캐시의 동작 방식&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1184&quot; data-origin-height=&quot;696&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bPPqwu/dJMcah546Jm/3CTZnUFCQ5Y86AS1YbLXnk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bPPqwu/dJMcah546Jm/3CTZnUFCQ5Y86AS1YbLXnk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bPPqwu/dJMcah546Jm/3CTZnUFCQ5Y86AS1YbLXnk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbPPqwu%2FdJMcah546Jm%2F3CTZnUFCQ5Y86AS1YbLXnk%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;1184&quot; height=&quot;696&quot; data-origin-width=&quot;1184&quot; data-origin-height=&quot;696&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;Heap 캐시는 객체를 Heap에 그대로 올려두고, 캐시 히트 시 &lt;b&gt;메모리 주소만 전달&lt;/b&gt;한다. 복사도, 변환도 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그냥 &quot;거기 있어&quot; 하고 주소를 가리키는 것과 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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;Offheap 캐시의 동작 방식&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Offheap은 다르다.&lt;/p&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&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1184&quot; data-origin-height=&quot;870&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bRKTuE/dJMcaf1oPNG/T0H91THybLKcqSpz3hu0c0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bRKTuE/dJMcaf1oPNG/T0H91THybLKcqSpz3hu0c0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bRKTuE/dJMcaf1oPNG/T0H91THybLKcqSpz3hu0c0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbRKTuE%2FdJMcaf1oPNG%2FT0H91THybLKcqSpz3hu0c0%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;1184&quot; height=&quot;870&quot; data-origin-width=&quot;1184&quot; data-origin-height=&quot;870&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;왜 Offheap은 직렬화가 필요할까?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JVM Heap의 Java 객체는 단순한 바이트 덩어리가 아니다.&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 widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1184&quot; data-origin-height=&quot;788&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dIuCAT/dJMcaaTmPhN/LLkZtKzHSMz1tSebrzGKFK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dIuCAT/dJMcaaTmPhN/LLkZtKzHSMz1tSebrzGKFK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dIuCAT/dJMcaaTmPhN/LLkZtKzHSMz1tSebrzGKFK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdIuCAT%2FdJMcaaTmPhN%2FLLkZtKzHSMz1tSebrzGKFK%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;1184&quot; height=&quot;788&quot; data-origin-width=&quot;1184&quot; data-origin-height=&quot;788&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 data-ke-size=&quot;size16&quot;&gt;이 구조를 Offheap으로 그대로 가져갈 수 없는 이유가 세 가지 있다.&lt;/p&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;이유 1 - 참조(포인터)는 JVM Heap 안에서만 유효하다&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1310&quot; data-origin-height=&quot;486&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bTI61w/dJMcagF6MG8/4QeZk12ObcAq6nQ9L7Covk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bTI61w/dJMcagF6MG8/4QeZk12ObcAq6nQ9L7Covk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bTI61w/dJMcagF6MG8/4QeZk12ObcAq6nQ9L7Covk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbTI61w%2FdJMcagF6MG8%2F4QeZk12ObcAq6nQ9L7Covk%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;1310&quot; height=&quot;486&quot; data-origin-width=&quot;1310&quot; data-origin-height=&quot;486&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Heap 포인터는 JVM이 관리하는 주소 공간 안에서만 의미를 가진다.&lt;/p&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 - GC가 객체를 이동시킨다&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1310&quot; data-origin-height=&quot;504&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dUbUWz/dJMcacczo6a/m7BxXuQbBHG4RMB8ZPPcXk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dUbUWz/dJMcacczo6a/m7BxXuQbBHG4RMB8ZPPcXk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dUbUWz/dJMcacczo6a/m7BxXuQbBHG4RMB8ZPPcXk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdUbUWz%2FdJMcacczo6a%2Fm7BxXuQbBHG4RMB8ZPPcXk%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;1310&quot; height=&quot;504&quot; data-origin-width=&quot;1310&quot; data-origin-height=&quot;504&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;Offheap에 주소를 저장해뒀다가 GC 후에 꺼내면 엉뚱한 곳을 가리키게 된다.&lt;/p&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 - Object Header는 JVM 전용 구조다&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Object Header에는 클래스 정보 포인터, GC 마킹 비트, 동기화 락 정보가 담겨 있는데, JVM 외부에서는 해석이 불가능하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 직렬화로 &quot;주소 없는 순수 데이터&quot;로 변환해서 저장하는 것이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1310&quot; data-origin-height=&quot;616&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tUJa1/dJMcabY5yt7/4ZUV1oCxuHXgIRmANZ0lFK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tUJa1/dJMcabY5yt7/4ZUV1oCxuHXgIRmANZ0lFK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tUJa1/dJMcabY5yt7/4ZUV1oCxuHXgIRmANZ0lFK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtUJa1%2FdJMcabY5yt7%2F4ZUV1oCxuHXgIRmANZ0lFK%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;1310&quot; height=&quot;616&quot; data-origin-width=&quot;1310&quot; data-origin-height=&quot;616&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;JVM Heap에 새 객체를 새로 생성&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;h3 data-ke-size=&quot;size23&quot;&gt;정리 - 캐시 히트라도 Offheap은 항상 역직렬화한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내 케이스에서 문제가 됐던 이유가 바로 이거다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;[Offheap 캐시 조회 시]
Offheap 메모리 &amp;rarr; [바이트 배열] &amp;rarr; 역직렬화 &amp;rarr; 새 HashMap 객체 생성
                                    &amp;uarr; 히트마다 이 비용 반복 발생&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;processRequest()&lt;/code&gt;는 요청마다 호출된다. 8500개짜리 Map을 요청마다 역직렬화하면 당연히 느리다.&lt;/p&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;Heap vs Offheap 선택 기준&lt;/h3&gt;
&lt;table style=&quot;height: 96px;&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;thead&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;th style=&quot;height: 20px;&quot;&gt;&amp;nbsp;&lt;/th&gt;
&lt;th style=&quot;height: 20px;&quot;&gt;Heap 유리&lt;/th&gt;
&lt;th style=&quot;height: 20px;&quot;&gt;Offheap 유리&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;객체 크기&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;작을 때 (~수 MB 이하)&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;매우 클 때 (수백 MB~GB)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;조회 빈도&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;높을 때&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;낮을 때&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;GC 상황&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;Stop-the-world 문제 없을 때&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;GC 부담이 심할 때&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;Heap 메모리&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;여유 있을 때&lt;/td&gt;
&lt;td style=&quot;height: 19px;&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;내 케이스는 약 300KB짜리 Map, 고빈도 조회 &amp;rarr; &lt;b&gt;Heap이 적합한 상황이었다.&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;자바 스프링에서의 구현&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Ehcache 설정에서 &lt;code&gt;&amp;lt;offheap&amp;gt;&lt;/code&gt;을 &lt;code&gt;&amp;lt;heap&amp;gt;&lt;/code&gt;으로 바꾸는 것만으로 해결됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;xml&quot;&gt;&lt;code&gt;&amp;lt;cache alias=&quot;dataCache&quot;&amp;gt;
    &amp;lt;key-type&amp;gt;java.lang.String&amp;lt;/key-type&amp;gt;
    &amp;lt;value-type&amp;gt;java.util.HashMap&amp;lt;/value-type&amp;gt;
    &amp;lt;expiry&amp;gt;
        &amp;lt;ttl unit=&quot;hours&quot;&amp;gt;1&amp;lt;/ttl&amp;gt;
    &amp;lt;/expiry&amp;gt;
    &amp;lt;resources&amp;gt;
        &amp;lt;heap unit=&quot;entries&quot;&amp;gt;1&amp;lt;/heap&amp;gt;
    &amp;lt;/resources&amp;gt;
&amp;lt;/cache&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;unit=&quot;entries&quot;&lt;/code&gt;는 바이트 크기가 아닌 &lt;b&gt;키-값 쌍의 개수&lt;/b&gt;로 용량을 제한하는 단위다. 캐시 키가 고정 문자열 하나뿐이라 엔트리가 항상 1개 &amp;rarr; &lt;code&gt;entries = 1&lt;/code&gt;로 설정했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;dart&quot;&gt;&lt;code&gt;@Cacheable(value = &quot;dataCache&quot;, key = &quot;'dataMap'&quot;)
//                                     &amp;uarr; 항상 고정 &amp;rarr; 엔트리 1개
public Map&amp;lt;String, String&amp;gt; getDataMap() {
    return dataRepository.findAll();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸로 캐시 히트 시 역직렬화 없이 바로 객체 참조를 반환하게 됐고, 성능 이슈가 해결됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;캐시 히트 = 빠름&quot;이라고만 생각하고 있었는데, Offheap의 경우 히트 시에도 역직렬화 비용이 매번 발생한다는 걸 처음으로 제대로 이해한 계기였다.&lt;/p&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;code&gt;&amp;lt;offheap&amp;gt;&lt;/code&gt; &amp;rarr; &lt;code&gt;&amp;lt;heap&amp;gt;&lt;/code&gt;)만 바꿨는데 성능이 개선됐다. 그 한 줄의 의미를 몰랐던 게 문제였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;캐시를 선택할 때는 &lt;b&gt;객체 크기&lt;/b&gt;와 &lt;b&gt;조회 빈도&lt;/b&gt;를 기준으로 생각해야겠다는 교훈을 얻었다.&lt;/p&gt;</description>
      <category>Backend/Jave&amp;amp;Spring</category>
      <author>giraffe_</author>
      <guid isPermaLink="true">https://programmingiraffe.tistory.com/200</guid>
      <comments>https://programmingiraffe.tistory.com/200#entry200comment</comments>
      <pubDate>Tue, 30 Jun 2026 12:11:03 +0900</pubDate>
    </item>
    <item>
      <title>[ClickHouse] ClickHouse JOIN이 느린 이유와 최적화 전략</title>
      <link>https://programmingiraffe.tistory.com/210</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;clickhouse-logo-black.png&quot; data-origin-width=&quot;2438&quot; data-origin-height=&quot;430&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/LpUI3/dJMcabq1Ioh/0qKpHOGXla1OnkPvH9ub9K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/LpUI3/dJMcabq1Ioh/0qKpHOGXla1OnkPvH9ub9K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/LpUI3/dJMcabq1Ioh/0qKpHOGXla1OnkPvH9ub9K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FLpUI3%2FdJMcabq1Ioh%2F0qKpHOGXla1OnkPvH9ub9K%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;2438&quot; height=&quot;430&quot; data-filename=&quot;clickhouse-logo-black.png&quot; data-origin-width=&quot;2438&quot; data-origin-height=&quot;430&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;&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;&amp;nbsp;ClickHouse를 처음 쓰다 보면 한 번쯤 이런 경험을 한다. MySQL에서는 아무 문제 없이 돌아가던 JOIN 쿼리가 ClickHouse에서는 유독 느리거나, WHERE 조건을 다 걸었는데도 풀스캔 수준의 시간이 걸리는 것이다. 이 글에서는 그 이유를 ClickHouse의 내부 동작 방식에서 찾고, 실제로 쓸 수 있는 최적화 전략을 정리한다.&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;어떤 상황인가&lt;/h2&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;수억 건의 유저 행동 로그(page_view_log)와 주문 데이터(order_log)를 JOIN해서 페이지를 본 특정 기간, 특정 유저의 구매 건수를 집계한다고 해보자. 아래처럼 쓰는 것이 자연스럽다.&lt;/p&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;pre class=&quot;stylus&quot; style=&quot;background-color: #fafafa;&quot;&gt;&lt;code&gt;SELECT count(*)
FROM default.page_view_log p
  JOIN default.order_log o
      ON p.user_id = o.user_id
    AND p.session_id = o.session_id
WHERE o.order_date BETWEEN '20260604' AND '20260610'
  AND o.user_id = 'user_001'
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;&amp;nbsp;그런데 이 쿼리는 유독 느리다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;실제로 왼쪽 테이블 약 3억 건, 오른쪽 테이블 약 7천만 건의 데이터를 위의 쿼리로 조회했을 때 &lt;b&gt;약 1분 40초&lt;/b&gt;가 걸렸다.&lt;/p&gt;
&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;787&quot; data-origin-height=&quot;31&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/oOPLW/dJMcagy3of5/0mNHKHtYUAXUqA5MM8oZ6K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/oOPLW/dJMcagy3of5/0mNHKHtYUAXUqA5MM8oZ6K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/oOPLW/dJMcagy3of5/0mNHKHtYUAXUqA5MM8oZ6K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FoOPLW%2FdJMcagy3of5%2F0mNHKHtYUAXUqA5MM8oZ6K%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;787&quot; height=&quot;31&quot; data-origin-width=&quot;787&quot; data-origin-height=&quot;31&quot;/&gt;&lt;/span&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;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;pre class=&quot;java&quot; style=&quot;background-color: #fafafa;&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;SELECT count(*)
FROM
    (SELECT * FROM default.order_log
     WHERE order_date BETWEEN '20260604' AND '20260610'
       AND user_id = 'user_001'
    ) O
    INNER JOIN (
        SELECT * FROM default.page_view_log
        WHERE user_id = 'user_001'
          AND created_at &amp;gt; '20260504'
    ) P ON P.user_id = O.user_id AND P.session_id = O.session_id&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;실제로 위의 개선된 쿼리로 다시 조회했을 때&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;약 0.6초&lt;/b&gt;&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;가 걸렸다.&lt;/span&gt;&lt;/p&gt;
&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;643&quot; data-origin-height=&quot;29&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/HdoWy/dJMcaiwTme7/HQCIXyTRJmTBEoAGRhgGIK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/HdoWy/dJMcaiwTme7/HQCIXyTRJmTBEoAGRhgGIK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/HdoWy/dJMcaiwTme7/HQCIXyTRJmTBEoAGRhgGIK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FHdoWy%2FdJMcaiwTme7%2FHQCIXyTRJmTBEoAGRhgGIK%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;643&quot; height=&quot;29&quot; data-origin-width=&quot;643&quot; data-origin-height=&quot;29&quot;/&gt;&lt;/span&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-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 쿼리의 논리적 결과는 같다. 그런데 왜 실행 시간이 다를까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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;style6&quot; /&gt;&lt;/div&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot; data-heading=&quot;이유 1 &amp;mdash; ClickHouse는 Predicate Pushdown을 자동으로 하지 않는다&quot; data-ke-size=&quot;size26&quot;&gt;이유 1 - ClickHouse는 Predicate Pushdown을 자동으로 하지 않는다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;&amp;nbsp;MySQL, PostgreSQL 같은 RDBMS는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;Cost-Based Optimizer(CBO)&lt;/b&gt;&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;를 탑재하고 있다. 테이블 통계를 바탕으로 &quot;어떤 순서로 실행하면 비용이 가장 낮을지&quot;를 계산하고, WHERE 조건을 JOIN 이전 단계로 밀어 넣는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;Predicate Pushdown&lt;/b&gt;&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;을 자동으로 수행한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot; data-heading=&quot;Cost-Based Optimizer란&quot; data-ke-size=&quot;size23&quot;&gt;Cost-Based Optimizer&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cpGZUE/dJMcagZ7DWx/tXMkuA1faqG6yReW9bksck/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cpGZUE/dJMcagZ7DWx/tXMkuA1faqG6yReW9bksck/img.gif&quot; data-origin-width=&quot;452&quot; data-origin-height=&quot;354&quot; data-is-animation=&quot;false&quot; style=&quot;width: 55.4273%; margin-right: 10px;&quot; data-widthpercent=&quot;56.08&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cpGZUE/dJMcagZ7DWx/tXMkuA1faqG6yReW9bksck/img.gif&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcpGZUE%2FdJMcagZ7DWx%2FtXMkuA1faqG6yReW9bksck%2Fimg.gif&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;452&quot; height=&quot;354&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Vtujc/dJMcahrhwjg/QObLcX3Nt715FQvw5k0Xck/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Vtujc/dJMcahrhwjg/QObLcX3Nt715FQvw5k0Xck/img.png&quot; data-origin-width=&quot;422&quot; data-origin-height=&quot;422&quot; data-is-animation=&quot;false&quot; style=&quot;width: 43.4099%;&quot; data-widthpercent=&quot;43.92&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Vtujc/dJMcahrhwjg/QObLcX3Nt715FQvw5k0Xck/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FVtujc%2FdJMcahrhwjg%2FQObLcX3Nt715FQvw5k0Xck%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;422&quot; height=&quot;422&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
  &lt;figcaption&gt;https://docs.oracle.com/en/database/oracle/oracle-database/21/tgsql/query-optimizer-concepts.html#GUID-298EDC61-405A-4E25-AEF6-C795E32AAC93&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;&amp;nbsp;CBO는 테이블 통계를 바탕으로 실행 비용을 계량화해서, 가장 낮은 비용의 실행 계획을 자동으로 선택하는 옵티마이저다. 테이블 행 수, 컬럼 카디널리티, 값의 분포(히스토그램), 인덱스 유무 같은 통계 정보를 활용해 &quot;A 방법은 예상 비용 1200, B 방법은 예상 비용 340&quot; 식으로 계산한 뒤 최적 순서를 결정한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;&amp;nbsp;이게 필요한 이유는 쿼리를 실행하는 방법이 하나가 아니기 때문이다. 아래 쿼리 하나만 해도 실행 순서가 여러 가지다.&lt;/span&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #fafafa;&quot;&gt;&lt;code&gt;SELECT * FROM orders o JOIN users u ON o.user_id = u.id
WHERE o.created_at &amp;gt; '2026-01-01' AND u.country = 'KR'
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;text-align: start;&quot; data-line=&quot;0&quot;&gt;orders를 먼저 필터링한 뒤&lt;span&gt;&amp;nbsp;&lt;/span&gt;users와 JOIN하거나&lt;/li&gt;
&lt;li style=&quot;text-align: start;&quot; data-line=&quot;1&quot;&gt;users를 먼저 필터링한 뒤&lt;span&gt;&amp;nbsp;&lt;/span&gt;orders와 JOIN하거나&lt;/li&gt;
&lt;li style=&quot;text-align: start;&quot; data-line=&quot;2&quot;&gt;두 테이블을 통째로 JOIN한 다음 WHERE를 적용하거나&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;&amp;nbsp;어떤 순서가 빠른지는 데이터 분포에 따라 다르다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;country = 'KR'&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;이 전체 유저의 90%라면 users 필터는 별 도움이 안 되고,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;created_at&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;조건이 orders를 1%로 줄인다면 거기서 먼저 필터링하는 게 훨씬 유리하다. CBO는 이 판단을 사람 대신 자동으로 해준다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #222222;&quot; data-ke-size=&quot;size16&quot; data-heading=&quot;Predicate Pushdown이란&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222;&quot; data-ke-size=&quot;size16&quot; data-heading=&quot;Predicate Pushdown이란&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222;&quot; data-ke-size=&quot;size16&quot; data-heading=&quot;Predicate Pushdown이란&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222;&quot; data-ke-size=&quot;size16&quot; data-heading=&quot;Predicate Pushdown이란&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222;&quot; data-ke-size=&quot;size16&quot; data-heading=&quot;Predicate Pushdown이란&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #222222;&quot; data-ke-size=&quot;size23&quot; data-heading=&quot;Predicate Pushdown이란&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;Predicate Pushdown&lt;/span&gt;&amp;nbsp;&lt;/h3&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&quot;Predicate(조건)를 Pushdown(밀어 넣는다)&quot;이라는 이름 그대로, WHERE 조건을 가능한 한 데이터 소스에 가깝게 밀어 넣어 일찍 필터링하는 최적화다. 데이터는 늦게 걸러낼수록 손해다. 100만 건을 JOIN한 뒤 1만 건만 남기는 것보다, 먼저 1만 건으로 줄인 뒤 JOIN하는 게 당연히 빠르다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #fafafa;&quot;&gt;&lt;code&gt;-- 개발자가 작성한 쿼리
SELECT * FROM orders o JOIN users u ON o.user_id = u.id
WHERE o.created_at &amp;gt; '2026-01-01' AND u.country = 'KR'

-- 옵티마이저가 Predicate Pushdown 적용 후 실행하는 형태
SELECT * FROM
    (SELECT * FROM orders WHERE created_at &amp;gt; '2026-01-01') o
JOIN
    (SELECT * FROM users WHERE country = 'KR') u
ON o.user_id = u.id
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;JOIN 전에 양쪽을 각자 줄여놓으니, 붙여야 할 데이터 자체가 작아진다. 개발자가 WHERE를 어디에 두든 옵티마이저가 알아서 이 순서로 재구성한다.&lt;/p&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;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;단, Predicate Pushdown이 항상 가능한 건 아니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;HAVING처럼 집계 이후에 적용되는 조건이나, 비결정적 함수(NOW()&lt;span&gt;&amp;nbsp;&lt;/span&gt;등)가 포함된 조건은 밀어 넣을 수 없다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #fafafa; color: #222222; text-align: start;&quot;&gt;&lt;code&gt;-- HAVING: 집계가 끝나야 cnt 값이 생기므로 GROUP BY 이전으로 밀어 넣을 수 없다
SELECT user_id, count(*) AS cnt
FROM orders
GROUP BY user_id
HAVING cnt &amp;gt; 10

-- 비결정적 함수: 실행 시점마다 값이 달라지므로 pushdown이 제한된다
WHERE created_at &amp;gt; NOW() - INTERVAL 7 DAY&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;h3 style=&quot;color: #222222;&quot; data-heading=&quot;ClickHouse에서는&quot; data-ke-size=&quot;size23&quot;&gt;ClickHouse에서는?&lt;/h3&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;&amp;nbsp;ClickHouse도 일부 Cost-Based 최적화를 지원하지만, MySQL&amp;middot;PostgreSQL의 CBO처럼 JOIN 실행 순서나 Predicate Pushdown이 RDBMS 수준으로 보장되지는 않는다. 단일 테이블에 대한 단순한 조건은 pushdown되기도 하지만, JOIN이 포함된 복잡한 쿼리에서는 WHERE 조건이 JOIN 이후에 적용되는 경우가 많다. 옵티마이저가 알아서 해줄 거라고 믿고 쓰기 어렵다.&lt;/span&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;pre class=&quot;erlang&quot; style=&quot;background-color: #fafafa;&quot;&gt;&lt;code&gt;page_view_log (전체) &amp;times; order_log (전체) &amp;rarr; JOIN &amp;rarr; WHERE 필터
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;&amp;nbsp;pushdown이 되더라도 부분적으로만 적용되거나 아예 되지 않는 경우가 많아, 결과를 예측하기 어렵다. 결국 뒤에서 다룰 &quot;서브쿼리로 먼저 줄이기&quot; 패턴은 CBO + Predicate Pushdown을 개발자가 직접 명시적으로 구현하는 것과 같다.&lt;/span&gt;&lt;/p&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;h2 style=&quot;color: #222222;&quot; data-heading=&quot;이유 2 &amp;mdash; Hash Join은 오른쪽 테이블을 메모리에 통째로 올린다&quot; data-ke-size=&quot;size26&quot;&gt;이유 2 - Hash Join은 오른쪽 테이블을 메모리에 통째로 올린다&lt;/h2&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;ClickHouse의 기본 JOIN 알고리즘은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Hash Join&lt;/b&gt;이다.&lt;/p&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li style=&quot;text-align: start;&quot; data-line=&quot;0&quot;&gt;오른쪽 테이블 전체를 해시 테이블로 메모리에 적재&lt;/li&gt;
&lt;li style=&quot;text-align: start;&quot; data-line=&quot;1&quot;&gt;왼쪽 테이블을 스트리밍하면서 해시 테이블과 매칭&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;4080&quot; data-origin-height=&quot;1820&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bIijeY/dJMcaaljies/IH51Rkk1qOYQKbLPQHhAt1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bIijeY/dJMcaaljies/IH51Rkk1qOYQKbLPQHhAt1/img.png&quot; data-alt=&quot;https://clickhouse.com/blog/clickhouse-fully-supports-joins-hash-joins-part2&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bIijeY/dJMcaaljies/IH51Rkk1qOYQKbLPQHhAt1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbIijeY%2FdJMcaaljies%2FIH51Rkk1qOYQKbLPQHhAt1%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;4080&quot; height=&quot;1820&quot; data-origin-width=&quot;4080&quot; data-origin-height=&quot;1820&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;https://clickhouse.com/blog/clickhouse-fully-supports-joins-hash-joins-part2&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2912&quot; data-origin-height=&quot;1270&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nsYVf/dJMcafUwqiy/JojCTd3gBQWCyrHEjHkbf0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nsYVf/dJMcafUwqiy/JojCTd3gBQWCyrHEjHkbf0/img.png&quot; data-alt=&quot;https://clickhouse.com/blog/clickhouse-fully-supports-joins-hash-joins-part2&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nsYVf/dJMcafUwqiy/JojCTd3gBQWCyrHEjHkbf0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnsYVf%2FdJMcafUwqiy%2FJojCTd3gBQWCyrHEjHkbf0%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;2912&quot; height=&quot;1270&quot; data-origin-width=&quot;2912&quot; data-origin-height=&quot;1270&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;https://clickhouse.com/blog/clickhouse-fully-supports-joins-hash-joins-part2&lt;/figcaption&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;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;오른쪽 테이블이 필터링되지 않은 대용량 로그라면 수억 건이 메모리에 올라가야 하고, 메모리가 부족하면 디스크로 넘쳐 더 느려진다. 서브쿼리로 미리 걸러서 오른쪽을 소량으로 줄이면, 메모리 적재 비용 자체가 작아져 전체 처리 시간이 크게 단축된다.&lt;/p&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;h2 data-ke-size=&quot;size26&quot;&gt;ClickHouse와&amp;nbsp;RDBMS,&amp;nbsp;설계&amp;nbsp;목적이&amp;nbsp;다르다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;&amp;nbsp;위의 두 가지 이유(Predicate Pushdown 미보장, Hash Join의 메모리 적재 방식)는 모두 ClickHouse가 RDBMS와 근본적으로 다른 설계 철학을 갖고 있기 때문에 생긴다.&lt;/span&gt;&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%; height: 94px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;항목&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;RDBMS&lt;/td&gt;
&lt;td style=&quot;height: 18px;&quot;&gt;ClickHouse&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;설계 목적&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;OLTP - 다양한 쿼리 패턴을 균형 있게 처리&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;OLAP - 단일 테이블 대규모 스캔 특화&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;옵티마이저&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;Cost-Based, 통계 기반 실행 계획 수립&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;상대적으로 단순한 Rule-Based&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;Predicate Pushdown&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;자동 수행&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;제한적, 명시적으로 작성해야 함&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;JOIN 알고리즘 선택&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;Nested Loop / Hash / Merge 중 자동 선택&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;기본 Hash Join 고정&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;ClickHouse는 처음부터 &quot;단일 테이블 집계를 극한으로 빠르게&quot; 하는 데 집중해서 설계됐다. 복잡한 옵티마이저 대신 컬럼 스토리지와 벡터화 연산에 투자했고, 그 결과 단일 테이블 집계는 빠르지만 JOIN은 개발자가 직접 챙겨야 한다.&lt;/p&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;style6&quot; /&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;h2 style=&quot;color: #222222;&quot; data-heading=&quot;최적화 전략&quot; data-ke-size=&quot;size26&quot;&gt;최적화 전략&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;Dictionary, View처럼 DDL 권한이 필요한 방법도 있지만, 여기서는 &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;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;h3 style=&quot;color: #222222;&quot; data-heading=&quot;1. 서브쿼리로 데이터를 먼저 줄이기&quot; data-ke-size=&quot;size23&quot;&gt;1. 서브쿼리로 데이터를 먼저 줄이기&lt;/h3&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;가장 기본이자 가장 효과적인 방법이다. JOIN 전에 각 테이블을 서브쿼리로 필터링해서 실제 JOIN 대상 데이터를 최소화한다. 앞에서 본 두 번째 쿼리가 이 원칙을 적용한 형태다.&lt;/p&gt;
&lt;pre id=&quot;code_1781493366294&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SELECT count(*)
FROM
    (SELECT * FROM default.order_log
     WHERE order_date BETWEEN '20260604' AND '20260610'
       AND user_id = 'user_001'
    ) O
    INNER JOIN (
        SELECT * FROM default.page_view_log
        WHERE user_id = 'user_001'
          AND created_at &amp;gt; '20260504'
    ) P ON P.user_id = O.user_id AND P.session_id = O.session_id&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;h3 style=&quot;color: #222222;&quot; data-heading=&quot;2. 작은 테이블을 오른쪽에 배치&quot; data-ke-size=&quot;size23&quot;&gt;2. 작은 테이블을 오른쪽에 배치&lt;/h3&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;Hash Join 특성상 오른쪽 테이블이 메모리에 올라가므로, 항상 더 작은 쪽을 오른쪽에 두어야 한다.&lt;/p&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;pre class=&quot;sql&quot; style=&quot;background-color: #fafafa;&quot; data-ke-language=&quot;sql&quot;&gt;&lt;code&gt;-- 나쁨: 필터링되지 않은 대용량 테이블이 오른쪽
FROM order_log O
JOIN page_view_log P ON ...

-- 좋음: 필터링된 작은 쪽이 오른쪽
FROM (SELECT * FROM page_view_log WHERE ...) P
JOIN (SELECT * FROM order_log WHERE ...) O ON ...&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;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;h3 style=&quot;color: #222222;&quot; data-heading=&quot;3. JOIN 알고리즘 명시적 지정&quot; data-ke-size=&quot;size23&quot;&gt;3. JOIN 알고리즘 명시적 지정&lt;/h3&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SETTINGS로 알고리즘을 직접 지정할 수 있다.&lt;/p&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;pre class=&quot;sql&quot; style=&quot;background-color: #fafafa;&quot; data-ke-language=&quot;sql&quot;&gt;&lt;code&gt;SELECT ...
FROM A JOIN B ON ...
SETTINGS join_algorithm = 'grace_hash'&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&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; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;color: #222222; text-align: start;&quot;&gt;알고리즘&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;color: #222222; text-align: start;&quot;&gt;특징&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;hash&lt;/td&gt;
&lt;td&gt;기본값. 오른쪽 전체를 메모리에 적재&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;partial_merge&lt;/td&gt;
&lt;td&gt;메모리를 아낄 때. 속도는 느림&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;grace_hash&lt;/td&gt;
&lt;td&gt;대용량 JOIN. 디스크를 보조로 활용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;auto&lt;/td&gt;
&lt;td&gt;메모리 상황에 따라 자동 선택&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-heading=&quot;마치며&quot; data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;ClickHouse에서 JOIN 성능은 옵티마이저를 믿는 것이 아니라 &lt;b&gt;개발자가 실행 순서를 직접 설계&lt;/b&gt;하는 데서 나온다. 서브쿼리로 데이터를 충분히 줄이고, 작은 쪽을 오른쪽에 두는 것만으로도 대부분의 성능 문제는 해결된다.&lt;/p&gt;</description>
      <category>Backend/Database</category>
      <category>Clickhouse</category>
      <category>SQL</category>
      <category>데이터베이스</category>
      <author>giraffe_</author>
      <guid isPermaLink="true">https://programmingiraffe.tistory.com/210</guid>
      <comments>https://programmingiraffe.tistory.com/210#entry210comment</comments>
      <pubDate>Mon, 15 Jun 2026 19:00:01 +0900</pubDate>
    </item>
    <item>
      <title>[후기] AWS Summit Seoul 2026</title>
      <link>https://programmingiraffe.tistory.com/209</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;3000&quot; data-origin-height=&quot;2250&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/baVvmH/dJMcadvtAoJ/dP7TnVaf28O2XNCnptDVLk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/baVvmH/dJMcadvtAoJ/dP7TnVaf28O2XNCnptDVLk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/baVvmH/dJMcadvtAoJ/dP7TnVaf28O2XNCnptDVLk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbaVvmH%2FdJMcadvtAoJ%2FdP7TnVaf28O2XNCnptDVLk%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;3000&quot; height=&quot;2250&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;3000&quot; data-origin-height=&quot;2250&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;AWS Summit Seoul 2026에 다녀왔다. 5월 20일, 1일차다. 뒤늦은 후기이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;백엔드 개발자로서 SSAFY와 사이드 프로젝트를 거치며 AWS를 써왔는데, 취업은 온프레미스 환경의 회사로 했다. 현재 회사에서 인프라를 거의 다루지 않다 보니 클라우드와 점점 멀어지고 있던 차에 AWS Summit 소식을 듣고, 소중한 연차 하루를 써서 다녀왔다.&lt;/p&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;다녀오길 잘했다. 지금 하는 개발이 클라우드나 AI와 직접적인 연관은 없지만, 개발자로서 영감을 얻을 수 있는 내용이 많았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;역시 AI 시대답게 거의 모든 세션이 AI 활용에 관한 내용이었다. 얼마 전까지만 해도 대용량 아키텍처나 인프라 설계가 주를 이뤘던 것 같은데, AI 시대가 도래하면서 주제도 달라졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;AWS Events 앱을 설치해서, 대학생 때 시간표를 짜듯 세션 라인업과 시간을 꼼꼼히 살펴봤다. 제목과 설명을 보고 끌리는 세션을 골랐고, 마땅히 보고 싶은 세션이 없는 시간대에는 일행을 따라가거나 부스를 구경했다.&lt;/p&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;내게 도움이 될 만한 세션은 대용량 트래픽 및 데이터 처리 관련 내용이었는데, 대부분이 AI 세션이라 고르기 쉽지 않았다. 제목과 개요만으로는 내용이 잘 와닿지 않는다는 것도 어려움이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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://aws.amazon.com/ko/events/summits/seoul/agenda/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://aws.amazon.com/ko/events/summits/seoul/agenda/&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1781098328444&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;강연 목록 | AWS Summit Seoul 2026&quot; data-og-description=&quot;2026년 5월 20-21일 오전 8시 30분: 등록 시작 오전 9시 30분 - 오전 10시 40분: 기조연설 오전 11시 10분 - 오후 4시 50분: 브레이크아웃 세션 오전 10시 - 오후 5시 30분: 엑스포&quot; data-og-host=&quot;aws.amazon.com&quot; data-og-source-url=&quot;https://aws.amazon.com/ko/events/summits/seoul/agenda/&quot; data-og-url=&quot;https://aws.amazon.com/ko/events/summits/seoul/agenda/&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/PYhUJ/dJMb8Z3zE9w/oKNxhxKfSb3LSh9qJNqR7k/img.png?width=1440&amp;amp;height=660&amp;amp;face=0_0_1440_660&quot;&gt;&lt;a href=&quot;https://aws.amazon.com/ko/events/summits/seoul/agenda/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://aws.amazon.com/ko/events/summits/seoul/agenda/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/PYhUJ/dJMb8Z3zE9w/oKNxhxKfSb3LSh9qJNqR7k/img.png?width=1440&amp;amp;height=660&amp;amp;face=0_0_1440_660');&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;강연 목록 | AWS Summit Seoul 2026&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;2026년 5월 20-21일 오전 8시 30분: 등록 시작 오전 9시 30분 - 오전 10시 40분: 기조연설 오전 11시 10분 - 오후 4시 50분: 브레이크아웃 세션 오전 10시 - 오후 5시 30분: 엑스포&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;aws.amazon.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;세션 장소는 산업별로 트랙이 구분되어 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Track1 : AI 시대의 리더십&lt;br /&gt;Track2, Track3, Track4 : 리테일 및 커머스, 금융 및 핀테크, SaaS 및 디지털 플랫폼&lt;br /&gt;Track5, Track6, Track7 : 제조 및 하이테크, 피지컬AI, 통신, 미디어 및 게임&lt;br /&gt;Track8, Track9 : 트래블 및 호스피탈리티, 공공 및 헬스케어&lt;/p&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;9시 30분 기조연설은 패스하고 11시 세션부터 들었다. 보고 싶은 세션이 대부분 Track2, Track3, Track4에 몰려 있어서 주로 3층 행사장에 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;확실히 Track2, Track3, Track4 행사장은 규모도 크고 사람도 많았다. 특히 금융 및 핀테크의 Track3은 줄이 길었다. 트랙별로 구역이 나뉘어져 있고 스크린도 별도지만, 한 공간 안에 있어서 다른 트랙 강연을 동시에 볼 수 있었다. 자리마다 헤드셋이 비치되어 있어, 채널을 바꾸면 다른 트랙 세션을 들을 수 있었다.&lt;/p&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 widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;4032&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/u5v8f/dJMcaicCych/lUkrXXWDSVCMRXE60rXwF0/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/u5v8f/dJMcaicCych/lUkrXXWDSVCMRXE60rXwF0/img.jpg&quot; data-alt=&quot;왼쪽에 스위치 딸깍으로 다른 트랙 강연을 들을 수 있었다&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/u5v8f/dJMcaicCych/lUkrXXWDSVCMRXE60rXwF0/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fu5v8f%2FdJMcaicCych%2FlUkrXXWDSVCMRXE60rXwF0%2Fimg.jpg&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;3024&quot; height=&quot;4032&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;4032&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;왼쪽에 스위치 딸깍으로 다른 트랙 강연을 들을 수 있었다&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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;style6&quot; /&gt;
&lt;h3 data-heading=&quot;KBaaS, 금융이 플랫폼 속으로 KB국민은행의 임베디드 금융 전략과 API 인프라 현대화&quot; data-ke-size=&quot;size23&quot;&gt;KBaaS, 금융이 플랫폼 속으로 KB국민은행의 임베디드 금융 전략과 API 인프라 현대화&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(11:10~11:50)&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;비금융 플랫폼 속에 금융을 녹여내는 임베디드 금융 시대, KB국민은행은 자체 개발한 BaaS 플랫폼 'KBaaS'를 통해 결제, 예금, 인증 등 다양한 금융 서비스의 영역을 비금융 플랫폼으로 확장하고 있습니다. 1,800여 개 API와 일 2억 건의 호출을 처리하는 KBaaS의 API 인프라를 3rd Party 솔루션에서 자체 개발 게이트웨이로 전환하고, AWS EKS 기반 클라우드 네이티브 아키텍처로 고도화한 실사례를 통해, 비용 절감&amp;middot;성능 향상&amp;middot;네트워크 혁신의 실질적 성과와 임베디드 금융 확산을 위한 인사이트를 제공합니다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;4032&quot; data-origin-height=&quot;3024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bDZTCq/dJMcaftmKCJ/gcONlk80kuSOeOGSU2Cpv1/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bDZTCq/dJMcaftmKCJ/gcONlk80kuSOeOGSU2Cpv1/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bDZTCq/dJMcaftmKCJ/gcONlk80kuSOeOGSU2Cpv1/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbDZTCq%2FdJMcaftmKCJ%2FgcONlk80kuSOeOGSU2Cpv1%2Fimg.jpg&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;4032&quot; height=&quot;3024&quot; data-origin-width=&quot;4032&quot; data-origin-height=&quot;3024&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;현재 회사에서도 게이트웨이에서 일 수억 건의 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;&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;style6&quot; /&gt;
&lt;h3 data-heading=&quot;AB180이 SaaS 에이전틱 AI를 설계하는 방법&quot; data-ke-size=&quot;size23&quot;&gt;AB180이 SaaS 에이전틱 AI를 설계하는 방법&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(12:50~13:30)&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;AB180은 2024년 11월 RAG 기반 챗봇 ASK Airbridge를 출시했으나 30% 미만의 낮은 유효 답변율로 사람의 개입이 필요했습니다. 본 세션에서는 Amazon Bedrock AgentCore 기반 에이전틱 AI 챗봇 Airbridge Pilot이 B2B SaaS 보안 요건을 만족하고 90% 이상의 유효 답변율을 달성한 여정을 공개합니다.&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;원래 일정은 Track3(금융 및 핀테크)의 '미래에셋증권의 GraphRAG 기반 상품지식DB 구축기'였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;GraphDB를 활용한 RAG 구성이 궁금했다. 현재 회사 기술과는 거리가 있지만, 요즘 개인적으로 사이드 프로젝트를 하면서 관심을 갖게 된 주제였다.&lt;/p&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;세션을 들으며 내용이 어려워 이해하느라 애를 쓰던 차에, Track4(SaaS 및 디지털 플랫폼) 화면에 회사에서 사용 중인 익숙한 솔루션이 눈에 들어왔다. 바로 채널을 바꿨다.&lt;/p&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 widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;4032&quot; data-origin-height=&quot;3024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cB89uA/dJMcaiwR8hI/7Ahb1UzQkhpP5hKj4ARrvk/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cB89uA/dJMcaiwR8hI/7Ahb1UzQkhpP5hKj4ARrvk/img.jpg&quot; data-alt=&quot;반갑다 에어브릿지&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cB89uA/dJMcaiwR8hI/7Ahb1UzQkhpP5hKj4ARrvk/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcB89uA%2FdJMcaiwR8hI%2F7Ahb1UzQkhpP5hKj4ARrvk%2Fimg.jpg&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;4032&quot; height=&quot;3024&quot; data-origin-width=&quot;4032&quot; data-origin-height=&quot;3024&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;반갑다 에어브릿지&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(내용 추가하기!)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;중앙대의료원 - 의료진이 직접 만드는 의료 AI&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(13:50~14:10)&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;중앙대의료원은 의료진이 AI를 단순히 활용하는 수준을 넘어 현장에서 필요한 기능을 직접 기획하고 구현할 수 있는 환경을 만들어가고 있습니다. 의료 현장의 구조적 과제를 해결하기 위한 전략으로 AI 전환을 추진해왔으며 AWS T&amp;amp;C 프로그램과 KIRO 기반의 바이브 코딩을 통해 개발 경험이 없는 의료진도 실제 업무에 필요한 도구를 빠르게 만들 수 있다는 가능성을 확인했습니다. 본 세션에서는 이러한 실행 경험을 바탕으로 의료 AI 혁신의 장벽을 낮추기 위한 사람 중심의 전환 전략과 의료진 주도 개발이 현장에 주는 인사이트를 공유합니다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;4032&quot; data-origin-height=&quot;3024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c1Hvux/dJMcabdn2Mr/rrv528FLkzIGP0f7EXDVFK/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c1Hvux/dJMcabdn2Mr/rrv528FLkzIGP0f7EXDVFK/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c1Hvux/dJMcabdn2Mr/rrv528FLkzIGP0f7EXDVFK/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc1Hvux%2FdJMcabdn2Mr%2Frrv528FLkzIGP0f7EXDVFK%2Fimg.jpg&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;4032&quot; height=&quot;3024&quot; data-origin-width=&quot;4032&quot; data-origin-height=&quot;3024&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;요즘 바이브 코딩이 주목받고 있다. 나도 Claude Code를 활용해 만들고 싶은 서비스를 바이브코딩으로 구현하고 있고, 최근에는 문과 친구에게 바이브 코딩을 가르치기도 했다. 비개발자가 AI를 활용해 현장에서 필요한 도구를 어떻게 만드는지 궁금해서 고른 세션이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;AWS Kiro가 바이브코딩에 필요한 과정을 자동화하고 있다는 점이 인상적이었다. 비개발자 입장에서 바이브 코딩의 진입장벽은 분명히 존재하는데, Kiro 같은 도구가 있다면 훨씬 쉬워질 것 같다. 비개발자도 코딩할 수 있는 시대가 가까워지고 있다는 걸 체감했다.&lt;/p&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;middot;배포는 할 수 있지만, 막상 사람들에게 진짜 필요한 서비스가 무엇인지는 잘 모른다는 게 항상 아쉬웠다. 직업 현장에서 일하는 사람들이 필요를 느껴 직접 만드는 서비스가 결국 더 많은 사람에게 쓰이고 살아남을 거라는 생각이 든다.&lt;/p&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;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;바비톡의 AX 여정 에이전틱 AI로 K-beauty를 바꾸다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(14:30~15:10)&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;1,000만 유저의 바비톡은 AI 특공대와 Monthly PoC로 AX를 추진하고 있습니다. 본 세션에서는 Amazon Bedrock 기반 K-Beauty 어시스턴트 등 Agentic AI 도입 사례와, AI-DLC(AI Development Life Cycle)를 사용해 마케팅팀과 개발팀 협업 모델 변화를 통한 개발 문화 및 생산성 혁신 경험을 공유합니다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;4032&quot; data-origin-height=&quot;3024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cy0zGv/dJMcagMDD49/RjoaY2nEb18xRCAPwdgi01/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cy0zGv/dJMcagMDD49/RjoaY2nEb18xRCAPwdgi01/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cy0zGv/dJMcagMDD49/RjoaY2nEb18xRCAPwdgi01/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcy0zGv%2FdJMcagMDD49%2FRjoaY2nEb18xRCAPwdgi01%2Fimg.jpg&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;4032&quot; height=&quot;3024&quot; data-origin-width=&quot;4032&quot; data-origin-height=&quot;3024&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;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;원래는 16:10~16:50에 열리는 '새벽 3시, 18만 개의 모델이 대신 판단한다 : 넥슨의 에이전틱 Ops'를 듣고 싶었다.&lt;/p&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;AI를 활용해 실시간 서비스의 장애를 어떻게 대응하는지 궁금했기 때문이다. 회사에서도 이슈가 발생할 때마다 실시간 알림을 받고 있는 입장이라 더 관심이 갔다. 하지만 시간 상 못 봤다. 나중에 영상으로 봐야겠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(내용 추가하기!)&lt;/p&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;style6&quot; /&gt;
&lt;h3 data-heading=&quot;부스&quot; data-ke-size=&quot;size23&quot;&gt;부스&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;역시 AI를 접목한 서비스들이 대부분이었다. AI, LLM, RAG... 아는 게 많지 않아 일행이 대신 질문을 해줬지만, 각 산업에서 어떤 니즈가 있고 AI로 어떤 기능을 구현할 수 있는지 영감 정도는 얻었다. 나도 AI 공부를 좀 더 해야겠다는 생각이 들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;부스를 돌아다니며&lt;span&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;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;3000&quot; data-origin-height=&quot;2250&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/OehC6/dJMcafUtDmc/kDZsTIfQeKNTDZ4LoajLS1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/OehC6/dJMcafUtDmc/kDZsTIfQeKNTDZ4LoajLS1/img.png&quot; data-alt=&quot;반갑다 클릭하우스&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/OehC6/dJMcafUtDmc/kDZsTIfQeKNTDZ4LoajLS1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FOehC6%2FdJMcafUtDmc%2FkDZsTIfQeKNTDZ4LoajLS1%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;3000&quot; height=&quot;2250&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;3000&quot; data-origin-height=&quot;2250&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;반갑다 클릭하우스&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;돌아다니다 Clickhouse 부스가 눈에 들어왔다. 회사에서 로그와 통계 쌓는 용으로 쓰고 있는 DB인데, 국내 사용자도 적고 정보도 많지 않아 애를 먹던 터라 반가움이 이루 말할 수 없었다. 나는 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;블로그에&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;Clickhouse 관련해서 글을 쓰고 있다. 많관부.&lt;/p&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;담당자분께 회사에서 Clickhouse를 사용하고 있다고 먼저 인사를 드렸다. 설문조사에 참여하고 Clickhouse Cloud 300달러 크레딧과 볼펜을 받았다. 노란 볼펜이 꽤 귀엽다. 회사에서 일하면서 볼펜 아주 잘 쓰고 있다.&lt;/p&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;정신없이 돌아다니다 보니 5시가 넘었다. 티셔츠 수령처에 갔더니 마감이라고 했다. AWS 티셔츠를 못 챙겨서 아쉬웠지만, Elastic 부스에서 사슴 캐릭터가 그려진 ELK 티셔츠를 받아왔다.&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-filename=&quot;blob&quot; data-origin-width=&quot;2250&quot; data-origin-height=&quot;3000&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ql7ZI/dJMb9902c87/pqtotx9W2SVt5SIVt0m7Dk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ql7ZI/dJMb9902c87/pqtotx9W2SVt5SIVt0m7Dk/img.png&quot; data-alt=&quot;귀엽다 스티커들&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ql7ZI/dJMb9902c87/pqtotx9W2SVt5SIVt0m7Dk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fql7ZI%2FdJMb9902c87%2Fpqtotx9W2SVt5SIVt0m7Dk%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;649&quot; height=&quot;865&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;2250&quot; data-origin-height=&quot;3000&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;귀엽다 스티커들&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>후기</category>
      <author>giraffe_</author>
      <guid isPermaLink="true">https://programmingiraffe.tistory.com/209</guid>
      <comments>https://programmingiraffe.tistory.com/209#entry209comment</comments>
      <pubDate>Thu, 11 Jun 2026 00:14:49 +0900</pubDate>
    </item>
    <item>
      <title>[Java/Spring] 파일 기반 재처리 아키텍처 - BlockingQueue로 데이터 유실 막기</title>
      <link>https://programmingiraffe.tistory.com/208</link>
      <description>&lt;h2 data-heading=&quot;배경&quot; data-ke-size=&quot;size26&quot;&gt;배경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;멀티스레드 배치 시스템에서 벌크 INSERT가 간헐적으로 타임아웃으로 실패했다. 기존 코드는 예외를 로그로만 남겼기 때문에 실패한 수천 건이 그대로 유실됐다. 게다가 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;타임아웃이 발생하는 시점은 DB 부하가 높은 구간이다. 실패가 연달아 수 천건씩 N번 발생했다.&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-heading=&quot;시도했던 방식&quot; data-ke-size=&quot;size23&quot;&gt;시도했던 방식&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;단순히 예외를 잡아 재시도하는 방식은 배치 스레드를 지연시키고, 실패가 연달아 발생하면 재시도 로직도 함께 실패한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1472&quot; data-origin-height=&quot;804&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dcLERG/dJMcagyWXJE/sKUrRMIwXK9SY7DKDWKKW1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dcLERG/dJMcagyWXJE/sKUrRMIwXK9SY7DKDWKKW1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dcLERG/dJMcagyWXJE/sKUrRMIwXK9SY7DKDWKKW1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdcLERG%2FdJMcagyWXJE%2FsKUrRMIwXK9SY7DKDWKKW1%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;1472&quot; height=&quot;804&quot; data-origin-width=&quot;1472&quot; data-origin-height=&quot;804&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;타임아웃이 발생하는 시점은 DB 부하가 높은 구간이다. 재시도를 즉시 실행하면 같은 부하 구간에서 다시 실패할 가능성이 높고, 그 사이 다른 배치도 같은 스레드풀을 기다린다. 실패가 연달아 수 천건씩 N번 발생하는 패턴에서 인라인 재시도는 수십 번 연속으로 쌓여 배치 전체를 마비시킬 수 있다.&lt;/p&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;b&gt;실패 데이터를 파일로 보존한 뒤 별도 스케줄러로 재처리&lt;/b&gt;하는 구조를 선택했다. 배치 스레드는 실패를 큐에 넣는 것만 하고 즉시 다음 chunk로 넘어간다. DB 부하가 해소된 뒤, 별도 스케줄러가 단건 INSERT로 재처리하면 타임아웃이 발생하지 않는다.&lt;/p&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;style6&quot; /&gt;
&lt;h2 data-heading=&quot;아키텍처 설계&quot; data-ke-size=&quot;size26&quot;&gt;아키텍처 설계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1244&quot; data-origin-height=&quot;842&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vipWz/dJMcai4epud/XAFwoNYkcN2ftE9vSybK40/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vipWz/dJMcai4epud/XAFwoNYkcN2ftE9vSybK40/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vipWz/dJMcai4epud/XAFwoNYkcN2ftE9vSybK40/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FvipWz%2FdJMcai4epud%2FXAFwoNYkcN2ftE9vSybK40%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;1244&quot; height=&quot;842&quot; data-origin-width=&quot;1244&quot; data-origin-height=&quot;842&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;b&gt;실패 감지 &amp;rarr; 큐에 삽입 &amp;rarr; 파일에 쓰기 &amp;rarr; 재처리&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;배치 스레드는 실패를 큐에 넣는 순간 할 일이 끝난다. 큐 뒤에서 무슨 일이 일어나는지 알 필요가 없다. FailFileWriter는 큐에서 데이터를 꺼내 파일에 쓰는 일만 한다. FailScheduler는 파일이 언제 만들어졌는지 신경 쓰지 않고 정해진 시각에 파일을 읽어 재처리한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;각 단계가 다음 단계를 직접 호출하지 않고, &lt;b&gt;큐&lt;/b&gt;와 &lt;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;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-heading=&quot;구현 상세&quot; data-ke-size=&quot;size26&quot;&gt;구현 상세&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;DAO 레이어 - INSERT 실패 감지&lt;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public int collectProductData(User user, List&amp;lt;Product&amp;gt; products, ...) {
    try (SqlSession sqlSession = ...) {
        sqlSession.insert(&quot;productMapper.insertBulk&quot;, products);
        return products.size();
    } catch (Exception e) {
        failQueue.offerAll(user, products, ...);  // 실패 &amp;rarr; 큐 삽입
        return 0;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;nbsp;DAO 레이어에서 실패를 감지&lt;/b&gt;하면&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;예외를 상위로 던지지 않고&lt;span&gt; `&lt;/span&gt;&lt;/span&gt;FailQueue.offerAll()`&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;을 호출해 실패 데이터를 큐에 넣는다. &lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;배치 스레드 입장에서 실패 처리는 여기서 끝나고, 이후 파일 기록과 재처리는 별도 컴포넌트가 담당한다.&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 style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;&amp;nbsp;큐 삽입이 블로킹되지 않기 때문에 배치 스레드는 즉시 다음 chunk로 넘어간다.&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-heading=&quot;FailQueue &amp;mdash; 공유 큐&quot; data-ke-size=&quot;size23&quot;&gt;FailQueue - 공유 큐&lt;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;private final BlockingQueue&amp;lt;Object&amp;gt; queue = new LinkedBlockingQueue&amp;lt;&amp;gt;(200_000);

public void offerAll(UserInfo user, List&amp;lt;Product&amp;gt; products) {
    for (Product product : products) {
        queue.offer(new FailedProduct(user, product));
    }
}

public boolean offer(CloseRequest closeRequest) {
    return queue.offer(closeRequest);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-heading=&quot;FailFileWriter &amp;mdash; 전용 consumer 스레드&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;nbsp;FailQueue&lt;/b&gt;&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;LinkedBlockingQueue&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;로 구현된 공유 버퍼다. DAO 레이어(producer)와 FailFileWriter(consumer) 사이를 연결한다. 배치 스레드가 여러 개여도 큐는 하나이기 때문에 동시성 처리는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;BlockingQueue&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;가 내부적으로 보장한다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&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;FailedProduct&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;(실패 데이터)와&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;CloseRequest&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;(배치 완료 신호) 두 타입을 같은 큐로 관리해 consumer가 순서를 보장받는다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-heading=&quot;FailFileWriter &amp;mdash; 전용 consumer 스레드&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-heading=&quot;FailFileWriter &amp;mdash; 전용 consumer 스레드&quot;&gt;&amp;nbsp;offer vs put: put은 큐가 꽉 차면 공간이 생길 때까지 블로킹한다. 배치 스레드가 큐 삽입 때문에 멈추면 안 되므로 블로킹하지 않는 offer를 사용했다. 큐가 꽉 차서 offer가 false를 반환하는 상황은 200,000건이 모두 쌓인 극단적인 경우로, 실운영에서는 발생하지 않을 것으로 판단했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-heading=&quot;FailFileWriter &amp;mdash; 전용 consumer 스레드&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-heading=&quot;FailFileWriter &amp;mdash; 전용 consumer 스레드&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-heading=&quot;FailFileWriter &amp;mdash; 전용 consumer 스레드&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-heading=&quot;FailFileWriter &amp;mdash; 전용 consumer 스레드&quot; data-ke-size=&quot;size23&quot;&gt;FailFileWriter - 전용 consumer 스레드&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;필드 구성&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;processing&quot;&gt;&lt;code&gt;private final FailQueue failQueue;
private final Map&amp;lt;String, BufferedWriter&amp;gt; writerMap = new ConcurrentHashMap&amp;lt;&amp;gt;();
private final ObjectMapper objectMapper = new ObjectMapper();
private volatile boolean running = true;
&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;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;필드&lt;/td&gt;
&lt;td&gt;설명&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;writerMap&lt;/td&gt;
&lt;td&gt;키로 Writer 관리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;running&lt;/td&gt;
&lt;td&gt;volatile 선언으로 @PreDestroy와 consumer 스레드 간 가시성 보장&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;objectMapper&lt;/td&gt;
&lt;td&gt;consumer 스레드가 1개이므로 인스턴스 공유 안전&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;writerMap :&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;userId#...#...#date&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;형태의 키로 Writer 인스턴스를 관리한다. 배치 유형, 날짜 등 조합마다 파일이 하나씩 열리기 때문에, 키만 보면 어떤 배치의 실패 데이터가 어느 파일에 기록되고 있는지 알 수 있다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;ConcurrentHashMap&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;을 쓴 이유는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;consumeLoop&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;(consumer 스레드)와&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;shutdown()&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;(&lt;/span&gt;@PreDestroy&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;, 메인 스레드)이 동시에 맵에 접근할 수 있어서다. consumer 스레드가 하나뿐이더라도 종료 시점에 두 스레드가 맵을 동시에 읽고 쓸 수 있으므로 일반&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;HashMap&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;은 안전하지 않다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;running&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;플래그는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;volatile&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;로 선언한다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;@PreDestroy&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;가 실행되는 메인 스레드에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;false&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;로 변경한 값이 consumer 스레드에 즉시 보여야 하기 때문이다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;volatile&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;이 없으면 JVM이 레지스터나 CPU 캐시에 값을 들고 있을 수 있어 consumer 스레드가 변경을 인식하지 못할 수 있다.&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;&lt;span style=&quot;color: #222222;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;voliatile에 대한 자세한 내용은 이전에 다룬 적이 있다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1780541582598&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;[Java] volatile - 멀티스레드 가시성 문제 트러블슈팅&quot; data-og-description=&quot;들어가며 독립 큐 + 전용 워커 스레드를 사용하는 서비스에서 종료 신호가 워커 스레드에 전달되지 않는 문제가 발생했다. @PreDestroy에서 running = false를 분명히 썼는데도 워커 스레드는 루프를 계&quot; data-og-host=&quot;programmingiraffe.tistory.com&quot; data-og-source-url=&quot;https://programmingiraffe.tistory.com/207&quot; data-og-url=&quot;https://programmingiraffe.tistory.com/207&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/c0Q1X6/dJMb85W0MYg/JIuVEMVTBioCpHVxtkCOG0/img.png?width=800&amp;amp;height=493&amp;amp;face=0_0_800_493,https://scrap.kakaocdn.net/dn/bAHxll/dJMb81fZ4FI/kmh43jWCU34GKyzKFVdp7K/img.png?width=800&amp;amp;height=493&amp;amp;face=0_0_800_493,https://scrap.kakaocdn.net/dn/ujkbK/dJMb8Rj9uS7/8BmbLrtMkUUifMlGn6ZJG1/img.png?width=1244&amp;amp;height=878&amp;amp;face=0_0_1244_878&quot;&gt;&lt;a href=&quot;https://programmingiraffe.tistory.com/207&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://programmingiraffe.tistory.com/207&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/c0Q1X6/dJMb85W0MYg/JIuVEMVTBioCpHVxtkCOG0/img.png?width=800&amp;amp;height=493&amp;amp;face=0_0_800_493,https://scrap.kakaocdn.net/dn/bAHxll/dJMb81fZ4FI/kmh43jWCU34GKyzKFVdp7K/img.png?width=800&amp;amp;height=493&amp;amp;face=0_0_800_493,https://scrap.kakaocdn.net/dn/ujkbK/dJMb8Rj9uS7/8BmbLrtMkUUifMlGn6ZJG1/img.png?width=1244&amp;amp;height=878&amp;amp;face=0_0_1244_878');&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;[Java] volatile - 멀티스레드 가시성 문제 트러블슈팅&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;들어가며 독립 큐 + 전용 워커 스레드를 사용하는 서비스에서 종료 신호가 워커 스레드에 전달되지 않는 문제가 발생했다. @PreDestroy에서 running = false를 분명히 썼는데도 워커 스레드는 루프를 계&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;programmingiraffe.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-ke-size=&quot;size16&quot;&gt;&lt;b&gt;스레드 생명주기&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;@PostConstruct
public void start() {
    Thread t = new Thread(this::consumeLoop, &quot;fail-file-writer&quot;);
    t.start();
}

@PreDestroy
public void shutdown() {
    running = false;
    for (BufferedWriter writer : writerMap.values()) {
        try { writer.close(); }
        catch (IOException e) { log.error(&quot;shutdown: 파일 닫기 실패&quot;); }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;애플리케이션 기동 시&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;@PostConstruct&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;가 consumer 스레드를 하나 띄운다. 스레드 이름을&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;fail-file-writer&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;로 명시한 것은 스레드 모니터링에서 역할을 식별하기 위해서다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@PreDestroy&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;에서는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;running = false&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;로 루프를 빠져나오게 한 뒤 열려있는 Writer를 모두 닫는다.&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;&lt;b&gt;consumeLoop&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;private void consumeLoop() {
    try {
        while (running) {
            Object object = failQueue.take(); // 큐가 빌 때까지 블로킹

            if (object instanceof FailedProduct(UserInfo user, Product product)) {
                String key = user.getId() + &quot;#&quot; + .. + &quot;#&quot; + LocalDate.now();

                BufferedWriter writer = writerMap.computeIfAbsent(key, k -&amp;gt; createWriter(k));
                writer.write(objectMapper.writeValueAsString(product));
                writer.newLine();
                writer.flush(); // 매 건마다 flush &amp;mdash; 프로세스 종료 시 유실 방지
            }

            if (object instanceof CloseRequest(String userId, ..)) {
                closeWriterInternal(userId + &quot;#&quot; + ..);
            }
        }
    } catch (Exception e) {
        log.error(&quot;consumeLoop 비정상 종료&quot;, e);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;take()&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;는 큐가 빌 때까지 블로킹한다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;poll()&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;처럼 타임아웃을 걸어 주기적으로 루프를 돌게 할 수도 있지만, 데이터가 없을 때 CPU를 낭비하지 않으려면&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;take()&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;로 블로킹하는 것이 낫다. 배치가 오랫동안 실행되지 않는 시간대에도 스레드가 CPU를 점유하지 않는다.&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;FailedProduct&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;를 꺼내면,&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;&amp;nbsp;키로 Writer를 찾거나 새로 생성해 JSON 직렬화 결과를 한 줄로 기록하고 즉시&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;flush()&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;한다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;BufferedWriter&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;는 내부 버퍼가 차야 디스크에 쓰기 때문에,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;flush()&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;없이 프로세스가 비정상 종료되면 버퍼에 남은 데이터가 사라진다. 재처리 대상을 보존하는 역할이므로 I/O 횟수보다 데이터 안전을 우선했다.&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;CloseRequest&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;를 꺼내면 해당 배치의 Writer를 flush/close한다. close 이후 같은 키로 데이터가 다시 들어오면&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;computeIfAbsent&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;가 Writer를 새로 생성하기 때문에 APPEND 옵션이 이 경우에도 중요하다.&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;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;파일 생성 - APPEND 옵션&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;processing&quot;&gt;&lt;code&gt;private BufferedWriter createWriter(String key) {
    try {
        Path dir = Paths.get(FAIL_PATH);
        Files.createDirectories(dir);
        Path file = dir.resolve(key + &quot;.txt&quot;);
        return Files.newBufferedWriter(file,
            StandardOpenOption.CREATE,
            StandardOpenOption.APPEND // 재시작 후에도 기존 파일에 이어쓰기
        );
    } catch (IOException e) {
        log.error(&quot;[{}] 파일 생성 실패&quot;, key);
        return null;
    }
}
&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;APPEND&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;옵션이 없으면&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;Files.newBufferedWriter&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;의 기본값인&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;TRUNCATE_EXISTING&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;이 적용돼 기존 파일 내용이 지워진다. 배치 실행 중에 앱이 재시작되면 이전 실패 데이터가 파일에 남아있는 상태에서 새 실패 데이터가 추가될 수 있는데,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;APPEND&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;&lt;span&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;&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;Writer 종료 - prefix 매칭&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;private void closeWriterInternal(String requestKey) {
    writerMap.entrySet().removeIf(entry -&amp;gt; {
        if (entry.getKey().startsWith(requestKey + &quot;#&quot;)) {
            try {
                entry.getValue().flush();
                entry.getValue().close();
            } catch (IOException e) {
                log.error(&quot;{} writer 닫기 실패&quot;, entry.getKey(), e);
            }
            return true;
        }
        return false;
    });
}
&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-heading=&quot;CloseRequest를 큐에 넣은 이유&quot; data-ke-size=&quot;size23&quot;&gt;CloseRequest를 큐에 넣은 이유&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;처음에는 closeWriter()를 직접 호출했다. 그런데 &lt;b&gt;큐에 아직 남아있는 FailedProduct가 처리되기 전에 close가 실행&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;이를 해결하려고 closeRequestIds Set을 두고 consumeLoop에서 FailedProduct 처리 직전에 체크하는 방식을 시도했다. 하지만 이 방식은 &lt;b&gt;마지막 FailedProduct 이후 큐가 비면 take()에서 블로킹&lt;/b&gt; &amp;rarr; close 신호가 Set에 있어도 영원히 실행되지 않는 문제가 있었다.&lt;/p&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;최종 해결책은 CloseRequest를 FailedProduct와 같은 큐에 넣는 것이다. 단일 consumer 스레드가 큐를 순서대로 소비하므로, CloseRequest 이전의 모든 FailedProduct가 반드시 먼저 처리된다.&lt;/p&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-heading=&quot;FailServiceImpl &amp;mdash; 재처리 로직&quot; data-ke-size=&quot;size23&quot;&gt;FailServiceImpl - 재처리 로직&lt;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public void retryInsert() {
    List&amp;lt;Path&amp;gt; failFiles = Files.list(failDir)
        .filter(p -&amp;gt; p.getFileName().toString().endsWith(&quot;.txt&quot;))
        .toList();

    for (Path failFile : failFiles) {
        String fileName = failFile.getFileName().toString();
        String userId   = extractUserId(fileName);
        ...

        UserInfo user = userDao.findUser(userId, ..);

        try (BufferedReader reader = Files.newBufferedReader(failFile)) {
            String line;
            while ((line = reader.readLine()) != null) {
                Product product = objectMapper.readValue(line, Product.class);
                insertProduct(user, product); // 단건 INSERT
            }
        }

        Files.delete(failFile);
    }
}&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;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;왜 단건 INSERT인가&lt;/b&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;벌크 INSERT가 타임아웃으로 실패했다는 것은 DB에 수천 건을 한 번에 밀어넣는 과정에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;max_statement_time을 초과했다는 의미다. 재처리를 다시 벌크로 하면 같은 이유로 또 실패할 가능성이 있다. 단건 INSERT는 한 건씩 순차 처리하기 때문에 쿼리 하나의 실행 시간이 짧아 타임아웃이 발생하지 않는다. 스케줄러가 배치 부하가 낮은 시간대에 실행되므로 단건이어도 전체 처리 시간은 문제가 되지 않는다.&lt;/p&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;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;h3 style=&quot;color: #222222;&quot; data-heading=&quot;FailScheduler&quot; data-ke-size=&quot;size23&quot;&gt;FailScheduler&lt;/h3&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;pre class=&quot;java&quot; style=&quot;background-color: #fafafa;&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Scheduled(cron = &quot;0 0 16 * * *&quot;)
public void retry() {
    failService.retryInsert();
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;h2 style=&quot;color: #222222;&quot; data-heading=&quot;정리&quot; data-ke-size=&quot;size26&quot;&gt;정리&lt;/h2&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;&amp;nbsp;핵심은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;실패 감지 &amp;rarr; 큐 &amp;rarr; 파일 &amp;rarr; 재처리&lt;/b&gt;&lt;span style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;흐름을 배치 로직과 완전히 분리한 것이다. DAO 레이어는 실패를 큐에 넣기만 하고, 파일 쓰기와 재처리는 각자 독립된 컴포넌트가 담당한다. 덕분에 재처리 로직이 배치 성능에 영향을 주지 않고, 컴포넌트별로 독립적인 테스트와 교체가 가능하다.&lt;/span&gt;&lt;/p&gt;
&lt;/div&gt;</description>
      <category>Backend/Jave&amp;amp;Spring</category>
      <author>giraffe_</author>
      <guid isPermaLink="true">https://programmingiraffe.tistory.com/208</guid>
      <comments>https://programmingiraffe.tistory.com/208#entry208comment</comments>
      <pubDate>Tue, 2 Jun 2026 01:01:48 +0900</pubDate>
    </item>
    <item>
      <title>[Java] volatile - 멀티스레드 가시성 문제 트러블슈팅</title>
      <link>https://programmingiraffe.tistory.com/207</link>
      <description>&lt;h2 data-heading=&quot;들어가며&quot; data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;독립 큐 + 전용 워커 스레드를 사용하는 서비스에서 종료 신호가 워커 스레드에 전달되지 않는 문제가 발생했다. @PreDestroy에서 running = false를 분명히 썼는데도 워커 스레드는 루프를 계속 돌고 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;private volatile boolean running = true;
&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;해결책은 volatile 한 키워드였지만, 왜 없으면 안 되는지는 설명하기 어려웠다. 이 글은 문제의 원인을 파악하고 올바른 구현에 이르기까지의 과정을 담았다.&lt;/p&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;style6&quot; /&gt;
&lt;h2 data-heading=&quot;1. 문제 발생 &amp;mdash; &amp;#96;volatile&amp;#96; 없이 어떤 일이 벌어지는가&quot; data-ke-size=&quot;size26&quot;&gt;1. 문제 발생 - volatile 없이 어떤 일이 벌어지는가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;volatile 없이 종료 플래그를 구현하면 어떤 일이 생기는지 코드로 먼저 보자.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// volatile 없음
private boolean running = true;

// 워커 스레드 (Core 1에서 실행)
private void runWorker(String id, BlockingQueue&amp;lt;Runnable&amp;gt; queue) {
    while (running) {  // &amp;larr; Core 1의 캐시에서 running 읽기
        Runnable task = queue.poll(500, TimeUnit.MILLISECONDS);
        if (task == null) continue;
        task.run();
        Thread.sleep(1000);
    }
    drainQueue(id, queue);
}

// 메인 스레드 (Core 0에서 실행)
@PreDestroy
public void shutdown() {
    running = false;  // &amp;larr; Core 0의 캐시를 false로 변경
    // 메인 메모리에 언제 반영될지 보장 없음
}&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;shutdown()이 running = false를 썼음에도 워커 스레드가 루프를 빠져나오지 않는다.&lt;/p&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;style6&quot; /&gt;
&lt;h2 data-heading=&quot;2. 원인 분석 &amp;mdash; CPU 캐시와 메모리 가시성&quot; data-ke-size=&quot;size26&quot;&gt;2. 원인 분석 - CPU 캐시와 메모리 가시성&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;현대 CPU는 메인 메모리(RAM)에 직접 접근하는 대신 &lt;b&gt;L1/L2/L3 캐시&lt;/b&gt;를 계층적으로 사용한다. 메인 메모리 접근은 수백 클럭이 걸리지만 L1 캐시는 수 클럭이면 충분하기 때문이다.&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 widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1244&quot; data-origin-height=&quot;804&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/9H3VW/dJMcabxkdIM/gz29AqRUtirdmToFrKRsAK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/9H3VW/dJMcabxkdIM/gz29AqRUtirdmToFrKRsAK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/9H3VW/dJMcabxkdIM/gz29AqRUtirdmToFrKRsAK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F9H3VW%2FdJMcabxkdIM%2Fgz29AqRUtirdmToFrKRsAK%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;1244&quot; height=&quot;804&quot; data-origin-width=&quot;1244&quot; data-origin-height=&quot;804&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;&amp;nbsp;Java Memory Model(JMM)&lt;/b&gt; 은 성능을 위해 각 스레드가 변수의 로컬 복사본(CPU 캐시 또는 레지스터)을 사용하도록 허용한다. 따라서 한 스레드가 변수를 변경해도 다른 스레드가 그 변경을 &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;b&gt;가시성(Visibility) 문제&lt;/b&gt;다. shutdown()이 running = false를 써도 변경이 메인 메모리에 즉시 flush되지 않을 수 있고, 설령 flush되더라도 Core 1의 캐시가 갱신되지 않으면 워커 스레드는 영원히 running == true를 보게 된다.&lt;/p&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;style6&quot; /&gt;
&lt;h2 data-heading=&quot;3. 해결책 &amp;mdash; &amp;#96;volatile&amp;#96;의 두 가지 보장&quot; data-ke-size=&quot;size26&quot;&gt;3. 해결책 - volatile 선언&lt;/h2&gt;
&lt;pre class=&quot;arduino&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;private volatile boolean running = true;&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;volatile을 선언하면 JMM은 두 가지를 보장한다.&lt;/p&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-heading=&quot;3-1. 가시성 보장 (Visibility)&quot; data-ke-size=&quot;size23&quot;&gt;3-1. 가시성 보장 (Visibility)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;volatile 변수에 대한 &lt;b&gt;쓰기는 즉시 메인 메모리에 flush&lt;/b&gt;되고, &lt;b&gt;읽기는 항상 메인 메모리에서 직접 읽는다&lt;/b&gt;. CPU 캐시를 우회하므로 모든 스레드가 최신 값을 본다.&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 widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1244&quot; data-origin-height=&quot;768&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b5qdzi/dJMcacJRLBg/ujeUOZRdbApdFKZ8197W71/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b5qdzi/dJMcacJRLBg/ujeUOZRdbApdFKZ8197W71/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b5qdzi/dJMcacJRLBg/ujeUOZRdbApdFKZ8197W71/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb5qdzi%2FdJMcacJRLBg%2FujeUOZRdbApdFKZ8197W71%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;1244&quot; height=&quot;768&quot; data-origin-width=&quot;1244&quot; data-origin-height=&quot;768&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;h3 data-heading=&quot;3-2. 명령어 재정렬 방지 (Happens-Before)&quot; data-ke-size=&quot;size23&quot;&gt;3-2. 명령어 재정렬 방지 (Happens-Before)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;JMM은 volatile 쓰기와 읽기 사이에 &lt;b&gt;happens-before 관계&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;span style=&quot;color: #666666; text-align: start;&quot;&gt;&amp;nbsp;volatile 변수에 대한 쓰기는 이후 같은 변수를 읽는 모든 작업보다 happens-before 한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;이 말은 running = false 이전에 수행된 모든 쓰기 작업이 running을 읽는 스레드에도 &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&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1244&quot; data-origin-height=&quot;878&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dsCMw0/dJMcaiDgRbH/JyKI9BTW2uH9sBWnP7G7QK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dsCMw0/dJMcaiDgRbH/JyKI9BTW2uH9sBWnP7G7QK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dsCMw0/dJMcaiDgRbH/JyKI9BTW2uH9sBWnP7G7QK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdsCMw0%2FdJMcaiDgRbH%2FJyKI9BTW2uH9sBWnP7G7QK%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;1244&quot; height=&quot;878&quot; data-origin-width=&quot;1244&quot; data-origin-height=&quot;878&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;style6&quot; /&gt;
&lt;h2 data-heading=&quot;4. 올바른 구현 &amp;mdash; 백그라운드 워커 스레드 종료&quot; data-ke-size=&quot;size26&quot;&gt;4. 올바른 구현 - 백그라운드 워커 스레드 종료&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;독립적인 작업 큐와 전용 워커 스레드를 운영하는 구조다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// volatile 있음
private volatile boolean running = true;  // &amp;larr; 핵심

// 워커 스레드 (Core 1에서 실행)
private void runWorker(String id, BlockingQueue&amp;lt;Runnable&amp;gt; queue) {
    while (running) {  // &amp;larr; 메인 메모리에서 매번 읽기
        Runnable task = queue.poll(500, TimeUnit.MILLISECONDS);
        if (task == null) continue;
        task.run();
        Thread.sleep(1000);
    }
    drainQueue(id, queue);  // 잔여 작업 처리
}

// 메인 스레드 (Core 0에서 실행)
@PreDestroy
public void shutdown() {
    running = false;  // &amp;larr; 메인 메모리에 즉시 반영
    executors.values().forEach(ExecutorService::shutdown);
    // ... awaitTermination
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;쓰는 스레드&lt;/b&gt;: @PreDestroy를 호출하는 메인(Spring 컨텍스트) 스레드&lt;/li&gt;
&lt;li&gt;&lt;b&gt;읽는 스레드&lt;/b&gt;: runWorker()를 실행 중인 워커 스레드들 (id당 1개)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;서로 다른 스레드이므로, volatile 없이는 워커 스레드가 running = false를 영원히 못 볼 수도 있다.&lt;/p&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 widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1472&quot; data-origin-height=&quot;680&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bHs0CD/dJMcaipLeoN/A2q2pkK8lsdejguYeN2LKk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bHs0CD/dJMcaipLeoN/A2q2pkK8lsdejguYeN2LKk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bHs0CD/dJMcaipLeoN/A2q2pkK8lsdejguYeN2LKk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbHs0CD%2FdJMcaipLeoN%2FA2q2pkK8lsdejguYeN2LKk%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;1472&quot; height=&quot;680&quot; data-origin-width=&quot;1472&quot; data-origin-height=&quot;680&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-heading=&quot;5. 주의사항 &amp;mdash; &amp;#96;volatile&amp;#96;이 해결하지 못하는 것&quot; data-ke-size=&quot;size26&quot;&gt;5. 주의사항 - volatile이 해결하지 못하는 것&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;volatile은 가시성과 happens-before를 보장하지만, &lt;b&gt;복합 연산의 원자성은 보장하지 않는다&lt;/b&gt;.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;private volatile int count = 0;

// 스레드 A와 B가 동시에
count++;&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;count++는 실제로 세 단계다: &lt;b&gt;읽기 &amp;rarr; 증가 &amp;rarr; 쓰기&lt;/b&gt;.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1244&quot; data-origin-height=&quot;512&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/eeOPMw/dJMcaakWuNq/rOiAHPc76n6tmJ6ecbPyWk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/eeOPMw/dJMcaakWuNq/rOiAHPc76n6tmJ6ecbPyWk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/eeOPMw/dJMcaakWuNq/rOiAHPc76n6tmJ6ecbPyWk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FeeOPMw%2FdJMcaakWuNq%2FrOiAHPc76n6tmJ6ecbPyWk%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;1244&quot; height=&quot;512&quot; data-origin-width=&quot;1244&quot; data-origin-height=&quot;512&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;두 스레드가 동시에 읽기 단계를 통과하면 둘 다 같은 값을 읽고, 둘 다 +1을 한 뒤 같은 값을 두 번 쓴다. 결과적으로 증가가 한 번밖에 반영되지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;이런 경우엔 &lt;b&gt;AtomicInteger&lt;/b&gt;를 써야 한다.&lt;/p&gt;
&lt;pre class=&quot;axapta&quot;&gt;&lt;code&gt;private final AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet();  // CAS 연산으로 원자성 보장
&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;&lt;/span&gt;&lt;a href=&quot;https://docs.oracle.com/javase/specs/jls/se21/html/jls-17.html#jls-17.4&quot;&gt;Java Language Specification &amp;mdash; &amp;sect;17.4 Memory Model&lt;/a&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;CPU 캐시와 가시성 문제 - JMM이 스레드별 로컬 복사본을 허용&lt;/li&gt;
&lt;li&gt;volatile 의 두 가지 보장 - 가시성(flush/read 보장)과 happens-before 관계&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Backend/Jave&amp;amp;Spring</category>
      <author>giraffe_</author>
      <guid isPermaLink="true">https://programmingiraffe.tistory.com/207</guid>
      <comments>https://programmingiraffe.tistory.com/207#entry207comment</comments>
      <pubDate>Tue, 12 May 2026 00:50:03 +0900</pubDate>
    </item>
    <item>
      <title>[DB] READ_UNCOMMITTED에서 DELETE가 타임아웃 나는 이유</title>
      <link>https://programmingiraffe.tistory.com/206</link>
      <description>&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot; data-heading=&quot;문제 상황&quot;&gt;문제 상황&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;특정 사용자의 데이터를 일괄 삭제하는 배치에서 두 종류의 타임아웃이 발생했다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-language=&quot;sql&quot;&gt;&lt;code&gt;Lock wait timeout exceeded; try restarting transaction   &amp;larr; 서브쿼리 DELETE 단계
Query execution was interrupted (max_statement_time exceeded)  &amp;larr; PK 기반 SELECT+DELETE 단계&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 data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;두 에러는 동시에 발생한 것이 아니다. &lt;b&gt;서브쿼리 DELETE를 사용할 때&lt;/b&gt; Lock wait timeout이 발생했고, &lt;b&gt;PK 기반 SELECT+DELETE로 전환한 뒤&lt;/b&gt; max_statement_time exceeded가 발생했다. 타임아웃 종류가 바뀐 것 자체가 분석 포인트다.&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;초기 코드는 서브쿼리 DELETE 구조였다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-language=&quot;sql&quot;&gt;&lt;code&gt;DELETE FROM products
WHERE id IN (
    SELECT id FROM (
        SELECT id FROM products
        WHERE user_id = #{userId}
        ORDER BY id
        LIMIT 5000
    ) t
)&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;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;nbsp;처음에는 서브쿼리가 타임아웃의 원인이라고 생각했다. 하지만 쿼리 형태가 아니라&lt;span&gt; 시스템의&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;구조적 문제에 의해 발생한 충돌&lt;/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;b&gt;같은 테이블&lt;/b&gt;에 &lt;b&gt;실시간 서비스&lt;/b&gt;가 &lt;b&gt;ON DUPLICATE KEY UPDATE 벌크 UPSERT&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 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 widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1244&quot; data-origin-height=&quot;842&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/3SAvL/dJMcah5oGEP/6R3NviCWMiH6fLkBOk90D1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/3SAvL/dJMcah5oGEP/6R3NviCWMiH6fLkBOk90D1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/3SAvL/dJMcah5oGEP/6R3NviCWMiH6fLkBOk90D1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F3SAvL%2FdJMcah5oGEP%2F6R3NviCWMiH6fLkBOk90D1%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;1244&quot; height=&quot;842&quot; data-origin-width=&quot;1244&quot; data-origin-height=&quot;842&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;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot; data-heading=&quot;실시간 서비스가 락을 잡는 방식&quot;&gt;실시간 서비스의 락 획득&lt;/h2&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;충돌 원인을 이해하려면 실시간 서비스의 락 획득 방식을 먼저 파악해야 한다.&lt;/p&gt;
&lt;pre class=&quot;aspectj&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Transactional
public void saveProductData(List&amp;lt;Product&amp;gt; productList) {
    sqlSession.insert(&quot;productMapper.saveProductData&quot;, productList);
}&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;sqlSession은 MyBatis SqlSessionTemplate으로 Spring 트랜잭션에 자동 참여한다. @Transactional 범위 전체 동안 커넥션과 락이 유지된다.&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;실제 실행 쿼리는 &amp;lt;foreach&amp;gt; 벌크 INSERT + ON DUPLICATE KEY UPDATE다.&lt;/p&gt;
&lt;pre class=&quot;sas&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-language=&quot;sql&quot;&gt;&lt;code&gt;&amp;lt;insert id=&quot;saveProductData&quot; parameterType=&quot;list&quot;&amp;gt;
    INSERT INTO products (user_id, name, code, price, ...)
    VALUES
    &amp;lt;foreach item=&quot;p&quot; collection=&quot;list&quot; separator=&quot;,&quot;&amp;gt;
        (#{p.userId}, #{p.name}, #{p.code}, #{p.price}, ...)
    &amp;lt;/foreach&amp;gt;
    ON DUPLICATE KEY UPDATE
        name   = IF(status='N', name,   VALUES(name))
      , price  = IF(status='N', price,  VALUES(price))
      , ...
&amp;lt;/insert&amp;gt;&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;ON DUPLICATE KEY UPDATE는 내부적으로 두 경로로 분기한다.&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 widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1244&quot; data-origin-height=&quot;1062&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/baFfaW/dJMcacpsysW/ogTSjGKreO39Pc32Dk1k20/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/baFfaW/dJMcacpsysW/ogTSjGKreO39Pc32Dk1k20/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/baFfaW/dJMcacpsysW/ogTSjGKreO39Pc32Dk1k20/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbaFfaW%2FdJMcacpsysW%2FogTSjGKreO39Pc32Dk1k20%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;1244&quot; height=&quot;1062&quot; data-origin-width=&quot;1244&quot; data-origin-height=&quot;1062&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;&lt;b&gt;X lock은 INSERT/UPDATE 완료 시점이 아니라 트랜잭션 COMMIT 시점까지 유지된다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/b&gt;N건 벌크라면 X lock이 최대 N개 동시에 쌓인 채로 COMMIT까지 유지된다.&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;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;h2 style=&quot;color: #222222;&quot; data-ke-size=&quot;size26&quot; data-heading=&quot;충돌 원인 분석&quot;&gt;충돌 원인 분석&lt;/h2&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;h3 style=&quot;color: #222222;&quot; data-ke-size=&quot;size23&quot; data-heading=&quot;(1) Dirty Read &amp;mdash; 미커밋 신규 row&quot;&gt;1. Dirty Read - 미커밋 신규 row&lt;/h3&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;DB 격리 수준이&lt;span&gt;&amp;nbsp;&lt;/span&gt;READ_UNCOMMITTED이므로 배치 SELECT는 아직 커밋되지 않은 row도 읽는다.&lt;/p&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;실시간 서비스가 신규 row를 INSERT 중인 트랜잭션(미커밋)이 X lock을 보유한 상태일 때, 배치 SELECT는 그 row를 Dirty Read로 읽고 DELETE 대상에 포함시킨다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1244&quot; data-origin-height=&quot;1074&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/neZIK/dJMcahROlBp/Cw1AHfm28tC83uznjc09q0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/neZIK/dJMcahROlBp/Cw1AHfm28tC83uznjc09q0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/neZIK/dJMcahROlBp/Cw1AHfm28tC83uznjc09q0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FneZIK%2FdJMcahROlBp%2FCw1AHfm28tC83uznjc09q0%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;1244&quot; height=&quot;1074&quot; data-origin-width=&quot;1244&quot; data-origin-height=&quot;1074&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;격리 수준이&lt;span&gt;&amp;nbsp;&lt;/span&gt;READ_COMMITTED였다면 커밋된 row만 읽히므로 미커밋 row는 애초에 SELECT에 잡히지 않았을 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;&amp;nbsp;&lt;b&gt;READ_UNCOMMITTED&lt;/b&gt;이기 때문에&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;아직 존재하지 않아야 할 row를 읽어서&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;DELETE까지 시도하게 된 것이다.&lt;/span&gt;&lt;/p&gt;
&lt;/div&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;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;h3 style=&quot;color: #222222;&quot; data-ke-size=&quot;size23&quot; data-heading=&quot;(2) UPDATE 경로 X lock &amp;mdash; 기존 row&quot;&gt;2. UPDATE 경로 X lock - 기존 row&lt;/h3&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;실시간 서비스의 UPSERT는 기존 데이터가 있는 경우 UPDATE 경로를 탄다. 이 경우 충돌하는 row는 커밋된 상태다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1244&quot; data-origin-height=&quot;1148&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/AhRku/dJMcaad7nbp/TKKKt5tRng5D7kX7bioMs1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/AhRku/dJMcaad7nbp/TKKKt5tRng5D7kX7bioMs1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/AhRku/dJMcaad7nbp/TKKKt5tRng5D7kX7bioMs1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FAhRku%2FdJMcaad7nbp%2FTKKKt5tRng5D7kX7bioMs1%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;1244&quot; height=&quot;1148&quot; data-origin-width=&quot;1244&quot; data-origin-height=&quot;1148&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;기존 데이터가 많을수록 UPDATE 경로 비율이 높아지고, 그만큼 X lock이 많이 걸려 DELETE와의 충돌 가능성이 올라간다.&lt;/p&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;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;h3 style=&quot;color: #222222;&quot; data-ke-size=&quot;size23&quot; data-heading=&quot;(3) 갭 락(Gap Lock) &amp;mdash; 범위 스캔&quot;&gt;3. 갭 락(Gap Lock) &amp;mdash; 범위 스캔&lt;/h3&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;기존 서브쿼리 DELETE는 쿼리 플래너가&lt;span&gt;&amp;nbsp;&lt;/span&gt;user_id&lt;span&gt;&amp;nbsp;&lt;/span&gt;인덱스 범위 스캔을 선택할 가능성이 높다. 이 과정에서 InnoDB가 Next-Key Lock(record + gap)을 설정한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&amp;nbsp;
&lt;table style=&quot;border-collapse: collapse; width: 66.9768%; height: 84px;&quot; border=&quot;1&quot; data-ke-style=&quot;style12&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; width: 14.8396%;&quot;&gt;&lt;span style=&quot;color: #222222; text-align: start;&quot;&gt;락 종류&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 15.0969%;&quot;&gt;&lt;span style=&quot;color: #222222; text-align: start;&quot;&gt;대상&amp;nbsp;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 33.5098%;&quot;&gt;&lt;span style=&quot;color: #222222; text-align: start;&quot;&gt;설명&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px; width: 14.8396%;&quot;&gt;Record Lock&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 15.0969%;&quot;&gt;레코드 자체&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 33.5098%;&quot;&gt;특정 row에 대한 X/S 락&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px; width: 14.8396%;&quot;&gt;Gap Lock&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 15.0969%;&quot;&gt;레코드 사이 간격&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 33.5098%;&quot;&gt;해당 간격의 INSERT 차단&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px; width: 14.8396%;&quot;&gt;Next-Key Lock&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 15.0969%;&quot;&gt;레코드 + 앞 간격&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 33.5098%;&quot;&gt;Record Lock + Gap Lock 조합 (InnoDB 기본)&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;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;INSERT 실행 시 InnoDB는 실제 레코드 락 전에 INSERT INTENTION LOCK을 해당 간격에 건다. INSERT INTENTION LOCK끼리는 호환되지만,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;해당 간격에 GAP LOCK이 걸려 있으면 블로킹&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;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1244&quot; data-origin-height=&quot;1208&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c3tIR2/dJMcafT1LGg/LhoUjIS72tiCW2zokk70k0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c3tIR2/dJMcafT1LGg/LhoUjIS72tiCW2zokk70k0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c3tIR2/dJMcafT1LGg/LhoUjIS72tiCW2zokk70k0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc3tIR2%2FdJMcafT1LGg%2FLhoUjIS72tiCW2zokk70k0%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;1244&quot; height=&quot;1208&quot; data-origin-width=&quot;1244&quot; data-origin-height=&quot;1208&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/div&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;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;h2 style=&quot;color: #222222;&quot; data-ke-size=&quot;size26&quot; data-heading=&quot;해결 과정&quot;&gt;해결 과정&lt;/h2&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;h3 style=&quot;color: #222222;&quot; data-ke-size=&quot;size23&quot; data-heading=&quot;Step 1 &amp;mdash; SELECT+DELETE 분리 + id(PK) 기반 삭제&quot;&gt;1. SELECT+DELETE 분리 + id(PK) 기반 삭제&lt;/h3&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;서브쿼리 DELETE를 SELECT와 DELETE로 분리하고,&lt;span&gt;&amp;nbsp;&lt;/span&gt;id(PK)로 삭제 대상을 지정한다.&lt;/p&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;pre class=&quot;pgsql&quot; style=&quot;background-color: #fafafa;&quot;&gt;&lt;code&gt;&amp;lt;select id=&quot;selectProductIds&quot; parameterType=&quot;map&quot; resultType=&quot;long&quot;&amp;gt;
    SELECT id FROM products
    WHERE user_id = #{userId}
    LIMIT 5000
&amp;lt;/select&amp;gt;

&amp;lt;delete id=&quot;deleteProductsBatch&quot; parameterType=&quot;list&quot;&amp;gt;
    DELETE FROM products
    WHERE id IN
    &amp;lt;foreach collection=&quot;list&quot; item=&quot;id&quot; open=&quot;(&quot; separator=&quot;,&quot; close=&quot;)&quot;&amp;gt;#{id}&amp;lt;/foreach&amp;gt;
&amp;lt;/delete&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;nbsp;이 방식에서 갭 락은 발생하지 않는다.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;InnoDB는 유니크 인덱스(PK 포함) 단건 조회에서 레코드 락만 걸고 갭 락은 설정하지 않는다. 갭 락은 범위 스캔이나 비고유 인덱스 스캔에서 사이에 새 row가 끼어들 수 없도록 막기 위해 설정하는 것인데, PK 포인트 룩업은 구간을 스캔하지 않기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1244&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dlAq92/dJMcacpsCHa/F82niTNuqhFJAgLg2oX35K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dlAq92/dJMcacpsCHa/F82niTNuqhFJAgLg2oX35K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dlAq92/dJMcacpsCHa/F82niTNuqhFJAgLg2oX35K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdlAq92%2FdJMcacpsCHa%2FF82niTNuqhFJAgLg2oX35K%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;1244&quot; height=&quot;1024&quot; data-origin-width=&quot;1244&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;에러 종류가 바뀌었다.&amp;nbsp;&lt;b&gt;갭 락은 제거됐지만, Dirty Read와 UPDATE 경로 X lock 충돌은 여전히 남는다.&lt;/b&gt; 그런데 이 단계에서 에러 종류가 Lock wait timeout에서 max_statement_time exceeded로 바뀐다.&lt;/p&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/div&gt;
&lt;h4 data-heading=&quot;왜 타임아웃 종류가 바뀌었나&quot; data-ke-size=&quot;size20&quot;&gt;왜 타임아웃 종류가 바뀌었나&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;서브쿼리 DELETE는 user_id 범위 스캔 중 GAP LOCK이 첫 번째 충돌 지점에서 즉시 블로킹을 걸었다. 단 한 번의 락 대기가 innodb_lock_wait_timeout을 넘으면서 Lock wait timeout이 발생했다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;서브쿼리 DELETE:
    범위 스캔 &amp;rarr; GAP LOCK &amp;rarr; 실시간 INSERT와 충돌
    &amp;rarr; 단일 대기 &amp;rarr; 50초 초과 &amp;rarr; Lock wait timeout exceeded
&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;PK 기반 DELETE WHERE id IN (id_1, ..., id_5000)로 바꾸면 갭 락이 사라진다. 하지만 Dirty Read로 읽힌 미커밋 row들이 id 리스트에 포함된 상태이므로, DELETE는 5000개 row에 X lock을 &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;실시간 서비스가 각 row를 짧게 잡고 빨리 커밋한다면 개별 락 대기는 40초에 못 미친다. 그러나 5000개 row를 처리하는 동안 짧은 대기가 수십~수백 번 누적되면 &lt;b&gt;statement 전체 실행 시간&lt;/b&gt;이 max_statement_time을 초과한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;PK 기반 DELETE:
    row 1:   락 획득 (즉시)
    row 2:   2초 대기 &amp;rarr; 획득
    row 3:   락 획득 (즉시)
    ...
    row 847: 5초 대기 &amp;rarr; 획득
    ...
    &amp;rarr; 개별 대기는 40초 미만
    &amp;rarr; 누적 실행 시간이 max_statement_time 초과
    &amp;rarr; Query execution was interrupted (max_statement_time exceeded)&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;갭 락 제거로 빠른 단일 블로킹은 사라졌지만, Dirty Read row들에 대한 반복 락 시도가 statement 실행 시간 자체를 길게 늘린 것이다.&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;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;h3 style=&quot;color: #222222;&quot; data-ke-size=&quot;size23&quot; data-heading=&quot;Step 2 &amp;mdash; updated_at &amp;lt; baseTime 조건 추가&quot;&gt;2.&amp;nbsp; updated_at &amp;lt; baseTime 조건 추가&lt;/h3&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;배치 시작 시점을&lt;span&gt;&amp;nbsp;&lt;/span&gt;baseTime으로 고정하고, SELECT 시 해당 시각 이전 데이터만 조회한다.&lt;/p&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;pre class=&quot;vbnet&quot; style=&quot;background-color: #fafafa;&quot;&gt;&lt;code&gt;&amp;lt;select id=&quot;selectProductIds&quot; parameterType=&quot;map&quot; resultType=&quot;long&quot;&amp;gt;
    SELECT id FROM products
    WHERE user_id = #{userId}
    AND updated_at &amp;amp;lt; #{baseTime}
    LIMIT 5000
&amp;lt;/select&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;background-color: #fafafa;&quot;&gt;&lt;code&gt;String baseTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern(&quot;yyyy-MM-dd HH:mm:ss&quot;));
Map&amp;lt;String, Object&amp;gt; param = new HashMap&amp;lt;&amp;gt;();
param.put(&quot;userId&quot;, userId);
param.put(&quot;baseTime&quot;, baseTime);

List&amp;lt;Long&amp;gt; ids;
do {
    ids = sqlSession.selectList(&quot;productMapper.selectProductIds&quot;, param);
    if (!ids.isEmpty()) {
        sqlSession.delete(&quot;productMapper.deleteProductsBatch&quot;, ids);
    }
} while (!ids.isEmpty());
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/div&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;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Dirty Read (미커밋 신규 row) &amp;rarr; 해소&lt;/b&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;신규 INSERT row의&lt;span&gt;&amp;nbsp;&lt;/span&gt;updated_at은 baseTime 이후다. Dirty Read로 읽혀도&lt;span&gt;&amp;nbsp;&lt;/span&gt;updated_at &amp;lt; baseTime&lt;span&gt;&amp;nbsp;&lt;/span&gt;조건에서 걸러지므로 DELETE 대상에 포함되지 않는다.&lt;/p&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1244&quot; data-origin-height=&quot;1076&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/byJhXE/dJMcaaSJbbU/GXDVAlXkec1eZ3XecYqHv1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/byJhXE/dJMcaaSJbbU/GXDVAlXkec1eZ3XecYqHv1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/byJhXE/dJMcaaSJbbU/GXDVAlXkec1eZ3XecYqHv1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbyJhXE%2FdJMcaaSJbbU%2FGXDVAlXkec1eZ3XecYqHv1%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;1244&quot; height=&quot;1076&quot; data-origin-width=&quot;1244&quot; data-origin-height=&quot;1076&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;UPDATE 경로 X lock (기존 row) &amp;rarr; 완전 해소 불가&lt;/b&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;기존 row는&lt;span&gt;&amp;nbsp;&lt;/span&gt;updated_at &amp;lt; baseTime이므로 SELECT에 포함된다. SELECT와 DELETE 사이 타이밍에 실시간 서비스가 같은 row의 X lock을 획득하면 충돌이 발생한다.&lt;/p&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1244&quot; data-origin-height=&quot;1098&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bGbFA0/dJMcaaFcEtW/pAd9o9etDKVkJoP2sbiStK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bGbFA0/dJMcaaFcEtW/pAd9o9etDKVkJoP2sbiStK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bGbFA0/dJMcaaFcEtW/pAd9o9etDKVkJoP2sbiStK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbGbFA0%2FdJMcaaFcEtW%2FpAd9o9etDKVkJoP2sbiStK%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;1244&quot; height=&quot;1098&quot; data-origin-width=&quot;1244&quot; data-origin-height=&quot;1098&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #ffffff; color: #222222; text-align: start;&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;baseTime 필터는 이러한 타이밍 경합을 차단하지 못한다. Dirty Read 케이스는 '읽히면 안 될 row를 읽는' 문제였지만, 이 케이스는 '읽혀야 할 row인데 DELETE 직전에 락이 걸린' 타이밍 문제이기 때문이다.&lt;/p&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;아직 이 케이스에서 문제가 발생하지 않은 것은 SELECT &amp;rarr; DELETE 사이 틈이 수 밀리초로 매우 짧고, INSERT보다 UPDATE 경로가 적게 발생하기 때문으로 보인다. 하지만 트래픽이 늘거나 배치 대상과 실시간 서비스가 겹치는 user_id가 많아지면 언제든 문제가 생길 수 있다.&lt;/p&gt;
&lt;/div&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;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot; data-heading=&quot;마치며&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;이번 트러블슈팅의 핵심은 하나의 타임아웃이 아니라 &lt;b&gt;세 가지 락 충돌 경로가 동시에 작동&lt;/b&gt;했다는 점이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;갭 락은 PK 기반 WHERE id IN (...) 으로 전환해 완전히 제거했고, Dirty Read는 updated_at &amp;lt; baseTime 필터로 DELETE 시도 자체를 막았다. UPDATE 경로 X lock 충돌만이 남는다. 격리 수준 변경, 실행 시간 분리 등 여러 방법이 있지만 현재 시스템 환경에서는 적용할 수 없어 이 기능은 여기서 마무리한다.&lt;/p&gt;</description>
      <category>Backend/Database</category>
      <author>giraffe_</author>
      <guid isPermaLink="true">https://programmingiraffe.tistory.com/206</guid>
      <comments>https://programmingiraffe.tistory.com/206#entry206comment</comments>
      <pubDate>Wed, 6 May 2026 17:04:47 +0900</pubDate>
    </item>
    <item>
      <title>[Spring/batch] BlockingQueue와 스레드를 활용한 Telegram 429 Rate Limit 해결</title>
      <link>https://programmingiraffe.tistory.com/205</link>
      <description>&lt;h2 data-heading=&quot;문제 상황&quot; data-ke-size=&quot;size26&quot;&gt;문제 상황&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;스프링 배치 서비스에서 여러 작업 대상을 스레드 풀로 병렬 처리하고, 각 스레드가 완료 후 Telegram으로 배치 결과를 알림하는 기능이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;평소에는 문제가 없었는데, &lt;b&gt;단기간(1~2초)에 끝나는 배치 대상이 많은&lt;/b&gt;&amp;nbsp;&lt;b&gt;특정 시간대의 배치&lt;/b&gt;에서만 아래와 같은 에러가 반복 발생했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1576&quot; data-origin-height=&quot;418&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dsgUjx/dJMcahdbHLd/R5DuQ0I4sjfaN3gI6DxnGK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dsgUjx/dJMcahdbHLd/R5DuQ0I4sjfaN3gI6DxnGK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dsgUjx/dJMcahdbHLd/R5DuQ0I4sjfaN3gI6DxnGK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdsgUjx%2FdJMcahdbHLd%2FR5DuQ0I4sjfaN3gI6DxnGK%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;1576&quot; height=&quot;418&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1576&quot; data-origin-height=&quot;418&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;14:30:00에 46건을 대상으로 배치 시작 후, 순차적으로 Telegram 알림을 발송했다.(비즈니스 로직 수행부터 알림 발송까지 배치 대상 당 1~2초 안에 끝난다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;그러다가 14:30:33부터 Telegram 알림 발송 에러가 발생했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;14:30:33  429 Too Many Requests  retry_after: 27
14:30:34  429 Too Many Requests  retry_after: 26
14:30:35  429 Too Many Requests  retry_after: 25
&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;429 Too Many Requests가 반환되며, 응답 본문에 retry_after 값(초)이 포함된다.&lt;/p&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-heading=&quot;429 발생 경로&quot; data-ke-size=&quot;size23&quot;&gt;상세 로직&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1244&quot; data-origin-height=&quot;866&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ABHqk/dJMcahYywXD/tpmdNSguzY9ktPtFNjrScK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ABHqk/dJMcahYywXD/tpmdNSguzY9ktPtFNjrScK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ABHqk/dJMcahYywXD/tpmdNSguzY9ktPtFNjrScK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FABHqk%2FdJMcahYywXD%2FtpmdNSguzY9ktPtFNjrScK%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;1244&quot; height=&quot;866&quot; data-origin-width=&quot;1244&quot; data-origin-height=&quot;866&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;여러 작업을 동시에 처리하기 위해 스레드 풀을 사용한다. 스레드 풀 안에는 worker-1, worker-2.. 다수의 워커 스레드가 존재하고, 각 스레드는 독립적으로 배치 작업을 처리한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;작업이 끝나면 결과에 따라 알림 경로가 나뉜다. 성공했을 때는 sendSuccess()를 통해 채팅방 A로, 실패했을 때는 sendFailure()를 통해 채팅방 B로 Telegram 알림이 전송된다. 두 워커가 병렬로 실행되기 때문에 이 알림들은 거의 동시에 발송된다.&lt;/p&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;배치 작업 대상이 많아질수록 두 워커는 빠르게 작업을 완료하고 연속적으로 알림을 쏟아낸다. 각 채팅방이 받는 메시지 건수가 쌓이다 보면 어느 순간 Telegram의 전송량 제한을 초과하게 되고, 그때부터 429 Too Many Requests 에러가 반복해서 발생한다.&lt;/p&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;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;원인 분석&lt;/h2&gt;
&lt;h3 data-heading=&quot;Telegram Bot API Rate Limit 정책&quot; data-ke-size=&quot;size23&quot;&gt;Telegram Bot Rate Limit 정책&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;Telegram Bot API는 과도한 메시지 전송을 막기 위해 Rate Limit 정책을 두고 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;815&quot; data-origin-height=&quot;198&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/deijLu/dJMcadPky2G/loFX2GkxXw2Vz2K3LvLWok/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/deijLu/dJMcadPky2G/loFX2GkxXw2Vz2K3LvLWok/img.png&quot; data-alt=&quot;텔레그램 봇 공식 문서 - https://core.telegram.org/bots/faq#my-bot-is-hitting-limits-how-do-i-avoid-this&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/deijLu/dJMcadPky2G/loFX2GkxXw2Vz2K3LvLWok/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdeijLu%2FdJMcadPky2G%2FloFX2GkxXw2Vz2K3LvLWok%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;815&quot; height=&quot;198&quot; data-origin-width=&quot;815&quot; data-origin-height=&quot;198&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;텔레그램 봇 공식 문서 - https://core.telegram.org/bots/faq#my-bot-is-hitting-limits-how-do-i-avoid-this&lt;/figcaption&gt;
&lt;/figure&gt;
&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: 51.6279%; height: 69px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 24.3023%; height: 17px;&quot;&gt;상황&lt;/td&gt;
&lt;td style=&quot;width: 27.2093%; height: 17px;&quot;&gt;제한&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 24.3023%; height: 21px;&quot;&gt;같은 채팅방&lt;/td&gt;
&lt;td style=&quot;width: 27.2093%; height: 21px;&quot;&gt;초당 1건 권장&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 24.3023%; height: 21px;&quot;&gt;&lt;b&gt;그룹 채팅&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 27.2093%; height: 21px;&quot;&gt;&lt;b&gt;분당 최대 20건&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 10px;&quot;&gt;
&lt;td style=&quot;width: 24.3023%; height: 10px;&quot;&gt;전체 (모든 채팅방 합산)&lt;/td&gt;
&lt;td style=&quot;width: 27.2093%; height: 10px;&quot;&gt;초당 약 30건&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;span style=&quot;letter-spacing: 0px;&quot;&gt;&amp;nbsp;이 로직에서 핵심이 되는 규칙은 &lt;b&gt;같은 채팅방에 분당 최대 20건&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;채팅방이 A, B로 분리되어 있으니 괜찮지 않을까 싶지만, 실제로는 그렇지 않았다. 이 배치 서비스에서 같은 채팅방에 메시지가 쌓이는 경로가 두 가지 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;b&gt;1.같은 채팅방으로 향하는 다른 배치 작업이 동시에 실행되는 경우&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;채팅방 A가 성공 알림방이라면, 이 서비스뿐 아니라 다른 배치 타입의 성공 알림도 같은 채팅방 A로 전송될 수 있다. 배치가 여러 개 겹쳐 실행되는 시간대에는 채팅방 A로 향하는 메시지가 여러 배치에서 동시에 몰려들고, 합산 건수가 20건을 넘기게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;nbsp;2. 배치 작업 대상 자체가 많은 경우&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;작업 대상이 수십 개라면 두 워커는 작업을 마칠 때마다 계속 알림을 전송한다. 워커가 빠르게 작업을 완료할수록 알림도 빠르게 쌓이고, 60초 안에 같은 채팅방으로 20건 이상이 누적되면 그 순간 429가 발생한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-heading=&quot;원인 분석&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-heading=&quot;원인 분석&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-heading=&quot;원인 분석&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-heading=&quot;원인 분석&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-heading=&quot;슬라이딩 윈도우 분석&quot; data-ke-size=&quot;size23&quot;&gt;슬라이딩 윈도우&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;nbsp;Telegram은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;슬라이딩 윈도우&lt;/b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;방식으로 제한을 적용한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1244&quot; data-origin-height=&quot;878&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/x99k2/dJMcacbSHkU/xmY4aBuHy9ANjpaB97lFHK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/x99k2/dJMcacbSHkU/xmY4aBuHy9ANjpaB97lFHK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/x99k2/dJMcacbSHkU/xmY4aBuHy9ANjpaB97lFHK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fx99k2%2FdJMcacbSHkU%2FxmY4aBuHy9ANjpaB97lFHK%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;1244&quot; height=&quot;878&quot; data-origin-width=&quot;1244&quot; data-origin-height=&quot;878&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;배치가 시작되면 병렬 워커들이 작업을 마칠 때마다 Telegram 알림을 연속으로 전송한다. Telegram은 이 전송량을 &lt;b&gt;슬라이딩 윈도우&lt;/b&gt; 방식으로 측정하는데, '지금 이 순간부터 과거 60초' 안에 들어온 메시지를 실시간으로 카운팅한다.&lt;/p&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;14:30:00에 배치가 시작된 후 33초 만에 메시지가 20건을 넘긴다. 그 순간 429가 반환되고, 응답에 담긴 retry_after 값이 매초 1씩 줄어드는 이유도 여기에 있다. 고정 윈도우였다면 14:31:00이 되는 순간 카운터가 리셋되겠지만, 슬라이딩 윈도우는 오래된 메시지가 60초 밖으로 밀려나야 비로소 한 건씩 여유가 생기기 때문이다. 14:31:00이 되어서야 retry_after가 0이 되고 전송이 재개된다.&lt;/p&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;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;해결책 후보&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세 가지 방법을 검토했다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 63.8372%; height: 101px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px; width: 29.1861%;&quot;&gt;방식&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 16.7442%;&quot;&gt;동시 발송 방지&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 17.7907%;&quot;&gt;워커 스레드 블록&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px; width: 29.1861%;&quot;&gt;sleep만 추가&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 16.7442%;&quot;&gt;X&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 17.7907%;&quot;&gt;X&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px; width: 29.1861%;&quot;&gt;synchronized + sleep&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 16.7442%;&quot;&gt;O&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 17.7907%;&quot;&gt;O&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px; width: 29.1861%;&quot;&gt;단일 BlockingQueue + 전용 스레드&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 16.7442%;&quot;&gt;O&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 17.7907%;&quot;&gt;X (비동기)&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;h3 data-ke-size=&quot;size23&quot;&gt;1. Thread.sleep() 추가&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;처음 떠오른 해결책은 메서드에 Thread.sleep()을 추가하는 것이었다.&lt;/p&gt;
&lt;pre class=&quot;lasso&quot;&gt;&lt;code&gt;public void sendSuccess(...) {
    Thread.sleep(1000); // 각 스레드가 1초 잠든 뒤
    telegramProcess.send(message); // 동시에 여기 도달 &amp;rarr; 여전히 동시 발송
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;만약 10개 스레드가 동시에 sleep(1000)을 호출하면, &lt;b&gt;1초 후에 동시에&lt;/b&gt; 깨어나 동시에 전송한다. sleep은 각 스레드의 실행을 지연시킬 뿐, 전송 순서를 직렬화하지 않는다.&lt;/p&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;2. synchronized + sleep&lt;/h3&gt;
&lt;pre class=&quot;processing&quot;&gt;&lt;code&gt;@Service
public class TelegramNotificationService {
    private final Object lock = new Object();
    private long lastSentMs = 0;

    public void send(String chatId, String token, String message) {
        synchronized (lock) {
            long elapsed = System.currentTimeMillis() - lastSentMs;
            if (elapsed &amp;lt; 3000) {
                Thread.sleep(3000 - elapsed);
            }
            TelegramApi.send(chatId, token, message);
            lastSentMs = System.currentTimeMillis();
        }
    }
}
&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;synchronized로 진입을 직렬화하고, 마지막 전송 이후 3초가 지나지 않았으면 나머지 시간만큼 sleep한다.&lt;/p&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;span style=&quot;color: #333333; text-align: start;&quot;&gt;synchronized&lt;span&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;&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;3. BlockingQueue + 전용 스레드&lt;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Service
public class TelegramNotificationService {
    private final BlockingQueue&amp;lt;Runnable&amp;gt; queue = new LinkedBlockingQueue&amp;lt;&amp;gt;(500);

    @PostConstruct
    public void startSender() {
        Thread.ofVirtual().name(&quot;telegram-sender&quot;).start(() -&amp;gt; {
            while (true) {
                try {
                    Runnable task = queue.take();
                    task.run();
                    Thread.sleep(3000);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    break;
                }
            }
        });
    }

    public void send(String chatId, String token, String message) {
        queue.offer(() -&amp;gt; TelegramApi.send(chatId, token, message));
    }
}&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;워커 스레드는 queue.offer()만 호출하고 즉시 리턴한다. 실제 전송은 전용 스레드 하나가 3초 간격으로 처리한다. 전체 전송량이 최대 20건/분(60초 &amp;divide; 3초)이 되므로, 어느 채팅방으로 보내든 Rate Limit에 걸리지 않는다.&lt;/p&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-heading=&quot;단일 큐의 한계 &amp;mdash; 채팅방별 독립 큐로&quot; data-ke-size=&quot;size26&quot;&gt;단일 큐의 한계 - 채팅방별 독립 큐로&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;단일 큐 방식은 Rate Limit 문제를 해결하지만, 채팅방이 여러 개일 때 한 가지 고민이 생긴다. (실제로 채팅방이 2개뿐 아니라 더 있다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;단일 큐에서는 채팅방 A와 채팅방 B의 메시지가 같은 줄을 서게 된다. 채팅방 A에 메시지가 몰리면, 채팅방 B의 메시지는 채팅방 A의 메시지가 빠져나갈 때까지 대기해야 한다. 전체 처리량도 전 채팅방 합산 20건/분에 묶인다.&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: 59.186%; height: 84px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px; width: 21.5116%;&quot;&gt;비교 항목&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 19.3024%;&quot;&gt;단일 큐&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 18.2557%;&quot;&gt;독립 큐&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px; width: 21.5116%;&quot;&gt;채팅방 간 간섭&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 19.3024%;&quot;&gt;있음&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 18.2557%;&quot;&gt;없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px; width: 21.5116%;&quot;&gt;채팅방별 최대 처리량&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 19.3024%;&quot;&gt;전체 합산 20건/분&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 18.2557%;&quot;&gt;채팅방당 20건/분&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;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;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;최종 구현&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;채팅방(chatId) 단위로 큐와 전용 발송 스레드를 관리한다. 같은 채팅방으로 향하는 메시지끼리만 직렬화되고, 서로 다른 채팅방은 독립적으로 동작한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1777543105542&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 채팅방별 큐
ConcurrentHashMap&amp;lt;String, BlockingQueue&amp;lt;Runnable&amp;gt;&amp;gt; roomQueues = new ConcurrentHashMap&amp;lt;&amp;gt;();
// 채팅방별 스레드
ConcurrentHashMap&amp;lt;String, ExecutorService&amp;gt; roomExecutors = new ConcurrentHashMap&amp;lt;&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;h3 style=&quot;color: #000000;&quot; data-ke-size=&quot;size23&quot;&gt;메시지 전송&amp;nbsp; 로직&lt;/h3&gt;
&lt;pre class=&quot;less&quot; style=&quot;background-color: #f8f8f8; color: #383a42;&quot;&gt;&lt;code&gt;send() 호출
  &amp;rarr; computeIfAbsent: 첫 호출이면 큐 + 가상 스레드 생성
  &amp;rarr; queue.offer(task): 큐에 적재 후 즉시 리턴 (워커 스레드 블록 없음)
  &amp;rarr; 가상 스레드: poll(500ms) &amp;rarr; task.run() &amp;rarr; sleep(3000) &amp;rarr; ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot; data-heading=&quot;실제 종료 로그&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000;&quot; data-heading=&quot;Graceful Shutdown 설계&quot; data-ke-size=&quot;size23&quot;&gt;Graceful Shutdown 추가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;애플리케이션이 갑작스럽게 종료되는 상황에서도 큐에 쌓인 메시지가 유실되지 않도록 Graceful Shutdown을 추가했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;boolean running 플래그로 루프를 자연 종료시킨다.&lt;/p&gt;
&lt;pre class=&quot;autoit&quot; style=&quot;background-color: #f8f8f8; color: #383a42;&quot;&gt;&lt;code&gt;@PreDestroy shutdown()
  &amp;rarr; running = false
  &amp;rarr; poll(500ms) 타임아웃 &amp;rarr; while 루프 탈출
  &amp;rarr; drainQueue(): 남은 메시지 순차 전송
  &amp;rarr; awaitTermination(): 드레인 완료 대기
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot; data-heading=&quot;실제 종료 로그&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot; data-heading=&quot;실제 종료 로그&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-heading=&quot;구현 코드&quot; data-ke-size=&quot;size23&quot;&gt;최종 구현 코드&lt;/h3&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Slf4j
@Service
public class TelegramNotificationService {

    private volatile boolean running = true;
    private final ConcurrentHashMap&amp;lt;String, BlockingQueue&amp;lt;Runnable&amp;gt;&amp;gt; roomQueues = new ConcurrentHashMap&amp;lt;&amp;gt;();
    private final ConcurrentHashMap&amp;lt;String, ExecutorService&amp;gt; roomExecutors = new ConcurrentHashMap&amp;lt;&amp;gt;();

    public void send(String chatId, String token, String message) {
        BlockingQueue&amp;lt;Runnable&amp;gt; queue = roomQueues.computeIfAbsent(chatId, id -&amp;gt; {
            LinkedBlockingQueue&amp;lt;Runnable&amp;gt; q = new LinkedBlockingQueue&amp;lt;&amp;gt;(500);
            ExecutorService executor = Executors.newSingleThreadExecutor(
                    Thread.ofVirtual().name(&quot;telegram-&quot; + id).factory()
            );
            executor.submit(() -&amp;gt; runSender(id, q));
            roomExecutors.put(id, executor);
            return q;
        });
        queue.offer(() -&amp;gt; TelegramApi.send(chatId, token, message));
    }

    private void runSender(String chatId, BlockingQueue&amp;lt;Runnable&amp;gt; queue) {
        while (running) {
            try {
                Runnable task = queue.poll(500, TimeUnit.MILLISECONDS);
                if (task == null) continue;
                task.run();
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                break;
            } catch (Exception e) {
                log.error(&quot;텔레그램 메시지 전송 실패 [chatId={}]&quot;, chatId, e);
            }
        }
        drainQueue(chatId, queue);
    }

    private void drainQueue(String chatId, BlockingQueue&amp;lt;Runnable&amp;gt; queue) {
        log.info(&quot;텔레그램 잔여 메시지 전송 [chatId={}, count={}]&quot;, chatId, queue.size());
        Runnable task;
        while ((task = queue.poll()) != null) {
            try {
                task.run();
            } catch (Exception e) {
                log.error(&quot;종료 중 메시지 전송 실패 [chatId={}]&quot;, chatId, e);
            }
        }
    }

    @PreDestroy
    public void shutdown() {
        log.info(&quot;텔레그램 발송 스레드 종료 시작 - 잔여 메시지 전송 중...&quot;);
        running = false;
        roomExecutors.values().forEach(ExecutorService::shutdown);
        roomExecutors.forEach((id, executor) -&amp;gt; {
            try {
                if (!executor.awaitTermination(10, TimeUnit.MINUTES)) {
                    log.warn(&quot;텔레그램 발송 타임아웃 [chatId={}]&quot;, id);
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
        log.info(&quot;텔레그램 발송 스레드 종료 완료&quot;);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-heading=&quot;실제 종료 로그&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-heading=&quot;실제 종료 로그&quot; data-ke-size=&quot;size23&quot;&gt;종료 테스트 로그&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1214&quot; data-origin-height=&quot;177&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nFBtS/dJMcagFpXDM/kMG4eKCoEhjhJslijJ1nZk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nFBtS/dJMcagFpXDM/kMG4eKCoEhjhJslijJ1nZk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nFBtS/dJMcagFpXDM/kMG4eKCoEhjhJslijJ1nZk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnFBtS%2FdJMcagFpXDM%2FkMG4eKCoEhjhJslijJ1nZk%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;1214&quot; height=&quot;177&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1214&quot; data-origin-height=&quot;177&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;</description>
      <category>Backend/Jave&amp;amp;Spring</category>
      <category>BlockingQueue</category>
      <category>Java</category>
      <category>RateLimit</category>
      <category>SpringBatch</category>
      <category>telegram</category>
      <category>멀티스레드</category>
      <author>giraffe_</author>
      <guid isPermaLink="true">https://programmingiraffe.tistory.com/205</guid>
      <comments>https://programmingiraffe.tistory.com/205#entry205comment</comments>
      <pubDate>Mon, 4 May 2026 16:05:45 +0900</pubDate>
    </item>
    <item>
      <title>[Obsidian] Notion에서 Obsidian으로 문서 관리 전환 후기</title>
      <link>https://programmingiraffe.tistory.com/204</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;노션과 함께한 시간들&lt;/h2&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;갈아탄 이유&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;노션 무료 플랜의 블록 제한&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;725&quot; data-origin-height=&quot;315&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bP8XlC/dJMcadWqclS/QiY8iuiCOB3vgwlulKOohk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bP8XlC/dJMcadWqclS/QiY8iuiCOB3vgwlulKOohk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bP8XlC/dJMcadWqclS/QiY8iuiCOB3vgwlulKOohk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbP8XlC%2FdJMcadWqclS%2FQiY8iuiCOB3vgwlulKOohk%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;725&quot; height=&quot;315&quot; data-origin-width=&quot;725&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;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;AI 시대, 마크다운이 유리하다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;Claude Code를 쓰기 시작하면서 인식이 완전히 바뀌었다. 옵시디언 문서는 전부 &lt;code&gt;.md&lt;/code&gt; 파일이라 LLM이 바로 읽고 편집할 수 있다. 문서 정리를 Claude Code에게 시키고, 정리된 문서를 기반으로 다른 문서를 작성하거나 개발 작업의 컨텍스트로 활용할 수 있다. 개발 관련 기록을 옵시디언으로 옮기고 나서 이 부분의 효율이 확실히 올라갔다.&lt;/p&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;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;옵시디언 쓰면서 좋은 점&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;마크다운 기반이라 LLM 활용이 쉽다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;앞서 언급했지만, 이게 지금 가장 큰 장점이다. 회사에서 프로젝트 정리, 내가 개발한 기능 기록, 트러블슈팅 정리, 개인적인 개발 공부 등을 옵시디언에 쌓아두면 Claude Code를 통해 재료로 바로 쓸 수 있다. 노션 문서는 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;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;PARA 기법으로 구조 잡기&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;Project&lt;/b&gt;: 현재 진행 중인 작업들 (사이드 프로젝트, 블로그 초안 등)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Area&lt;/b&gt;: 지속적으로 관리하는 영역 (커리어, 학습 등)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Resource&lt;/b&gt;: 나중에 참고할 자료들&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Archive&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;이 구조를 잡고 나니 어디에 뭘 써야 할지 고민이 줄었다. 노션에서는 페이지를 어디에 넣어야 할지 항상 애매했는데, PARA는 기준이 명확해서 좋다.&lt;/p&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;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-04-14 오후 11.18.15.png&quot; data-origin-width=&quot;1112&quot; data-origin-height=&quot;1140&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bNOKar/dJMcacpdZkz/7Yko939YICH5lRFVzygxDk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bNOKar/dJMcacpdZkz/7Yko939YICH5lRFVzygxDk/img.png&quot; data-alt=&quot;내 옵시디언 그래프 뷰&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bNOKar/dJMcacpdZkz/7Yko939YICH5lRFVzygxDk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbNOKar%2FdJMcacpdZkz%2F7Yko939YICH5lRFVzygxDk%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;548&quot; height=&quot;562&quot; data-filename=&quot;스크린샷 2026-04-14 오후 11.18.15.png&quot; data-origin-width=&quot;1112&quot; data-origin-height=&quot;1140&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;내 옵시디언 그래프 뷰&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;문서 간 링크를 걸어두면 그래프 뷰에서 시각적으로 연결 관계를 볼 수 있다. 처음엔 &quot;이게 실용적인가?&quot; 싶었는데, 기록이 쌓일수록 내가 어떤 주제에 집중하고 있는지, 어떤 개념들이 연결되어 있는지 한눈에 파악할 수 있어서 유용하다.&lt;/p&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;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;아쉬운 점&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;동기화가 번거롭다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;로컬 저장소라는 특성상, 여러 기기에서 쓰려면 동기화를 직접 해야 한다. iCoud 구글드라이브 등이 있는데 나는 GitHub에 백업하는 방식을 선택했다. 회사 컴퓨터와 개인 노트북에서 &lt;code&gt;git pull&lt;/code&gt; / &lt;code&gt;git push&lt;/code&gt;를 하면서 쓰고 있다. 물론 직접할 필요없고 옵시디언에 Git 플러그인이 있다. 습관이 들면 크게 불편하지는 않은데, 처음엔 세팅하고 매번 push &amp;amp; pull을 하는 것이 좀 귀찮다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;모바일 동기화는 사실상 유료&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;옵시디언 공식 동기화 서비스인 Obsidian Sync는 유료다. 핸드폰에서 바로 접근하고 싶을 때 이 부분이 아쉽다. GitHub 연동으로 모바일에서 읽는 방법도 있긴 한데 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;가볍게 열람용으로만 좋다&lt;/span&gt;. 편하게 편집까지 하려면 결국 Obsidian Sync를 써야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;처음 세팅이 어렵다&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-04-14 오후 11.23.11.png&quot; data-origin-width=&quot;532&quot; data-origin-height=&quot;1078&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bf3MDF/dJMcafGf3CA/8E3iCEzr3g50lpDPwQ5Rt1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bf3MDF/dJMcafGf3CA/8E3iCEzr3g50lpDPwQ5Rt1/img.png&quot; data-alt=&quot;내 옵시디언 폴더 구조&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bf3MDF/dJMcafGf3CA/8E3iCEzr3g50lpDPwQ5Rt1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbf3MDF%2FdJMcafGf3CA%2F8E3iCEzr3g50lpDPwQ5Rt1%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;274&quot; height=&quot;555&quot; data-filename=&quot;스크린샷 2026-04-14 오후 11.23.11.png&quot; data-origin-width=&quot;532&quot; data-origin-height=&quot;1078&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;내 옵시디언 폴더 구조&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;노션을 완전히 버린 건 아니다. 팀 협업이나 외부 공유가 필요한 경우에는 여전히 노션이 낫다. 하지만 개인 문서 관리, 특히 개발 관련 기록을 쌓고 LLM과 함께 활용하는 용도로는 옵시디언이 훨씬 유리하다.&lt;/p&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;진입장벽이 있는 건 맞다. 나도 한 번 포기했다. 그런데 Claude Code를 쓰기 시작하면서 마크다운 기반의 로컬 파일이 얼마나 강력한지 실감하게 됐고, 지금은 잘 쓰고 있다. 도구 자체보다 어떻게 쓸지를 먼저 고민하는 게 맞겠지만, 옵시디언은 그 방향이 맞는 사람에게는 노션보다 훨씬 좋은 선택지가 될 수 있다.&lt;/p&gt;</description>
      <category>Dev</category>
      <author>giraffe_</author>
      <guid isPermaLink="true">https://programmingiraffe.tistory.com/204</guid>
      <comments>https://programmingiraffe.tistory.com/204#entry204comment</comments>
      <pubDate>Tue, 21 Apr 2026 22:40:44 +0900</pubDate>
    </item>
    <item>
      <title>[ClickHouse] Distributed 테이블 - DROP PARTITION이 왜 일부 데이터만 지워질까?</title>
      <link>https://programmingiraffe.tistory.com/203</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;ClickHouse 클러스터를 처음 사용하다 보면 당황스럽다.&lt;/p&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;code&gt;DROP PARTITION&lt;/code&gt;을 실행했는데. 알고 보면 삭제된 건 클러스터 전체가 아니라 &lt;b&gt;일부 Shard의 데이터뿐&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;배경: Distributed 테이블은 라우터다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://programmingiraffe.tistory.com/201&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;앞선 글&lt;/a&gt;에서 다뤘지만 다시 짚고 넘어가자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ClickHouse 클러스터에서 테이블은 두 벌이다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;default.user_events          &amp;larr; Distributed 테이블 (라우터, 데이터 없음)
local.user_events_local      &amp;larr; 로컬 테이블 (실제 데이터 저장)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2048&quot; data-origin-height=&quot;1483&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/otdQ9/dJMcag55xAS/rsiX1wfe7AcsqWLhLI08K1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/otdQ9/dJMcag55xAS/rsiX1wfe7AcsqWLhLI08K1/img.png&quot; data-alt=&quot;https://clickhouse.com/docs/assets/ideal-img/shards_replicas_01.f8da341.2048.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/otdQ9/dJMcag55xAS/rsiX1wfe7AcsqWLhLI08K1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FotdQ9%2FdJMcag55xAS%2FrsiX1wfe7AcsqWLhLI08K1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2048&quot; height=&quot;1483&quot; data-origin-width=&quot;2048&quot; data-origin-height=&quot;1483&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;https://clickhouse.com/docs/assets/ideal-img/shards_replicas_01.f8da341.2048.png&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;애플리케이션은 보통 &lt;code&gt;default.user_events&lt;/code&gt; (Distributed&amp;nbsp;테이블)만 바라본다. SELECT, INSERT 모두 여기로 날리면 내부적으로 각 Shard로 분산된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;문제는 &lt;b&gt;DDL 동작이 SELECT/INSERT와 다르다&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;무슨 일이 일어나는가?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Distributed 테이블에서 &lt;code&gt;DROP PARTITION&lt;/code&gt;을 실행하면 어떻게 될까?&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;-- Distributed 테이블에서 실행
ALTER TABLE default.user_events
DROP PARTITION '20260318';&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;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;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;클러스터 구성
├── Shard 1 (서버 A, B)
└── Shard 2 (서버 C)

ALTER TABLE default.user_events DROP PARTITION '20260318'
&amp;rarr; 쿼리가 서버 A로 접속된 경우: 서버 A의 로컬 데이터만 삭제
&amp;rarr; 서버 C의 데이터는 그대로 남음&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;조회해보면 데이터가 여전히 보인다. Distributed 테이블은 모든 Shard를 합쳐서 보여주기 때문에, Shard 2의 데이터가 남아있으면 삭제가 안 된 것처럼 보인다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 이렇게 동작하나?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Distributed 테이블은 SELECT/INSERT는 각 Shard에 분산시키지만, &lt;b&gt;DDL은 분산시키지 않는다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ClickHouse 공식 문서에도 명시되어 있다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Distributed table does not store any data itself. It only routes queries to remote servers.&lt;br /&gt;DDL queries (like ALTER) are applied only to the current server.&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;즉 Distributed 테이블에서 &lt;code&gt;ALTER&lt;/code&gt;를 실행하면 해당 서버의 로컬 테이블에만 적용된다. 다른 Shard로 전파되지 않는다.&lt;/p&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;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;올바른 방법: 로컬 테이블 + ON CLUSTER&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해결책은 두 가지다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;방법 1: ON CLUSTER 사용 (권장)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬 테이블을 직접 지정하되, &lt;code&gt;ON CLUSTER&lt;/code&gt; 구문을 붙이면 ClickHouse가 ZooKeeper를 통해 클러스터 내 &lt;b&gt;모든 노드에 자동으로 DDL을 배포&lt;/b&gt;한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;-- 올바른 방법: 로컬 테이블 + ON CLUSTER
ALTER TABLE local.user_events_local ON CLUSTER my_cluster
DROP PARTITION '20260318';&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;한 번의 쿼리로 서버 A, B, C 모두에서 해당 파티션이 삭제된다.&lt;/p&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;방법 2: 각 노드에 직접 접속해서 실행&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ON CLUSTER&lt;/code&gt; 없이 각 Shard 서버에 직접 접속해서 로컬 테이블에 실행하는 방법도 있다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;-- 서버 A에 접속해서 실행
ALTER TABLE local.user_events_local DROP PARTITION '20260318';

-- 서버 C에 접속해서 실행
ALTER TABLE local.user_events_local DROP PARTITION '20260318';&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;code&gt;ON CLUSTER&lt;/code&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리&lt;/h2&gt;
&lt;table style=&quot;height: 77px;&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;th style=&quot;height: 20px;&quot;&gt;실행 대상&lt;/th&gt;
&lt;th style=&quot;height: 20px;&quot;&gt;ON CLUSTER&lt;/th&gt;
&lt;th style=&quot;height: 20px;&quot;&gt;결과&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;Distributed 테이블&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;X&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;쿼리 수신 노드 1개만 적용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;로컬 테이블&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;X&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;해당 노드 1개만 적용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;로컬 테이블&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;O&lt;/td&gt;
&lt;td style=&quot;height: 19px;&quot;&gt;클러스터 전체 적용&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;Distributed 테이블은 SELECT/INSERT만 분산시킨다. DDL은 &lt;code&gt;로컬 테이블 + ON CLUSTER&lt;/code&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;덧붙임: Replica 동기화는 자동이다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;ON CLUSTER&lt;/code&gt;로 실행하면 각 Shard의 Primary 노드에 DDL이 전달된다. 그러면 Replica는 어떻게 되나?&lt;/p&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;Replica 간 동기화는 ZooKeeper가 담당한다. Primary에 DDL이 적용되면 ZooKeeper를 통해 같은 Shard의 Replica에 자동으로 반영된다. 별도로 Replica 서버에 접속해서 실행할 필요가 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;ALTER TABLE local.user_events_local ON CLUSTER my_cluster DROP PARTITION '20260318'
    │
    ├── Shard 1 - 서버 A (Primary) &amp;rarr; ZooKeeper &amp;rarr; 서버 B (Replica) 자동 반영
    └── Shard 2 - 서버 C &amp;rarr; (Replica 없으면 바로 완료)&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;처음엔 &quot;Distributed 테이블에 쿼리 날리면 다 되는 거 아니야?&quot;라고 생각했는데, DDL만큼은 예외라는 걸 직접 겪어보고 나서야 제대로 이해했다.&lt;/p&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;SELECT/INSERT와 DDL의 동작 방식이 다르다는 점, &lt;code&gt;ON CLUSTER&lt;/code&gt;가 어떤 역할을 하는지를 이해하면 ClickHouse 클러스터 운영에서 의도치 않은 실수를 줄일 수 있다.&lt;/p&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;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://clickhouse.com/docs/sql-reference/distributed-ddl&quot;&gt;https://clickhouse.com/docs/sql-reference/distributed-ddl&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://clickhouse.com/docs/sql-reference/statements/alter/partition#drop-partitionpart&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://clickhouse.com/docs/sql-reference/statements/alter/partition#drop-partitionpart&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://clickhouse.com/docs/en/engines/table-engines/mergetree-family/replication&quot;&gt;https://clickhouse.com/docs/en/engines/table-engines/mergetree-family/replication&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Backend/Database</category>
      <author>giraffe_</author>
      <guid isPermaLink="true">https://programmingiraffe.tistory.com/203</guid>
      <comments>https://programmingiraffe.tistory.com/203#entry203comment</comments>
      <pubDate>Thu, 2 Apr 2026 22:46:49 +0900</pubDate>
    </item>
    <item>
      <title>[ClickHouse] 데이터 삭제 - ALTER DELETE vs DROP PARTITION</title>
      <link>https://programmingiraffe.tistory.com/202</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;ClickHouse에서 잘못 적재된 데이터를 지워야 하는 상황이 생겼다.&lt;/p&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;MySQL이라면 그냥 &lt;code&gt;DELETE FROM ... WHERE ...&lt;/code&gt; 날리면 끝인데, ClickHouse 클러스터 환경에서는 그게 안 된다. &lt;code&gt;DELETE FROM&lt;/code&gt; 문법 자체는 존재하지만, &lt;code&gt;ON CLUSTER&lt;/code&gt;를 지원하지 않아 클러스터 전체에 적용되지 않는다. 클러스터 환경에서는 반드시 &lt;code&gt;ALTER TABLE ... DELETE&lt;/code&gt;나 &lt;code&gt;DROP PARTITION&lt;/code&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;b&gt;클러스터 환경&lt;/b&gt;을 전제로 데이터를 삭제하는 두 가지 방법인 &lt;code&gt;ALTER DELETE&lt;/code&gt;와 &lt;code&gt;DROP PARTITION&lt;/code&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;b&gt;데이터 양이 아니라 삭제 패턴&lt;/b&gt;이다. 작은 양이라도 &lt;code&gt;ALTER DELETE&lt;/code&gt;는 파트 재작성이 필요한 구조라 느리고, &lt;code&gt;DROP PARTITION&lt;/code&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;ClickHouse에서 DELETE가 느린 이유&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2048&quot; data-origin-height=&quot;1412&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/HdKTe/dJMcaax0QGW/K5yRsBBhooB2b7a8myXKiK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/HdKTe/dJMcaax0QGW/K5yRsBBhooB2b7a8myXKiK/img.png&quot; data-alt=&quot;https://clickhouse.com/docs/assets/ideal-img/part.323bb36.2048.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/HdKTe/dJMcaax0QGW/K5yRsBBhooB2b7a8myXKiK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FHdKTe%2FdJMcaax0QGW%2FK5yRsBBhooB2b7a8myXKiK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2048&quot; height=&quot;1412&quot; data-origin-width=&quot;2048&quot; data-origin-height=&quot;1412&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;https://clickhouse.com/docs/assets/ideal-img/part.323bb36.2048.png&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;ClickHouse는 데이터를 &lt;b&gt;파트(Part)&lt;/b&gt; 단위로 디스크에 저장한다. INSERT가 들어올 때마다 새로운 파트가 생성되고, 백그라운드에서 주기적으로 파트들을 병합(Merge)한다.&lt;/p&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;이 구조 덕분에 대용량 INSERT와 SELECT가 빠르지만, 반대로 &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;이게 바로 ClickHouse의 삭제 방식인 &lt;b&gt;Mutation&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;방법 1: ALTER DELETE (Mutation)&lt;/h2&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;ALTER TABLE local.user_events_local ON CLUSTER my_cluster
DELETE WHERE event_date = '2026-03-18';&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;h3 data-ke-size=&quot;size23&quot;&gt;동작 방식&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;1136&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/pNSdm/dJMcaiiuoZ8/XvSxDHcKdw32djKEvsoKv0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/pNSdm/dJMcaiiuoZ8/XvSxDHcKdw32djKEvsoKv0/img.png&quot; data-alt=&quot;https://clickhouse.com/uploads/mutation_01_24b8cb7f88.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/pNSdm/dJMcaiiuoZ8/XvSxDHcKdw32djKEvsoKv0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FpNSdm%2FdJMcaiiuoZ8%2FXvSxDHcKdw32djKEvsoKv0%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;1600&quot; height=&quot;1136&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;1136&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;https://clickhouse.com/uploads/mutation_01_24b8cb7f88.png&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; 삭제 작업이 &lt;b&gt;Mutation 큐에 등록&lt;/b&gt;되고, &lt;b&gt;백그라운드에서 비동기&lt;/b&gt;로 처리된다. 대상 파트를 디스크에서 읽어 조건에 해당하는 행을 제외한 뒤 새 파트로 재작성한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;기본값은 비동기라 쿼리가 바로 반환되지만, 삭제가 완전히 끝났다는 보장이 없다. 모든 Replica에서 완료될 때까지 기다리려면 &lt;code&gt;mutations_sync&lt;/code&gt; 설정을 추가한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;ALTER TABLE local.user_events_local ON CLUSTER my_cluster
DELETE WHERE event_date = '2026-03-18'
SETTINGS mutations_sync = 2;  -- 0: 비동기(기본), 1: 현재 서버 완료 대기, 2: 모든 Replica 완료 대기&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;h3 data-ke-size=&quot;size23&quot;&gt;특징&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;조건부 삭제 가능&lt;/b&gt; :&amp;nbsp;&lt;code&gt;WHERE&lt;/code&gt; 절로 원하는 행만 정확히 제거할 수 있다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;느리고 높은 I/O 비용&lt;/b&gt; : 파트 전체를 재작성하기 때문에 느리다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;code&gt;ON CLUSTER&lt;/code&gt; 지원&lt;/b&gt; : 각 Shard로 자동 전파된다. 클러스터 환경에서 조건부 삭제가 필요하다면 이 방법만 가능하다.&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;방법 2: DROP PARTITION&lt;/h2&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;-- 반드시 로컬 테이블 + ON CLUSTER 조합으로 실행
ALTER TABLE local.user_events_local ON CLUSTER my_cluster
DROP PARTITION '20260318';&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;h3 data-ke-size=&quot;size23&quot;&gt;동작 방식&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1528&quot; data-origin-height=&quot;1134&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/br1csH/dJMcaiQh55x/Bt28zXcQk8XVhoENkCQCck/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/br1csH/dJMcaiQh55x/Bt28zXcQk8XVhoENkCQCck/img.png&quot; data-alt=&quot;https://clickhouse.com/uploads/partition_delete_3ee4669374.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/br1csH/dJMcaiQh55x/Bt28zXcQk8XVhoENkCQCck/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbr1csH%2FdJMcaiQh55x%2FBt28zXcQk8XVhoENkCQCck%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;1528&quot; height=&quot;1134&quot; data-origin-width=&quot;1528&quot; data-origin-height=&quot;1134&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;https://clickhouse.com/uploads/partition_delete_3ee4669374.png&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;파티션을 &lt;b&gt;비활성 상태로 표시&lt;/b&gt;하고 약 10분 후 완전히 삭제한다. 파트를 읽거나 재작성하지 않고 통째로 버리기 때문에 매우 빠르다. 쿼리 반환 시점에는 이미 해당 파티션이 조회에서 제외되므로 체감상 즉시 삭제처럼 동작한다.&lt;/p&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;빠르고 낮은 I/O 비용&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;파티션 단위로만 삭제 가능&lt;/b&gt; : 조건부 삭제 불가. 파티션 키 단위로만 끊어서 지울 수 있다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Distributed 테이블에서 실행하면 안 됨&lt;/b&gt; : 반드시 &lt;code&gt;로컬 테이블 + ON CLUSTER&lt;/code&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;비교&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;ALTER DELETE&lt;/th&gt;
&lt;th&gt;DROP PARTITION&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;속도&lt;/td&gt;
&lt;td&gt;느림&lt;/td&gt;
&lt;td&gt;빠름&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;I/O&lt;/td&gt;
&lt;td&gt;높음&lt;/td&gt;
&lt;td&gt;낮음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;삭제 단위&lt;/td&gt;
&lt;td&gt;행 단위 (조건부)&lt;/td&gt;
&lt;td&gt;파티션 단위&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ON CLUSTER&lt;/code&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;Distributed 테이블 실행&lt;/td&gt;
&lt;td&gt;자동 전파&lt;/td&gt;
&lt;td&gt;한 노드에만 적용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;즉시 반영&lt;/td&gt;
&lt;td&gt;비동기 (기본)&lt;/td&gt;
&lt;td&gt;비활성 즉시 / 실제 삭제 ~10분 후&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;적합한 경우&lt;/td&gt;
&lt;td&gt;특정 조건의 일부 행 삭제&lt;/td&gt;
&lt;td&gt;날짜 단위 전체 재처리&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;언제 뭘 써야 하나?&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;DROP PARTITION을 쓰는 경우&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;테이블 파티션이 날짜 단위로 나뉘어 있고, 특정 날짜 데이터를 &lt;b&gt;통째로&lt;/b&gt; 다시 적재해야 할 때 적합하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;예를 들어 배치 잡이 특정 날짜에 잘못된 데이터를 적재했고, 해당 날짜 전체를 지우고 다시 넣어야 하는 상황이다. 데이터 양에 상관없이 빠르고, 지우고 다시 넣는 패턴이 가장 깔끔하다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;-- 1. 잘못된 파티션 삭제
ALTER TABLE local.user_events_local ON CLUSTER my_cluster
DROP PARTITION '20260318';

-- 2. 올바른 데이터 재적재
INSERT INTO default.user_events SELECT ...&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;h3 data-ke-size=&quot;size23&quot;&gt;ALTER DELETE를 쓰는 경우&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;파티션 경계와 무관하게 &lt;b&gt;특정 조건의 행만&lt;/b&gt; 골라서 지워야 할 때 사용한다. 여러 파티션에 걸쳐있는 특정 조건의 데이터를 지워야 하거나, 파티션 단위로 끊기 어려운 경우다. 단, 느리다는 점을 감수해야 한다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;-- 특정 사용자의 이벤트 데이터만 삭제
ALTER TABLE local.user_events_local ON CLUSTER my_cluster
DELETE WHERE user_id = 12345
SETTINGS mutations_sync = 2;&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;code&gt;DELETE FROM table WHERE ...&lt;/code&gt; 문법은 클러스터 환경에서 쓸 수 없다. &lt;code&gt;ON CLUSTER&lt;/code&gt;를 지원하지 않기 때문에 단일 노드에만 적용된다. 클러스터 환경에서는 반드시 &lt;code&gt;ALTER TABLE ... DELETE ON CLUSTER&lt;/code&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;특정 날짜(파티션) 데이터를 통째로 재처리해야 한다&lt;/b&gt; &amp;rarr; &lt;code&gt;DROP PARTITION&lt;/code&gt;. 데이터 양에 상관없이 빠르다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;파티션 경계와 무관하게 특정 조건의 행만 골라서 지워야 한다&lt;/b&gt; &amp;rarr; &lt;code&gt;ALTER DELETE ON CLUSTER&lt;/code&gt;. 느린 건 감수해야 한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;소량 row 삭제가 잦다&lt;/b&gt; &amp;rarr; ClickHouse 자체가 맞지 않는다.&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;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;ClickHouse에서 데이터를 지우는 건 MySQL처럼 가볍지 않다. 특히 클러스터 환경에서는 &lt;code&gt;DELETE FROM&lt;/code&gt; 같은 편의 문법도 쓸 수 없다. 그래서 처음부터 &quot;잘못 들어오면 어떻게 고칠 것인가&quot;를 설계 단계에서 함께 고려하는 게 좋다.&lt;/p&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;code&gt;DROP PARTITION&lt;/code&gt;으로 빠르게 처리할 수 있다. 반대로 파티션 설계가 잘못되면 매번 느린 &lt;code&gt;ALTER DELETE&lt;/code&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;style6&quot; /&gt;
&lt;h1&gt;참고&lt;/h1&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://clickhouse.com/blog/handling-updates-and-deletes-in-clickhouse&quot;&gt;https://clickhouse.com/blog/handling-updates-and-deletes-in-clickhouse&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;figure id=&quot;og_1774965279137&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;Handling Updates and Deletes in ClickHouse&quot; data-og-description=&quot;With the recent addition of Lightweight Deletes, read about the latest best practices for handling updates and deletes in ClickHouse.&quot; data-og-host=&quot;clickhouse.com&quot; data-og-source-url=&quot;https://clickhouse.com/blog/handling-updates-and-deletes-in-clickhouse&quot; data-og-url=&quot;https://clickhouse.com/blog/handling-updates-and-deletes-in-clickhouse&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bRt8Ks/dJMb81GXquB/NxV8DJOduj7vYeXCz5xHJ1/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/CmHFu/dJMb83Si0j2/W5eZXS6dAJKWupar5jDeZ0/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630&quot;&gt;&lt;a href=&quot;https://clickhouse.com/blog/handling-updates-and-deletes-in-clickhouse&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://clickhouse.com/blog/handling-updates-and-deletes-in-clickhouse&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bRt8Ks/dJMb81GXquB/NxV8DJOduj7vYeXCz5xHJ1/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630,https://scrap.kakaocdn.net/dn/CmHFu/dJMb83Si0j2/W5eZXS6dAJKWupar5jDeZ0/img.png?width=1200&amp;amp;height=630&amp;amp;face=0_0_1200_630');&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;Handling Updates and Deletes in ClickHouse&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;With the recent addition of Lightweight Deletes, read about the latest best practices for handling updates and deletes in ClickHouse.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;clickhouse.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;</description>
      <category>Backend/Database</category>
      <author>giraffe_</author>
      <guid isPermaLink="true">https://programmingiraffe.tistory.com/202</guid>
      <comments>https://programmingiraffe.tistory.com/202#entry202comment</comments>
      <pubDate>Tue, 31 Mar 2026 22:54:45 +0900</pubDate>
    </item>
  </channel>
</rss>