<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en-US"><generator uri="https://jekyllrb.com/" version="4.3.3">Jekyll</generator><link href="https://klise.now.sh/feed.xml" rel="self" type="application/atom+xml" /><link href="https://klise.now.sh/" rel="alternate" type="text/html" hreflang="en-US" /><updated>2026-07-26T00:31:40+09:00</updated><id>https://klise.now.sh/feed.xml</id><title type="html">늘로그</title><subtitle>항상 건강하세요 여러분 &lt;a href=&quot;https://github.com/le2sky&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;@github&lt;/a&gt;</subtitle><author><name>Haneul Lee</name><email>leehaneul990623@gmail.com</email></author><entry><title type="html">Spring Batch Job 수행 시간 개선</title><link href="https://klise.now.sh/spring-batch-job-performance/" rel="alternate" type="text/html" title="Spring Batch Job 수행 시간 개선" /><published>2026-04-02T00:00:00+09:00</published><updated>2026-04-02T00:00:00+09:00</updated><id>https://klise.now.sh/spring-batch-job-performance</id><content type="html" xml:base="https://klise.now.sh/spring-batch-job-performance/"><![CDATA[<h2 id="스프링-배치-job-수행-시간-개선---목표편">스프링 배치 Job 수행 시간 개선 - 목표편</h2>

<p>스프링 배치 마이그레이션을 수행한 뒤에 실행해 봤는데, 41분 정도의 시간이 소요됐다. 실행 구성은 다음과 같았다.</p>

<ul>
  <li>JVM 환경 : Amazon Corretto 17.0.4, -Xms1024m, -Xmx1024m, +UseG1GC, ActiveProcessorCount = 1</li>
  <li>외부 의존성(docker) : MySQL, MailHog, MailProxy(TPS = 100)</li>
  <li>발송 대상 건수 : 16,087건</li>
</ul>

<p>이 <strong>수행 시간을 줄여야 하는지에 대해서 사용자와 운영 관점에서 고민</strong>했고, 필요하다고 판단했다.</p>

<ul>
  <li><strong>사용자 관점</strong> : 메일 발송을 한다고 사용자와 약속한 시각이 7시기 때문에 최대한 지키는 게 옳다고 판단했다. 단, 과거 사용자 인터뷰에서 크게 중요치 않다는 의견이 있었기 때문에 극단적인 개선은 불필요하다고 생각했다.</li>
  <li><strong>운영 관점</strong> : 실행 시간이 적을수록 서버 비용이 줄어든다는 이점이 존재했다.<sup id="job-cost"><a href="#job-cost-ref">[1]</a></sup></li>
</ul>

<p>실행 시간은 AWS Lambda의 실행 시간 제한을 고려해 상한(15분)을 설정했고, SES TPS 100이 할당된 상황에서 처리 속도와 안정성 간의 trade-off를 검토해 하한(5분 21초)을 설정했다.</p>

<table>
  <thead>
    <tr>
      <th style="text-align: center">속도</th>
      <th style="text-align: center">시간</th>
      <th style="text-align: center">이유</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: center">상한</td>
      <td style="text-align: center">15분</td>
      <td style="text-align: center">AWS Lambda 실행 시간 제한을 고려</td>
    </tr>
    <tr>
      <td style="text-align: center">하한</td>
      <td style="text-align: center">5분 12초</td>
      <td style="text-align: center">처리율 제한 에러 예방 및 메일 차단 가능성 낮추기 위함</td>
    </tr>
  </tbody>
</table>

<ul>
  <li><strong>trade-off</strong> : 속도를 높일수록 실행 시간이 단축되어 서버 비용 감소 효과가 있다. 하지만, 메일 수신 서버에서 차단할 가능성이 증가한다.</li>
  <li><strong>상한</strong> : AWS Lambda 선택지를 살리기 위해 실행 제한 15분을 상한으로 설정했다.</li>
  <li><strong>하한</strong> : AWS SES TPS를 100을 할당받은 상황에서 전체를 전부 사용할지, 절반만 사용할지를 결정<sup id="lower-bound"><a href="#lower-bound-ref">[2]</a></sup>할 수 있었다. 절반만 사용하면, 토큰 버킷 알고리즘의 특정 초 경계에서 발생하는 버스트가 최대 TPS를 넘기지 않기 때문에 처리율 제한 에러 예방에도 효과적이다. (안정성 + 처리율 제한 에러 예방 차원에서 50TPS만 사용하기로 결정)</li>
  <li><strong>성능 목표</strong> : 에러가 발생하지 않으면서, 5분 12초(16087건 / 50TPS) ~ 15분 사이의 수행 속도를 목표로 결정했다.</li>
</ul>

<h2 id="스프링-배치-job-수행-시간-개선---개선편">스프링 배치 Job 수행 시간 개선 - 개선편</h2>

<p>접근 방식은 <strong>단일 스레드로 할 수 있는 개선을 수행하고, 그럼에도 성능 목표를 달성하기 어려운 경우에 멀티 스레딩을 도입하기로 결정</strong>했다. 이를 결정하기에는 다음과 같은 시행착오가 존재했다.</p>

<p><img src="img/spring-batch-single-thread-plan.png" alt="단일 스레드 우선 개선 접근" /></p>

<ul>
  <li>처음에는 무작정 로컬 파티셔닝을 사용한 멀티 스레드 방식을 도입했고, wait time을 기준으로 적정 스레드 풀 크기를 계산하며 여러 차례 실험했다.</li>
  <li>하지만 적정 스레드 풀 크기는 wait time에 따라 계속 달라지며, 이후 다른 최적화를 적용할 때마다 다시 조정이 필요했다. 이 경험을 통해 단일 스레드 수준에서 최적화를 수행하고, 필요한 경우에만 멀티 스레딩을 도입하는 것이 더 안정적인 접근 방식이라고 판단했다.</li>
</ul>

<p>처음 수행한 것은 <strong>Writer(일간 메일 생성, 주간 메일 생성, 메일 전송)의 Item 별로 수행되는 쿼리 요청을 청크 단위로 DB에 전송하여 수행 시간을 개선</strong><sup id="writer-query"><a href="#writer-query-ref">[3]</a></sup>했다. 대표적으로 일간 메일을 생성하는 DailyMailSendWriter는 사용자가 받은 질문 내역(SubscribeQuestion)을 저장하고, 메일 발송을 위한 내역(ForwardLog)을 저장한다.</p>

<p>이때, 과거에 받은 질문 내역이라면 해당 내역을 제거하고 새로 생성한다는 요구 사항이 존재했다. 이 요구 사항을 달성하기 위해서, 청크의 Item 별로 총 4번의 쿼리가 발생하고 있었다. (insert 2회, delete 1회, select 1회) 이 비효율을 제거하기 위해서 청크 단위로 처리하도록 개선했다.</p>

<p><img src="img/spring-batch-writer-query-batch.png" alt="Writer 쿼리 청크 단위 처리 코드" /></p>

