<?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, 13 May 2026 08:14:01 +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] 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 data-ke-size=&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>
    <item>
      <title>[ClickHouse] 클러스터 구조 - Shard, Replica, Distributed 테이블</title>
      <link>https://programmingiraffe.tistory.com/201</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이나 MariaDB에서는 그냥 테이블 만들고 쿼리 날리면 됐는데, ClickHouse는 처음부터 &quot;Shard&quot;, &quot;Replica&quot;, &quot;Distributed 테이블&quot;, &quot;ZooKeeper&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;이 글에서는 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;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는 왜 클러스터 구조를 쓸까?&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;630&quot; data-origin-height=&quot;258&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/FOWmk/dJMcacbr4qC/JHaQXbNuKsuBAgABdjz3p0/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/FOWmk/dJMcacbr4qC/JHaQXbNuKsuBAgABdjz3p0/img.gif&quot; data-alt=&quot;https://clickhouse.com/docs/faq/general/columnar-database&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/FOWmk/dJMcacbr4qC/JHaQXbNuKsuBAgABdjz3p0/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/FOWmk/dJMcacbr4qC/JHaQXbNuKsuBAgABdjz3p0/img.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;630&quot; height=&quot;258&quot; data-origin-width=&quot;630&quot; data-origin-height=&quot;258&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;https://clickhouse.com/docs/faq/general/columnar-database&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;대용량 분석 쿼리&lt;/b&gt;에 최적화된 컬럼형 DB다. 수억 건의 데이터를 빠르게 집계하는 게 주 목적이다 보니, 데이터를 여러 서버에 나눠 저장하고 병렬로 처리하는 구조가 기본이 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;이때 등장하는 두 가지 핵심 개념이 &lt;b&gt;Shard&lt;/b&gt;와 &lt;b&gt;Replica&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;Shard - 데이터를 나눠 갖는다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;Shard는 &lt;b&gt;데이터를 수평으로 분할&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;3588&quot; data-origin-height=&quot;2300&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Fnlzv/dJMcabQ8xLV/qFjitJdSmkbHkIOKW95U51/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Fnlzv/dJMcabQ8xLV/qFjitJdSmkbHkIOKW95U51/img.png&quot; data-alt=&quot;https://clickhouse.com/docs/ko/assets/images/shards_04-2c51656ae086b256719302d6cf54039c.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Fnlzv/dJMcabQ8xLV/qFjitJdSmkbHkIOKW95U51/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FFnlzv%2FdJMcabQ8xLV%2FqFjitJdSmkbHkIOKW95U51%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;3588&quot; height=&quot;2300&quot; data-origin-width=&quot;3588&quot; data-origin-height=&quot;2300&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;https://clickhouse.com/docs/ko/assets/images/shards_04-2c51656ae086b256719302d6cf54039c.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;예를 들어 1억 건의 데이터가 있을 때, Shard 1에 5천만 건, Shard 2에 5천만 건을 나눠 저장한다. 쿼리를 날리면 두 Shard가 동시에 각자의 데이터를 처리하고 결과를 합산한다. 서버가 2대면 처리 속도가 2배에 가까워진다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;전체 데이터 1억 건