<p>실제 Writer에서는 청크 전체를 <code class="language-plaintext highlighter-rouge">DailyMailPayloads</code>로 묶은 뒤, 기존 이력 삭제와 신규 이력/전송 로그 저장을 배치 단위로 수행한다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Component</span>
<span class="nd">@RequiredArgsConstructor</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">DailyMailSendWriter</span> <span class="kd">implements</span> <span class="nc">ItemWriter</span><span class="o">&lt;</span><span class="nc">AbstractMailPayload</span><span class="o">&gt;</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">SubscribeQuestionDao</span> <span class="n">subscribeQuestionDao</span><span class="o">;</span>
    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">ForwardDao</span> <span class="n">forwardDao</span><span class="o">;</span>

    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">write</span><span class="o">(</span><span class="nc">Chunk</span><span class="o">&lt;?</span> <span class="kd">extends</span> <span class="nc">AbstractMailPayload</span><span class="o">&gt;</span> <span class="n">chunk</span><span class="o">)</span> <span class="o">{</span>
        <span class="nc">DailyMailPayloads</span> <span class="n">dailyMailPayloads</span> <span class="o">=</span> <span class="nc">DailyMailPayloads</span><span class="o">.</span><span class="na">withChunk</span><span class="o">(</span><span class="n">chunk</span><span class="o">);</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">dailyMailPayloads</span><span class="o">.</span><span class="na">isEmpty</span><span class="o">())</span> <span class="o">{</span>
            <span class="k">return</span><span class="o">;</span>
        <span class="o">}</span>

        <span class="n">rollingHistory</span><span class="o">(</span><span class="n">dailyMailPayloads</span><span class="o">);</span>
        <span class="n">saveSendLogs</span><span class="o">(</span><span class="n">dailyMailPayloads</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="kd">private</span> <span class="kt">void</span> <span class="nf">removeAlreadySaved</span><span class="o">(</span><span class="nc">DailyMailPayloads</span> <span class="n">payloads</span><span class="o">)</span> <span class="o">{</span>
        <span class="nc">List</span><span class="o">&lt;</span><span class="nc">SubscribeQuestionKey</span><span class="o">&gt;</span> <span class="n">keys</span> <span class="o">=</span> <span class="n">payloads</span><span class="o">.</span><span class="na">getSubscribeQuestionKeys</span><span class="o">();</span>
        <span class="nc">List</span><span class="o">&lt;</span><span class="nc">Long</span><span class="o">&gt;</span> <span class="n">removeTargetIds</span> <span class="o">=</span> <span class="n">subscribeQuestionDao</span><span class="o">.</span><span class="na">findIdsByKeys</span><span class="o">(</span><span class="n">keys</span><span class="o">);</span>

        <span class="n">subscribeQuestionDao</span><span class="o">.</span><span class="na">deleteByIds</span><span class="o">(</span><span class="n">removeTargetIds</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="kd">private</span> <span class="kt">void</span> <span class="nf">saveSubscribeQuestions</span><span class="o">(</span><span class="nc">DailyMailPayloads</span> <span class="n">payloads</span><span class="o">)</span> <span class="o">{</span>
        <span class="nc">List</span><span class="o">&lt;</span><span class="nc">SubscribeQuestion</span><span class="o">&gt;</span> <span class="n">subscribeQuestions</span> <span class="o">=</span> <span class="n">payloads</span><span class="o">.</span><span class="na">toSubscribeQuestions</span><span class="o">();</span>

        <span class="n">subscribeQuestionDao</span><span class="o">.</span><span class="na">batchInsert</span><span class="o">(</span><span class="n">subscribeQuestions</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="kd">private</span> <span class="kt">void</span> <span class="nf">saveSendLogs</span><span class="o">(</span><span class="nc">DailyMailPayloads</span> <span class="n">payloads</span><span class="o">)</span> <span class="o">{</span>
        <span class="nc">List</span><span class="o">&lt;</span><span class="nc">ForwardLog</span><span class="o">&gt;</span> <span class="n">logs</span> <span class="o">=</span> <span class="n">payloads</span><span class="o">.</span><span class="na">toForwardLogs</span><span class="o">();</span>

        <span class="n">forwardDao</span><span class="o">.</span><span class="na">batchInsert</span><span class="o">(</span><span class="n">logs</span><span class="o">);</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p><strong>Writer DB I/O 개선으로 Writer 간 소요 시간을 191초에서 24초로 개선</strong>할 수 있었다. 그리고, 그다음은 Job에서 가장 많은 시간이 소요되는 메일 발송 로직이다. 해당 로직은 JavaMailSender.send에서 수행되며, 2,275초가 수행되는 가장 큰 병목 구간이었다.</p>

<table>
  <thead>
    <tr>
      <th style="text-align: center">대상</th>
      <th style="text-align: center">before</th>
      <th style="text-align: center">after</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: center">DailyMailSendWriter</td>
      <td style="text-align: center">123.4s</td>
      <td style="text-align: center">12.4s</td>
    </tr>
    <tr>
      <td style="text-align: center">WeeklyMailSendWriter</td>
      <td style="text-align: center">22s</td>
      <td style="text-align: center">7s</td>
    </tr>
    <tr>
      <td style="text-align: center">ForwardWriter</td>
      <td style="text-align: center">46.2s</td>
      <td style="text-align: center">4.3s</td>
    </tr>
  </tbody>
</table>

<p>플레임그래프의 호출 트리를 탐색하다 스프링 부트 메일 스타터의 JavaMailSenderImpl의 <strong>Transport 객체를 연결하는 데만 1,921초가 수행</strong><sup id="transport"><a href="#transport-ref">[4]</a></sup>되는 것을 파악했다. 메일을 전송할 때마다 연결을 맺는지 실제로 확인하기 위해서 SMTP 패킷을 확인한 결과, 매 TCP 스트림마다 메일을 전송하고 연결을 종료<sup id="smtp-quit"><a href="#smtp-quit-ref">[5]</a></sup>하는 것을 알 수 있었다.</p>

<p><img src="img/spring-batch-mail-flamegraph.png" alt="메일 발송 플레임그래프" /></p>

<p><img src="img/spring-batch-smtp-packet.png" alt="SMTP 연결 종료 패킷" /></p>

<p><strong>배치는 짧은 시간에 다수 메일을 연속 전송하기 때문에 커넥션 재사용 효과가 크다고 판단</strong>했다. 이를 위해 관리되고 있는 SMTP Pool 라이브러리가 존재하는지 탐색했지만, 꾸준히 관리되고 있는 라이브러리가 없어 직접 Apache Commons Pool<sup id="commons-pool"><a href="#commons-pool-ref">[6]</a></sup>을 활용해 만들기로 결정했다.</p>

<p><img src="img/spring-batch-smtp-pool-structure.png" alt="SMTP 커넥션 풀 구조" /></p>

<p>AWS SES의 비활성 커넥션 유지 시간은 10초로 짧으며, API 서버는 요청 간 간격이 길기 때문에 풀링의 이점이 적다고 판단했다. 따라서, <strong>배치만 JavaMailSender Proxy 구현체를 주입하는 방식으로 풀링 적용 범위를 최소화</strong>했다.</p>

<p>배치 애플리케이션에서는 <code class="language-plaintext highlighter-rouge">BeanPostProcessor</code>를 사용해 <code class="language-plaintext highlighter-rouge">mailSender</code> 빈만 커넥션 풀 기반 프록시로 교체했다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="kd">class</span> <span class="nc">MailSenderBeanPostProcessor</span> <span class="kd">implements</span> <span class="nc">BeanPostProcessor</span><span class="o">,</span> <span class="nc">DisposableBean</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="nc">SmtpConnectionPoolProxy</span> <span class="n">pooledMailSender</span><span class="o">;</span>

    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="nc">Object</span> <span class="nf">postProcessAfterInitialization</span><span class="o">(</span><span class="nc">Object</span> <span class="n">bean</span><span class="o">,</span> <span class="nc">String</span> <span class="n">beanName</span><span class="o">)</span> <span class="kd">throws</span> <span class="nc">BeansException</span> <span class="o">{</span>
        <span class="k">if</span> <span class="o">(!</span><span class="s">"mailSender"</span><span class="o">.</span><span class="na">equals</span><span class="o">(</span><span class="n">beanName</span><span class="o">)</span> <span class="o">||</span> <span class="o">!(</span><span class="n">bean</span> <span class="k">instanceof</span> <span class="nc">JavaMailSenderImpl</span> <span class="n">delegate</span><span class="o">))</span> <span class="o">{</span>
            <span class="k">return</span> <span class="n">bean</span><span class="o">;</span>
        <span class="o">}</span>

        <span class="nc">SmtpConnectionProperties</span> <span class="n">settings</span> <span class="o">=</span> <span class="nc">SmtpConnectionProperties</span><span class="o">.</span><span class="na">from</span><span class="o">(</span><span class="n">delegate</span><span class="o">);</span>
        <span class="nc">SmtpConnectionPool</span> <span class="n">connectionPool</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">SmtpConnectionPool</span><span class="o">(</span><span class="n">settings</span><span class="o">);</span>
        <span class="nc">SmtpConnectionPoolProxy</span> <span class="n">pooledMailSender</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">SmtpConnectionPoolProxy</span><span class="o">(</span><span class="n">delegate</span><span class="o">,</span> <span class="n">connectionPool</span><span class="o">);</span>
        <span class="n">pooledMailSender</span><span class="o">.</span><span class="na">testConnection</span><span class="o">();</span>

        <span class="k">this</span><span class="o">.</span><span class="na">pooledMailSender</span> <span class="o">=</span> <span class="n">pooledMailSender</span><span class="o">;</span>

        <span class="k">return</span> <span class="k">this</span><span class="o">.</span><span class="na">pooledMailSender</span><span class="o">;</span>
    <span class="o">}</span>

    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">destroy</span><span class="o">()</span> <span class="o">{</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">pooledMailSender</span> <span class="o">!=</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span>
            <span class="n">pooledMailSender</span><span class="o">.</span><span class="na">close</span><span class="o">();</span>
        <span class="o">}</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>프록시는 메일 전송 시점마다 새로운 SMTP 연결을 만들지 않고, 풀에서 빌린 <code class="language-plaintext highlighter-rouge">Transport</code>로 메시지를 전송한다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">public</span> <span class="kd">class</span> <span class="nc">SmtpConnectionPoolProxy</span> <span class="kd">implements</span> <span class="nc">JavaMailSender</span><span class="o">,</span> <span class="nc">AutoCloseable</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">JavaMailSenderImpl</span> <span class="n">delegate</span><span class="o">;</span>
    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">SmtpConnectionPool</span> <span class="n">connectionPool</span><span class="o">;</span>

    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">send</span><span class="o">(</span><span class="nc">MimeMessage</span><span class="o">...</span> <span class="n">mimeMessages</span><span class="o">)</span> <span class="kd">throws</span> <span class="nc">MailException</span> <span class="o">{</span>
        <span class="k">try</span> <span class="o">{</span>
            <span class="n">connectionPool</span><span class="o">.</span><span class="na">doWithConnection</span><span class="o">(</span><span class="n">getSendMailCallback</span><span class="o">(</span><span class="n">mimeMessages</span><span class="o">));</span>
        <span class="o">}</span> <span class="k">catch</span> <span class="o">(</span><span class="nc">Exception</span> <span class="n">e</span><span class="o">)</span> <span class="o">{</span>
            <span class="k">throw</span> <span class="k">new</span> <span class="nf">MailSendException</span><span class="o">(</span><span class="s">"메일 전송을 실패했습니다."</span><span class="o">,</span> <span class="n">e</span><span class="o">);</span>
        <span class="o">}</span>
    <span class="o">}</span>

    <span class="kd">private</span> <span class="nc">SmtpConnectionCallback</span> <span class="nf">getSendMailCallback</span><span class="o">(</span><span class="nc">MimeMessage</span><span class="o">[]</span> <span class="n">mimeMessages</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">return</span> <span class="n">transport</span> <span class="o">-&gt;</span> <span class="o">{</span>
            <span class="k">for</span> <span class="o">(</span><span class="nc">MimeMessage</span> <span class="n">mimeMessage</span> <span class="o">:</span> <span class="n">mimeMessages</span><span class="o">)</span> <span class="o">{</span>
                <span class="n">sendMessage</span><span class="o">(</span><span class="n">transport</span><span class="o">,</span> <span class="n">mimeMessage</span><span class="o">);</span>
            <span class="o">}</span>
        <span class="o">};</span>
    <span class="o">}</span>

    <span class="kd">private</span> <span class="kt">void</span> <span class="nf">sendMessage</span><span class="o">(</span><span class="nc">Transport</span> <span class="n">transport</span><span class="o">,</span> <span class="nc">MimeMessage</span> <span class="n">mimeMessage</span><span class="o">)</span> <span class="kd">throws</span> <span class="nc">Exception</span> <span class="o">{</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">mimeMessage</span><span class="o">.</span><span class="na">getSentDate</span><span class="o">()</span> <span class="o">==</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span>
            <span class="n">mimeMessage</span><span class="o">.</span><span class="na">setSentDate</span><span class="o">(</span><span class="k">new</span> <span class="nc">Date</span><span class="o">());</span>
        <span class="o">}</span>
        <span class="nc">String</span> <span class="n">messageId</span> <span class="o">=</span> <span class="n">mimeMessage</span><span class="o">.</span><span class="na">getMessageID</span><span class="o">();</span>
        <span class="n">mimeMessage</span><span class="o">.</span><span class="na">saveChanges</span><span class="o">();</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">messageId</span> <span class="o">!=</span> <span class="kc">null</span><span class="o">)</span> <span class="o">{</span>
            <span class="n">mimeMessage</span><span class="o">.</span><span class="na">setHeader</span><span class="o">(</span><span class="s">"Message-ID"</span><span class="o">,</span> <span class="n">messageId</span><span class="o">);</span>
        <span class="o">}</span>

        <span class="nc">Address</span><span class="o">[]</span> <span class="n">recipients</span> <span class="o">=</span> <span class="n">mimeMessage</span><span class="o">.</span><span class="na">getAllRecipients</span><span class="o">();</span>
        <span class="n">transport</span><span class="o">.</span><span class="na">sendMessage</span><span class="o">(</span><span class="n">mimeMessage</span><span class="o">,</span> <span class="n">recipients</span> <span class="o">!=</span> <span class="kc">null</span> <span class="o">?</span> <span class="n">recipients</span> <span class="o">:</span> <span class="k">new</span> <span class="nc">Address</span><span class="o">[</span><span class="mi">0</span><span class="o">]);</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>연결이 종료된 커넥션 사용<sup id="evictor"><a href="#evictor-ref">[7]</a></sup>으로 인한 메일 발송 실패를 고려해 Spring Retry 기반 재시도 백오프 로직을 추가했고, 해당 커넥션은 풀에서 제거하도록 구현해 연결 종료 커넥션 사용 리스크를 완화했다.</p>

<p>커넥션 풀링을 도입한 이후, <strong>2,275초가 수행되는 메일 전송 구간을 180초로 개선</strong>할 수 있었다.</p>

<p><img src="img/spring-batch-smtp-pool-result.png" alt="SMTP 커넥션 풀 적용 결과" /></p>

<p>마지막으로 사용자의 다음 질문을 결정하는 데 사용하는 <strong>시퀀스 컬럼을 업데이트하는 Tasklet에서 사용하는 쿼리의 소요 시간이 프로덕션 기준으로 36초 수행</strong>되고 있었다.</p>

<p>오래 수행되는 update 쿼리는 배치 실행 시간에 직접적인 영향을 주며, DB 레벨에서 CPU, I/O, 버퍼와 같은 자원을 장시간 점유하기 때문에 다른 기능에 간접적인 성능 영향을 줄 수 있다. 특히 해당 쿼리가 수행되는 시점에 사용자의 update 요청이 지연될 가능성도 있다고 판단했다.</p>

<ul>
  <li><strong>업데이트 대상 건(subscribe)</strong> : 1.6만 건</li>
  <li><strong>업데이트 대상 관련 테이블 건(subscribe_question)</strong> : 250만 건</li>
</ul>

<p>해당 쿼리는 <code class="language-plaintext highlighter-rouge">ChangeSequenceTasklet</code>에서 배치 마지막 단계에 수행한다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@StepScope</span>
<span class="nd">@Component</span>
<span class="nd">@RequiredArgsConstructor</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">ChangeSequenceTasklet</span> <span class="kd">implements</span> <span class="nc">Tasklet</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">SubscribeQuestionDao</span> <span class="n">subscribeQuestionDao</span><span class="o">;</span>

    <span class="nd">@Value</span><span class="o">(</span><span class="s">"#{jobParameters['datetime']}"</span><span class="o">)</span>
    <span class="kd">private</span> <span class="nc">LocalDateTime</span> <span class="n">dateTime</span><span class="o">;</span>

    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="nc">RepeatStatus</span> <span class="nf">execute</span><span class="o">(</span><span class="nc">StepContribution</span> <span class="n">contribution</span><span class="o">,</span> <span class="nc">ChunkContext</span> <span class="n">chunkContext</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">try</span> <span class="o">{</span>
            <span class="n">subscribeQuestionDao</span><span class="o">.</span><span class="na">increaseNextQuestionSequence</span><span class="o">(</span><span class="n">dateTime</span><span class="o">);</span>
        <span class="o">}</span> <span class="k">catch</span> <span class="o">(</span><span class="nc">Exception</span> <span class="n">e</span><span class="o">)</span> <span class="o">{</span>
            <span class="n">log</span><span class="o">.</span><span class="na">error</span><span class="o">(</span><span class="s">"구독자 시퀀스 증가 실패 baseDatetime = {}"</span><span class="o">,</span> <span class="n">dateTime</span><span class="o">,</span> <span class="n">e</span><span class="o">);</span>
        <span class="o">}</span>

        <span class="k">return</span> <span class="nc">RepeatStatus</span><span class="o">.</span><span class="na">FINISHED</span><span class="o">;</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>우선 해당 쿼리를 개선하기 위해 select 형태로 변경하고, 병목 부분의 실행 계획을 확인했다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">UPDATE</span> <span class="n">subscribe</span> <span class="k">AS</span> <span class="n">s</span>
<span class="k">SET</span> <span class="n">s</span><span class="p">.</span><span class="n">next_question_sequence</span> <span class="o">=</span> <span class="n">s</span><span class="p">.</span><span class="n">next_question_sequence</span> <span class="o">+</span> <span class="p">(</span>
    <span class="k">SELECT</span> <span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span>
    <span class="k">FROM</span> <span class="n">subscribe_question</span> <span class="k">AS</span> <span class="n">sq</span>
    <span class="k">WHERE</span> <span class="n">sq</span><span class="p">.</span><span class="n">subscribe_id</span> <span class="o">=</span> <span class="n">s</span><span class="p">.</span><span class="n">id</span>
      <span class="k">AND</span> <span class="n">sq</span><span class="p">.</span><span class="n">created_at</span> <span class="o">&gt;=</span> <span class="p">:</span><span class="n">baseDatetime</span>
<span class="p">)</span>
<span class="k">WHERE</span> <span class="n">s</span><span class="p">.</span><span class="n">deleted_at</span> <span class="k">IS</span> <span class="k">NULL</span><span class="p">;</span>
</code></pre></div></div>

<ul>
  <li>해당 쿼리는 상관 서브 쿼리 구조로 outer 테이블의 row 수 만큼 실행되고 있었다.</li>
  <li>서브 쿼리 내부에서 Index look up으로 평균 78.6건의 데이터를 탐색하고 생성일을 기준으로 필터링하는 작업을 outer 테이블의 rows만큼 실행하고 있었다. (loops = 16087)</li>
</ul>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Index lookup on sq using idx_sq_subscribe_question (subscribe_id=s.id)
(cost=25.1 rows=87.5) (actual time=0.302..0.307 rows=78.6 loops=16087)
</code></pre></div></div>

<p><strong>처음에는 상관 서브 쿼리의 실행 시간을 극단적으로 단축하면, 매번 실행하는 구조라도 상식적인 응답 시간 내에 수행될 수 있을 것이라 생각</strong>했다. 이를 위해 (subscribe_id, created_at) 기준으로 복합 인덱스를 생성했다. 그리고, 커버링 인덱스를 이용한 PK 클러스터링 인덱스 접근 감소와 Index Range 스캔을 통해 실행 시간 향상을 기대했다.</p>

<p>실제로 커버링 인덱스 구조로 프로덕션 환경에서 36초 걸리던 update 쿼리가 3초로 단축됐다. 하지만, Index Range 스캔을 하지 않고, s.id를 통한 index lookup을 수행하고 있었다.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Covering index lookup on sq using idx_sq_subscribe_created_at (subscribe_id=s.id)
(cost=5.91 rows=135) (actual time=0.0202..0.0341 rows=138 loops=16087)
</code></pre></div></div>

<p>이 원인을 파악하기 위해서 옵티마이저 트레이스 옵션을 사용해 고려했던 실행 계획을 확인했는데, 옵티마이저가 Range 스캔을 고려조차 하지 않았다. 이 단계에서 <strong>현재 쿼리 형태에서 더 이상의 개선은 어렵다고 판단</strong>했다. 각 쿼리 시간이 극단적으로 줄이는 것에 실패했다고 판단했기 때문이다.<sup id="range-condition"><a href="#range-condition-ref">[8]</a></sup> 이에 더 이상 매몰되지 않고, <strong>group by + join 형태로 쿼리 구조를 변경</strong>했다. 해당 쿼리는 (created_at, subscribe_id) 복합 인덱스를 추가해 Covering Index Range 스캔 형태로 <strong>프로덕션 DB 기준 평균 379.6ms에 수행</strong>됐고, 이 방법을 채택했다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">UPDATE</span> <span class="n">subscribe</span> <span class="k">AS</span> <span class="n">s</span>
<span class="k">JOIN</span> <span class="p">(</span>
    <span class="k">SELECT</span> <span class="n">sq</span><span class="p">.</span><span class="n">subscribe_id</span><span class="p">,</span> <span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span> <span class="k">AS</span> <span class="n">amount</span>
    <span class="k">FROM</span> <span class="n">subscribe_question</span> <span class="k">AS</span> <span class="n">sq</span>
    <span class="k">WHERE</span> <span class="n">sq</span><span class="p">.</span><span class="n">created_at</span> <span class="o">&gt;=</span> <span class="p">:</span><span class="n">baseDatetime</span>
    <span class="k">GROUP</span> <span class="k">BY</span> <span class="n">sq</span><span class="p">.</span><span class="n">subscribe_id</span>
<span class="p">)</span> <span class="k">AS</span> <span class="n">sub</span>
<span class="k">ON</span> <span class="n">sub</span><span class="p">.</span><span class="n">subscribe_id</span> <span class="o">=</span> <span class="n">s</span><span class="p">.</span><span class="n">id</span>
<span class="k">SET</span> <span class="n">s</span><span class="p">.</span><span class="n">next_question_sequence</span> <span class="o">=</span> <span class="n">s</span><span class="p">.</span><span class="n">next_question_sequence</span> <span class="o">+</span> <span class="n">sub</span><span class="p">.</span><span class="n">amount</span>
<span class="k">WHERE</span> <span class="n">s</span><span class="p">.</span><span class="n">deleted_at</span> <span class="k">IS</span> <span class="k">NULL</span><span class="p">;</span>
</code></pre></div></div>

<p>(최종 결과, 단일 스레드 기준) 메일 생성 스텝 1분 내외 + 전송 스텝 5분 30초 + 시퀀스 증가 Tasklet 1초 내 -&gt; <strong>6분 30초대</strong></p>

<h3 id="메모">메모</h3>

<p><small id="job-cost-ref"><sup><a href="#job-cost">[1]</a></sup> 속도가 높을수록 동시 사용자도 높아진다는 단점이 존재하지만, 대부분 vercel 레벨에 캐시된 페이지를 읽기 때문에 고려 대상이 아니었다. 외부 자원을 API와 배치가 동시에 사용하는 시간이 줄어든다는 이점도 존재하지만, 이 역시 고려 대상이 아니었다.</small></p>

<p><small id="lower-bound-ref"><sup><a href="#lower-bound">[2]</a></sup> 하한값을 계산하는 데 지나치게 매몰되기보다 의사결정 가능한 기준을 먼저 세우는 것이 중요하다고 판단했다. 이에 SES 할당량인 100TPS와, 안전 마진을 고려한 50TPS를 비교 가능한 옵션으로 두고 검토했다.</small></p>

<p><small id="writer-query-ref"><sup><a href="#writer-query">[3]</a></sup> Processor 레벨에서 DB 쿼리를 사용하는 부분도 있지만, 해당 부분은 마이그레이션하기 이전부터 Spring Cache를 사용했기 때문에 고려 대상이 아니었다.</small></p>

<p><small id="transport-ref"><sup><a href="#transport">[4]</a></sup> Transport는 커넥션을 담당하는 jakarta.mail 표준 클래스다.</small></p>

<p><small id="smtp-quit-ref"><sup><a href="#smtp-quit">[5]</a></sup> 매 TCP 스트림마다 QUIT과 221 Bye라는 SMTP 메시지가 존재했다. 이는 SMTP에서 연결을 종료하기 위해 사용된다.</small></p>

<p><small id="commons-pool-ref"><sup><a href="#commons-pool">[6]</a></sup> Apache Commons Pool은 오브젝트 풀링 구현을 제공해주는 오픈소스 라이브러리다.</small></p>

<p><small id="evictor-ref"><sup><a href="#evictor">[7]</a></sup> Evictor 스레드를 활용해 비활성 유휴 커넥션을 주기적으로 정리한다. 비활성이 아닌 커넥션은 오래 사용할 수 있지만 하나의 커넥션을 길게 유지하면, 커넥션 종료 문제가 발생할 수 있다. AWS SES SMTP 엔드포인트는 ELB 뒤에 존재하는 EC2로 구성되어 있고, 시스템이 최신 상태 및 내결함성을 유지하기 위해 주기적으로 종료되고 새 인스턴스로 교체되기 때문이다.</small></p>

<p><small id="range-condition-ref"><sup><a href="#range-condition">[8]</a></sup> 공식 문서를 확인한 결과, Range Condition에는 상수로 취급될 수 있는 값만 허용된다는 것을 파악했다. 그리고, 이 원인에 대해 subscribe_id가 외부 outer의 값에 따라 동적으로 변하는 값이기 때문인 걸로 추측했다.</small></p>]]></content><author><name>Haneul Lee</name><email>leehaneul990623@gmail.com</email></author><category term="project" /><summary type="html"><![CDATA[Spring Batch Job 수행 시간 개선]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://klise.now.sh/" /><media:content medium="image" url="https://klise.now.sh/" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">메일 발송 로직 Spring Batch로 마이그레이션</title><link href="https://klise.now.sh/spring-batch-migration/" rel="alternate" type="text/html" title="메일 발송 로직 Spring Batch로 마이그레이션" /><published>2026-04-01T00:00:00+09:00</published><updated>2026-04-01T00:00:00+09:00</updated><id>https://klise.now.sh/spring-batch-migration</id><content type="html" xml:base="https://klise.now.sh/spring-batch-migration/"><![CDATA[<h2 id="마이그레이션한-이유">마이그레이션한 이유</h2>

<p>서비스에서 가장 많이 호출되는 조회 API는 PK 기반 단건 조회와 Vercel 캐싱으로 충분한 처리량을 확보했지만, 메일 발송을 API 응답과 함께 수행하면서 <strong>스케일 조정이 어려운 구조</strong><sup id="api-scale"><a href="#api-scale-ref">[1]</a></sup>였다. 또한, 청크 기반 처리를 하지 않고 있으므로 사용자가 늘어나면 <strong>메모리 문제가 발생해 서버 장애로 이어질 가능성이 존재</strong>했다.</p>

<p>이 문제를 해결하기 위해서 직접 청크 처리 및 별도 서버 분리를 수행할 수도 있었지만, 이미 스프링 배치에서 청크 처리, 확장성, 내결함성 기능을 지원해 주고 있기 때문에 <strong>스프링 배치로 기존 전송 로직을 마이그레이션 하기로 결정</strong>했다. 이 과정에서 고민했던 부분과 중요하게 생각한 부분은 다음과 같다.</p>

<p><img src="img/spring-batch-migration-before-after.png" alt="스프링 배치 마이그레이션 전후 구조" /></p>

<p>실제 Job은 메일 생성, 메일 전송, 시퀀스 변경을 별도 Step으로 분리했다. 메일 생성 Step에서는 구독자를 읽어 메일 페이로드를 만들고, 메일 전송 Step에서는 생성된 전송 내역을 발송한다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Bean</span>
<span class="kd">public</span> <span class="nc">Job</span> <span class="nf">mailSendJob</span><span class="o">(</span>
        <span class="nc">Step</span> <span class="n">mailGenerateStep</span><span class="o">,</span>
        <span class="nc">Step</span> <span class="n">mailSendStep</span><span class="o">,</span>
        <span class="nc">Step</span> <span class="n">changeSequenceStep</span><span class="o">,</span>
        <span class="nc">JobExecutionListener</span> <span class="n">mailSendJobReportListener</span>
<span class="o">)</span> <span class="o">{</span>
    <span class="k">return</span> <span class="k">new</span> <span class="nf">JobBuilder</span><span class="o">(</span><span class="s">"mailSendJob"</span><span class="o">,</span> <span class="n">jobRepository</span><span class="o">)</span>
            <span class="o">.</span><span class="na">start</span><span class="o">(</span><span class="n">mailGenerateStep</span><span class="o">)</span>
            <span class="o">.</span><span class="na">next</span><span class="o">(</span><span class="n">mailSendStep</span><span class="o">)</span>
            <span class="o">.</span><span class="na">next</span><span class="o">(</span><span class="n">changeSequenceStep</span><span class="o">)</span>
            <span class="o">.</span><span class="na">listener</span><span class="o">(</span><span class="n">mailSendJobReportListener</span><span class="o">)</span>
            <span class="o">.</span><span class="na">build</span><span class="o">();</span>
<span class="o">}</span>

<span class="nd">@Bean</span>
<span class="kd">public</span> <span class="nc">Step</span> <span class="nf">mailGenerateStep</span><span class="o">(</span>
        <span class="nc">JdbcPagingItemReader</span><span class="o">&lt;</span><span class="nc">Subscribe</span><span class="o">&gt;</span> <span class="n">subscribeReader</span><span class="o">,</span>
        <span class="nc">CompositeItemProcessor</span><span class="o">&lt;</span><span class="nc">Subscribe</span><span class="o">,</span> <span class="nc">AbstractMailPayload</span><span class="o">&gt;</span> <span class="n">mailSendProcessor</span><span class="o">,</span>
        <span class="nc">ClassifierCompositeItemWriter</span><span class="o">&lt;</span><span class="nc">AbstractMailPayload</span><span class="o">&gt;</span> <span class="n">mailSendWriter</span>
<span class="o">)</span> <span class="o">{</span>
    <span class="k">return</span> <span class="k">new</span> <span class="nf">StepBuilder</span><span class="o">(</span><span class="s">"mailGenerateStep"</span><span class="o">,</span> <span class="n">jobRepository</span><span class="o">)</span>
            <span class="o">.&lt;</span><span class="nc">Subscribe</span><span class="o">,</span> <span class="nc">AbstractMailPayload</span><span class="o">&gt;</span><span class="n">chunk</span><span class="o">(</span><span class="no">CHUNK_SIZE</span><span class="o">,</span> <span class="n">transactionManager</span><span class="o">)</span>
            <span class="o">.</span><span class="na">reader</span><span class="o">(</span><span class="n">subscribeReader</span><span class="o">)</span>
            <span class="o">.</span><span class="na">processor</span><span class="o">(</span><span class="n">mailSendProcessor</span><span class="o">)</span>
            <span class="o">.</span><span class="na">writer</span><span class="o">(</span><span class="n">mailSendWriter</span><span class="o">)</span>
            <span class="o">.</span><span class="na">build</span><span class="o">();</span>
<span class="o">}</span>

<span class="nd">@Bean</span>
<span class="kd">public</span> <span class="nc">Step</span> <span class="nf">mailSendStep</span><span class="o">(</span>
        <span class="nc">JdbcPagingItemReader</span><span class="o">&lt;</span><span class="nc">ForwardLog</span><span class="o">&gt;</span> <span class="n">mailSendReader</span><span class="o">,</span>
        <span class="nc">ForwardProcessor</span> <span class="n">forwardProcessor</span><span class="o">,</span>
        <span class="nc">ForwardWriter</span> <span class="n">forwardWriter</span>
<span class="o">)</span> <span class="o">{</span>
    <span class="k">return</span> <span class="k">new</span> <span class="nf">StepBuilder</span><span class="o">(</span><span class="s">"mailSendStep"</span><span class="o">,</span> <span class="n">jobRepository</span><span class="o">)</span>
            <span class="o">.&lt;</span><span class="nc">ForwardLog</span><span class="o">,</span> <span class="nc">ForwardLog</span><span class="o">&gt;</span><span class="n">chunk</span><span class="o">(</span><span class="no">CHUNK_SIZE</span><span class="o">,</span> <span class="n">transactionManager</span><span class="o">)</span>
            <span class="o">.</span><span class="na">reader</span><span class="o">(</span><span class="n">mailSendReader</span><span class="o">)</span>
            <span class="o">.</span><span class="na">processor</span><span class="o">(</span><span class="n">forwardProcessor</span><span class="o">)</span>
            <span class="o">.</span><span class="na">writer</span><span class="o">(</span><span class="n">forwardWriter</span><span class="o">)</span>
            <span class="o">.</span><span class="na">build</span><span class="o">();</span>
<span class="o">}</span>
</code></pre></div></div>

<h2 id="고민했던-부분">고민했던 부분</h2>

<h3 id="reader-선택">Reader 선택</h3>

<p>초기에는 쿼리가 가장 단순하고, 스냅샷 효과를 내는 <code class="language-plaintext highlighter-rouge">JpaCursorItemReader</code>를 사용했다. 하지만, 프로세스 크래시 상황에서 스냅샷 효과를 잃을뿐더러 장시간 트랜잭션으로 인한 리스크(MySQL 언두 로그 증가)를 짊어질 필요가 없다고 생각해 Keyset Pagination을 사용하는 <code class="language-plaintext highlighter-rouge">JdbcPagingItemReader</code>를 선택했다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Component</span>
<span class="nd">@RequiredArgsConstructor</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">MailSendItemReader</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">DataSource</span> <span class="n">dataSource</span><span class="o">;</span>

    <span class="kd">public</span> <span class="nc">JdbcPagingItemReader</span><span class="o">&lt;</span><span class="nc">Subscribe</span><span class="o">&gt;</span> <span class="nf">generate</span><span class="o">(</span><span class="nc">LocalDateTime</span> <span class="n">datetime</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">return</span> <span class="k">new</span> <span class="nc">JdbcPagingItemReaderBuilder</span><span class="o">&lt;</span><span class="nc">Subscribe</span><span class="o">&gt;()</span>
                <span class="o">.</span><span class="na">name</span><span class="o">(</span><span class="s">"subscribeReader"</span><span class="o">)</span>
                <span class="o">.</span><span class="na">dataSource</span><span class="o">(</span><span class="n">dataSource</span><span class="o">)</span>
                <span class="o">.</span><span class="na">pageSize</span><span class="o">(</span><span class="mi">100</span><span class="o">)</span>
                <span class="o">.</span><span class="na">selectClause</span><span class="o">(</span><span class="s">"select id, email, category, next_question_sequence, token, deleted_at, frequency"</span><span class="o">)</span>
                <span class="o">.</span><span class="na">fromClause</span><span class="o">(</span><span class="s">"from subscribe"</span><span class="o">)</span>
                <span class="o">.</span><span class="na">whereClause</span><span class="o">(</span><span class="s">"where created_at &lt;= :createdAt and deleted_at is null"</span><span class="o">)</span>
                <span class="o">.</span><span class="na">sortKeys</span><span class="o">(</span><span class="nc">Map</span><span class="o">.</span><span class="na">of</span><span class="o">(</span><span class="s">"id"</span><span class="o">,</span> <span class="nc">Order</span><span class="o">.</span><span class="na">ASCENDING</span><span class="o">))</span>
                <span class="o">.</span><span class="na">parameterValues</span><span class="o">(</span><span class="nc">Map</span><span class="o">.</span><span class="na">of</span><span class="o">(</span><span class="s">"createdAt"</span><span class="o">,</span> <span class="n">datetime</span><span class="o">))</span>
                <span class="o">.</span><span class="na">rowMapper</span><span class="o">(</span><span class="n">getSubscribeRowMapper</span><span class="o">())</span>
                <span class="o">.</span><span class="na">build</span><span class="o">();</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<h3 id="일간주간-메일-생성-분기">일간/주간 메일 생성 분기</h3>

<p>구독 주기에 따라 일간/주간 메일 생성 로직이 달라지기 때문에 <code class="language-plaintext highlighter-rouge">ClassifierCompositeItemProcessor</code>, <code class="language-plaintext highlighter-rouge">ClassifierCompositeItemWriter</code>를 사용해 분기했다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Component</span>
<span class="nd">@RequiredArgsConstructor</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">MailSendProcessorClassifier</span> <span class="kd">implements</span> <span class="nc">Classifier</span><span class="o">&lt;</span><span class="nc">Subscribe</span><span class="o">,</span> <span class="nc">ItemProcessor</span><span class="o">&lt;?,</span> <span class="o">?</span> <span class="kd">extends</span> <span class="nc">AbstractMailPayload</span><span class="o">&gt;&gt;</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">ItemProcessor</span><span class="o">&lt;</span><span class="nc">Subscribe</span><span class="o">,</span> <span class="nc">AbstractMailPayload</span><span class="o">&gt;</span> <span class="n">dailyMailSendProcessor</span><span class="o">;</span>
    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">ItemProcessor</span><span class="o">&lt;</span><span class="nc">Subscribe</span><span class="o">,</span> <span class="nc">AbstractMailPayload</span><span class="o">&gt;</span> <span class="n">weeklyMailSendProcessor</span><span class="o">;</span>

    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="nc">ItemProcessor</span><span class="o">&lt;</span><span class="nc">Subscribe</span><span class="o">,</span> <span class="o">?</span> <span class="kd">extends</span> <span class="nc">AbstractMailPayload</span><span class="o">&gt;</span> <span class="nf">classify</span><span class="o">(</span><span class="nc">Subscribe</span> <span class="n">classifiable</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">if</span> <span class="o">(</span><span class="no">DAILY</span><span class="o">.</span><span class="na">equals</span><span class="o">(</span><span class="n">classifiable</span><span class="o">.</span><span class="na">getFrequency</span><span class="o">()))</span> <span class="o">{</span>
            <span class="k">return</span> <span class="n">dailyMailSendProcessor</span><span class="o">;</span>
        <span class="o">}</span>

        <span class="k">return</span> <span class="n">weeklyMailSendProcessor</span><span class="o">;</span>
    <span class="o">}</span>
<span class="o">}</span>

<span class="nd">@Component</span>
<span class="nd">@RequiredArgsConstructor</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">MailSendWriterClassifier</span> <span class="kd">implements</span> <span class="nc">Classifier</span><span class="o">&lt;</span><span class="nc">AbstractMailPayload</span><span class="o">,</span> <span class="nc">ItemWriter</span><span class="o">&lt;?</span> <span class="kd">super</span> <span class="nc">AbstractMailPayload</span><span class="o">&gt;&gt;</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">ItemWriter</span><span class="o">&lt;</span><span class="nc">AbstractMailPayload</span><span class="o">&gt;</span> <span class="n">dailyMailSendWriter</span><span class="o">;</span>
    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">ItemWriter</span><span class="o">&lt;</span><span class="nc">AbstractMailPayload</span><span class="o">&gt;</span> <span class="n">weeklyMailSendWriter</span><span class="o">;</span>

    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="nc">ItemWriter</span><span class="o">&lt;?</span> <span class="kd">super</span> <span class="nc">AbstractMailPayload</span><span class="o">&gt;</span> <span class="nf">classify</span><span class="o">(</span><span class="nc">AbstractMailPayload</span> <span class="n">classifiable</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">classifiable</span> <span class="k">instanceof</span> <span class="nc">DailyMailPayload</span><span class="o">)</span> <span class="o">{</span>
            <span class="k">return</span> <span class="n">dailyMailSendWriter</span><span class="o">;</span>
        <span class="o">}</span>

        <span class="k">return</span> <span class="n">weeklyMailSendWriter</span><span class="o">;</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<h3 id="테스트-코드-추가">테스트 코드 추가</h3>

<p>레거시 로직을 배치로 이전하며 약 90개의 테스트를 추가해 안정성을 확보했다. (테스트 컨테이너 기반 통합 테스트 및 단위 테스트)</p>

<h3 id="at-most-once--at-least-once-전략-선택">at-most-once / at-least-once 전략 선택</h3>

<p>AWS SES 호출은 멱등하지 않아 배치 서버에서 SES로 보내는 메시지를 Exactly Once 처리하기 어려웠다. 이를 위해 at-least-once와 at-most-once 전략을 고려했다.</p>

<table>
  <thead>
    <tr>
      <th style="text-align: center">전략</th>
      <th style="text-align: center">내용</th>
      <th style="text-align: center">사용자 경험</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: center">at-least-once</td>
      <td style="text-align: center">중복 발송으로 인한 메일 발송 비용 증가</td>
      <td style="text-align: center">중복 메일 수신으로 사용자 경험 저하</td>
    </tr>
    <tr>
      <td style="text-align: center">at-most-once</td>
      <td style="text-align: center">메일 발송 비용 변동 없음</td>
      <td style="text-align: center">메일 누락으로 사용자 경험 저하</td>
    </tr>
  </tbody>
</table>

<p>at-most-once와 at-least-once는 각각 메일 누락과 중복 발송이라는 형태로 모두 사용자 경험에 부정적인 영향을 줄 수 있다. 현재는 중복 발송으로 인한 비용 증가와 처리 복잡도보다, 일부 불확정 호출을 누락으로 처리하는 편이 더 낮은 운영 비용을 만든다고 판단해 <strong>at-most-once 전략을 선택</strong>했다. 이후 실제 운영에서 발생하는 호출 불확정 케이스를 관찰하며 현재 정책을 보완할 계획이다. 아래는 at-most-once를 달성하기 위한 설계다.</p>

<p><img src="img/spring-batch-mail-state-flow.png" alt="메일 전송 상태 흐름" /></p>

<ul>
  <li>메일 전송 내역을 만드는 스텝과 메일을 전송하는 스텝을 분리했고, 전송 내역에 PENDING, PROCESSING, DONE, FAILED 등 4가지 상태를 추가해 <strong>호출 불확정 상태를 식별</strong>했다.</li>
  <li>전송 내역 청크를 처리하는 Writer에서 청크 트랜잭션과 독립된 별도의 트랜잭션을 생성해 현재 전송 내역 청크를 <strong>PROCESSING 상태로 변경하고, 메일 발송을 수행</strong>한다.</li>
  <li>메일 전송 메서드에서 Spring Retry를 통해 재시도 가능한 예외(처리율 제한 에러, SMTP 커넥션 종료 등)는 처리하되, <strong>Read Timeout과 같은 호출 불확정인 상태는 재처리하지 않고, PROCESSING 상태로 유지</strong>했다.</li>
</ul>

<p>코드 레벨에서는 Read, Processing, Writer 단계가 다음처럼 나뉜다.</p>

<h4 id="read">Read</h4>

<p>전송 Step의 Reader는 특정 시간 범위의 <code class="language-plaintext highlighter-rouge">forward_log</code>를 읽는다. 상태 필터링은 Reader가 아니라 Processor에서 수행하도록 분리했다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Component</span>
<span class="nd">@RequiredArgsConstructor</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">ForwardReader</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">DataSource</span> <span class="n">dataSource</span><span class="o">;</span>

    <span class="kd">public</span> <span class="nc">JdbcPagingItemReader</span><span class="o">&lt;</span><span class="nc">ForwardLog</span><span class="o">&gt;</span> <span class="nf">generate</span><span class="o">(</span><span class="nc">LocalDateTime</span> <span class="n">startDateTime</span><span class="o">,</span> <span class="nc">LocalDateTime</span> <span class="n">endDateTime</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">return</span> <span class="k">new</span> <span class="nc">JdbcPagingItemReaderBuilder</span><span class="o">&lt;</span><span class="nc">ForwardLog</span><span class="o">&gt;()</span>
                <span class="o">.</span><span class="na">name</span><span class="o">(</span><span class="s">"forwardLogReader"</span><span class="o">)</span>
                <span class="o">.</span><span class="na">dataSource</span><span class="o">(</span><span class="n">dataSource</span><span class="o">)</span>
                <span class="o">.</span><span class="na">pageSize</span><span class="o">(</span><span class="mi">100</span><span class="o">)</span>
                <span class="o">.</span><span class="na">selectClause</span><span class="o">(</span><span class="s">"select id, target, subject, message, status"</span><span class="o">)</span>
                <span class="o">.</span><span class="na">fromClause</span><span class="o">(</span><span class="s">"from forward_log"</span><span class="o">)</span>
                <span class="o">.</span><span class="na">whereClause</span><span class="o">(</span><span class="sh">"""
                        where created_at &gt;= :startDateTime
                          and created_at &lt; :endDateTime
                        """</span><span class="o">)</span>
                <span class="o">.</span><span class="na">sortKeys</span><span class="o">(</span><span class="nc">Map</span><span class="o">.</span><span class="na">of</span><span class="o">(</span><span class="s">"id"</span><span class="o">,</span> <span class="nc">Order</span><span class="o">.</span><span class="na">ASCENDING</span><span class="o">))</span>
                <span class="o">.</span><span class="na">parameterValues</span><span class="o">(</span><span class="nc">Map</span><span class="o">.</span><span class="na">of</span><span class="o">(</span>
                        <span class="s">"startDateTime"</span><span class="o">,</span> <span class="n">startDateTime</span><span class="o">,</span>
                        <span class="s">"endDateTime"</span><span class="o">,</span> <span class="n">endDateTime</span>
                <span class="o">))</span>
                <span class="o">.</span><span class="na">dataRowMapper</span><span class="o">(</span><span class="nc">ForwardLog</span><span class="o">.</span><span class="na">class</span><span class="o">)</span>
                <span class="o">.</span><span class="na">build</span><span class="o">();</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<h4 id="processing">Processing</h4>

<p>Processor는 재시도 가능한 상태만 다음 단계로 넘긴다. <code class="language-plaintext highlighter-rouge">PENDING</code>과 <code class="language-plaintext highlighter-rouge">FAILED</code>만 재처리 대상으로 보고, <code class="language-plaintext highlighter-rouge">PROCESSING</code>은 호출 불확정 상태로 남겨 중복 발송을 피한다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Slf4j</span>
<span class="nd">@Component</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">ForwardProcessor</span> <span class="kd">implements</span> <span class="nc">ItemProcessor</span><span class="o">&lt;</span><span class="nc">ForwardLog</span><span class="o">,</span> <span class="nc">ForwardLog</span><span class="o">&gt;</span> <span class="o">{</span>

    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="nc">ForwardLog</span> <span class="nf">process</span><span class="o">(</span><span class="nc">ForwardLog</span> <span class="n">item</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">if</span> <span class="o">(!</span><span class="n">item</span><span class="o">.</span><span class="na">isRetryable</span><span class="o">())</span> <span class="o">{</span>
            <span class="n">log</span><span class="o">.</span><span class="na">info</span><span class="o">(</span><span class="s">"메일 전송 호출을 식별할 수 없어 질문지를 전송할 수 없습니다. email = {} status = {}"</span><span class="o">,</span> <span class="n">item</span><span class="o">.</span><span class="na">getTarget</span><span class="o">(),</span> <span class="n">item</span><span class="o">.</span><span class="na">getStatus</span><span class="o">());</span>
            <span class="k">return</span> <span class="kc">null</span><span class="o">;</span>
        <span class="o">}</span>

        <span class="k">return</span> <span class="n">item</span><span class="o">;</span>
    <span class="o">}</span>
<span class="o">}</span>