├── Shard 1 (서버 A): 5천만 건
└── Shard 2 (서버 B): 5천만 건&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;Shard는 &quot;성능과 용량&quot;을 위한 개념이다.&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;Replica - 똑같은 데이터를 복제한다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;Replica는 &lt;b&gt;같은 Shard의 데이터를 다른 서버에 복제&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;2980&quot; data-origin-height=&quot;2158&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bC58LN/dJMcabwPAqn/KIDusKiGyHZHsjglvE9iTK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bC58LN/dJMcabwPAqn/KIDusKiGyHZHsjglvE9iTK/img.png&quot; data-alt=&quot;https://clickhouse.com/docs/ko/assets/images/shards_replicas_01-5c3bbfd0e4c3e23e8df544d2ad3d83d8.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bC58LN/dJMcabwPAqn/KIDusKiGyHZHsjglvE9iTK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbC58LN%2FdJMcabwPAqn%2FKIDusKiGyHZHsjglvE9iTK%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;2980&quot; height=&quot;2158&quot; data-origin-width=&quot;2980&quot; data-origin-height=&quot;2158&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;https://clickhouse.com/docs/ko/assets/images/shards_replicas_01-5c3bbfd0e4c3e23e8df544d2ad3d83d8.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;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;Shard 1
├── 서버 A (Primary)  - 5천만 건
└── 서버 B (Replica)  - 5천만 건 (A의 복제본)&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;Replica는 &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;Shard가 데이터를 나누는 것이라면, Replica는 데이터를 복사하는 것이다. 둘은 독립적인 개념이지만 함께 쓰인다.&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;ZooKeeper는 무슨 역할을 하나?&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;683&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/m6a7n/dJMcadnRlUh/G7dv4pGNoR5CsJz5XihGR0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/m6a7n/dJMcadnRlUh/G7dv4pGNoR5CsJz5XihGR0/img.png&quot; data-alt=&quot;https://clickhouse.com/docs/assets/ideal-img/both.ab6f28d.1024.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/m6a7n/dJMcadnRlUh/G7dv4pGNoR5CsJz5XihGR0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fm6a7n%2FdJMcadnRlUh%2FG7dv4pGNoR5CsJz5XihGR0%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;1024&quot; height=&quot;683&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;683&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;https://clickhouse.com/docs/assets/ideal-img/both.ab6f28d.1024.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;Replica 간 동기화를 조율하는 &lt;b&gt;코디네이터&lt;/b&gt;다. Primary에 데이터가 쓰이면 ZooKeeper를 통해 Replica에 복제 작업이 트리거된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클러스터 DDL을 실행할 때도 ZooKeeper가 각 노드에 작업을 배포한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&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;로컬 테이블 vs Distributed 테이블&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;여기서 헷갈리기 시작한다. ClickHouse에는 같은 이름처럼 보이는 테이블이 두 벌 존재할 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3584&quot; data-origin-height=&quot;1556&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/biUwcw/dJMcahjyViu/ge7VsR6amEcAJbnHs7OHuk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/biUwcw/dJMcahjyViu/ge7VsR6amEcAJbnHs7OHuk/img.png&quot; data-alt=&quot;https://clickhouse.com/docs/ko/assets/images/shards_03-c0314e996d7af8b2af4fa2ea1bb7baad.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/biUwcw/dJMcahjyViu/ge7VsR6amEcAJbnHs7OHuk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbiUwcw%2FdJMcahjyViu%2Fge7VsR6amEcAJbnHs7OHuk%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;3584&quot; height=&quot;1556&quot; data-origin-width=&quot;3584&quot; data-origin-height=&quot;1556&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;https://clickhouse.com/docs/ko/assets/images/shards_03-c0314e996d7af8b2af4fa2ea1bb7baad.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;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;로컬 테이블 (ReplicatedMergeTree)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;각 서버(노드)에 실제로 데이터를 저장하는 테이블이다. &lt;code&gt;ReplicatedMergeTree&lt;/code&gt; 엔진을 사용하며, ZooKeeper를 통해 같은 Shard 내 Replica끼리 자동으로 동기화된다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;-- local 스키마에 생성 (각 노드에 물리적으로 존재)
CREATE TABLE local.user_events_local (
    event_date  Date,
    user_id     UInt64,
    event_type  String,
    created_at  DateTime
) ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/user_events', '{replica}')
PARTITION BY toYYYYMMDD(event_date)
ORDER BY (event_date, user_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;h3 data-ke-size=&quot;size23&quot;&gt;Distributed 테이블&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;데이터를 직접 저장하지 않는다. &lt;b&gt;각 Shard의 로컬 테이블을 가리키는 라우터&lt;/b&gt; 역할만 한다. 애플리케이션은 이 테이블에만 쿼리를 날리면 되고, 내부적으로 알아서 각 Shard로 분산시켜 준다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;-- 스키마에 생성 (라우터 역할)
CREATE TABLE default.user_events
ENGINE = Distributed('my_cluster', 'local', 'user_events_local', rand());&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;rand()&lt;/code&gt;는 INSERT 시 데이터를 어느 Shard로 보낼지 결정하는 방식이다. &lt;code&gt;rand()&lt;/code&gt;를 쓰면 무작위로 분산된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Distributed 테이블 = 빈 껍데기. 데이터는 로컬 테이블에만 있다.&lt;/p&gt;
&lt;p data-ke-size=&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;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;애플리케이션
    │
    ▼
[default.user_events]  &amp;larr; Distributed 테이블 (라우터)
    │                    │
    ▼                    ▼
[Shard 1]            [Shard 2]
서버 A (Primary)     서버 C
서버 B (Replica)          │
    │       │            │
    └───────┴────────────┘
              │
          [ZooKeeper]
        (레플리카 동기화)&lt;/code&gt;&lt;/pre&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;역할&lt;/th&gt;
&lt;th&gt;키워드&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Shard&lt;/td&gt;
&lt;td&gt;데이터를 수평 분할&lt;/td&gt;
&lt;td&gt;성능, 용량&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Replica&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;실제 데이터 저장 (ReplicatedMergeTree)&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;ZooKeeper&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;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;처음에 &quot;테이블이 왜 두 개야?&quot;라는 의문에서 출발했는데, 구조를 이해하고 나면 오히려 자연스럽다. Distributed 테이블이 라우터 역할을 분리해줌으로써 애플리케이션은 클러스터 구조를 몰라도 되는 설계다.&lt;/p&gt;
&lt;p 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;을 Distributed 테이블에서 실행하는 경우인데, 이건 다음 글에서 다뤄볼 예정이다.&lt;/p&gt;</description>
      <category>Backend/Database</category>
      <author>giraffe_</author>
      <guid isPermaLink="true">https://programmingiraffe.tistory.com/201</guid>
      <comments>https://programmingiraffe.tistory.com/201#entry201comment</comments>
      <pubDate>Thu, 26 Mar 2026 23:00:51 +0900</pubDate>
    </item>
    <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;pre class=&quot;gcode&quot;&gt;&lt;code&gt;┌─────────────────────────────────────────────┐
│                  JVM Process                │
│                                             │
│  ┌─────────────────┐   ┌─────────────────┐  │
│  │   Heap 영역      │   │  Offheap 영역   │  │
│  │  (GC 관리 대상)  │   │  (GC 관리 외부) │  │
│  │                 │   │                 │  │
│  │  Java가 생성한   │   │  Ehcache가      │  │
│  │  모든 객체       │   │  직접 관리하는  │  │
│  │                 │   │  네이티브 메모리 │  │
│  └─────────────────┘   └─────────────────┘  │
└─────────────────────────────────────────────┘&lt;/code&gt;&lt;/pre&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;pre class=&quot;gcode&quot;&gt;&lt;code&gt;요청 &amp;rarr; Ehcache &amp;rarr; HashMap 객체 참조(주소) 반환 &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;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;pre class=&quot;css&quot;&gt;&lt;code&gt;[저장 시]
HashMap 객체 &amp;rarr; 직렬화 &amp;rarr; [바이트 배열] &amp;rarr; Offheap 메모리 저장

[조회 시]
Offheap 메모리 &amp;rarr; [바이트 배열] &amp;rarr; 역직렬화 &amp;rarr; 새 HashMap 객체 생성&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;캐시 히트가 일어날 때마다 역직렬화가 발생한다.&lt;/b&gt; 이 부분이 핵심이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;HashMap 객체 (JVM Heap 내부)

┌──────────────────────────────────┐
│  Object Header                   │  &amp;larr; 클래스 메타데이터 포인터
│  (class pointer, hash, lock...)  │     GC 정보, 동기화 락 등
├──────────────────────────────────┤
│  table (Entry[] 참조)            │  &amp;rarr; 다른 객체를 가리키는
│  size: 8500                      │    메모리 주소(참조)
└──────────────────────────────────┘
              &amp;darr;
    ┌─────────────────┐
    │  Entry 객체들    │
    │  key &amp;rarr; &quot;item1&quot;  │
    │  val &amp;rarr; &quot;100&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;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 &amp;mdash; 참조(포인터)는 JVM Heap 안에서만 유효하다&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;gams&quot;&gt;&lt;code&gt;JVM Heap                     Offheap (Native Memory)
┌──────────────┐             ┌──────────────────────┐
│  HashMap     │             │                      │
│  table &amp;rarr; 0x4A2F ──────────&amp;rarr;│ 0x4A2F ???           │
│              │             │ (이 주소가 여기선      │
└──────────────┘             │  아무 의미가 없음)     │
                             └──────────────────────┘&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이유 2 &amp;mdash; GC가 객체를 이동시킨다&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;GC 실행 전          GC 실행 후 (메모리 압축)
HashMap: 0x4A2F  &amp;rarr;  HashMap: 0x1B3C  (주소가 바뀜!)&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;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 &amp;mdash; 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 data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;gcode&quot;&gt;&lt;code&gt;직렬화 = 참조 그래프를 끊고 &amp;rarr; 평탄한 바이트 배열로

HashMap { &quot;item1&quot;&amp;rarr;&quot;100&quot;, &quot;item2&quot;&amp;rarr;&quot;200&quot; }
  (참조로 연결된 객체 그래프)
          &amp;darr; 직렬화
[타입정보 | &quot;item1&quot; | &quot;100&quot; | &quot;item2&quot; | &quot;200&quot; | ...]
  (자기완결적인 순수 바이트 배열)
          &amp;darr; Offheap 저장 가능&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;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;정리 &amp;mdash; 캐시 히트라도 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>Fri, 6 Mar 2026 22:49:13 +0900</pubDate>
    </item>
    <item>
      <title>[Spring/Redis] lettuce-core 버전 문제 - Caused by: io.lettuce.core.RedisCommandExecutionException: NOAUTH Authentication required.</title>
      <link>https://programmingiraffe.tistory.com/198</link>
      <description>&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;⚠️ 문제 상황&lt;/h2&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;Spring Boot에서 Gradle에 있는 Dependency의 버전을 정리하던 중에 Redis 사용을 위한 Lettuce 의존성을 수정하던 중 문제가 발생했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에는 사용하는 Lettuce 라이브러리의 버전을 다음과 같이 명시적으로 적어줬었다.&lt;/p&gt;
&lt;pre id=&quot;code_1757428019538&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;implementation 'io.lettuce:lettuce-core:6.0.0.RELEASE'&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;하지만 현재 Spring Boot 버전에 맞게 사용하도록 라이브러리의 버전을 때도록 수정해줬다.&lt;/p&gt;
&lt;pre id=&quot;code_1757427774576&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;implementation 'io.lettuce:lettuce-core'&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;빌드는 제대로 되지만 런타임 중에 에러가 발생하는 문제가 발생했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&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 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;❗️에러 메시지&lt;/h2&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Caused&amp;nbsp;by:&amp;nbsp;io.lettuce.core.RedisCommandExecutionException:&amp;nbsp;NOAUTH&amp;nbsp;Authentication&amp;nbsp;required.&lt;br /&gt;&lt;br /&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;h2 data-ke-size=&quot;size26&quot;&gt;  개발 환경&lt;/h2&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;Framework :&lt;span&gt;&amp;nbsp;&lt;/span&gt;Spring Boot&lt;span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;3.4.1&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;Language : Java 21&lt;/li&gt;
&lt;li style=&quot;list-style-type: disc;&quot;&gt;DB :&lt;span&gt; Redis ???&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  원인&lt;/h2&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;Spring Boot&lt;span style=&quot;color: #333333; text-align: left;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;3.4.1 버전의 dependency 버전들의 목록을 보면, lettuce-core `6.4.1.RELEASE` 버전을 사용하게 되어있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-09-25 오후 10.50.05.png&quot; data-origin-width=&quot;838&quot; data-origin-height=&quot;175&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b8Ex3W/btsQM7oqYVy/U0vKDfTD0vHwBKjy5DX4i0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b8Ex3W/btsQM7oqYVy/U0vKDfTD0vHwBKjy5DX4i0/img.png&quot; data-alt=&quot;spring boot 3.4.1의 dependencies&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b8Ex3W/btsQM7oqYVy/U0vKDfTD0vHwBKjy5DX4i0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb8Ex3W%2FbtsQM7oqYVy%2FU0vKDfTD0vHwBKjy5DX4i0%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;733&quot; height=&quot;175&quot; data-filename=&quot;스크린샷 2025-09-25 오후 10.50.05.png&quot; data-origin-width=&quot;838&quot; data-origin-height=&quot;175&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;spring boot 3.4.1의 dependencies&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&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-09-25 오후 10.51.38.png&quot; data-origin-width=&quot;923&quot; data-origin-height=&quot;81&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/WjKtw/btsQN8tALqm/4GKoxhnw6acFdMwl9qXjzk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/WjKtw/btsQN8tALqm/4GKoxhnw6acFdMwl9qXjzk/img.png&quot; data-alt=&quot;letter-core를 찾았다&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/WjKtw/btsQN8tALqm/4GKoxhnw6acFdMwl9qXjzk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FWjKtw%2FbtsQN8tALqm%2F4GKoxhnw6acFdMwl9qXjzk%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;803&quot; height=&quot;81&quot; data-filename=&quot;스크린샷 2025-09-25 오후 10.51.38.png&quot; data-origin-width=&quot;923&quot; data-origin-height=&quot;81&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;letter-core를 찾았다&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&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-09-25 오후 10.48.18.png&quot; data-origin-width=&quot;582&quot; data-origin-height=&quot;190&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cdCjT6/btsQNEl3aSq/EajWNK9pmCtlVlVs60RET0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cdCjT6/btsQNEl3aSq/EajWNK9pmCtlVlVs60RET0/img.png&quot; data-alt=&quot;lettuce.version 확인&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cdCjT6/btsQNEl3aSq/EajWNK9pmCtlVlVs60RET0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcdCjT6%2FbtsQNEl3aSq%2FEajWNK9pmCtlVlVs60RET0%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;582&quot; height=&quot;190&quot; data-filename=&quot;스크린샷 2025-09-25 오후 10.48.18.png&quot; data-origin-width=&quot;582&quot; data-origin-height=&quot;190&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;lettuce.version 확인&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;하지만, spring-boot 프로젝트 깃허브에 가보면, lettuce-core `6.4.1.REALSE` 버전에서 &lt;b&gt;&quot;NOAUTH Authentication required&quot;&lt;/b&gt; 에러가 있다는 이슈가 보고 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/redis/lettuce/issues/3104&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/redis/lettuce/issues/3104&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1758808508790&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;&amp;quot;NOAUTH Authentication required&amp;quot; upgrading to Lettuce 6.4.1 &amp;middot; Issue #3104 &amp;middot; redis/lettuce&quot; data-og-description=&quot;Bug Report Current Behavior A number of Spring Boot users upgrading to Spring Boot 3.4.x are reporting that the upgrade broke their redis-based app. They also reported that upgrading from Lettuce 6...&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/redis/lettuce/issues/3104&quot; data-og-url=&quot;https://github.com/redis/lettuce/issues/3104&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/b9UoOB/hyZJWbTvvV/k26EjlcTgUrtdgmjkNi3k0/img.png?width=1200&amp;amp;height=600&amp;amp;face=995_116_1053_179,https://scrap.kakaocdn.net/dn/bk1Y30/hyZJT0yPxI/xgOvpqaTVsdzkKqw3P9iL0/img.png?width=1200&amp;amp;height=600&amp;amp;face=995_116_1053_179&quot;&gt;&lt;a href=&quot;https://github.com/redis/lettuce/issues/3104&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/redis/lettuce/issues/3104&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/b9UoOB/hyZJWbTvvV/k26EjlcTgUrtdgmjkNi3k0/img.png?width=1200&amp;amp;height=600&amp;amp;face=995_116_1053_179,https://scrap.kakaocdn.net/dn/bk1Y30/hyZJT0yPxI/xgOvpqaTVsdzkKqw3P9iL0/img.png?width=1200&amp;amp;height=600&amp;amp;face=995_116_1053_179');&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;&quot;NOAUTH Authentication required&quot; upgrading to Lettuce 6.4.1 &amp;middot; Issue #3104 &amp;middot; redis/lettuce&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Bug Report Current Behavior A number of Spring Boot users upgrading to Spring Boot 3.4.x are reporting that the upgrade broke their redis-based app. They also reported that upgrading from Lettuce 6...&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  해결&lt;/h2&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2025-09-25 오후 10.57.14.png&quot; data-origin-width=&quot;934&quot; data-origin-height=&quot;633&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/W4Kit/btsQQHaleOw/pksv1PwaSGZquVzvURbkdK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/W4Kit/btsQQHaleOw/pksv1PwaSGZquVzvURbkdK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/W4Kit/btsQQHaleOw/pksv1PwaSGZquVzvURbkdK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FW4Kit%2FbtsQQHaleOw%2Fpksv1PwaSGZquVzvURbkdK%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;738&quot; height=&quot;500&quot; data-filename=&quot;스크린샷 2025-09-25 오후 10.57.14.png&quot; data-origin-width=&quot;934&quot; data-origin-height=&quot;633&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;Spring Boot maintainer 왈, `6.4.0.RELEASE`로 다운그래이드 하거나, `6.4.2.RELEASE`로 고치라고 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;첨엔 에러 메시지 복사해서 ChatGPT한테 물어봤다가, 계속 config 내 Redis 설정을 바꾸는 방법만 알려줬고 적용했는데도 안되었다.&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;h2 data-ke-size=&quot;size26&quot;&gt;  참고&lt;/h2&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/redis/lettuce/issues/3104&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/redis/lettuce/issues/3104&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1758808593511&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;&amp;quot;NOAUTH Authentication required&amp;quot; upgrading to Lettuce 6.4.1 &amp;middot; Issue #3104 &amp;middot; redis/lettuce&quot; data-og-description=&quot;Bug Report Current Behavior A number of Spring Boot users upgrading to Spring Boot 3.4.x are reporting that the upgrade broke their redis-based app. They also reported that upgrading from Lettuce 6...&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/redis/lettuce/issues/3104&quot; data-og-url=&quot;https://github.com/redis/lettuce/issues/3104&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/b9UoOB/hyZJWbTvvV/k26EjlcTgUrtdgmjkNi3k0/img.png?width=1200&amp;amp;height=600&amp;amp;face=995_116_1053_179,https://scrap.kakaocdn.net/dn/bk1Y30/hyZJT0yPxI/xgOvpqaTVsdzkKqw3P9iL0/img.png?width=1200&amp;amp;height=600&amp;amp;face=995_116_1053_179&quot;&gt;&lt;a href=&quot;https://github.com/redis/lettuce/issues/3104&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/redis/lettuce/issues/3104&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/b9UoOB/hyZJWbTvvV/k26EjlcTgUrtdgmjkNi3k0/img.png?width=1200&amp;amp;height=600&amp;amp;face=995_116_1053_179,https://scrap.kakaocdn.net/dn/bk1Y30/hyZJT0yPxI/xgOvpqaTVsdzkKqw3P9iL0/img.png?width=1200&amp;amp;height=600&amp;amp;face=995_116_1053_179');&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;&quot;NOAUTH Authentication required&quot; upgrading to Lettuce 6.4.1 &amp;middot; Issue #3104 &amp;middot; redis/lettuce&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Bug Report Current Behavior A number of Spring Boot users upgrading to Spring Boot 3.4.x are reporting that the upgrade broke their redis-based app. They also reported that upgrading from Lettuce 6...&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Backend/Jave&amp;amp;Spring</category>
      <author>giraffe_</author>
      <guid isPermaLink="true">https://programmingiraffe.tistory.com/198</guid>
      <comments>https://programmingiraffe.tistory.com/198#entry198comment</comments>
      <pubDate>Thu, 25 Sep 2025 23:00:58 +0900</pubDate>
    </item>
    <item>
      <title>[Spring] CollectionUtils 활용한 null, empty 체크</title>
      <link>https://programmingiraffe.tistory.com/196</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;자바에서 프로그래밍할 때 언제나 조심해야 하는 것은 NullPointerException이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 최근 실무에서 null 체크를 꼼꼼히 안했다가 운영에서 에러가 찍히는 사태가 발생했다 &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 List가 null 인지와 비었는지를 모두 확인해줘야 할 일이 생겼는데, Spring에서 제공하는 &lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif; color: #333333; text-align: start;&quot;&gt;CollectionUtils을 쓰면 한 번에 체크할 수 있다는 것을 알게 되었다. 그래서 그 내용을 정리하고자 한다.&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;1. null vs empty&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;우선 null과 empty의 차이에 대해 정리해보자.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif; color: #333333; text-align: start;&quot;&gt;아래 그림은 null과 empty의 차이를 아주 잘 설명해주는 짤이다.&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;660&quot; data-origin-height=&quot;370&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bnbc6T/btsPO3FgIaD/djXooZK8jdm6lqh4z7HmJ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bnbc6T/btsPO3FgIaD/djXooZK8jdm6lqh4z7HmJ0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bnbc6T/btsPO3FgIaD/djXooZK8jdm6lqh4z7HmJ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbnbc6T%2FbtsPO3FgIaD%2FdjXooZK8jdm6lqh4z7HmJ0%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;660&quot; height=&quot;370&quot; data-origin-width=&quot;660&quot; data-origin-height=&quot;370&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;왼쪽 칸을 보면 휴지의 주요 내용물인 흰 부분(데이터)가 없고, 휴지를 담고 있던 휴지심(객체)는 있는 상태이다.&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;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;null&lt;/span&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;/li&gt;
&lt;li&gt;실제 객체(Objects)가 메모리에 존재하지 않음&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;248&quot; data-origin-height=&quot;115&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bDmJDN/btsPNNiDnqs/5sqMRCqsyNFO5RCFRVaiyk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bDmJDN/btsPNNiDnqs/5sqMRCqsyNFO5RCFRVaiyk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bDmJDN/btsPNNiDnqs/5sqMRCqsyNFO5RCFRVaiyk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbDmJDN%2FbtsPNNiDnqs%2F5sqMRCqsyNFO5RCFRVaiyk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;248&quot; height=&quot;115&quot; data-origin-width=&quot;248&quot; data-origin-height=&quot;115&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;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;String str = null;
System.out.println(str.length());  // NullPointerException 발생&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;List&amp;lt;String&amp;gt; list = null;
System.out.println(list.size()); // NullPointerException 발생
System.out.println(list.isEmpty()); // NullPointerException 발생&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 alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;656&quot; data-origin-height=&quot;282&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bq5nwr/btsPO2zCrdW/k40tnSh9Tp8QcrAWhLkNBK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bq5nwr/btsPO2zCrdW/k40tnSh9Tp8QcrAWhLkNBK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bq5nwr/btsPO2zCrdW/k40tnSh9Tp8QcrAWhLkNBK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbq5nwr%2FbtsPO2zCrdW%2Fk40tnSh9Tp8QcrAWhLkNBK%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;656&quot; height=&quot;282&quot; data-origin-width=&quot;656&quot; data-origin-height=&quot;282&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;null 상태에서 메서드를 호출하면 `NullPointerException`이 발생한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;empty&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;객체(Objects)는 존재하지만, 그 내용이 비어 있는 상태&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;305&quot; data-origin-height=&quot;124&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/SPApW/btsPOGp3A0k/2P35KFg3kqsiu60WU42EZ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/SPApW/btsPOGp3A0k/2P35KFg3kqsiu60WU42EZ0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/SPApW/btsPOGp3A0k/2P35KFg3kqsiu60WU42EZ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FSPApW%2FbtsPOGp3A0k%2F2P35KFg3kqsiu60WU42EZ0%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;305&quot; height=&quot;124&quot; data-origin-width=&quot;305&quot; data-origin-height=&quot;124&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;pre class=&quot;processing&quot;&gt;&lt;code&gt;String str = &quot;&quot;;
System.out.println(str.length());  // 0
&lt;/code&gt;&lt;/pre&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;List&amp;lt;String&amp;gt; list = new ArrayList&amp;lt;&amp;gt;();
System.out.println(list.size()); // 0
System.out.println(list.isEmpty()); // 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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;2. CollectionUtils&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif; color: #333333; text-align: left;&quot;&gt;예외 상황 방어를 위해 null 인 경우와 empty인 경우 둘 다 검사해야 하는 경우가 생기게 된다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래와 같이 많이 쓰게 될 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&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;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;AS-IS&lt;/span&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;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;null 인 경우와 empty인 경우 둘 다 검사해야 한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;null에 대해 .isEmpty()를 바로 호출하면 예외가 발생하므로, 아래처럼 순서를 주의해야 한다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;if (list != null &amp;amp;&amp;amp; !list.isEmpty()) {
    ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;이걸 한 번에 처리할 수 있는 것이 있다. springframework에서 제공하는 CollectionUtils 클래스이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;List, Set, Map 과 같은 Collection 자료 구조에 유용한 메소드들을 제공한다.&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;* 이미 Spring 프로젝트를 사용하고 있다면 별도로 추가할 필요가 없다.&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;import org.springframework.util.CollectionUtils;&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;figure id=&quot;og_1754880645425&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;CollectionUtils&quot; data-og-description=&quot;Check whether the given Collection contains the given element instance. Enforces the given instance to be present, rather than returning true for an equal element as well.&quot; data-og-host=&quot;docs.spring.io&quot; data-og-source-url=&quot;https://docs.spring.io/spring-framework/docs/3.2.4.RELEASE_to_4.0.0.RC2/Spring%20Framework%203.2.4.RELEASE/org/springframework/util/CollectionUtils.html&quot; data-og-url=&quot;https://docs.spring.io/spring-framework/docs/3.2.4.RELEASE_to_4.0.0.RC2/Spring%20Framework%203.2.4.RELEASE/org/springframework/util/CollectionUtils.html&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://docs.spring.io/spring-framework/docs/3.2.4.RELEASE_to_4.0.0.RC2/Spring%20Framework%203.2.4.RELEASE/org/springframework/util/CollectionUtils.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://docs.spring.io/spring-framework/docs/3.2.4.RELEASE_to_4.0.0.RC2/Spring%20Framework%203.2.4.RELEASE/org/springframework/util/CollectionUtils.html&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;CollectionUtils&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Check whether the given Collection contains the given element instance. Enforces the given instance to be present, rather than returning true for an equal element as well.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;docs.spring.io&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;`isEmpty()` : null 이거나 empty인 경우 모두를 검사하여, 결과 값을 boolean으로 리턴하고 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;430&quot; data-origin-height=&quot;516&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mkEBW/btsPNPU3BgY/gwKgTRrFsu8U4sJYET5pv1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mkEBW/btsPNPU3BgY/gwKgTRrFsu8U4sJYET5pv1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mkEBW/btsPNPU3BgY/gwKgTRrFsu8U4sJYET5pv1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmkEBW%2FbtsPNPU3BgY%2FgwKgTRrFsu8U4sJYET5pv1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;430&quot; height=&quot;516&quot; data-origin-width=&quot;430&quot; data-origin-height=&quot;516&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;h3 data-ke-size=&quot;size23&quot;&gt;TO-BE&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;null 인 경우와 empty인 경우를 한 번에 검사할 수 있다.&lt;/li&gt;
&lt;li&gt;NullPointerException을 방지할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;java&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;List&amp;lt;String&amp;gt; list1 = null;
List&amp;lt;String&amp;gt; list2 = new ArrayList&amp;lt;&amp;gt;();

if (CollectionUtils.isEmpty(list1)) { // true
    ...
}

if (CollectionUtils.isEmpty(list2)) { // 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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&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;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif; color: #333333; text-align: start;&quot;&gt;CollectionUtils.isEmpty()`를 무지성으로 남발하면 안된다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;null만 확인하고 싶은 경우도 있기 때문에 잘 따져서 그럴 경우에는 `list != null`만 쓰는 것이 좋다.&lt;/span&gt;&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>CollectionUtils</category>
      <category>Java</category>
      <category>Spring</category>
      <category>스프링</category>
      <category>자바</category>
      <author>giraffe_</author>
      <guid isPermaLink="true">https://programmingiraffe.tistory.com/196</guid>
      <comments>https://programmingiraffe.tistory.com/196#entry196comment</comments>
      <pubDate>Mon, 11 Aug 2025 12:16:04 +0900</pubDate>
    </item>
  </channel>
</rss>