<span class="kd">public</span> <span class="kd">class</span> <span class="nc">ForwardLog</span> <span class="kd">extends</span> <span class="nc">BaseEntity</span> <span class="kd">implements</span> <span class="nc">MailMessage</span> <span class="o">{</span>

    <span class="kd">public</span> <span class="kt">boolean</span> <span class="nf">isRetryable</span><span class="o">()</span> <span class="o">{</span>
        <span class="k">return</span> <span class="nc">ForwardStatus</span><span class="o">.</span><span class="na">FAILED</span><span class="o">.</span><span class="na">equals</span><span class="o">(</span><span class="n">status</span><span class="o">)</span> <span class="o">||</span> <span class="nc">ForwardStatus</span><span class="o">.</span><span class="na">PENDING</span><span class="o">.</span><span class="na">equals</span><span class="o">(</span><span class="n">status</span><span class="o">);</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<h4 id="writer">Writer</h4>

<p>Writer는 메일을 보내기 전에 별도 트랜잭션으로 상태를 <code class="language-plaintext highlighter-rouge">PROCESSING</code>으로 변경한다. 그 뒤 실제 메일 발송을 수행하고, 명확히 성공/실패로 판단된 항목만 <code class="language-plaintext highlighter-rouge">DONE</code>, <code class="language-plaintext highlighter-rouge">FAILED</code>로 갱신한다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Component</span>
<span class="nd">@RequiredArgsConstructor</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">ForwardWriter</span> <span class="kd">implements</span> <span class="nc">ItemWriter</span><span class="o">&lt;</span><span class="nc">ForwardLog</span><span class="o">&gt;</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">ForwardDao</span> <span class="n">forwardDao</span><span class="o">;</span>
    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">ForwardSender</span> <span class="n">forwardSender</span><span class="o">;</span>

    <span class="nd">@Override</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">write</span><span class="o">(</span><span class="nc">Chunk</span><span class="o">&lt;?</span> <span class="kd">extends</span> <span class="nc">ForwardLog</span><span class="o">&gt;</span> <span class="n">chunk</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">chunk</span><span class="o">.</span><span class="na">isEmpty</span><span class="o">())</span> <span class="k">return</span><span class="o">;</span>
        <span class="n">forwardDao</span><span class="o">.</span><span class="na">changeStateWithNewTx</span><span class="o">(</span><span class="n">chunk</span><span class="o">.</span><span class="na">getItems</span><span class="o">(),</span> <span class="nc">ForwardStatus</span><span class="o">.</span><span class="na">PROCESSING</span><span class="o">);</span>
        <span class="n">chunk</span><span class="o">.</span><span class="na">forEach</span><span class="o">(</span><span class="nl">forwardSender:</span><span class="o">:</span><span class="n">sendMailSync</span><span class="o">);</span>
        <span class="n">bulkWrite</span><span class="o">(</span><span class="n">chunk</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="kd">private</span> <span class="kt">void</span> <span class="nf">bulkWrite</span><span class="o">(</span><span class="nc">Chunk</span><span class="o">&lt;?</span> <span class="kd">extends</span> <span class="nc">ForwardLog</span><span class="o">&gt;</span> <span class="n">chunk</span><span class="o">)</span> <span class="o">{</span>
        <span class="nc">List</span><span class="o">&lt;</span><span class="nc">ForwardStatus</span><span class="o">&gt;</span> <span class="n">targets</span> <span class="o">=</span> <span class="nc">List</span><span class="o">.</span><span class="na">of</span><span class="o">(</span><span class="nc">ForwardStatus</span><span class="o">.</span><span class="na">DONE</span><span class="o">,</span> <span class="nc">ForwardStatus</span><span class="o">.</span><span class="na">FAILED</span><span class="o">);</span>

        <span class="n">targets</span><span class="o">.</span><span class="na">forEach</span><span class="o">(</span><span class="n">it</span> <span class="o">-&gt;</span> <span class="o">{</span>
            <span class="nc">List</span><span class="o">&lt;?</span> <span class="kd">extends</span> <span class="nc">ForwardLog</span><span class="o">&gt;</span> <span class="n">logs</span> <span class="o">=</span> <span class="n">getLogs</span><span class="o">(</span><span class="n">chunk</span><span class="o">,</span> <span class="n">it</span><span class="o">);</span>
            <span class="n">forwardDao</span><span class="o">.</span><span class="na">changeState</span><span class="o">(</span><span class="n">logs</span><span class="o">,</span> <span class="n">it</span><span class="o">);</span>
        <span class="o">});</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>호출 결과가 명확하면 <code class="language-plaintext highlighter-rouge">ForwardSender</code>가 상태를 <code class="language-plaintext highlighter-rouge">DONE</code> 또는 <code class="language-plaintext highlighter-rouge">FAILED</code>로 변경한다. 반면, 호출 성공 여부를 알 수 없는 예외는 <code class="language-plaintext highlighter-rouge">PROCESSING</code>으로 유지한다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Slf4j</span>
<span class="nd">@Component</span><span class="o">(</span><span class="s">"forwardMailSender"</span><span class="o">)</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">ForwardSender</span> <span class="kd">extends</span> <span class="nc">AbstractMailSender</span><span class="o">&lt;</span><span class="nc">ForwardLog</span><span class="o">&gt;</span> <span class="o">{</span>

    <span class="nd">@Override</span>
    <span class="kd">protected</span> <span class="kt">void</span> <span class="nf">handleSuccess</span><span class="o">(</span><span class="nc">ForwardLog</span> <span class="n">forwardLog</span><span class="o">)</span> <span class="o">{</span>
        <span class="n">forwardLog</span><span class="o">.</span><span class="na">setStatus</span><span class="o">(</span><span class="nc">ForwardStatus</span><span class="o">.</span><span class="na">DONE</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="nd">@Override</span>
    <span class="kd">protected</span> <span class="kt">void</span> <span class="nf">handleFailure</span><span class="o">(</span><span class="nc">ForwardLog</span> <span class="n">forwardLog</span><span class="o">)</span> <span class="o">{</span>
        <span class="n">forwardLog</span><span class="o">.</span><span class="na">setStatus</span><span class="o">(</span><span class="nc">ForwardStatus</span><span class="o">.</span><span class="na">FAILED</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="nd">@Override</span>
    <span class="kd">protected</span> <span class="kt">void</span> <span class="nf">handleAmbiguous</span><span class="o">(</span><span class="nc">ForwardLog</span> <span class="n">forwardLog</span><span class="o">)</span> <span class="o">{</span>
        <span class="n">forwardLog</span><span class="o">.</span><span class="na">setStatus</span><span class="o">(</span><span class="nc">ForwardStatus</span><span class="o">.</span><span class="na">PROCESSING</span><span class="o">);</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">PROCESSING</code>으로 먼저 변경하는 작업은 별도 트랜잭션에서 수행한다. 따라서 이후 메일 발송 중 프로세스가 종료되어도 이미 발송 시도 중이던 청크를 다시 <code class="language-plaintext highlighter-rouge">PENDING</code>처럼 읽지 않는다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Component</span>
<span class="nd">@RequiredArgsConstructor</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">ForwardDao</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">NamedParameterJdbcTemplate</span> <span class="n">jdbcTemplate</span><span class="o">;</span>

    <span class="nd">@Transactional</span><span class="o">(</span><span class="n">propagation</span> <span class="o">=</span> <span class="nc">Propagation</span><span class="o">.</span><span class="na">REQUIRES_NEW</span><span class="o">)</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">changeStateWithNewTx</span><span class="o">(</span><span class="nc">List</span><span class="o">&lt;?</span> <span class="kd">extends</span> <span class="nc">ForwardLog</span><span class="o">&gt;</span> <span class="n">logs</span><span class="o">,</span> <span class="nc">ForwardStatus</span> <span class="n">status</span><span class="o">)</span> <span class="o">{</span>
        <span class="n">changeState</span><span class="o">(</span><span class="n">logs</span><span class="o">,</span> <span class="n">status</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="nd">@Transactional</span>
    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">changeState</span><span class="o">(</span><span class="nc">List</span><span class="o">&lt;?</span> <span class="kd">extends</span> <span class="nc">ForwardLog</span><span class="o">&gt;</span> <span class="n">logs</span><span class="o">,</span> <span class="nc">ForwardStatus</span> <span class="n">status</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">logs</span><span class="o">.</span><span class="na">isEmpty</span><span class="o">())</span> <span class="o">{</span>
            <span class="k">return</span><span class="o">;</span>
        <span class="o">}</span>

        <span class="n">logs</span><span class="o">.</span><span class="na">forEach</span><span class="o">(</span><span class="n">it</span> <span class="o">-&gt;</span> <span class="n">it</span><span class="o">.</span><span class="na">setStatus</span><span class="o">(</span><span class="n">status</span><span class="o">));</span>
        <span class="n">doChangeStatus</span><span class="o">(</span><span class="n">logs</span><span class="o">,</span> <span class="n">status</span><span class="o">);</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<h2 id="메모">메모</h2>

<p><small id="api-scale-ref"><sup><a href="#api-scale">[1]</a></sup> 서비스에서 가장 많이 호출되는 질문 조회 API는 PK const 쿼리로 vercel 캐싱이 아니어도, 동시 사용자 50명 기준 300TPS 이상의 처리량을 보인다. 이 외에 API 호출은 상대적으로 호출량이 적다. 다만, API 서버가 메일 발송을 함께 수행하므로, 스케일 다운이 어려운 상황이었다.</small></p>]]></content><author><name>Haneul Lee</name><email>leehaneul990623@gmail.com</email></author><category term="project" /><summary type="html"><![CDATA[메일 발송 로직 Spring Batch로 마이그레이션]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://klise.now.sh/" /><media:content medium="image" url="https://klise.now.sh/" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">매일메일 AWS SES 처리율 제한 장치 구현</title><link href="https://klise.now.sh/aws-ses-rate-limit/" rel="alternate" type="text/html" title="매일메일 AWS SES 처리율 제한 장치 구현" /><published>2026-03-31T00:00:00+09:00</published><updated>2026-03-31T00:00:00+09:00</updated><id>https://klise.now.sh/aws-ses-rate-limit</id><content type="html" xml:base="https://klise.now.sh/aws-ses-rate-limit/"><![CDATA[<h2 id="aws-ses-처리율-제한-v1---스레드-풀--threadsleep-방식">AWS SES 처리율 제한 v1 - 스레드 풀 + Thread.sleep 방식</h2>

<p>오픈카톡방을 대상으로 홍보를 진행한 이후, 하루 메일 전송 건이 50건에서 400건으로 급증했다. 다음 날 관리자용 메일 발송 결과 알림을 확인하는 과정에서 일부 메일이 정상적으로 전송되지 않은 것을 발견했다. 메일 발송 누락의 원인을 확인하기 위해 서버 로그를 분석한 결과, AWS SES(Simple Email Service)<sup id="ses"><a href="#ses-ref">[1]</a></sup>의 초당 <strong>최대 전송 속도 제한인 14TPS를 초과</strong>한 요청이 발생하고 있었고, 이로 인해 메일 전송 과정에서 오류가 발생한 것을 확인할 수 있었다.</p>

<p>이 문제를 해결하기 위해 <strong>비동기 스레드 풀 내부의 Queue를 활용해 일정한 처리량을 유지</strong><sup id="core-pool-size"><a href="#core-pool-size-ref">[2]</a></sup>하도록 구조를 수정했다.</p>

<ul>
  <li>비동기 스레드 풀 내부 Queue의 capacity를 무한으로 설정</li>
  <li>메일 전송 노드를 2대의 EC2로 운영하면서 각 노드의 비동기 코어 풀 사이즈를 3으로 설정</li>
  <li>비동기 스레드에서 메일을 발송한 뒤 500ms의 지연 추가</li>
</ul>

<p><img src="img/aws-ses-v1-thread-pool.png" alt="AWS SES 처리율 제한 v1 - 스레드 풀 + Thread.sleep 방식" /></p>

<p>비동기 스레드 풀을 사용한 처리율 제한은 지난 1년 이상 동안 문제없이 작동하여 <strong>AWS SES의 처리량 초과 예외 발생률을 0%를 달성</strong>할 수 있었다. 그리고, 다음과 같은 옵션도 함께 고려했다.</p>

<ul>
  <li>토큰 버킷 알고리즘 기반의 Bucket4j 적용 : 토큰을 소비한 이후 실제 전송이 지연되는 상황에서 새로운 토큰이 리필될 경우 AWS SES의 TPS 제한을 초과할 가능성이 있다고 판단했다.</li>
  <li>별도의 처리율 제한 없이 단순히 재시도 수행 : 불필요한 네트워크 I/O 비용을 증가시킬 수 있다고 판단했다.</li>
</ul>

<h2 id="aws-ses-처리율-제한-v2---토큰-버킷--임대-방식">AWS SES 처리율 제한 v2 - 토큰 버킷 + 임대 방식</h2>

<p>사용자 1만 명 시점에 OOM이 발생해 사용자에게 전송될 메일이 누락된 적이 있다. 힙 덤프 분석 결과 <strong>메일 발송 스레드 풀 내부 Queue가 과도한 메모리를 점유</strong>하고 있음을 확인했다. 또한, 기존 구조는 core pool 사이즈와 sleep의 양을 기준으로 처리량을 제어하기 때문에 서버를 확장하기 어렵다는 단점을 가지고 있었다.</p>

<p>이 문제를 해결하기 위해서 <strong>bucket4j-mysql</strong><sup id="bucket4j"><a href="#bucket4j-ref">[3]</a></sup>을 도입했다. 중앙 버킷 저장소로 MySQL을 선택한 이유는 다음과 같다.</p>

<ul>
  <li>Redis는 인메모리 기반 저장소로 빠른 응답을 기대할 수 있지만, 클러스터 및 센티넬과 같은 HA 구성을 할 수 있을 정도로 팀 내부 자원이 여유가 있지 않았다.</li>
  <li>IMDG인 Hazelcast는 별도의 인스턴스를 분리하지 않아도 각 노드의 메모리를 사용해서 HA 구성<sup id="hazelcast-ha"><a href="#hazelcast-ha-ref">[4]</a></sup>을 할 수 있었다. 하지만, 현재 시스템 트래픽이나 메일 발송량을 고려했을 때 JVM 인스턴스 메모리를 사용하는 것은 MySQL의 한계점을 겪은 뒤에 해도 늦지 않다고 판단했다.</li>
</ul>

<p>Hazelcast의 embedded mode와 클러스터 일관성에 대한 내용은 <a href="/hazelcast/">Hazelcast 메모</a>에 따로 정리했다.</p>

<p><img src="img/aws-ses-v2-heap-dump.png" alt="메일 발송 스레드 풀 Queue 힙 덤프 분석 결과" /></p>

<p>하지만, bucket4j-mysql을 그대로 사용하기에는 부족함이 있다고 생각했다. 왜냐하면, bucket4j-mysql은 토큰을 소비하기 위해 매번 <code class="language-plaintext highlighter-rouge">select ... for update</code> 쿼리를 발생시키고, 소비가 가능한 경우에는 <code class="language-plaintext highlighter-rouge">update</code> 쿼리를 추가로 발생시키기 때문이다. 이러한 동작은 메일 발송을 초당 수십-수백건씩 발송시키는 상황에서 <strong>DB 장애로 이어질 수 있다고 판단</strong>했다. 뿐만 아니라, SES 호출량을 최대치로 사용하지 못하는 상황에서는 <strong>매번 DB RTT가 포함되기 때문에 처리량 저하가 예상</strong>됐다.</p>

<p>초기 구현은 <code class="language-plaintext highlighter-rouge">RateLimiter</code>에서 bucket4j의 MySQL 기반 <code class="language-plaintext highlighter-rouge">ProxyManager</code>를 사용해 중앙 버킷을 조회하고, <code class="language-plaintext highlighter-rouge">tryConsume</code>마다 토큰 소비를 시도하는 방식이었다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Component</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">RateLimiter</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">String</span> <span class="n">bucketKey</span><span class="o">;</span>
    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">Duration</span> <span class="n">waitTimeout</span><span class="o">;</span>
    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">BucketConfiguration</span> <span class="n">bucketConfiguration</span><span class="o">;</span>
    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">MySQLSelectForUpdateBasedProxyManager</span><span class="o">&lt;</span><span class="nc">Long</span><span class="o">&gt;</span> <span class="n">proxyManager</span><span class="o">;</span>
    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">ConcurrentMap</span><span class="o">&lt;</span><span class="nc">String</span><span class="o">,</span> <span class="nc">BucketProxy</span><span class="o">&gt;</span> <span class="n">buckets</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">ConcurrentHashMap</span><span class="o">&lt;&gt;();</span>

    <span class="kd">public</span> <span class="nf">RateLimiter</span><span class="o">(</span>
            <span class="nc">DataSource</span> <span class="n">dataSource</span><span class="o">,</span>
            <span class="nd">@Value</span><span class="o">(</span><span class="s">"${mail.ses.rate-limit.bucket-key}"</span><span class="o">)</span> <span class="nc">String</span> <span class="n">bucketKey</span><span class="o">,</span>
            <span class="nd">@Value</span><span class="o">(</span><span class="s">"${mail.ses.rate-limit.capacity}"</span><span class="o">)</span> <span class="kt">int</span> <span class="n">capacity</span><span class="o">,</span>
            <span class="nd">@Value</span><span class="o">(</span><span class="s">"${mail.ses.rate-limit.refill-amount}"</span><span class="o">)</span> <span class="kt">int</span> <span class="n">refillAmount</span><span class="o">,</span>
            <span class="nd">@Value</span><span class="o">(</span><span class="s">"${mail.ses.rate-limit.refill-seconds}"</span><span class="o">)</span> <span class="kt">long</span> <span class="n">refillSeconds</span><span class="o">,</span>
            <span class="nd">@Value</span><span class="o">(</span><span class="s">"${mail.ses.rate-limit.wait-timeout-millis}"</span><span class="o">)</span> <span class="kt">long</span> <span class="n">waitTimeoutMillis</span>
    <span class="o">)</span> <span class="o">{</span>
        <span class="k">this</span><span class="o">.</span><span class="na">bucketKey</span> <span class="o">=</span> <span class="n">bucketKey</span><span class="o">;</span>
        <span class="k">this</span><span class="o">.</span><span class="na">waitTimeout</span> <span class="o">=</span> <span class="nc">Duration</span><span class="o">.</span><span class="na">ofMillis</span><span class="o">(</span><span class="n">waitTimeoutMillis</span><span class="o">);</span>
        <span class="k">this</span><span class="o">.</span><span class="na">bucketConfiguration</span> <span class="o">=</span> <span class="n">createConfiguration</span><span class="o">(</span><span class="n">capacity</span><span class="o">,</span> <span class="n">refillAmount</span><span class="o">,</span> <span class="nc">Duration</span><span class="o">.</span><span class="na">ofSeconds</span><span class="o">(</span><span class="n">refillSeconds</span><span class="o">));</span>

        <span class="nc">SQLProxyConfiguration</span><span class="o">&lt;</span><span class="nc">Long</span><span class="o">&gt;</span> <span class="n">sqlProxyConfiguration</span> <span class="o">=</span> <span class="nc">SQLProxyConfiguration</span><span class="o">.</span><span class="na">builder</span><span class="o">()</span>
                <span class="o">.</span><span class="na">withTableSettings</span><span class="o">(</span><span class="nc">BucketTableSettings</span><span class="o">.</span><span class="na">getDefault</span><span class="o">())</span>
                <span class="o">.</span><span class="na">build</span><span class="o">(</span><span class="n">dataSource</span><span class="o">);</span>
        <span class="k">this</span><span class="o">.</span><span class="na">proxyManager</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">MySQLSelectForUpdateBasedProxyManager</span><span class="o">&lt;&gt;(</span><span class="n">sqlProxyConfiguration</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="kd">private</span> <span class="nc">BucketConfiguration</span> <span class="nf">createConfiguration</span><span class="o">(</span><span class="kt">int</span> <span class="n">capacity</span><span class="o">,</span> <span class="kt">int</span> <span class="n">refillAmount</span><span class="o">,</span> <span class="nc">Duration</span> <span class="n">refillDuration</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">return</span> <span class="nc">BucketConfiguration</span><span class="o">.</span><span class="na">builder</span><span class="o">()</span>
                <span class="o">.</span><span class="na">addLimit</span><span class="o">(</span><span class="nc">Bandwidth</span><span class="o">.</span><span class="na">builder</span><span class="o">()</span>
                        <span class="o">.</span><span class="na">capacity</span><span class="o">(</span><span class="n">capacity</span><span class="o">)</span>
                        <span class="o">.</span><span class="na">refillIntervally</span><span class="o">(</span><span class="n">refillAmount</span><span class="o">,</span> <span class="n">refillDuration</span><span class="o">)</span>
                        <span class="o">.</span><span class="na">build</span><span class="o">())</span>
                <span class="o">.</span><span class="na">build</span><span class="o">();</span>
    <span class="o">}</span>

    <span class="kd">public</span> <span class="kt">void</span> <span class="nf">tryConsume</span><span class="o">()</span> <span class="o">{</span>
        <span class="k">if</span> <span class="o">(!</span><span class="n">tryConsume</span><span class="o">(</span><span class="n">bucketKey</span><span class="o">,</span> <span class="n">bucketConfiguration</span><span class="o">,</span> <span class="n">waitTimeout</span><span class="o">))</span> <span class="o">{</span>
            <span class="k">throw</span> <span class="k">new</span> <span class="nf">IllegalStateException</span><span class="o">(</span><span class="s">"SES 처리율 제한을 초과했습니다."</span><span class="o">);</span>
        <span class="o">}</span>
    <span class="o">}</span>

    <span class="kd">private</span> <span class="kt">boolean</span> <span class="nf">tryConsume</span><span class="o">(</span><span class="nc">String</span> <span class="n">key</span><span class="o">,</span> <span class="nc">BucketConfiguration</span> <span class="n">configuration</span><span class="o">,</span> <span class="nc">Duration</span> <span class="n">waitTimeout</span><span class="o">)</span> <span class="o">{</span>
        <span class="nc">BucketProxy</span> <span class="n">bucket</span> <span class="o">=</span> <span class="n">getOrCreateBucket</span><span class="o">(</span><span class="n">key</span><span class="o">,</span> <span class="n">configuration</span><span class="o">);</span>
        <span class="k">try</span> <span class="o">{</span>
            <span class="k">return</span> <span class="n">bucket</span><span class="o">.</span><span class="na">asBlocking</span><span class="o">().</span><span class="na">tryConsume</span><span class="o">(</span><span class="mi">1</span><span class="o">,</span> <span class="n">waitTimeout</span><span class="o">);</span>
        <span class="o">}</span> <span class="k">catch</span> <span class="o">(</span><span class="nc">InterruptedException</span> <span class="n">e</span><span class="o">)</span> <span class="o">{</span>
            <span class="nc">Thread</span><span class="o">.</span><span class="na">currentThread</span><span class="o">().</span><span class="na">interrupt</span><span class="o">();</span>
            <span class="k">return</span> <span class="kc">false</span><span class="o">;</span>
        <span class="o">}</span>
    <span class="o">}</span>

    <span class="kd">private</span> <span class="nc">BucketProxy</span> <span class="nf">getOrCreateBucket</span><span class="o">(</span><span class="nc">String</span> <span class="n">key</span><span class="o">,</span> <span class="nc">BucketConfiguration</span> <span class="n">configuration</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">return</span> <span class="n">buckets</span><span class="o">.</span><span class="na">computeIfAbsent</span><span class="o">(</span><span class="n">key</span><span class="o">,</span> <span class="n">bucketKey</span> <span class="o">-&gt;</span> <span class="o">{</span>
            <span class="kt">long</span> <span class="n">bucketId</span> <span class="o">=</span> <span class="n">bucketKey</span><span class="o">.</span><span class="na">hashCode</span><span class="o">();</span>
            <span class="k">return</span> <span class="n">proxyManager</span><span class="o">.</span><span class="na">builder</span><span class="o">().</span><span class="na">build</span><span class="o">(</span><span class="n">bucketId</span><span class="o">,</span> <span class="n">configuration</span><span class="o">);</span>
        <span class="o">});</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>이를 해결하기 위한 하나의 방법은 서버마다 각자의 <strong>고정된 처리 비율을 할당하고, 인메모리 방식</strong>으로 처리율 제한을 수행하는 거라고 생각했다. 하지만, 이 방식은 <strong>최대 호출량이 낭비된다는 단점</strong>이 있다고 생각했다. 왜냐하면, 배치 서버는 오전에만 실행되고, API는 항상 실행되기 때문이다. 예를 들어, 배치에 60TPS를 할당하고 API 서버에 40TPS를 할당하면, 배치가 종료된 이후에 최대 호출량에서 60TPS가 낭비가 된다.</p>

<p><img src="img/aws-ses-fixed-quota.png" alt="고정 처리 비율 방식" /></p>

<p>이 문제를 해결하기 위해서 DB 부하를 줄이는 대표적인 방법인 캐싱을 시도했다. <strong>토큰 N개를 로컬 메모리에 저장하고, 임대 토큰이 유효한 시간 안에 사용하는 방법</strong><sup id="leased-token"><a href="#leased-token-ref">[5]</a></sup>이다. 이 방법은 DB 부하를 줄이면서도 최대 TPS는 제한할 수 있고, 동적으로 처리 비율이 결정되는 구조를 만들 수 있다고 생각했다.</p>

<p><img src="img/aws-ses-leased-token.png" alt="임대 토큰 방식" /></p>

<p>코드 레벨의 내부 동작 방식은 다음과 같다.</p>

<ul>
  <li>임대 토큰이 존재하지만, 해당 임대 토큰이 유효 기간이 지난 경우에는 임대 토큰을 폐기</li>
  <li>유효한 임대 토큰이 존재하면 소비하고, 아니라면 임대 토큰 대여</li>
  <li>bucket4j의 refill 정책을 intervally aligned 옵션<sup id="intervally-aligned"><a href="#intervally-aligned-ref">[6]</a></sup>으로 설정해서 유효 기간을 계산</li>
</ul>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Component</span>
<span class="kd">public</span> <span class="kd">class</span> <span class="nc">DistributedRateLimitSupport</span> <span class="o">{</span>

    <span class="kd">private</span> <span class="kd">final</span> <span class="nc">DistributedTokenLeaseService</span> <span class="n">leaseService</span><span class="o">;</span>
    <span class="kd">private</span> <span class="kd">final</span> <span class="kt">int</span> <span class="n">leaseAmount</span><span class="o">;</span>

    <span class="kd">private</span> <span class="kt">long</span> <span class="n">leasedTokens</span> <span class="o">=</span> <span class="mi">0</span><span class="o">;</span>
    <span class="kd">private</span> <span class="kt">long</span> <span class="n">leaseExpiresAtMillis</span> <span class="o">=</span> <span class="mi">0</span><span class="o">;</span>

    <span class="kd">public</span> <span class="kd">synchronized</span> <span class="kt">boolean</span> <span class="nf">tryConsume</span><span class="o">()</span> <span class="o">{</span>
        <span class="c1">// 로컬에 임대한 토큰은 다음 refill 경계까지만 유효하므로, 소비 전에 먼저 만료 여부를 확인한다.</span>
        <span class="n">expireLeasedTokens</span><span class="o">(</span><span class="nc">Instant</span><span class="o">.</span><span class="na">now</span><span class="o">());</span>

        <span class="k">if</span> <span class="o">(!</span><span class="n">ensureLeasedTokens</span><span class="o">())</span> <span class="o">{</span>
            <span class="k">return</span> <span class="kc">false</span><span class="o">;</span>
        <span class="o">}</span>

        <span class="k">return</span> <span class="nf">consume</span><span class="o">();</span>
    <span class="o">}</span>

    <span class="kd">private</span> <span class="kt">void</span> <span class="nf">expireLeasedTokens</span><span class="o">(</span><span class="nc">Instant</span> <span class="n">now</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">leasedTokens</span> <span class="o">&gt;</span> <span class="mi">0</span> <span class="o">&amp;&amp;</span> <span class="o">(</span><span class="n">now</span><span class="o">.</span><span class="na">toEpochMilli</span><span class="o">()</span> <span class="o">&gt;=</span> <span class="n">leaseExpiresAtMillis</span><span class="o">))</span> <span class="o">{</span>
            <span class="n">leasedTokens</span> <span class="o">=</span> <span class="mi">0</span><span class="o">;</span>
            <span class="n">leaseExpiresAtMillis</span> <span class="o">=</span> <span class="mi">0</span><span class="o">;</span>
        <span class="o">}</span>
    <span class="o">}</span>

    <span class="kd">private</span> <span class="kt">boolean</span> <span class="nf">ensureLeasedTokens</span><span class="o">()</span> <span class="o">{</span>
        <span class="c1">// 아직 유효한 임대 토큰이 남아 있으면 중앙 버킷을 조회하지 않고 로컬 토큰을 사용한다.</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">leasedTokens</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="o">)</span> <span class="o">{</span>
            <span class="k">return</span> <span class="kc">true</span><span class="o">;</span>
        <span class="o">}</span>

        <span class="c1">// 로컬 토큰이 없을 때만 중앙 버킷에서 leaseAmount만큼 토큰을 빌린다.</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">leaseService</span><span class="o">.</span><span class="na">tryLeaseToken</span><span class="o">(</span><span class="n">leaseAmount</span><span class="o">))</span> <span class="o">{</span>
            <span class="n">addLeasedTokens</span><span class="o">(</span><span class="n">leaseAmount</span><span class="o">,</span> <span class="nc">Instant</span><span class="o">.</span><span class="na">now</span><span class="o">());</span>
            <span class="k">return</span> <span class="kc">true</span><span class="o">;</span>
        <span class="o">}</span>

        <span class="k">return</span> <span class="kc">false</span><span class="o">;</span>
    <span class="o">}</span>

    <span class="kd">private</span> <span class="kt">void</span> <span class="nf">addLeasedTokens</span><span class="o">(</span><span class="kt">int</span> <span class="n">amount</span><span class="o">,</span> <span class="nc">Instant</span> <span class="n">acquiredAt</span><span class="o">)</span> <span class="o">{</span>
        <span class="n">leasedTokens</span> <span class="o">=</span> <span class="n">amount</span><span class="o">;</span>
        <span class="c1">// bucket4j의 intervally aligned refill 정책과 동일한 초 경계를 로컬 토큰의 만료 시각으로 사용한다.</span>
        <span class="n">leaseExpiresAtMillis</span> <span class="o">=</span> <span class="n">calculateNextRefillBoundary</span><span class="o">(</span><span class="n">acquiredAt</span><span class="o">).</span><span class="na">toEpochMilli</span><span class="o">();</span>
    <span class="o">}</span>

    <span class="kd">private</span> <span class="nc">Instant</span> <span class="nf">calculateNextRefillBoundary</span><span class="o">(</span><span class="nc">Instant</span> <span class="n">now</span><span class="o">)</span> <span class="o">{</span>
        <span class="k">return</span> <span class="nc">Instant</span><span class="o">.</span><span class="na">ofEpochSecond</span><span class="o">(</span><span class="n">now</span><span class="o">.</span><span class="na">getEpochSecond</span><span class="o">()</span> <span class="o">+</span> <span class="mi">1</span><span class="o">);</span>
    <span class="o">}</span>

    <span class="kd">private</span> <span class="kt">boolean</span> <span class="nf">consume</span><span class="o">()</span> <span class="o">{</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">leasedTokens</span> <span class="o">&lt;</span> <span class="mi">1</span><span class="o">)</span> <span class="o">{</span>
            <span class="k">return</span> <span class="kc">false</span><span class="o">;</span>
        <span class="o">}</span>

        <span class="n">leasedTokens</span> <span class="o">-=</span> <span class="mi">1</span><span class="o">;</span>
        <span class="k">if</span> <span class="o">(</span><span class="n">leasedTokens</span> <span class="o">==</span> <span class="mi">0</span><span class="o">)</span> <span class="o">{</span>
            <span class="n">leaseExpiresAtMillis</span> <span class="o">=</span> <span class="mi">0</span><span class="o">;</span>
        <span class="o">}</span>

        <span class="k">return</span> <span class="kc">true</span><span class="o">;</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>메일 전송 시점에는 <code class="language-plaintext highlighter-rouge">AbstractMailSender</code>에서 처리율 제한 토큰을 먼저 소비한 뒤, MIME 메시지를 생성하고 실제 전송을 수행한다.</p>

<div class="language-java highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">private</span> <span class="kt">void</span> <span class="nf">doSend</span><span class="o">(</span><span class="no">T</span> <span class="n">message</span><span class="o">)</span> <span class="o">{</span>
    <span class="k">try</span> <span class="o">{</span>
        <span class="n">limiter</span><span class="o">.</span><span class="na">consumeBlocking</span><span class="o">(</span><span class="no">WAIT_TIMEOUT</span><span class="o">);</span>
        <span class="n">logSending</span><span class="o">(</span><span class="n">message</span><span class="o">);</span>
        <span class="nc">MimeMessage</span> <span class="n">emptyMimeMessage</span> <span class="o">=</span> <span class="n">javaMailSender</span><span class="o">.</span><span class="na">createMimeMessage</span><span class="o">();</span>
        <span class="nc">MimeMessage</span> <span class="n">targetMimeMessage</span> <span class="o">=</span> <span class="n">mimeMessageCustomizer</span><span class="o">.</span><span class="na">customize</span><span class="o">(</span><span class="n">emptyMimeMessage</span><span class="o">,</span> <span class="n">message</span><span class="o">);</span>
        <span class="n">javaMailSender</span><span class="o">.</span><span class="na">send</span><span class="o">(</span><span class="n">targetMimeMessage</span><span class="o">);</span>
        <span class="n">handleSuccess</span><span class="o">(</span><span class="n">message</span><span class="o">);</span>
    <span class="o">}</span> <span class="k">catch</span> <span class="o">(</span><span class="nc">RateLimitExceededException</span> <span class="n">e</span><span class="o">)</span> <span class="o">{</span>
        <span class="n">logMailSendingFailed</span><span class="o">(</span><span class="n">e</span><span class="o">);</span>
        <span class="k">throw</span> <span class="k">new</span> <span class="nf">RetryableMailException</span><span class="o">(</span><span class="n">e</span><span class="o">);</span>
    <span class="o">}</span> <span class="k">catch</span> <span class="o">(</span><span class="nc">MailException</span> <span class="n">e</span><span class="o">)</span> <span class="o">{</span>
        <span class="n">logMailSendingFailed</span><span class="o">(</span><span class="n">e</span><span class="o">);</span>
        <span class="n">throwWhenCanRetry</span><span class="o">(</span><span class="n">e</span><span class="o">);</span>
        <span class="n">handleAmbiguous</span><span class="o">(</span><span class="n">message</span><span class="o">);</span>
    <span class="o">}</span> <span class="k">catch</span> <span class="o">(</span><span class="nc">Exception</span> <span class="n">e</span><span class="o">)</span> <span class="o">{</span>
        <span class="n">log</span><span class="o">.</span><span class="na">error</span><span class="o">(</span><span class="s">"예기치 않은 오류 발생: {}"</span><span class="o">,</span> <span class="n">e</span><span class="o">.</span><span class="na">getMessage</span><span class="o">(),</span> <span class="n">e</span><span class="o">);</span>
        <span class="n">handleFailure</span><span class="o">(</span><span class="n">message</span><span class="o">);</span>
    <span class="o">}</span>
<span class="o">}</span>
</code></pre></div></div>

<p>개선 결과를 확인하기 위해서 배치의 <code class="language-plaintext highlighter-rouge">mailSendStep</code>을 기준으로 매번 DB로 요청을 보내는 v1과 필요할 때만 요청하는 v2를 비교했다. <strong>DB QPS가 84.7% 감소했고, 처리량도 50TPS 미만을 유지</strong><sup id="throughput"><a href="#throughput-ref">[7]</a></sup>했다. 버킷 한도를 최대치로 사용하지 않는 상황에서는 <strong>RTT 감소의 효과로 인해 처리량 향상 또한 기대</strong>할 수 있다.</p>

<p>임대량 5, bucket capacity 50, refill amount 50, refill duration 1sec, 처리 메일 건수 16087 기준 측정 결과는 다음과 같았다.</p>

<table>
  <thead>
    <tr>
      <th style="text-align: center">지표</th>
      <th style="text-align: center">V1</th>
      <th style="text-align: center">V2</th>
      <th style="text-align: center">변화</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: center">메일 발송 TPS</td>
      <td style="text-align: center">46.2076</td>
      <td style="text-align: center">48.1456</td>
      <td style="text-align: center">+4.2%</td>
    </tr>
    <tr>
      <td style="text-align: center">전체 DB Questions</td>
      <td style="text-align: center">66,754</td>
      <td style="text-align: center">9,831</td>
      <td style="text-align: center">-85.3%</td>
    </tr>
    <tr>
      <td style="text-align: center">DB QPS</td>
      <td style="text-align: center">191.7425</td>
      <td style="text-align: center">29.4225</td>
      <td style="text-align: center">-84.7%</td>
    </tr>
    <tr>
      <td style="text-align: center">메일 건당 DB 수신 트래픽</td>
      <td style="text-align: center">1.88KB</td>
      <td style="text-align: center">0.15KB</td>
      <td style="text-align: center">-92.1%</td>
    </tr>
  </tbody>
</table>

<h3 id="메모">메모</h3>

<p><small id="ses-ref"><sup><a href="#ses">[1]</a></sup> 메일 발송 서비스로 AWS SES를 사용했다. SES의 TPS 제한이 존재하는 이유는 고속의 대량 메일 발송을 하는 경우, 메일 수신 서버의 차단 가능성이 존재하기 때문이다.</small></p>

<p><small id="core-pool-size-ref"><sup><a href="#core-pool-size">[2]</a></sup> core pool size = 3인 상황에서 500ms의 지연을 추가하면 초당 고정 처리량은 12TPS이며, 실제는 메일 발송 및 DB I/O가 포함되어 그 이하의 처리량이다.</small></p>

<p><small id="bucket4j-ref"><sup><a href="#bucket4j">[3]</a></sup> bucket4j는 메모리 문제와 확장성 문제를 해결할 수 있는 토큰 버킷 기반 Rate Limiting 라이브러리다.</small></p>

<p><small id="hazelcast-ha-ref"><sup><a href="#hazelcast-ha">[4]</a></sup> hazelcast에서 각 데이터 엔트리는 하나 이상의 파티션에 매핑되고, 해당 파티션의 replica에 저장된다. 데이터를 읽고 쓸 때는 primary replica를 보유한 hazelcast 멤버(노드)와 통신하게 된다.</small></p>

<p><small id="leased-token-ref"><sup><a href="#leased-token">[5]</a></sup> 모든 서버에서 최대로 메일 발송을 수행할 때, 임대 토큰 할당량에 따라서 처리량이 동적으로 결정된다. A의 임대량을 1, B의 임대량을 5로 설정했을 때, TPS는 N1 = 10, N2 = 40이었다. API는 일 평균 30-40건의 메일을 발송하기 때문에 캐시 히트율이 낮다고 판단했다. 따라서, 임대량을 1로 설정했다.</small></p>

<p><small id="intervally-aligned-ref"><sup><a href="#intervally-aligned">[6]</a></sup> bucket4j의 intervally aligned 옵션은 일정 간격으로 전체 토큰을 충전하고, 첫 번째 충전 시간이 발생하는 시간을 지정할 수 있다. 구현을 기준으로 첫 번째 충전 시간을 Instant.EPOCH로 설정했다. 따라서, 충전 주기를 1초로 설정한다면 벽시계 기준으로 매초마다 토큰이 충전된다는 것을 보장할 수 있다.</small></p>

<p><small id="throughput-ref"><sup><a href="#throughput">[7]</a></sup> 처리량이 정확히 50이 아닌 이유는 메일 발송을 writer에서 수행하기 때문이다. 즉, reader와 processor의 영향으로 전체 처리량이 50이 아니게 된다. 실제로는 더 정확한 테스트를 위해 v1 RateLimiter와 v2 RateLimiter의 최대 TPS가 50을 초과하지 않는지 검증했다.</small></p>]]></content><author><name>Haneul Lee</name><email>leehaneul990623@gmail.com</email></author><category term="project" /><summary type="html"><![CDATA[매일메일 AWS SES 처리율 제한 장치 구현]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://klise.now.sh/" /><media:content medium="image" url="https://klise.now.sh/" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">AWS ALB 마이그레이션 - Haproxy Active-Passive 구성(feat. Keepalived)</title><link href="https://klise.now.sh/mm-lb/" rel="alternate" type="text/html" title="AWS ALB 마이그레이션 - Haproxy Active-Passive 구성(feat. Keepalived)" /><published>2025-07-31T00:00:00+09:00</published><updated>2025-07-31T00:00:00+09:00</updated><id>https://klise.now.sh/mm-lb</id><content type="html" xml:base="https://klise.now.sh/mm-lb/"><![CDATA[<blockquote>
  <p>ALB와 Public Ipv4 비용을 절감하기 위해 Haproxy + Keepalived Active Passive 구성으로 마이그레이션하기로 결정했다.</p>
</blockquote>

<h2 id="1-사전-작업">1. 사전 작업</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 타임존 설정</span>
<span class="nb">sudo </span>timedatectl set-timezone Asia/Seoul

<span class="c"># haproxy + keepalived</span>
<span class="nb">sudo </span>apt-get update
<span class="nb">sudo </span>apt-get <span class="nb">install </span>haproxy
<span class="nb">sudo </span>apt-get <span class="nb">install </span>keepalived

<span class="c"># 인증서 및 설정 동기화 자동화</span>
<span class="nb">sudo </span>apt <span class="nb">install </span>inotify-tools

<span class="c"># certbot</span>
<span class="nb">sudo </span>snap <span class="nb">install </span>core
<span class="nb">sudo </span>snap refresh core
<span class="nb">sudo </span>snap <span class="nb">install</span> <span class="nt">--classic</span> certbot
<span class="nb">sudo ln</span> <span class="nt">-s</span> /snap/bin/certbot /usr/bin/certbot

<span class="c"># aws cli</span>
<span class="nb">sudo </span>apt-get <span class="nb">install </span>unzip
curl <span class="s2">"https://awscli.amazonaws.com/awscli-exe-linux-aarch64.zip"</span> <span class="nt">-o</span> <span class="s2">"awscliv2.zip"</span>
unzip awscliv2.zip
<span class="nb">sudo</span> ./aws/install
</code></pre></div></div>

<h2 id="2-haproxy-설정">2. Haproxy 설정</h2>

<ul>
  <li>설정 파일 : /etc/haproxy/haproxy.cfg</li>
  <li>로그 파일 : /var/log/haproxy.log</li>
</ul>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code># Haproxy Dashboard - 관리자 IP 대역만 접근할 수 있도록 SG 설정 필요
frontend stats
        bind *:8404
        stats enable
        stats uri /
        stats refresh 10s

# HTTP
frontend http-api.maeil-mail.kr
        bind *:80
        http-request set-header X-Forwarded-Proto http
        default_backend apps

# HTTPS
frontend https-api.maeil-mail.kr
        bind *:443 ssl crt /etc/haproxy/certs/site.pem
        http-request set-header X-Forwarded-Proto https
        http-request set-header X-SSL %[ssl_fc]
        acl letsencrypt-acl path_beg /.well-known/acme-challenge/
        use_backend letsencrypt-backend if letsencrypt-acl
        default_backend apps

# APPS
backend apps
        redirect scheme https code 301 if !{ ssl_fc }
        balance roundrobin
        server mm-app-1 172.31.82.62:8080 check
        server mm-app-2 172.31.98.184:8080 check

# 인증서 발급 및 갱신 - HTTPS의 ACL letsencrypt-acl 설정 확인
# Letsencrypt
backend letsencrypt-backend
        server letsencrypt 127.0.0.1:9090
</code></pre></div></div>

<h2 id="3-haproxy와-커널-튜닝">3. Haproxy와 커널 튜닝</h2>

<p>로드 밸런서는 일반적인 애플리케이션 서버보다 훨씬 많은 연결을 직접 받아야 한다. 따라서 Haproxy 설정뿐만 아니라 리눅스 커널의 네트워크 관련 설정도 함께 조정해야 한다.</p>

<h3 id="haproxy-튜닝">Haproxy 튜닝</h3>

<p>Haproxy에서는 워커 프로세스 또는 스레드 수를 조정해서 여러 CPU 코어를 활용할 수 있다. 또한, 동시에 처리할 수 있는 연결 수를 제한하는 <code class="language-plaintext highlighter-rouge">maxconn</code> 값을 상황에 맞게 설정해야 한다.</p>

<pre><code class="language-cfg">global
        nbthread 2
        maxconn 100000
        tune.ssl.cachesize 100000
        tune.ssl.lifetime 600
</code></pre>

<p><code class="language-plaintext highlighter-rouge">nbthread</code>는 Haproxy가 사용할 스레드 수를 설정한다. 예전에는 프로세스 수를 늘리는 방식도 사용했지만, 현재는 스레드 기반 설정을 우선 고려하는 편이 운영하기 쉽다. <code class="language-plaintext highlighter-rouge">maxconn</code>은 Haproxy가 동시에 처리할 수 있는 최대 연결 수를 의미한다.</p>

<p>SSL 캐시도 같이 설정할 수 있다. 같은 클라이언트가 여러 번 TLS 연결을 맺는 경우 세션 정보를 재사용할 수 있으므로, TLS 핸드셰이크 비용을 줄이는 데 도움이 된다.</p>

<h3 id="커널-튜닝">커널 튜닝</h3>

<p>리눅스의 기본 네트워크 설정은 높은 연결 속도를 처리할 필요가 없는 일반적인 시스템에 맞춰져 있다. 로드 밸런서처럼 짧은 시간에 많은 연결이 몰리는 서버에서는 TCP 백로그, 수신 큐, 파일 디스크립터 제한을 조정해야 한다.</p>

<div class="language-conf highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">net</span>.<span class="n">ipv4</span>.<span class="n">tcp_max_syn_backlog</span> = <span class="m">100000</span>
<span class="n">net</span>.<span class="n">core</span>.<span class="n">somaxconn</span> = <span class="m">100000</span>
<span class="n">net</span>.<span class="n">core</span>.<span class="n">netdev_max_backlog</span> = <span class="m">100000</span>
<span class="n">fs</span>.<span class="n">file</span>-<span class="n">max</span> = <span class="m">262144</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">net.ipv4.tcp_max_syn_backlog</code>는 시스템이 견딜 수 있는 반개방 TCP 연결 수와 관련된다. 클라이언트의 <code class="language-plaintext highlighter-rouge">SYN</code>을 받고 서버가 <code class="language-plaintext highlighter-rouge">SYN+ACK</code>를 보냈지만, 아직 클라이언트의 최종 <code class="language-plaintext highlighter-rouge">ACK</code>가 도착하지 않은 상태를 반개방 연결이라고 한다. 부하가 급증하는 동안 로드 밸런서에 반개방 연결이 쌓이는 것은 자연스러운 일이므로, 이 값을 충분히 높이는 것이 좋다. 백로그 크기가 작으면 고부하 상황에서 신규 요청을 받아들이기 어려워진다.</p>

<p><code class="language-plaintext highlighter-rouge">net.core.somaxconn</code>은 listen backlog의 상한과 관련된다. 애플리케이션이나 Haproxy에서 큰 backlog를 설정해도 커널 상한이 낮으면 실제로는 그만큼 사용할 수 없으므로 함께 높여야 한다.</p>

<p><code class="language-plaintext highlighter-rouge">net.core.netdev_max_backlog</code>는 네트워크 인터페이스로 들어온 패킷이 커널에서 처리되기 전까지 대기할 수 있는 수신 큐 크기를 의미한다. 순간적으로 패킷이 몰리는 환경에서는 이 값을 키워 패킷 드롭 가능성을 줄일 수 있다.</p>

<p>파일 디스크립터 제한도 중요하다. Haproxy는 클라이언트 연결과 백엔드 연결을 모두 다루기 때문에 일반적인 작업 부하에서도 많은 파일 디스크립터를 사용한다. <code class="language-plaintext highlighter-rouge">fs.file-max</code>는 리눅스 커널이 할당할 수 있는 최대 파일 핸들 수를 설정한다. 일반적으로 4MB RAM당 256개의 파일 핸들을 기준으로 잡을 수 있으며, 4GB RAM VM이라면 <code class="language-plaintext highlighter-rouge">4096 / 4 * 256 = 262144</code> 정도가 된다.</p>

<p><code class="language-plaintext highlighter-rouge">fs.nr_open</code>은 프로세스가 열 수 있는 파일 디스크립터 수의 상한과 관련된다. 다만 이 값의 최대치는 커널 내부의 <code class="language-plaintext highlighter-rouge">sysctl_nr_open_max</code>에 의해 제한되며, x86_64 기준으로는 <code class="language-plaintext highlighter-rouge">2147483584</code>까지 설정할 수 있다.</p>

<h2 id="4-keepalived-구성">4. Keepalived 구성</h2>

<ul>
  <li>설정 파일 : /etc/keepalived/keepalived.conf</li>
  <li>로그 파일 : /var/log/syslog</li>
</ul>

<p>active 설정</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>global_defs {
    router_id maeilmail-rt-active
    script_user root
}

vrrp_script check_haproxy {
    script "systemctl is-active --quiet haproxy"
    interval 2
    fall 2
    rise 2
}

vrrp_instance VI_1 {
    debug 2
    interface ens5
    state MASTER
    virtual_router_id 50
    advert_int 3
    priority 110
    unicast_src_ip 172.31.27.180

    authentication {
        auth_type PASS
	    auth_pass testpassword
    }

    unicast_peer {
        172.31.34.184
    }

    track_script {
        check_haproxy
    }

    notify_master /etc/keepalived/failover.sh
}
</code></pre></div></div>

<p>passive 설정</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>global_defs {
    router_id maeilmail-rt-passive
    script_user root
}

vrrp_script check_haproxy {
    script "systemctl is-active --quiet haproxy"
    interval 2
    fall 2
    rise 2
}

vrrp_instance VI_1 {
    debug 2
    interface ens5
    state BACKUP
    virtual_router_id 50
    advert_int 3
    priority 100
    unicast_src_ip 172.31.34.184

    authentication {
        auth_type PASS
        auth_pass testpassword
    }

    unicast_peer {
        172.31.27.180
    }

    track_script {
        check_haproxy
    }

    notify_master /etc/keepalived/failover.sh
}
</code></pre></div></div>

<p>failover 스크립트(failover.sh)</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/bash</span>

<span class="nv">EIP</span><span class="o">=</span> <span class="c"># EIP</span>
<span class="nv">INSTANCE_ID</span><span class="o">=</span> <span class="c"># 인스턴스 ID</span>

/usr/local/bin/aws ec2 disassociate-address <span class="nt">--public-ip</span> <span class="nv">$EIP</span> <span class="nt">--endpoint-url</span> https://ec2.ap-northeast-2.api.aws <span class="nt">--profile</span> lb-user
/usr/local/bin/aws ec2 associate-address <span class="nt">--public-ip</span> <span class="nv">$EIP</span> <span class="nt">--instance-id</span> <span class="nv">$INSTANCE_ID</span> <span class="nt">--endpoint-url</span> https://ec2.ap-northeast-2.api.aws <span class="nt">--profile</span> lb-user
</code></pre></div></div>

<ul>
  <li>backup 노드에서 해당 스크립트를 실행하려면 인터넷 접근이 필요하다. 하지만, 우리는 EIP가 active 노드에만 존재한다.</li>
  <li>이 문제를 해결하기 위해서 ipv6를 설정해서 인터넷에 접근했다. (아래에 설정 방법 정리)</li>
  <li>다만 CLI의 기본 경로는 https://ec2.ap-northeast-2.amazonaws.com/ 로 ipv4만 지원하기 때문에 connection timeout이 발생한다.</li>
  <li>따라서, 이를 듀얼 스택 경로로 요청하도록 변경해야하는데, 서울 리전을 기준으로 ec2.ap-northeast-2.api.aws에 요청해야한다.</li>
</ul>

<h2 id="5-ssl-설정">5. SSL 설정</h2>

<p>인증서 발급, fullchain.pem이랑 privkey.pem을 합쳐서 site.pem으로 만들어야한다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>certbot certonly <span class="se">\</span>
    <span class="nt">--standalone</span> <span class="se">\ </span>
    <span class="nt">--agree-tos</span> 
    <span class="nt">-m</span> team.maeilmail@gmail.com <span class="se">\</span>
    <span class="nt">-w</span> /var/www/letsencrypt <span class="se">\</span>
    <span class="nt">-d</span> test.maeil-mail.kr <span class="se">\ </span>
    <span class="nt">--http-01-port</span><span class="o">=</span>9090

<span class="nv">DOMAIN</span><span class="o">=</span><span class="s1">'test.maeil-mail.kr'</span> <span class="nb">sudo</span> <span class="nt">-E</span> bash <span class="nt">-c</span> <span class="s1">'cat /etc/letsencrypt/live/$DOMAIN/fullchain.pem /etc/letsencrypt/live/$DOMAIN/privkey.pem &gt; /etc/haproxy/certs/site.pem'</span>
</code></pre></div></div>

<p>아래는 certbot renew 타이머 정보를 조회하는 방법이다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># certbot renew 타이머 정보 조회</span>
systemctl <span class="nb">cat </span>snap.certbot.renew.timer

<span class="c"># 다음 renew 실행 시간 조회</span>
systemctl list-timers | <span class="nb">grep </span>certbot
</code></pre></div></div>

<p>renew 할때마다 fullchain.pem이랑 privkey.pem 합쳐야해서 deploy hook 사용해야한다.</p>

<p>/etc/letsencrypt/renewal-hooks/deploy/haproxy-pem-hook.sh</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/bash</span>

<span class="nv">DOMAIN</span><span class="o">=</span><span class="s2">"test.maeil-mail.kr"</span>
<span class="nv">TARGET_PEM</span><span class="o">=</span><span class="s2">"/etc/haproxy/certs/site.pem"</span>
<span class="nv">CERT</span><span class="o">=</span><span class="s2">"/etc/letsencrypt/live/</span><span class="nv">$DOMAIN</span><span class="s2">"</span>

<span class="nb">cat</span> <span class="nv">$CERT</span>/fullchain.pem <span class="nv">$CERT</span>/privkey.pem <span class="o">&gt;</span> <span class="nv">$TARGET_PEM</span>

<span class="nb">echo</span> <span class="s2">"</span><span class="nv">$TARGET_PEM</span><span class="s2"> generated."</span>
</code></pre></div></div>

<p>스크립트 실행되는지 실행하려면 강제 리뉴얼로 테스트할 수 있다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>certbot renew <span class="nt">--force-renewal</span>
</code></pre></div></div>

<h2 id="6-설정-동기화">6. 설정 동기화</h2>

<p>/home/ubuntu/sync/watch_sync.sh</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/bash</span>
<span class="nv">WATCH_DIRS</span><span class="o">=</span><span class="s2">"/etc/haproxy /etc/haproxy/certs"</span>
<span class="nv">PIPE</span><span class="o">=</span><span class="s2">"/tmp/haproxy-pipe"</span>
<span class="nv">LOG_FILE</span><span class="o">=</span><span class="s2">"/home/ununtu/sync/log/haproxy-sync.log"</span>
<span class="nv">REMOTE_USER</span><span class="o">=</span><span class="s2">"ubuntu"</span>
<span class="nv">REMOTE_HOST</span><span class="o">=</span><span class="s2">"172.31.34.184"</span>
<span class="nv">INOTIFY_PID</span><span class="o">=</span>0

<span class="nb">exec</span> <span class="o">&gt;&gt;</span> <span class="nv">$LOG_FILE</span> 2&gt;&amp;1

cleanup<span class="o">()</span> <span class="o">{</span>
  <span class="nb">echo</span> <span class="s2">"</span><span class="si">$(</span><span class="nb">date</span><span class="si">)</span><span class="s2"> - cleanup() called, killing </span><span class="nv">$INOTIFY_PID</span><span class="s2">"</span>
  <span class="o">[[</span> <span class="nv">$INOTIFY_PID</span> <span class="nt">-gt</span> 0 <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="nb">kill</span> <span class="nv">$INOTIFY_PID</span>
  <span class="nb">rm</span> <span class="nt">-f</span> <span class="s2">"</span><span class="nv">$PIPE</span><span class="s2">"</span>
  <span class="nb">exit </span>0
<span class="o">}</span>
<span class="nb">trap </span>cleanup SIGINT SIGTERM EXIT

<span class="o">[[</span> <span class="nt">-p</span> <span class="s2">"</span><span class="nv">$PIPE</span><span class="s2">"</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="nb">rm</span> <span class="nt">-f</span> <span class="s2">"</span><span class="nv">$PIPE</span><span class="s2">"</span>
<span class="nb">mkfifo</span> <span class="s2">"</span><span class="nv">$PIPE</span><span class="s2">"</span>

<span class="nb">echo</span> <span class="s2">"</span><span class="si">$(</span><span class="nb">date</span><span class="si">)</span><span class="s2"> - File Sync Started, main PID = </span><span class="nv">$$</span><span class="s2">"</span>

inotifywait <span class="nt">-mrq</span> <span class="nt">-e</span> close_write <span class="nv">$WATCH_DIRS</span> <span class="o">&gt;</span> <span class="s2">"</span><span class="nv">$PIPE</span><span class="s2">"</span> &amp;
<span class="nv">INOTIFY_PID</span><span class="o">=</span><span class="nv">$!</span>

<span class="k">while </span><span class="nb">read </span>path action file<span class="p">;</span>  <span class="k">do
  </span><span class="nb">echo</span> <span class="s2">"</span><span class="si">$(</span><span class="nb">date</span><span class="si">)</span><span class="s2"> - Detected </span><span class="nv">$action</span><span class="s2"> on </span><span class="nv">$path$file</span><span class="s2">"</span>

  <span class="k">if</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$file</span><span class="s2">"</span> <span class="o">=</span> <span class="s2">"haproxy.cfg"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
    </span>scp <span class="nt">-p</span> /etc/haproxy/haproxy.cfg <span class="nv">$REMOTE_USER</span>@<span class="nv">$REMOTE_HOST</span>:/home/ubuntu/tmp/
    ssh <span class="nv">$REMOTE_USER</span>@<span class="nv">$REMOTE_HOST</span> <span class="s2">"sudo mv /home/ubuntu/tmp/haproxy.cfg /etc/haproxy/ &amp;&amp; sudo systemctl reload haproxy"</span>
    <span class="nb">sudo </span>systemctl reload haproxy
  <span class="k">fi

  if</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$file</span><span class="s2">"</span> <span class="o">=</span> <span class="s2">"site.pem"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
    </span>scp <span class="nt">-p</span> /etc/haproxy/certs/site.pem <span class="nv">$REMOTE_USER</span>@<span class="nv">$REMOTE_HOST</span>:/home/ubuntu/tmp/
    ssh <span class="nv">$REMOTE_USER</span>@<span class="nv">$REMOTE_HOST</span> <span class="s2">"sudo mv /home/ubuntu/tmp/site.pem /etc/haproxy/certs/ &amp;&amp; sudo systemctl reload haproxy"</span>
    <span class="nb">sudo </span>systemctl reload haproxy
  <span class="k">fi
done</span> &lt; <span class="s2">"</span><span class="nv">$PIPE</span><span class="s2">"</span>
</code></pre></div></div>

<ul>
  <li>active-passive 노드 간 site.pem이랑 haproxy 설정 동기화를 위해서 inotifywait으로 감지한다.</li>
  <li>close_wait만 감지하도록 설정하고, if로 haproxy.cfg랑 site.pem이 변경되면 백업 노드로 전송한다.
    <ul>
      <li>이거 하려면, passive 노드에 해당 경로에 대한 디렉터리를 미리 만들어야 하고..</li>
      <li>active 노드의 public key를 passive 노드 ~/.ssh/authorized_keys에 추가해야한다.</li>
    </ul>
  </li>
  <li>스크립트 프로세스에 SIGINT SIGTERM 신호 받으면 트랩으로 inotify 프로세스랑 네임드 파이프를 정리한다. (inotifywait 종료안돼서.. 진짜 골치아팠다..)</li>
  <li>위에 renewal-hook이랑 마찬가지로 항상 설정을 바꾸면 reload 시켜야한다.</li>
  <li>입력 리디렉션으로 while에 inotifywait 반환값을 줘야한다.</li>
  <li>로그는 /home/ubuntu/sync/log/haproxy-sync.log에 출력했다.</li>
</ul>

<p>이제 해당 스크립트를 자동으로 실행되게 만들려고 한다. systemd에 service로 등록해줄것이다.</p>

<p>/usr/lib/systemd/system/watch-sync.service</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[Unit]
Description=HAProxy Config Sync Service
After=network-online.target

[Service]
Type=simple
ExecStart=/home/ubuntu/sync/watch_sync.sh
Restart=on-failure
User=ubuntu

[Install]
WantedBy=multi-user.target
</code></pre></div></div>

<p>아래와 같이 입력하고 인스턴스를 재부팅했다.</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>systemctl daemon-reload
<span class="nb">sudo </span>systemctl start watch-sync
<span class="nb">sudo </span>systemctl <span class="nb">enable </span>watch-sync
<span class="nb">sudo </span>systemctl status watch-sync
</code></pre></div></div>

<figure>
<img src="./img/lb-file-sync-1.png" alt="shell" />
<figcaption> 서비스랑 프로세스 모두 잘 띄워져있다..! </figcaption>
</figure>

<p>마지막으로 동기화 테스트를 해보자.</p>

<figure>
<img src="./img/lb-file-sync-2.gif" alt="shell" />
<figcaption> </figcaption>
</figure>

<h2 id="7-failover">7. Failover</h2>

<p>앞서 언급했던 것과 같이 AWS CLI를 사용해서 EIP를 할당해야하기 때문에 인터넷 접근이 필요하다.
EIP가 없는 백업 노드는 인터넷 접근이 불가능하다. public ipv4를 사용해서 발생하는 비용을 줄이기 위해 ipv6를 할당해서 페일오버에 필요한 인터넷 접근을 했다.</p>

<h3 id="ipv6-할당">ipv6 할당</h3>

<figure>
<img src="./img/lb-1.png" alt="shell" />
<figcaption> public ipv4 없이도 인터넷이 된다..! </figcaption>
</figure>

<ul>
  <li>VPC ipv6 CIDR을 설정한다.</li>
  <li>public subnet에 ipv6 CIDR을 설정한다.</li>
  <li>EC2에 ipv6 주소를 할당한다.</li>
  <li>퍼블릭 서브넷 라우팅 테이블 ::/0 경로에 인터넷 게이트웨이를 추가해줘야한다.</li>
  <li>DNS checker에서 <a href="https://dnschecker.org/ping-ipv6.php">IPv6 Address Lookup</a> 수행</li>
  <li>잘안되는 경우, 라우팅 테이블 및 SG에 ipv6 관련 규칙을 살펴보자.</li>
  <li>클라이언트가 ipv6 주소가 있다면 http://[ipv6 address]로 접속할 수도 있긴한데, 국내 ipv6 보급률은 높지 않기 때문에 해당 경로에 대한 haproxy bind와 SG 규칙은 따로 추가하지 않는다.</li>
</ul>

<h3 id="aws-configuration">AWS Configuration</h3>

<ul>
  <li>EIP를 할당하기 위한 IAM 사용자를 만든다.</li>
  <li>고객관리형 정책 하나 만들고 -&gt; “ec2:DisassociateAddress”, “ec2:AssociateAddress”, “sns:Publish” 쓰기 권한을 추가한다.</li>
  <li>사용자에게 정책 할당 및 access key 발급</li>
  <li><code class="language-plaintext highlighter-rouge">aws confiure --profile lb-user</code> 실행하여 키를 비롯한 설정 정보 입력한다. <strong>(주의 : failover 스크립트를 실행할 리눅스 유저로 접속하여 configure 해야한다.)</strong></li>
</ul>

<h3 id="failover-test">Failover Test</h3>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>systemctl start haproxy
<span class="nb">sudo </span>systemctl start keepalived
<span class="nb">sudo </span>systemctl <span class="nb">enable </span>haproxy
<span class="nb">sudo </span>systemctl <span class="nb">enable </span>keepalived
</code></pre></div></div>

<p>현재 API 주소에 요청을 보내면 active 노드에서만 요청을 처리한다. keepalived에서 active 노드는 MASTER STAGE가 되고, passive 노드는 BACKUP STAGE로 대기한다. 테스트는 haproxy 다운, keepalived 다운 순서로 진행했다.</p>

<p><strong>1번 케이스.</strong> active 노드 haproxy 종료되는 경우</p>

<figure>
<img src="./img/lb-2.png" alt="shell" />
<figcaption> haproxy 다운 시 failover 수행</figcaption>
</figure>

<figure>
<img src="./img/lb-3.png" alt="shell" />
<figcaption> haproxy 재시작 시 기존 active 노드 MASTER 승격</figcaption>
</figure>

<p><strong>2번 케이스.</strong> active 노드 keepalived 종료되는 경우</p>

<figure>
<img src="./img/lb-4.png" alt="shell" />
<figcaption> keepalived 다운 시 failover 수행</figcaption>
</figure>

<figure>
<img src="./img/lb-5.png" alt="shell" />
<figcaption> keepalived 재시작 시 기존 active 노드 MASTER 승격</figcaption>
</figure>

<h3 id="알림-추가하기">알림 추가하기</h3>

<p>관리자도 모르게 failover 되면 기존 active 노드를 조치하지 못해 passive 마저 다운될 가능성이 있다. 따라서, 알림이 필요하다.
기존 active는 굳이 알림이 필요없다고 판단해서 passive 노드의 failover.sh 스크립트만 아래처럼 수정했다.
사전에 SNS 표준형 토픽을 하나 만들고, 이메일로 구독했다.</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">EIP</span><span class="o">=</span> <span class="c"># EIP</span>
<span class="nv">INSTANCE_ID</span><span class="o">=</span> <span class="c"># 인스턴스 ID</span>
<span class="nv">TOPIC_ARN</span><span class="o">=</span> <span class="c"># 토픽 ARN</span>
<span class="nv">FAILOVER_MESSAGE</span><span class="o">=</span> <span class="s2">"</span><span class="si">$(</span><span class="nb">date</span><span class="si">)</span><span class="s2"> - active down, haproxy failover start. - 172.31.34.184 will be active"</span>

/usr/local/bin/aws ec2 disassociate-address <span class="nt">--public-ip</span> <span class="nv">$EIP</span> <span class="nt">--endpoint-url</span> https://ec2.ap-northeast-2.api.aws <span class="nt">--profile</span> lb-user
/usr/local/bin/aws ec2 associate-address <span class="nt">--public-ip</span> <span class="nv">$EIP</span> <span class="nt">--instance-id</span> <span class="nv">$INSTANCE_ID</span> <span class="nt">--endpoint-url</span> https://ec2.ap-northeast-2.api.aws <span class="nt">--profile</span> lb-user
/usr/local/bin/aws sns publish <span class="nt">--topic-arn</span> <span class="nv">$TOPIC_ARN</span> <span class="nt">--message</span> <span class="s2">"</span><span class="nv">$FAILOVER_MESSAGE</span><span class="s2">"</span> <span class="nt">--endpoint-url</span> https://sns.ap-northeast-2.api.aws <span class="nt">--profile</span> lb-user
</code></pre></div></div>

<p>이제 active 노드 인스턴스를 종료 시켜보려고 한다.</p>
<figure>
<img src="./img/lb-6.png" alt="shell" />
<figcaption> active 노드 종료 시 failver 수행</figcaption>
</figure>

<figure>
<img src="./img/lb-7.png" alt="shell" />
<figcaption> 알림도 잘온다! </figcaption>
</figure>

<h3 id="함께-봤던-자료">함께 봤던 자료</h3>

<ul>
  <li><a href="https://docs.aws.amazon.com/whitepapers/latest/real-time-communication-on-aws/floating-ip-pattern-for-ha-between-activestandby-stateful-servers.html">AWS - 활성-대기 상태 서버 간 HA를 위한 플로팅 IP 패턴</a></li>
  <li><a href="https://aws.amazon.com/ko/blogs/tech/aws-overlay-ip-with-mccs/">AWS - AWS 환경에서 Overlay IP 주소를 활용한 고가용성 구성 및 MCCS 솔루션을 통한 자동 장애조치</a></li>
  <li><a href="https://inpa.tistory.com/entry/AWS-%F0%9F%93%9A-AWS-CLI-%EC%84%A4%EC%B9%98-%EC%82%AC%EC%9A%A9%EB%B2%95-%EC%89%BD%EA%B3%A0-%EB%B9%A0%EB%A5%B4%EA%B2%8C">AWS - CLI 설치 &amp; 등록 방법 - 쉽고 빠르게 설명</a></li>
  <li><a href="https://docs.aws.amazon.com/cli/latest/">AWS - CLI Command Reference</a></li>
  <li><a href="https://docs.aws.amazon.com/ko_kr/ec2/latest/devguide/ec2-endpoints.html#ipv6">AWS - Amazon EC2 서비스 엔드포인트</a></li>
  <li><a href="https://int-i.github.io/web/2021-10-18/lets-encrypt-certbot-https/">SSL - 듣고있나요 나의 이 모든 패킷을: certbot과 함께하는 HTTPS 적용 (Feat. HAProxy)</a></li>
  <li><a href="https://atl.kr/dokuwiki/doku.php/let_s_encrypt_certbot_ssl_with_haproxy">SSL - Let’s Encrypt(CertBot) SSL with HAProxy</a></li>
  <li><a href="https://community.99stack.com/d/511-how-do-i-automatically-renew-tls-certificates-in-haproxy">SSL - How do I automatically renew TLS certificates in Haproxy</a></li>
  <li><a href="https://certbot.eff.org/instructions?ws=haproxy&amp;os=snap&amp;tab=standard">SSL - certbot 퀵스타트</a></li>
  <li><a href="https://wonsss.github.io/deploy/https-webroot/">SSL - Https를 Webroot 방식으로 적용하기(nginx, LetsEncrypt)</a></li>
  <li><a href="https://wonsss.github.io/deploy/deploy-https/">SSL - 배포 및 HTTPS 설정(EC2, Nginx, certbot, dns 방식)</a></li>
  <li><a href="https://skarlso.github.io/2017/02/15/how-to-https-with-hugo-letsencrypt-haproxy/">SSL - How to HTTPS with Hugo LetsEncrypt and HAProxy</a></li>
  <li><a href="https://linked2ev.github.io/devlog/2019/07/21/WEB-What-is-X-Forwarded-Proto/">Network - X-Forwarded-Proto (XFP) 이란?</a></li>
  <li><a href="https://tttsss77.tistory.com/212">OS - 프로세스 종료 신호(SIGINT, SIGTERM) 후킹하기</a></li>
  <li><a href="https://www.sangchul.kr/979">OS - inotifywait 명령어 사용하는 방법</a></li>
  <li><a href="https://wiki.kldp.org/HOWTO/html/Adv-Bash-Scr-HOWTO/subshells.html">OS - 고급 Bash 스크립팅 가이드: Bash를 이용한 쉘 스크립팅 완전 가이드</a></li>
  <li><a href="https://www.haproxy.com/documentation/">haproxy - 공식 문서</a></li>
  <li><a href="https://engmisankim.tistory.com/56">haproxy - haproxy(+keepalived)를 이용한 로드밸런싱 구성</a></li>
  <li><a href="https://www.rapid7.com/blog/post/2014/12/03/keepalived-and-haproxy-in-aws-an-exploratory-guide/">haproxy - Keepalived and HAProxy in AWS: An Exploratory Guide</a></li>
  <li><a href="https://d2.naver.com/helloworld/284659">haproxy - L4/L7 스위치의 대안, 오픈 소스 로드 밸런서 HAProxy</a></li>
  <li><a href="https://www.sangchul.kr/104">haproxy - HAProxy 로깅(haproxy logging) 설정하는 방법</a></li>
  <li><a href="https://medium.com/@yahyasghiouri1998/building-a-high-availability-cluster-with-haproxy-keepalived-and-docker-a-step-by-step-guide-9325f4ac8aa7">haproxy - Building a High Availability Cluster with HAProxy, Keepalived, and Docker: A Step-by-Step Guide</a></li>
  <li><a href="https://grimoire.carcano.ch/blog/high-available-ha-proxy-tutorial-with-keepalived/">haproxy - High Available HA Proxy Tutorial With Keepalived</a></li>
  <li><a href="https://youtu.be/o4gjiBetlZw?si=tRMBbYoAP52G9uvQ">haproxy - Nginx vs HAProxy Performance (HTTP/1 - HTTP/2 - HTTPS - Compression)</a></li>
  <li><a href="https://medium.com/naver-cloud-platform/keepalived%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%98%EC%97%AC-%EA%B0%84%EB%8B%A8%ED%95%98%EA%B2%8C-ha-%EA%B5%AC%EC%84%B1%ED%95%B4%EB%B3%B4%EA%B8%B0-73ab791c60fc">keepalived - Keepalived를 활용하여 간단하게 HA 구성해보기</a></li>
</ul>]]></content><author><name>Haneul Lee</name><email>leehaneul990623@gmail.com</email></author><category term="project" /><summary type="html"><![CDATA[ALB 마이그레이션 메모]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://klise.now.sh/" /><media:content medium="image" url="https://klise.now.sh/" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Performance Engineering 짤짤이 메모</title><link href="https://klise.now.sh/mm-perf/" rel="alternate" type="text/html" title="Performance Engineering 짤짤이 메모" /><published>2025-07-08T00:00:00+09:00</published><updated>2025-07-08T00:00:00+09:00</updated><id>https://klise.now.sh/mm-perf</id><content type="html" xml:base="https://klise.now.sh/mm-perf/"><![CDATA[<blockquote>
  <p>다듬는 중</p>
</blockquote>

<p>컨텐츠 작성이 중요했던 서비스 초반에 성능을 깊이있게 고려하지 않았기 때문에 많이 불안했다. 불안을 달래고자 성능과 비용에 대해서 <a href="https://tech-lovat.vercel.app/mm-traffic/">사용자가 늘어난다. 어쩌지..?</a>라는 글을 썼던 적도 있다. 이제는 충분한 컨텐츠가 작성됐기 때문에 시간적인 여유가 생겼다. 뿐만아니라 AWS 프리티어가 8-9월 즈음에 끝나므로 리소스 최적화도 필요다. 그리고.. 때마침 OOM도 터져주셨다 ^_^… 
서버 성능 개선, 리소스 최적화, 메일 발송 개선에 대한 체계적인 계획과 실천을 하는 것이 매일메일의 2-Pause이다.</p>

<h1 id="1-메일-발송-개선">1. 메일 발송 개선</h1>

<p>subscribe 전체를 메모리에 적재한 다음 필터링한 이후 메일을 만들고, 각 서버 비동기 스레드 풀 큐에 대략 6,000건씩 요청을 쌓아 고정 처리율을 가진 메일 발송 배치를 구현했다.
서비스 초반에도 이 부분이 불안했기 때문에 주기적으로 메모리 메트릭을 봤었고, 256MB로도 충분히 안정적으로 보낼 수 있다고 판단했다. 
그 근거는 GC 이후 힙 메모리 여유 공간을 확보하는 모습을 계속 관찰했기 때문이다. 하지만, <strong>부실한 근거로 얻은 안정감에 취해서 구독자가 증가할수록 점점 올라가는 오전 7시 Heap Used 수치를 미리 알지 못했다.</strong></p>

<p><strong>인지와 대응</strong>은 다음과 같이 했다.</p>

<ul>
  <li>관리자용 메일 전송 결과에 누락 메일 약 1,000건 확인</li>
  <li>서버 로그 확인</li>
  <li>다수의 Heap Space OOM 식별</li>
  <li>API는 동작하지만 메트릭 관련 스레드가 종료되어 메트릭 확인 불가 = 사건 발생 이후 메트릭 수집 중단됨</li>
  <li>(초기 대응) JVM 메모리 사이즈 512MB로 변경</li>
</ul>

<p><strong>원인 식별</strong>은 다음과 같이 했다.</p>
<ul>
  <li>실제 운영 환경에서 메일 발송을 수행할 수 없기 때문에 로컬 Docker Compose 환경 구축하고 메모리 및 cpu 제한</li>
  <li>운영 DB 데이터를 로컬 Docker DB에 적재</li>
  <li>로컬 Docker Compose 테스트 환경을 Datadog에 연결</li>
  <li>OOM 발생 시 힙덤프 파일을 생성하는 플래그를 추가하고 테스트를 진행했으나 OOM 발생안함(-XX:+HeapDumpOnOutOfMemoryError)</li>
  <li>발송 도중에 jmap으로 힙덤프 파일 획득하는 방향으로 진행 -&gt; 덤프 파일 획득
    <ul>
      <li>하는 과정에 삽질했던 부분 -&gt; Docker 베이스 이미지를 distroless를 사용했는데, distroless는 초경량이라 jmap이 없었음</li>
      <li>다른 임시 Java 컨테이너를 생성 -&gt; PID 네임스페이스 공유를 통해서 임시 Java 컨테이너의 jmap으로 distroless 컨테이너의 힙덤프 파일 생성</li>
    </ul>
  </li>
  <li>이클립스 MAT으로 가장 많이 사용되는 객체 식별
    <ul>
      <li>dominator_tree에서 Retained Heap<sup id="retained_heap"><a href="#retained_heap">[1]</a></sup> 순으로 정렬</li>
      <li>비동기 스레드풀 큐가 메모리를 가장 많이 사용함</li>
    </ul>
  </li>
</ul>

<figure>
<img src="./img/mm-perf-mat.png" alt="shell" />
<figcaption>OOM이 발생하지 않는 테스트였단 점을 기억해야한다. 이는 subscribe 전체를 메모리에 올리는 것도 문제가 될 수 있지만 이번 테스트에서는 다루지 않았음을 의미한다. </figcaption>
</figure>

<p>결국 이 사건의 원인은 <strong>메일을 일괄 전송하기 위해 한번에 많은 데이터를 적재하고 처리하기 때문에 OOM이 발생한 것으로 결론</strong>지었으며, 메모리를 무한 증설하기는 어렵기 때문에 분할 처리를 하기로 결정했다.
메일 전송을 위해서 메일 컨텐츠를 조회하고 전송 대상자인지 검증하고.. 메일을 전송한 이후 성공과 실패 내역을 저장하는 일련의 로직이 있고, 처리율과 동시 처리 제어, 재전송 처리와 같은 로직이 있다. 
이러한 로직들을 꾸준히 유지보수하는데 어려움이 있다고 판단하여, 분할 처리, 배치와 관련된 부가 기능, 기능 확장성을 확보할 수 있는 스프링 배치 프레임워크로 마이그레이션하려고 한다.</p>

<h1 id="2-리소스-최적화에-대한-개략적인-목표">2. 리소스 최적화에 대한 개략적인 목표</h1>

<p>가용성 및 확장성을 확보하면서도 리소스를 최적화하는 것이 개략적인 목표다.
아직 제대로 정해진 바는 없는데 <strong>ECS + 컨테이너 기반 인프라</strong>를 고려하고 있다.
상시 유지 API 컨테이너는 한대. 스케줄을 통해 스프링 배치 프로세스를 필요할때만 띄우는 방향성을 생각하고 있다. (ECS 스팟 인스턴스 및 람다를 고려)</p>

<h1 id="3-서버-성능-개선-목표">3. 서버 성능 개선 목표</h1>

<p>서버 성능을 아무 기준도 없이 향상시키는 것은 원치않다. 예를 들어, <strong>Redis 도입 -&gt; 빨라짐!</strong> 과 같은 기준 없는 성능 향상은 
(학습의 관점에서는 도움이 되겠지만) 서비스 관점에서는 크게 도움이 되었음을 증명하기 어렵기도 하며, 합리적인 성능 목표가 없기 때문에 불필요한 리소스를 사용할 가능성을 만든다.
그래서.. 성능 개선을 하기 위한 기준을 만들기로 결정했다. <br /></p>

<p>이번 글에서는 정확한 목표를 수립하기보다는 목표를 수립하기 위한 절차를 계획하는 것을 다룰 것이다. 그 이유는 더욱 구체적으로 목표를 수립하기 위한 기준 데이터를 수집할 기간이 필요하기 때문이다.</p>

<h2 id="1단계-개략적인-서비스-규모">1단계. 개략적인 서비스 규모</h2>

<ul>
  <li><strong>목표 서비스 규모</strong> : 최대 30,000명의 구독자를 수용하는 시스템을 만든다.
    <ul>
      <li>현재 서비스 규모는 구독자 11,000명, MAU는 12,000, DAU 1,000, Edge Request 대략 4,000건 (7시부터 12시간 동안)</li>
      <li>향후 2년뒤에도 안정적으로 운영되는 서비스를 만들고 싶다. (주니어 초반 바쁜점을 반영)</li>
      <li>마케팅을 고려하지 않고 단순 구독자 증가 추이를 근거로 하는 경우, 월 평균 500명의 신규 사용자가 유입되므로 2년 이후에는 23,000명</li>
      <li>분야별 질문 사이클이 6개월이라는 것을 고려, 6개월 단위로 마케팅 유입을 허용한다는 가정하에 30,000명 달성 가능성이 존재한다고 판단</li>
      <li>뿐만아니라 예기치 못한 이벤트(유명인의 홍보)로 사용자가 급증하는 상황이 발생할때 20,000명을 목표로하는 것보다 30,000명을 목표로하는 것이 여유롭다고 판단</li>
      <li>낙관적 전망으로 30,000명이지만.. 15,000명도 달성하지 못하고.. 구독자가 점차 감소하는 추세도 나타날 수 있다.</li>
      <li>이럴 경우에 대해 확신이 있으면 비관적 전망을 채택하는게 개선 비용 관점에서 효율적이지만, 알 수 없는 영역의 일이라 판단하여 우선 낙관적 전망을 택한다.</li>
    </ul>
  </li>
  <li><strong>고민</strong> : API 서버를 오토 스케일 그룹에 추가, 상시에는 서버 1대로 15,000명을 수용하다 최대 30,000명까지 수용하는 시스템으로 자동 확장하는 방향성
    <ul>
      <li>(궁금한 점) 평시에는 서버 1대로 150TPS 유지 -&gt; 기준에 비해서 고부하인 경우 서버 1대 증설(300TPS)하는 방향을 택할지? 혹은 서버 1대로 300TPS 유지할지? 고민된다.</li>
      <li>일단, 1대 기준 30,000명을 수용하도록 최대한 개선해보고! 목표 달성을 위한 개선 비용 확인하고.. 어떻게 할지 결정해보자. (이 부분이 애매하네)</li>
    </ul>
  </li>
</ul>

<h2 id="2단계-서비스-운영-예산-결정">2단계. 서비스 운영 예산 결정</h2>

<blockquote>
  <p>여기서부터는 2번 글인 리소스 최적화의 이유로 봐도 될거 같다. 글을 쓰다가 궁금한게.. 수익 모델이 있으면, 예산을 결정하고 이를 바탕으로 예상 서비스 규모를 결정하는가?가 궁금하다. 우리 서비스는 정해진 예산은 없고, 사용자 규모와 전망만 있어서 서비스 규모 먼저 설정했다.</p>
</blockquote>

<ul>
  <li>지금 현재 AWS에서 지원받은 크레딧이 812달러 남은 상태이며, 예산을 넉넉하게 잡아도 (대략 1년) 이후부터 운영자들의 돈이 나간다.</li>
  <li>일단 (대략 1년)을 최대한 늦추는 것이 첫번째 목표다.
    <ul>
      <li>7월 지불 예정 : 48</li>
      <li>8월부터 764 / 12 = 최대 63달러 ~ 더 낮으면 너무 좋음!</li>
      <li>(구독자가 3배가 되면 월 평균 90만개 메일을 발송하는데 이 비용만 대략 70달러인데 그때는 어쩌지?.. ㅎㅎ;; -&gt; 메일 전송에서 알림이나… 카톡이나…다른 수단으로 핵심만 유지하는 방식으로 개선..? 혹은 휴면 계정 도입을..)</li>
    </ul>
  </li>
  <li>수익 모델이 있으면 좋겠지만, 수익 모델이 없으므로 정확한 예산 산정이 불가하다. 그저.. 최대한 적을수록 좋다.</li>
  <li><strong>(중요)</strong> 이러한 경우에는 초반에는 목표를 설정하고 -&gt; 성능 개선한 이후 -&gt; 더욱 빈번히 비용 최적화를 수행하여 -&gt; 비용을 핏하게 사용하는 서비스로 만드는 것이 이상적이지 않을까 싶다. + 수익 모델을 추가하거나?</li>
  <li>만약에 최적화된 비용조차 감당하기 어려울 정도로 팀원들의 동기가 없는 경우에는 Oracle Cloud Infrastructure로 마이그레이션하는 것도 진지하게 고민하고 있다.</li>
</ul>

<h2 id="3단계-전체-시스템-성능-목표-수립">3단계. 전체 시스템 성능 목표 수립</h2>

<blockquote>
  <p>여기서부터는 조대협님의 <a href="https://bcho.tistory.com/787">성능 엔지니어링 대한 접근 방법 (Performance tuning)</a>이라는 글에서 큰 영감을 받았다. 그리고, 구체적인 수치는 목표를 수립하기 위한 예시로 보면 된다.</p>
</blockquote>

<p>로드밸런서 활성 커넥션 지표 중 가장 높은 25 connection/s를 근거로 <strong>최대 동시 접속자는 75명(25명 * 3)</strong>로 산정했다.
팀 내부 합의를 통해서 <strong>시스템 목표 평균 응답 시간(network time + transaction time의 평균 = 평균 응답 시간)은 200ms</strong>로 가정한다. 
여기서 말하는 시스템 목표 평균 응답 시간은 여러 사용자가 동시 다발적으로 중요 시나리오에 해당되는 API를 호출했을 때, 평균적으로 발생하는 응답 시간을 의미한다. <br /></p>

<blockquote>
  <p>활성 커넥션 지표 25는 저녁 시간대에 측정한거다. 서비스 트래픽 패턴을 고려해 가장 사용자가 많은 오전 7시부터 오전 10시 사이의 최대 활성 커넥션의 수를 식별해야한다고 생각한다. (이상하게 클라우드와치는 초단위 쿼리는 최근 3시간까지만 되는 것 같다..)</p>
</blockquote>

<p>시스템 목표 평균 응답 시간을 결정하기가 어려워 아직 정하지 못했다. 일단 이 글에서 200ms를 가정한 이유는 서칭 과정에서 특정 개발팀에서 200ms를 목표로 설정했다는 글을 봤기 때문이다. <br /></p>

<p><a href="https://velog.io/@sontulip/web-performance-budget">성능 튜닝 전에 성능 예산부터 구하자</a> 글의 사례에서는 중요 페이지를 선정하고 <strong>LCP(Largest Contentful Paint)</strong>, <strong>TTI(Time To Interactive)</strong>, <strong>FCP(First Contentful Paint)</strong>, 그리고 몇가지 규칙과 가정으로 시간 예산을 분배하는 방식을 사용했다. 개인적으로 이 글이 너무 마음에 들었는데, 사용자 관점에서 생각하려는 노력이 보였기 때문이다. 특히, “사용자는 20% 이하의 성능 차이는 인지하지 못한다.”라는 가정을 토대로 서비스 경쟁력을 위해 타서비스에 비해 20% 이상 뛰어난 서비스를 만들겠다는 논리가 인상깊었다. <br /></p>

<p>돌고 돌아서.. 결론은 전체 시스템 성능 목표는 <strong>“75명의 동시 사용자를 기준으로 동시 다발적으로 시스템에 요청을 보내도 200ms 이내로 응답을 해야한다.”</strong> 가 되고, <strong>전체 시스템 처리량은 375TPS</strong>를 목표로 한다는 것이다. (아래식 참고)</p>

\[\begin{align*}
TPS = \frac{\text{Active User}}{\text{Average Response Time}}
\end{align*}\]

<h2 id="4단계-개별-기능-성능-목표-수립">4단계. 개별 기능 성능 목표 수립</h2>

<p>예를 들어, 시스템에 다음과 같은 기능이 있다고 했을 때</p>

<ul>
  <li>질문에 대한 답변 조회</li>
  <li>받은 질문 목록 조회</li>
  <li>구독</li>
</ul>

<p>각각의 평균 호출 횟수는 다음과 같다고 가정하겠다.</p>

<ul>
  <li>질문에 대한 답변 조회 : 2회</li>
  <li>받은 질문 목록 조회 : 1회</li>
  <li>구독 : 1회</li>
</ul>

<p>전체 기능 호출 횟수가 4건일때 질문 보기 상세는 2, 리스트 조회는 1, 구독은 1회다. 이때, 사용자 규모가 3배 늘어난다면 기능 호출 횟수는 다음과 같다. 질문 보기 상세가 변동이 없는 이유는 해당 기능의 응답은 Next.js에서 캐싱이 수행되기 때문에 사용자 규모 증가에 영향을 받지 않기 때문이다.</p>

<ul>
  <li>질문 보기 상세 : 2회(캐싱됨)</li>
  <li>받은 질문 목록 조회 : 3회</li>
  <li>구독 : 3회</li>
</ul>

<p>여기서 짚고 가야할 부분이 하나 있다. 받은 질문 목록 조회의 성능을 개선하기 위해서 로드 밸런서 뒷단에 캐시 계층을 추가했다. 그러면, 구독자 6만명이 될때 호출 횟수를 증가시켜야하는가?에 대해서 잠깐 고민했는데 기본적으로 동시 사용자를 로드 밸런서에 대한 활성 커넥션 수로 정의했기 때문에 증가시켜야 한다고 생각한다. 그렇지 않으면, 다른 기능이 실제와 다르게 호출 비율이 높게 계산될 것이기 때문이다. <br /></p>

<p>기능 호출 비율은 25 : 37.5 : 37.5가 된다. 이 비율을 전체 시스템 TPS에 적용하면 아래와 같이 개별 기능 성능 목표를 알 수 있다.</p>

<ul>
  <li>질문 보기 상세 : 93.75TPS(375 * 0.25)</li>
  <li>받은 질문 목록 조회 : 140.6 TPS(375 * 0.375)</li>
  <li>구독 : 140.6TPS(375 * 0.375)</li>
</ul>

<p>동시 호출 사용자의 경우에는 다음과 같다. 75명에 기능 호출 비율을 곱하거나, 아래와 같이 TPS에 응답 시간을 곱해도 된다.</p>

\[\begin{align*}
\text{Active User} = {\text{TPS}} * {\text{Average Response Time}}
\end{align*}\]

<ul>
  <li>질문 보기 상세 : 18.7명(93.72 * 0.2)</li>
  <li>받은 질문 목록 조회 : 28.12명(140.6 * 0.2)</li>
  <li>구독 : 28.12명(140.6 * 0.2)</li>
</ul>

<h2 id="5단계-테스팅---개선---목표-변경">5단계. 테스팅 - 개선 - 목표 변경</h2>

<p>성능 테스트를 진행한 이후, 목표에 도달하지 못한다면 성능 개선을 진행한다. 만약에 진행하는 과정에서 현실적으로 개선이 어렵다고 판단하는 경우에는 목표를 조정한다.</p>

<h3 id="메모">메모</h3>

<p><small id="retained_heap"><sup><a href="#retained_heap">[1]</a></sup> Retained Heap이란 해당 객체와 연결된 모든 객체를 포함한 메모리 사용량을 의미한다.</small></p>]]></content><author><name>Haneul Lee</name><email>leehaneul990623@gmail.com</email></author><category term="project" /><summary type="html"><![CDATA[Performance Engineering 짤짤이 메모]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://klise.now.sh/" /><media:content medium="image" url="https://klise.now.sh/" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">데이터독 RDS(MySQL 8.0) 모니터링 설정</title><link href="https://klise.now.sh/dd-db/" rel="alternate" type="text/html" title="데이터독 RDS(MySQL 8.0) 모니터링 설정" /><published>2025-06-18T00:00:00+09:00</published><updated>2025-06-18T00:00:00+09:00</updated><id>https://klise.now.sh/dd-db</id><content type="html" xml:base="https://klise.now.sh/dd-db/"><![CDATA[<blockquote>
  <p>최근 MySQL 8.4로 마이그레이션했는데 잘 동작한다.</p>
</blockquote>

<h2 id="1-rds-파라미터-그룹-설정">1. RDS 파라미터 그룹 설정</h2>

<p>RDS 파라미터 그룹에 다음과 같은 설정을 추가해야한다.</p>

<ul>
  <li>performance-schema : 1
    <ul>
      <li>MySQL 성능 스키마 활성화 작업 : MySQL 서버 내부에서 실행되는 작업에 대한 상세 메트릭</li>
    </ul>
  </li>
  <li>max-digest-length : 4096
    <ul>
      <li>정규화된 명령문 다이제스트 계산을 위해 세션당 사용 가능한 바이트 수, 기본값으로 설정할 경우 1024자 미만의 쿼리 수집 불가</li>
    </ul>
  </li>
  <li>performance-schema-max-digest-length : 4096(max_digest-length에 맞춤)</li>
  <li>performance-schema-max-sql-text-length : 4096(max-digest-length에 맞춤)</li>
</ul>

<p>digest 관련 옵션의 키워드는 MySQL ‘Performance Schema Statement Digest’이다. 실행 쿼리들을 그룹화(비슷한 형태의 쿼리를 그룹화)하여 통계를 제공하기 위해 사용하는 것으로 보인다.
위 설정은 ‘비슷한 형태 = digest’의 길이와 연관이 있다.</p>

<p>아래 쿼리를 실행해서 설정이 제대로 됐는지 확인하고, 적용이 안됐다면 DB를 재시작한다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">SELECT</span> <span class="o">*</span> <span class="k">FROM</span> <span class="n">performance_schema</span><span class="p">.</span><span class="n">global_variables</span><span class="p">;</span>
</code></pre></div></div>

<h2 id="2-mysql-작업">2. MySQL 작업</h2>

<p>datadog 스키마와 사용자를 추가하고, 권한을 설정한다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">USER</span> <span class="n">datadog</span><span class="o">@</span><span class="s1">'%'</span> <span class="n">IDENTIFIED</span> <span class="k">by</span> <span class="s1">'&lt;UNIQUEPASSWORD&gt;'</span><span class="p">;</span>
<span class="k">ALTER</span> <span class="k">USER</span> <span class="n">datadog</span><span class="o">@</span><span class="s1">'%'</span> <span class="k">WITH</span> <span class="n">MAX_USER_CONNECTIONS</span> <span class="mi">5</span><span class="p">;</span>
<span class="k">GRANT</span> <span class="n">REPLICATION</span> <span class="n">CLIENT</span> <span class="k">ON</span> <span class="o">*</span><span class="p">.</span><span class="o">*</span> <span class="k">TO</span> <span class="n">datadog</span><span class="o">@</span><span class="s1">'%'</span><span class="p">;</span>
<span class="k">GRANT</span> <span class="n">PROCESS</span> <span class="k">ON</span> <span class="o">*</span><span class="p">.</span><span class="o">*</span> <span class="k">TO</span> <span class="n">datadog</span><span class="o">@</span><span class="s1">'%'</span><span class="p">;</span>
<span class="k">GRANT</span> <span class="k">SELECT</span> <span class="k">ON</span> <span class="n">performance_schema</span><span class="p">.</span><span class="o">*</span> <span class="k">TO</span> <span class="n">datadog</span><span class="o">@</span><span class="s1">'%'</span><span class="p">;</span>

<span class="k">CREATE</span> <span class="k">SCHEMA</span> <span class="n">IF</span> <span class="k">NOT</span> <span class="k">EXISTS</span> <span class="n">datadog</span><span class="p">;</span>
<span class="k">GRANT</span> <span class="k">EXECUTE</span> <span class="k">ON</span> <span class="n">datadog</span><span class="p">.</span><span class="o">*</span> <span class="k">to</span> <span class="n">datadog</span><span class="o">@</span><span class="s1">'%'</span><span class="p">;</span>
<span class="k">GRANT</span> <span class="k">CREATE</span> <span class="k">TEMPORARY</span> <span class="n">TABLES</span> <span class="k">ON</span> <span class="n">datadog</span><span class="p">.</span><span class="o">*</span> <span class="k">TO</span> <span class="n">datadog</span><span class="o">@</span><span class="s1">'%'</span><span class="p">;</span>
</code></pre></div></div>

<ul>
  <li>datadog 사용자에게 프로세스, 리플리케이션, performance_schema 조회 권한을 부여한다.</li>
  <li>datadog 스키마를 생성하고, datadog 스키마 내 프로시저 실행 권한 및 임시 테이블 생성 권한을 부여한다.</li>
</ul>

<p>datadog에서 실행 계획을 수집할 수 있도록 프로시저를 생성한다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">DELIMITER</span> <span class="err">$$</span>
<span class="k">CREATE</span> <span class="k">PROCEDURE</span> <span class="n">datadog</span><span class="p">.</span><span class="n">explain_statement</span><span class="p">(</span><span class="k">IN</span> <span class="n">query</span> <span class="nb">TEXT</span><span class="p">)</span>
    <span class="k">SQL</span> <span class="k">SECURITY</span> <span class="k">DEFINER</span>
<span class="k">BEGIN</span>
    <span class="k">SET</span> <span class="o">@</span><span class="k">explain</span> <span class="p">:</span><span class="o">=</span> <span class="n">CONCAT</span><span class="p">(</span><span class="s1">'EXPLAIN FORMAT=json '</span><span class="p">,</span> <span class="n">query</span><span class="p">);</span>
    <span class="k">PREPARE</span> <span class="n">stmt</span> <span class="k">FROM</span> <span class="o">@</span><span class="k">explain</span><span class="p">;</span>
    <span class="k">EXECUTE</span> <span class="n">stmt</span><span class="p">;</span>
    <span class="k">DEALLOCATE</span> <span class="k">PREPARE</span> <span class="n">stmt</span><span class="p">;</span>
<span class="k">END</span> <span class="err">$$</span>
<span class="k">DELIMITER</span> <span class="p">;</span>


<span class="k">DELIMITER</span> <span class="err">$$</span>
<span class="k">CREATE</span> <span class="k">PROCEDURE</span> <span class="o">&lt;</span><span class="n">YOUR_SCHEMA</span><span class="o">&gt;</span><span class="p">.</span><span class="n">explain_statement</span><span class="p">(</span><span class="k">IN</span> <span class="n">query</span> <span class="nb">TEXT</span><span class="p">)</span>
    <span class="k">SQL</span> <span class="k">SECURITY</span> <span class="k">DEFINER</span>
<span class="k">BEGIN</span>
    <span class="k">SET</span> <span class="o">@</span><span class="k">explain</span> <span class="p">:</span><span class="o">=</span> <span class="n">CONCAT</span><span class="p">(</span><span class="s1">'EXPLAIN FORMAT=json '</span><span class="p">,</span> <span class="n">query</span><span class="p">);</span>
    <span class="k">PREPARE</span> <span class="n">stmt</span> <span class="k">FROM</span> <span class="o">@</span><span class="k">explain</span><span class="p">;</span>
    <span class="k">EXECUTE</span> <span class="n">stmt</span><span class="p">;</span>
    <span class="k">DEALLOCATE</span> <span class="k">PREPARE</span> <span class="n">stmt</span><span class="p">;</span>
<span class="k">END</span> <span class="err">$$</span>
<span class="k">DELIMITER</span> <span class="p">;</span>
<span class="k">GRANT</span> <span class="k">EXECUTE</span> <span class="k">ON</span> <span class="k">PROCEDURE</span> <span class="o">&lt;</span><span class="n">YOUR_SCHEMA</span><span class="o">&gt;</span><span class="p">.</span><span class="n">explain_statement</span> <span class="k">TO</span> <span class="n">datadog</span><span class="o">@</span><span class="s1">'%'</span><span class="p">;</span>
</code></pre></div></div>

<p>RDS를 사용하는 경우에는 성능 스키마 컨슈머 설정이 어렵기 때문에, 아래와 같이 프로시저를 만들어서 런타임에 에이전트가 컨슈머를 활성화할 수 있는 권한을 제공한다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">DELIMITER</span> <span class="err">$$</span>
<span class="k">CREATE</span> <span class="k">PROCEDURE</span> <span class="n">datadog</span><span class="p">.</span><span class="n">enable_events_statements_consumers</span><span class="p">()</span>
    <span class="k">SQL</span> <span class="k">SECURITY</span> <span class="k">DEFINER</span>
<span class="k">BEGIN</span>
    <span class="k">UPDATE</span> <span class="n">performance_schema</span><span class="p">.</span><span class="n">setup_consumers</span> <span class="k">SET</span> <span class="n">enabled</span><span class="o">=</span><span class="s1">'YES'</span> <span class="k">WHERE</span> <span class="n">name</span> <span class="k">LIKE</span> <span class="s1">'events_statements_%'</span><span class="p">;</span>
    <span class="k">UPDATE</span> <span class="n">performance_schema</span><span class="p">.</span><span class="n">setup_consumers</span> <span class="k">SET</span> <span class="n">enabled</span><span class="o">=</span><span class="s1">'YES'</span> <span class="k">WHERE</span> <span class="n">name</span> <span class="o">=</span> <span class="s1">'events_waits_current'</span><span class="p">;</span>
<span class="k">END</span> <span class="err">$$</span>
<span class="k">DELIMITER</span> <span class="p">;</span>
<span class="k">GRANT</span> <span class="k">EXECUTE</span> <span class="k">ON</span> <span class="k">PROCEDURE</span> <span class="n">datadog</span><span class="p">.</span><span class="n">enable_events_statements_consumers</span> <span class="k">TO</span> <span class="n">datadog</span><span class="o">@</span><span class="s1">'%'</span><span class="p">;</span>
</code></pre></div></div>

<h2 id="3-에이전트-설정">3. 에이전트 설정</h2>

<p>우분투 기준 <code class="language-plaintext highlighter-rouge">/etc/datadog-agent/conf.d/mysql.d/</code> 경로에 conf.yaml 파일을 생성하고 다음과 같이 작성한다. (에이전트 설치 필요 -&gt; 데이터독 사이트 참고)</p>

<div class="language-yml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">init_config</span><span class="pi">:</span>

<span class="na">instances</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">dbm</span><span class="pi">:</span> <span class="kc">true</span>
    <span class="na">host</span><span class="pi">:</span> <span class="s1">'</span><span class="s">&lt;AWS_INSTANCE_ENDPOINT&gt;'</span>
    <span class="na">port</span><span class="pi">:</span> <span class="m">3306</span>
    <span class="na">username</span><span class="pi">:</span> <span class="s">datadog</span>
    <span class="na">password</span><span class="pi">:</span> <span class="s1">'</span><span class="s">&lt;YOUR_CHOSEN_PASSWORD&gt;'</span>
    <span class="na">aws</span><span class="pi">:</span>
      <span class="na">instance_endpoint</span><span class="pi">:</span> <span class="s1">'</span><span class="s">&lt;AWS_INSTANCE_ENDPOINT&gt;'</span>
</code></pre></div></div>

<p>에이전트를 재시작하고, 연동이 잘되는지 확인한다.</p>

<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>service datadog-agent restart 
</code></pre></div></div>

<h3 id="함께-봤던-자료">함께 봤던 자료</h3>

<ul>
  <li><a href="https://docs.datadoghq.com/ko/database_monitoring/setup_mysql/rds/?tab=mysql56">Amazon RDS 매니지드 MySQL에 대한 데이터베이스 모니터링 설정</a></li>
  <li><a href="https://hoing.io/archives/3811">MySQL Performance Schema - 성능 스키마</a></li>
  <li><a href="https://myinfrabox.tistory.com/194">MySQL Performance Schema 소개 및 사용방법</a></li>
</ul>]]></content><author><name>Haneul Lee</name><email>leehaneul990623@gmail.com</email></author><category term="devops" /><summary type="html"><![CDATA[데이터독 RDS 모니터링 설정]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://klise.now.sh/" /><media:content medium="image" url="https://klise.now.sh/" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Hazelcast 메모</title><link href="https://klise.now.sh/hazelcast/" rel="alternate" type="text/html" title="Hazelcast 메모" /><published>2025-06-04T00:00:00+09:00</published><updated>2025-06-04T00:00:00+09:00</updated><id>https://klise.now.sh/hazelcast</id><content type="html" xml:base="https://klise.now.sh/hazelcast/"><![CDATA[<h2 id="redis-대신-hazelcast를-선택할-수-있는-근거">Redis 대신 Hazelcast를 선택할 수 있는 근거</h2>

<p>Redis를 사용한다면 EC2 한 대 정도는 더 사용할 수 있다고 판단했다. 다만 이 경우 standalone 구성이 되기 때문에 SPOF가 염려됐다.</p>

<p>Redis를 HA 구성으로 운영하려면 Sentinel 또는 Cluster 구성을 위한 추가 인스턴스를 운영해야 한다. 단순한 전역 상태 저장소 하나를 위해 Redis HA 구성까지 운영하는 것은 과하다고 판단했다.</p>

<p>반면 Hazelcast는 기존 애플리케이션 프로세스 안에서 embedded member로 실행할 수 있다. 각 JVM 프로세스에 Hazelcast 멤버를 추가하면 멤버끼리 클러스터링하여 고가용성과 내결함성을 가진 전역 상태 저장소를 만들 수 있다. Hazelcast embedded mode는 실행 중인 JVM의 메모리를 사용한다.</p>

<p>Redis와 비교하면 다음과 같이 볼 수 있다.</p>

<ul>
  <li>Redis standalone은 단순하지만 SPOF가 된다.</li>
  <li>Redis HA 구성을 하려면 Sentinel 또는 Cluster 구성이 필요하다.</li>
  <li>Redis Cluster도 HA를 위해 replica 프로세스가 필요하다.</li>
  <li>Hazelcast embedded mode는 애플리케이션 프로세스가 확장될 때 함께 확장할 수 있다.</li>
  <li>Hazelcast는 별도 저장소 인프라를 크게 늘리지 않고 클러스터 구성이 가능하다.</li>
  <li>대신 애플리케이션 JVM의 메모리와 GC 영향을 같이 고려해야 한다.</li>
</ul>

<h2 id="고가용성과-내결함성">고가용성과 내결함성</h2>

<p>Hazelcast는 스토리지 데이터, 연산 데이터, 백업을 모든 클러스터 멤버에 분산한다. 멤버가 손실되더라도 Hazelcast가 백업 데이터를 복원하여 지속적인 가용성을 제공할 수 있다.</p>

<p>백업은 메모리에 분산되어 저장된다. 분산은 파티션 수준에서 이루어지며, 기본 데이터와 백업은 각 파티션에 저장된다.</p>

<p>클러스터의 멤버가 손실되면 Hazelcast는 나머지 멤버에 백업을 재분배하여 모든 파티션에 백업을 저장한다. 이를 통해 데이터 손실에 대한 복원력을 확보할 수 있다. 백업 횟수는 구성 가능하며, 구성에 따라 파티션의 여러 복제본에 데이터를 보관할 수 있다.</p>

<p>Hazelcast에서는 클러스터 멤버가 서로의 상태를 모니터링한다. 네트워크 장애와 같은 이벤트로 인해 클러스터 멤버에 접근할 수 없게 되면 다른 멤버들이 협력하여 상태를 진단하고, 장애가 발생한 멤버의 작업을 인계받는다.</p>

<p>멤버가 접근할 수 없거나 작동이 중단되었는지 확인하기 위해 Hazelcast는 내장 장애 감지기를 제공한다.</p>

<h2 id="ap-데이터-구조를-위한-replication">AP 데이터 구조를 위한 Replication</h2>

<p>각 데이터 엔트리는 하나의 Hazelcast 파티션에 매핑되고, 그 파티션의 replica에 저장된다. replica 중 하나가 primary replica로 선출되어 해당 파티션의 연산을 담당한다.</p>

<p>데이터 엔트리를 읽거나 쓸 때 사용자는 해당 파티션의 primary replica가 할당된 Hazelcast 멤버와 통신한다. 즉 정상 상황에서는 각 요청이 데이터 엔트리의 최신 버전에 접근할 수 있다.</p>

<p>backup replica는 primary replica가 장애가 날 때까지 대기 상태를 유지한다. primary replica에 장애가 발생하면 backup replica 중 하나가 primary 역할로 승격된다.</p>

<p>지연 복제(lazy replication)에서는 primary replica가 어떤 키에 대한 업데이트 연산을 받으면 로컬에서 먼저 실행하고 backup replica에 전파한다. 업데이트에는 타임스탬프가 있어서 순서를 보장한다.</p>

<p>backup replication에는 동기 방식과 비동기 방식이 있다.</p>

<ul>
  <li>동기 : backup replica가 backup update를 적용하고 ack를 호출자에게 돌려줄 때까지 호출자를 블로킹한다.</li>
  <li>비동기 : 전송만 하고 기다리지 않는다.</li>
</ul>

<p>동기 방식에서도 실행 결과가 먼저 호출자에게 응답으로 전달되고, 호출자는 그 응답을 받은 다음 설정된 동기 백업 수만큼 ack를 사전에 정의된 timeout 안에서 기다린다. 기본 timeout은 5초다.</p>

<p>backup update는 오래된 파티션 테이블 정보, 네트워크 중단, 멤버 crash 등으로 누락될 수 있다.</p>

<ul>
  <li>부정확한 파티션 정보로 인해 backup replica를 가진 멤버에 update 요청을 하지 못할 수 있다.</li>
  <li>네트워크 중단으로 update를 보내지 못할 수 있다.</li>
  <li>update를 보낼 멤버가 죽었을 수 있다.</li>
</ul>

<p>결론적으로 동기 backup ack 대기에는 timeout이 필요하다.</p>

<p>동기든 비동기든 관계없이 backup update가 누락되면, 주기적으로 실행되는 anti-entropy 메커니즘이 불일치를 감지하여 backup replica를 primary와 동기화한다.</p>

<p>anti-entropy는 분산 시스템에서 업데이트 내역이 모든 노드에 전파되지 않아 엔트로피가 올라가는 상황을 다시 질서 있게 만드는 메커니즘이라고 이해했다.</p>

<h2 id="분할과-복제">분할과 복제</h2>

<p>기본적으로 Hazelcast는 하나의 파티션에 대해 하나의 backup replica를 생성한다. 파티션에 여러 replica가 생성되도록 설정할 수도 있다. <code class="language-plaintext highlighter-rouge">backup-count</code>는 최대 6까지 설정할 수 있고, 설정한 backup count만큼 동시 장애를 허용한다. 그 이상 장애가 발생하면 데이터 유실이 발생할 수 있다.</p>

<p align="center">
  <img src="img/hazelcast-partition-replication-1.png" alt="Hazelcast 파티션과 복제" />
</p>

<p>특정 데이터 항목을 읽고 사용할 때는 해당 데이터 항목을 포함하는 파티션 owner와 통신한다. 파티션이 보유 가능한 데이터 항목의 양은 시스템의 물리적 용량에 따라 제한된다.</p>

<p>새로운 멤버가 합류하면 기본 파티션과 백업 파티션 일부를 새 멤버로 이동한다. Hazelcast는 파티션을 균등하게 분배한다.</p>

<p>Hazelcast는 해싱 알고리즘을 통해 데이터 항목을 파티션에 분배한다.</p>

<ul>
  <li>키를 직렬화하여 byte array로 변환한다.</li>
  <li>변환된 byte array를 hash한다.</li>
  <li>hash 결과를 파티션 수로 modulo 연산한다.</li>
  <li><code class="language-plaintext highlighter-rouge">MOD(hash result, partition count)</code> 결과가 partition id가 된다.</li>
</ul>

<p>partition table에는 partition id와 파티션이 속한 클러스터 멤버의 주소가 저장된다. 클러스터의 모든 멤버가 이 정보를 알고 있어야 각 멤버가 데이터의 위치를 알 수 있다.</p>

<p>클러스터에서 가장 오래된 멤버, 즉 master member가 최초에 partition table을 생성한다. 새로운 멤버가 생길 때마다 partition table을 업데이트하고, 주기적으로 다른 모든 멤버에게 partition table을 전달한다. 기본 전송 간격은 15초이며 설정 가능하다.</p>

<p>master member가 다운되면 다음으로 오래된 멤버가 partition table 정보를 다른 멤버에게 전송한다.</p>

<p>리밸런싱은 클러스터에 멤버가 가입하거나 탈퇴하는 경우에 발생한다.</p>

<h2 id="consistency-문제">Consistency 문제</h2>

<p>Hazelcast는 backup partition을 운영하지만, 기본적으로 primary partition을 통해 조회와 변경이 이루어진다. 따라서 정상 상황에서는 일관성 문제가 발생하지 않는 것으로 이해했다.</p>

<p>다만 몇 가지 시나리오에서는 일관성이 깨질 수 있다.</p>

<p>첫 번째는 네트워크 파티션 이후 통합 상황이다. 네트워크 파티션으로 인해 클러스터가 독립적으로 작동하면 split-brain 문제가 발생할 수 있다.</p>

<p>Hazelcast는 이 상황에 대해 두 가지 접근 방식을 제공한다.</p>

<ul>
  <li>split-brain protection</li>
  <li>split-brain recovery</li>
</ul>

<p>split-brain protection은 consistency가 중요한 경우에 사용한다. 특정 데이터 구조를 사용 가능하게 유지하려면 최소 클러스터 크기가 필요하다. 클러스터 크기가 정의된 split-brain protection size보다 작으면 작업은 실패한다. 즉 사용할 수 없게 만든다.</p>

<p>split-brain recovery는 양쪽에서 데이터 구조를 사용하도록 하고, 분할이 해결되면 데이터를 병합하는 방식이다. 파티션 해소 시 작은 클러스터가 큰 클러스터로 합류하는 것은 클러스터 레벨 규칙이고, key/value 충돌을 어떤 값으로 살릴지는 merge policy가 결정한다.</p>

<p>두 번째는 primary replica와 backup replica 사이에 복제 지연이 있는 동안 primary replica 멤버가 장애를 겪는 상황이다.</p>

<p>Hazelcast는 다음과 같은 active anti-entropy 방식으로 이 시나리오의 영향을 줄이려고 한다.</p>

<ul>
  <li>각 Hazelcast 멤버는 백그라운드에서 주기적인 작업을 실행한다.</li>
  <li>할당된 각 primary replica에 대해 요약 정보를 작성하여 backup으로 전송한다.</li>
  <li>각 backup 멤버는 요약 정보를 자신의 데이터와 비교하여 primary와 최신 상태인지 확인한다.</li>
  <li>backup 멤버가 누락된 업데이트를 감지하면 primary 멤버와 동기화 프로세스를 시작한다.</li>
</ul>

<p>따라서 Hazelcast AP 데이터 구조는 강한 일관성보다는 최종 일관성에 가깝다고 이해하는 편이 안전하다.</p>

<h2 id="실행-보장">실행 보장</h2>

<p>AP 제품인 Hazelcast는 exactly-once를 보장하지 않는다. 일반적으로 at-least-once만 보장하는 솔루션에 가깝다.</p>

<p>중복 실행이 발생할 수 있는 상황은 다음과 같다.</p>

<ul>
  <li>보류 중인 호출의 대상 멤버 응답을 기다리는 동안 호출 대상 멤버가 클러스터를 떠난다.</li>
  <li>해당 호출은 새 partition table로 인해 새 멤버에게 다시 제출된다.</li>
  <li>그런데 기존에 클러스터에서 떠난 호출 대상 멤버의 호출이 이미 실행된 상태일 수 있다.</li>
  <li>backup update가 backup replica로 전파되었지만 호출자가 응답을 받지 못한 상황일 수 있다.</li>
  <li>이 경우 작업이 두 번 실행될 수 있다.</li>
</ul>

<p>호출이 제때 응답받지 못하는 경우 <code class="language-plaintext highlighter-rouge">OperationTimeoutException</code>이 발생한다. 기본값은 2분이고, <code class="language-plaintext highlighter-rouge">hazelcast.operation.call.timeout.millis</code> 시스템 속성으로 정의된다.</p>

<p>timeout이 지나면 호출 결과는 불확정(indeterminate) 상태가 된다. 전혀 실행되지 않았을 수도 있고, 한 번 실행되었을 수도 있고, 두 번 실행되었을 수도 있다.</p>

<p>Hazelcast에는 불확정 상황에서 <code class="language-plaintext highlighter-rouge">IndeterminateOperationStateException</code>을 던지는 옵션이 있다. invocation이 retry되면서 중복 호출이 가능하므로, 이 설정을 통해 호출자가 불확정 상태를 명시적으로 다룰 수 있다.</p>

<p>이 예외는 다음과 같은 경우에 발생할 수 있다.</p>

<ul>
  <li>primary replica 멤버에서 <code class="language-plaintext highlighter-rouge">MemberLeftException</code>이 발생한 경우</li>
  <li>지정된 timeout 기간 동안 backup replica로부터 ack가 하나 이상 누락된 경우</li>
</ul>

<p>이 설정이 rollback을 해주는 것은 아니다. 다만 성공 응답을 받았다면 caller, primary, backup까지 실행이 완료되었다고 더 보수적으로 판단할 수 있다.</p>

<h2 id="최선의-노력-일관성">최선의 노력 일관성</h2>

<p>AP 데이터 구조에 대한 복제 알고리즘은 Hazelcast 클러스터가 높은 처리량을 제공할 수 있도록 한다. 하지만 네트워크 중단과 같은 일시적인 상황으로 인해 backup이 update를 놓치고 primary와 분리될 수 있다.</p>

<p>backup replica는 VM pause 또는 긴 GC pause로 인해 primary보다 뒤처질 수도 있다. 이를 replication lag라고 한다.</p>

<p>Hazelcast partition primary replica 멤버가 자신과 backup 사이에 replication lag가 있는 동안 장애가 발생하면 데이터의 강한 일관성이 손실될 수 있다.</p>

<p>결국 Hazelcast AP 데이터 구조는 높은 처리량과 가용성을 위해 coordination을 줄이는 선택을 한 구조다. 따라서 강한 일관성이 필요한 데이터인지, 일시적인 불일치를 감수하고 이후 복구할 수 있는 데이터인지 먼저 구분해야 한다.</p>

<h2 id="참고">참고</h2>

<ul>
  <li><a href="https://docs.hazelcast.com/hazelcast/5.5/architecture/data-partitioning">Hazelcast Data Partitioning and Replication</a></li>
  <li><a href="https://docs.hazelcast.com/hazelcast/5.7/network-partitioning/dealing-with-network-partitions">Hazelcast Dealing with Network Partitions</a></li>
</ul>]]></content><author><name>Haneul Lee</name><email>leehaneul990623@gmail.com</email></author><category term="project" /><summary type="html"><![CDATA[Hazelcast 고가용성, 파티션, 복제, split-brain, consistency 메모]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://klise.now.sh/" /><media:content medium="image" url="https://klise.now.sh/" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">JVM의 힙 메모리를 어떻게 설정해야할까?</title><link href="https://klise.now.sh/jvm-memory-size/" rel="alternate" type="text/html" title="JVM의 힙 메모리를 어떻게 설정해야할까?" /><published>2025-05-24T00:00:00+09:00</published><updated>2025-05-24T00:00:00+09:00</updated><id>https://klise.now.sh/jvm-memory-size</id><content type="html" xml:base="https://klise.now.sh/jvm-memory-size/"><![CDATA[<h2 id="1-힙-메모리">1. 힙 메모리</h2>

<figure>
<img src="/jvm-memory-size/jvm-memory-layout.png" alt="shell" />
<figcaption>Fig 1. JVM Memory Layout</figcaption>
</figure>

<p>위 그림에서 JVM Memory 부분을 Runtime Data Area라고도 부른다. 여기서 Heap은 모든 스레드가 공유하는 영역으로 런타임에 생성된 객체들이 저장되는 공간이며,
GC의 주된 관심사이기도 하다. Heap의 크기는 <code class="language-plaintext highlighter-rouge">-Xms</code>, <code class="language-plaintext highlighter-rouge">-Xmx</code>로 관여할 수 있다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">-Xms</code>: 자바 프로세스의 최초 힙 크기</li>
  <li><code class="language-plaintext highlighter-rouge">-Xmx</code>: 자바 프로세스의 최대 힙 크기</li>
</ul>

<h2 id="2-기본값은-뭘까">2. 기본값은 뭘까?</h2>

<p>JVM은 시작될 때 java 명령어 줄에 있는 옵션을 파싱한다. 일부 명령은 자바 프로그램에서 적절한 JIT 컴파일러를 선택하는 등의 작업을 수행하기 위해 사용되며 일부 명령은 JVM에 전달된다. 만약 <strong>JIT 컴파일러 타입이나 자바 힙 크기 할당 옵션이 지정되지 않는 경우에는 시스템의 상황에 맞게 설정</strong>된다.</p>

<p><a herf="https://docs.oracle.com/en/java/javase/17/gctuning/hotspot-virtual-machine-garbage-collection-tuning-guide.pdf" rel="noopener" target="_blank">Oracle - HotSpot VM GC Tuning Guide</a>에 의하면 Oracle HotSpot VM 17의 주요 기본 설정값은 다음과 같다.</p>

<ul>
  <li>GC : Garbage-First (G1) Collector (메모리 사이즈에 따라서 다른 GC가 선택될 수 있는 걸로 알고 있다.)</li>
  <li>최대 GC 스레드 : 사용 가능한 CPU 자원과 힙 사이즈로 결정</li>
  <li>초기 힙 사이즈 : 물리 메모리의 1/64</li>
  <li>최대 힙 사이즈 : 물리 메모리의 1/4</li>
  <li>JIT 컴파일러 : C1, C2 모두 사용</li>
</ul>

<p>여기서 글의 주제인 힙 사이즈만을 직접 확인해보기로 했다. <code class="language-plaintext highlighter-rouge">-XX:+PrintFlagsFinal</code> 옵션을 추가하면 설정된 JVM 플래그<sup id="flag"><a href="#user-ref">[1]</a></sup> 목록을 확인할 수 있다.
테스트는 Oracle OpenJDK 17.0.12, 메모리 16GB 환경에서 진행했다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[Global flags]
   size_t InitialHeapSize                          = 268435456                                 {product} {ergonomic}
   size_t MaxHeapSize                              = 4294967296                                {product} {ergonomic}
   size_t MinHeapDeltaBytes                        = 2097152                                   {product} {ergonomic}
   size_t MinHeapSize                              = 8388608                                   {product} {ergonomic}
    uintx MinHeapFreeRatio                         = 40                                     {manageable} {default}
    uintx MaxHeapFreeRatio                         = 70                                     {manageable} {default}
   double InitialRAMPercentage                     = 1.562500                                  {product} {default}
   double MaxRAMPercentage                         = 25.000000                                 {product} {default}
   double MinRAMPercentage                         = 50.000000                                 {product} {default}
</code></pre></div></div>

<p>기본적인 설명은 아래와 같다. 컨테이너 환경인 경우에는 전체 물리 메모리가 아닌 컨테이너 자체 할당된 메모리를 기준으로 계산된다. 위 결과를 보면 InitialHeapSize와 MaxHeapSize를 보면 각각 물리 메모리의 1/64, 1/4로 설정되는 것을 알 수 있다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">InitialHeapSize</code> : 초기 힙 영역 할당 사이즈</li>
  <li><code class="language-plaintext highlighter-rouge">MaxHeapSize</code> : 최대 힙 영역 할당 사이즈(해당 사이즈까지 Heap Expansion을 수행)</li>
  <li><code class="language-plaintext highlighter-rouge">MinHeapDeltaBytes</code> : GC로 인한 최소 힙 영역 변화</li>
  <li><code class="language-plaintext highlighter-rouge">MinHeapSize</code> : 최소 힙 영역 할당 사이즈(Heap Shrinkage의 마지노선으로 생각했다.)</li>
  <li><code class="language-plaintext highlighter-rouge">MinHeapFreeRatio</code> : Heap Expansion 수행 임계치(GC 이후 여유 Heap 공간이 40% 미만이면 Heap Expansion 수행)</li>
  <li><code class="language-plaintext highlighter-rouge">MaxHeapFreeRatio</code> : Heap Shrinkage 수행 임계치(GC 이후 여유 Heap 공간이 70% 이상이면 Heap Shrinkage 수행)</li>
  <li><code class="language-plaintext highlighter-rouge">InitialRAMPercentage</code> : 초기 힙 크기 설정에 사용(ms 설정 시 해당 옵션 무시)</li>
  <li><code class="language-plaintext highlighter-rouge">MaxRAMPercentage</code> : 최대 힙 크기 설정에 사용(일반 메모리)</li>
  <li><code class="language-plaintext highlighter-rouge">MinRAMPercentage</code> : 최대 힙 크기 설정에 사용(소형 메모리, 기준은 대략 256MB 부근)</li>
</ul>

<p>JVM은 GC 이후 남은 여유 공간을 근거로 힙 영역을 MinHeapSize(하한)와 MaxHeapSize(상한)의 범위에서 축소(Shrinkage)하거나 확장(Expansion)한다. <strong>기본값은 초기 힙 사이즈와 최대 힙 사이즈의 간격이 크기 때문에 확장을 위한 오버헤드가 발생하여 성능이 저하될 것으로</strong> 보인다. (실제 테스트로 증명 해보려고 했는데.. 차이가 크지는 않았다. 케이스를 잘못 작성했을 수도 있으니.. 추후에 시간나면 다시 다뤄봐야겠다!)</p>

<h3 id="메모">메모</h3>

<p><small id="user-ref"><sup><a href="#flag">[1]</a></sup> JVM에서 플래그는 JVM 내부 동작을 제어하거나 설정을 변경할 수 있는 옵션을 의미한다.</small></p>]]></content><author><name>Haneul Lee</name><email>leehaneul990623@gmail.com</email></author><category term="jvm" /><summary type="html"><![CDATA[JVM 힙 메모리 제한 설정]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://klise.now.sh/" /><media:content medium="image" url="https://klise.now.sh/" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">좋아요 기능 관련 짧은 아이디어</title><link href="https://klise.now.sh/mm-like/" rel="alternate" type="text/html" title="좋아요 기능 관련 짧은 아이디어" /><published>2025-01-21T00:00:00+09:00</published><updated>2025-01-21T00:00:00+09:00</updated><id>https://klise.now.sh/mm-like</id><content type="html" xml:base="https://klise.now.sh/mm-like/"><![CDATA[<h2 id="아이디어">아이디어</h2>

<p>최근에 매일메일 좋아요 기능에 대한 동시성 문제를 고민하면서 재밌는 아이디어가 생각나서 짧게 남긴다. 한 사용자는 한 게시글에 달린 답변 당 최대 한 번의 좋아요만 수행할 수 있다. 쉽게 이야기하자면, 인스타 게시물의 좋아요 버튼(토글)이라고 생각하면 된다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">insert</span> <span class="k">into</span> <span class="n">comment_like</span> <span class="p">(</span><span class="n">member_id</span><span class="p">,</span> <span class="n">comment_id</span><span class="p">)</span> <span class="k">values</span> <span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="mi">2</span><span class="p">);</span>
</code></pre></div></div>

<p>아이디어는 다음과 같다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">select</span> <span class="o">*</span>
<span class="k">from</span> <span class="n">comment_like</span> <span class="n">cl</span>
<span class="k">where</span>
    <span class="n">cl</span><span class="p">.</span><span class="n">comment_id</span> <span class="o">=</span> <span class="mi">1</span> <span class="k">and</span>
    <span class="n">cl</span><span class="p">.</span><span class="n">member_id</span> <span class="o">=</span> <span class="mi">2</span>
</code></pre></div></div>

<ul>
  <li>동시 요청을 제어하지 않으며, 모든 동시 요청들을 유효하다고 판단한다. (서버에서 최소한의 개수 검증 코드는 작성) </li>
  <li>백그라운드에서 중복된 comment_like 레코드를 주기적으로 지워주는 건 선택의 몫이다.</li>
  <li>조회의 경우, 위와 같은 쿼리의 결과를 CommentLike 객체의 리스트로 변환한다. 그리고 중복을 제거한다. (스택을 사용하거나, Set을 사용할 수 있다.)</li>
</ul>

<p>comment_like 레코드가 1억 건이 반환되면, oom이 발생하겠지만.. 코어가 10개인 내 노트북을 기준으로, 동시 호출로 인한 중복 저장은 최대 10건 정도이다. 따라서, t2.micro ec2 2대인 상황에서는 위 쿼리에서 1억 건이 반환될 이유가 떠오르지 않는다. 해당 방식의 단점은 추가적인 cpu 리소스를 사용한다는 점, 추가적인 DB 저장 공간을 사용할 수 있다는 점, 인지 비용(왜 중복을 제거해야 하지?)이 든다는 점이다. 당장 해당 방식을 채택하지는 않을 테지만, 해당 방식이 합리적인 상황도 분명 있을 것이라 생각한다.</p>

<h2 id="민수와의-대화">민수와의 대화</h2>

<blockquote>
  <p>위 아이디어를 보고, 민수가 유니크로 막으면 되지 않는가?라고 이야기했다. 아래는 그에대한 나의 답변이다.</p>
</blockquote>

<p>원래는 유니크에 대한 한계 케이스도 작성했었는데, 작성하다보니 유니크가 더 나은 것 같아서 해당 아이디어를 보류 시켰어요. ㅋㅋㅋ</p>

<p>[궁금하실까봐 생각했던 한계 케이스를 남깁니다.]</p>

<p>가령, 미디엄의 좋아요 기능을 만든다면, (클랩이라고 하던가?) 한 사용자가 게시글에 여러번 좋아요(최대 제한 수는 있습니다.)를 누를 수 있어야합니다. 이때, 유니크를 사용하려면(member_id, comment_id, sequence) 조합으로 사용해야 한다고 생각했어요. (다른 방법이 있으면 공유 부탁 드립니다.)</p>

<p>만약, 10개의 요청이 아주 빠른 간격으로 서버로 전달되며, 해당 기능이 5개의 좋아요 제한이 있다면 10개의 요청이 도착해도 5개의 좋아요를 채울 수 없을 수도 있어요.</p>

<ol>
  <li>(1, 2, 지금 시퀀스가 뭐지? = 0) = 성공</li>
  <li>(1, 2. 지금 시퀀스가 뭐지? = 0) = 실패</li>
  <li>(1, 2, 지금 시퀀스가 뭐지? = 1) = 성공</li>
  <li>(1, 2, 지금 시퀀스가 뭐지? = 1) = 실패</li>
  <li>(1, 2, 지금 시퀀스가 뭐지? = 1) = 실패</li>
</ol>

<p>해당 방식으로 접근하면, 스키마 변경 없이 10개 요청 중에서 좋아요 제한 수만큼은 성공, 나머지 요청은 무시하면 되므로 10개 요청으로도 좋아요 제한 수를 채울 수 있다고 생각했어요.</p>]]></content><author><name>Haneul Lee</name><email>leehaneul990623@gmail.com</email></author><category term="project" /><summary type="html"><![CDATA[좋아요 기능 관련 짧은 아이디어]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://klise.now.sh/" /><media:content medium="image" url="https://klise.now.sh/" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">캐싱 관련 짤짤이 메모</title><link href="https://klise.now.sh/mm-cache/" rel="alternate" type="text/html" title="캐싱 관련 짤짤이 메모" /><published>2025-01-02T00:00:00+09:00</published><updated>2025-01-02T00:00:00+09:00</updated><id>https://klise.now.sh/mm-cache</id><content type="html" xml:base="https://klise.now.sh/mm-cache/"><![CDATA[<h2 id="들어가는-말">들어가는 말</h2>

<p>아마존은 로딩속도가 1초 빨라지면, 매출이 68억 달러가 증가한다고 발표했으며, 핀터레스트의 경우 로딩속도를 40% 개선한 이후 트래픽이 15% 증가하고, 회원가입이 15% 증가했다고 말한다. 만약 캐싱을 하게 된다면, 클라이언트 레벨에서 로딩 속도 향상의 이점이 존재하며, 서버 레벨에서는 사용하지 않는 메모리를 활용하여 원본 데이터베이스의 부하를 줄여 더 적은 리소스로 더욱 많은 트래픽을 처리할 수 있으니 비용 효율이라는 이점을 얻어갈 수 있다.</p>

<p>라이언이 Next.js 구간에서 컨텐츠 캐싱을 하는 것은 어떤지 제안해 줬다. 덕분에 캐시 레이어에 대한 비용, 관리 측면의 고민들을 덜 할 수 있게 되었다. 글의 목적은 Next.js의 캐싱으로 인해 발생할 수 있는 문제점이 존재하는지 점검하고, 서버 개발자로 협업이 예상되는 지점들을 고민하기 위해 작성한다.</p>

<h2 id="매일메일-컨텐츠-캐싱의-현주소">매일메일 컨텐츠 캐싱의 현주소</h2>

<h3 id="api를-위한-캐싱">API를 위한 캐싱</h3>

<p>매일메일 서비스의 서버는 GET /question/{id} API에 대한 호출이 가장 잦다. 전체 구독자 5600명 중 하루 평균 로드밸런서 요청 건수가 4000건이라고 할 때, 보수적으로 해당 API에 대한 요청이 4000건이라고 가정해 봤다. 이때, 다음과 같은 쿼리를 작성해 보면 100건의 레코드가 반환된다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">select</span> <span class="n">category</span><span class="p">,</span> <span class="n">next_question_sequence</span>
<span class="k">from</span> <span class="n">subscribe</span>
<span class="k">group</span> <span class="k">by</span> <span class="n">category</span><span class="p">,</span> <span class="n">next_question_sequence</span>
</code></pre></div></div>

<p>이 결과가 시사하는 바는 하루에 실질적으로 소비되는 컨텐츠는 100개라는 것이다. OS나 DBMS의 캐시, 구간에 대한 분포를 고려하지 않고 개략적으로 계산해 보자면 1개의 컨텐츠는 평균적으로 40번 읽힌다. 결국 읽히는 컨텐츠는 어느 정도 예견되어 있으며, 적은 비용으로 효과를 낼 수 있다. </p>

<h3 id="벌크성-메일-전송을-위한-캐싱">벌크성 메일 전송을 위한 캐싱</h3>

<p>벌크성 메일 전송을 위한 로컬 캐시의 경우 138개의 컨텐츠를 캐싱한다. 메일 전송을 위한 컨텐츠는 1시간 동안 로컬에서 유효한 상태로 남아있고 없어진다. 벌크성 메일 전송 시간대에는 캐시 일관성에 대한 고민이 필요 없었기 때문이다. 아직은 위와 같이 필요한 데이터만 핏 하게 캐싱을 하는 것은 불필요할 수 있다 판단된다.(코드의 복잡도를 생각한다면) 다만, 시간이 늘어 <strong>컨텐츠의 수가 많아진다면 위와 같이 필요한 데이터만 캐싱하는 접근 방식이 요구될 것이다.</strong></p>

<h3 id="과거-의사결정과-nextjs-컨텐츠-캐싱에-대한-생각">과거 의사결정과  Next.js 컨텐츠 캐싱에 대한 생각</h3>

<p>1개의 컨텐츠가 40번 읽힐 가능성이 존재하지만, API 요청에 대해서는 로컬 캐시를 사용하지 않는다. 벌크성 메일 전송과는 다르게 접근 시간대가 다양하기 때문에 캐시 일관성에 대한 고려가 필요했기 때문이다. 이를 해결하기 위해  글로벌 캐시를 도입할 수 있다. 글로벌 캐시는 인프라 비용과 관리 비용이 들지만 이에 비해서 얻는 이득이 적을 수 있기 때문에 도입을 망설이고 있었다. (TPS 20 정도만 나와도 4000건 요청을 3분 이내 쳐낼 수 있다. 그리고, 쿼리 대상 테이블의 레코드 수는 현재 138건, 주에 10건씩 쌓이며, PK 기반 조회이다. 물론 전송  네트워크 트래픽 사용량도 따져봐야겠지만.. 미디어가 포함되지 않으니 논의에서 제외해보겠다.)</p>

<p>서버 측에서는 사용자가 지금보다 더욱 늘어나고, 이에 대한 충분한 지표가 수집되는 경우 글로벌 캐시를 도입해보려고 했었다. 하지만, 프론트엔드 측 팀 리소스가 남아있고 활용 가능한 Next.js라는 인프라가 존재하니 Next.js 레벨 캐싱에 대해서는 긍정적으로 생각힌다. 이로 인해 얻을 수 있는 로딩 속도 개선과 서버 비용 절감을 기대한다.</p>

<h2 id="예견되는-문제와-협업할-수-있는-부분">예견되는 문제와 협업할 수 있는 부분</h2>

<p>크게 세 가지 문제가 발생할 수 있다. (최대 캐싱 가능 컨텐츠 수, 캐시 관통, 캐시 일관성)</p>

<h3 id="최대-캐싱-가능-컨텐츠의-수">최대 캐싱 가능  컨텐츠의 수</h3>

<p>Next.js는 서버 프로세스로 이해하고 있다. 결국 해당 프로세스에 할당된 메모리나 디스크 자원에 서버 사이드 렌더링된 컨텐츠를 저장할 것이다. 그러면 최대 저장 가능한 컨텐츠의 수는 <strong>얼마나 되는 지(저장 가능한 한도)와 컨텐츠의 캐싱 기간, 공간이 꽉 찼을 때 어떤 것을 희생시킬지에 대한 정책</strong>들이 필요하지 않을까 고민했다. 저장 공간 절감과 관련해서 서버 측에서 도움을 줄 수 있는 부분은 해당 날에 발송될 컨텐츠들을 미리 예측해서 내려주는 것이 방안이 될 수 있다. 그리고, Look Aside 방식으로 캐싱되지 않은 컨텐츠는 그때 조회해서 적재하는 방식으로 풀어 볼 수 있다.</p>

<h3 id="캐시-관통">캐시 관통</h3>

<p><a href="https://toss.tech/article/25301">토스 기술 블로그의 캐시 문제 해결 가이드</a> 아티클에 따르면, 원본 데이터베이스에서 값을 읽었음에도 불구하고 캐싱되지 않은 상황을 캐시 관통이라고 표현한다. 이로인해 불필요한 조회 요청이 자주 발생하고, 캐시의 의미가 퇴색될 수 있다. 데이터가 존재하지 않는다는 사실 자체를 캐싱하는 것을 통해 불필요한 서버 측 요청을 줄일 수 있다. 관련해서 서버와 협업할 가능성이 있는 부분은 조금 더 고민해봐야한다.</p>

<h3 id="캐시-일관성">캐시 일관성</h3>

<p>캐싱된 데이터가 오래된 데이터일 가능성이 존재한다. 캐싱된 데이터가 오래됐다는 것은 변경이 발생했다는 것이다. 그렇다면, <strong>컨텐츠는 수정되는 데이터인가?</strong>를 따져볼 필요성이 있다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">select</span> <span class="k">count</span><span class="p">(</span><span class="o">*</span><span class="p">)</span>
<span class="k">from</span> <span class="n">question</span>
<span class="k">where</span> <span class="n">datediff</span><span class="p">(</span><span class="n">updated_at</span><span class="p">,</span> <span class="n">created_at</span><span class="p">)</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">;</span>
</code></pre></div></div>

<p>위와 같은 쿼리를 작성했을 때, 반환되는 수는 58이다. 즉, 한 개의 컨텐츠는 다시 수정될 가능성이 높다. (변경의 정확한 빈도를 알아내려면 바이너리 로그를 활용해야하지만 그정도로 중요한 지표는 아니므로 넘어간다.) 다음 질문은 <strong>한 번 수정된 컨텐츠는 다시 재수정될 가능성이 있는가?</strong>이다.</p>

<p> 관리자의 경우, 한 번 공들여 작성한 컨텐츠에 대해서 시간을 많이 사용하여 작성하기 때문에 다시 수정할 일이 드물다. “이정도 작성했으면, 충분하지 않을까?”와 같은 생각이 들때 발행하기 때문이다. 질문의 수정은 주로 질문지에 대해서 구독자가 컨텐츠 피드백을 하는 경우에 발생한다. 컨텐츠 피드백을 통해서 수정이 된다고 하면, 그 이후에 수정하는 경우는 경험 상 적다. 따라서, 나의 가설은.. 수정의 빈도는 적다는 것이다. (재수정될 가능성이 적기 때문이다.)</p>

<p>그럼에도 불구하고 일관성 문제는 발생한다. 라이언이 제안해 준 해결책은 업데이트 주기를 설정하여, 정해진 주기마다 업데이트를 하는 방식이다. 일관성이 깨지는 시간이 정해진 주기정도 될 테지만, 서비스에 큰 영향을 줄 정도는 아니라고 판단된다. 예상되는 문제지만, 해결책이 있으므로 상대적으로 걱정이 덜하다. 관련해서 서버와 협업할 가능성이 있는 부분은 이 역시 조금 더 고민해봐야 한다.</p>]]></content><author><name>Haneul Lee</name><email>leehaneul990623@gmail.com</email></author><category term="project" /><summary type="html"><![CDATA[캐싱 관련 짤짤이 메모]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://klise.now.sh/" /><media:content medium="image" url="https://klise.now.sh/" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>