<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0"
  xmlns:atom="http://www.w3.org/2005/Atom"
  xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Yun Blog</title>
    <link>https://cheese10yun.github.io/</link>
    
    <atom:link href="https://cheese10yun.github.io/rss2.xml" rel="self" type="application/rss+xml"/>
    
    <description>기술 블로그</description>
    <pubDate>Sun, 14 Jun 2026 14:26:10 GMT</pubDate>
    <generator>http://hexo.io/</generator>
    
    <item>
      <title>Claude Code Statusline 커스텀하기</title>
      <link>https://cheese10yun.github.io/claude-code-statusline-custom/</link>
      <guid>https://cheese10yun.github.io/claude-code-statusline-custom/</guid>
      <pubDate>Sat, 13 Jun 2026 15:00:00 GMT</pubDate>
      
      <description>Claude Code의 Statusline을 커스텀하는 방법에 대해 알아보겠습니다.</description>
      
      
      
      <content:encoded><![CDATA[<p>Claude Code를 사용하다 보면 하단 상태 표시줄(status line)이 기본적으로 제공되는데, 여기에 모델명·컨텍스트 사용률·비용·Git 브랜치 같은 정보를 직접 구성할 수 있습니다. 이 글에서는 statusline이 어떻게 동작하는지 개념을 설명하고, 설정 방법 두 가지를 비교한 뒤, 제가 실제로 사용 중인 커스텀 구성을 소개합니다.</p><p>제가 현재 사용하는 statusline의 출력 예시는 이렇습니다:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">fable 5 ~/L/M/i/Documents/PKM ⎇ main 24.8k [░░░░░░░░] 2% · ↻3h50m ↑32%  ↻4d9h ↑3% $10.32</span><br></pre></td></tr></table></figure><p>한 줄 안에 모델명, 경로, Git 브랜치, 토큰 수, 컨텍스트 사용률, rate limit 남은 시간, 세션 비용까지 담았습니다.</p><hr><h2><span id="statuslineiran">statusline이란?</span><a href="#statuslineiran" class="header-anchor">#</a></h2><p>Claude Code statusline은 <strong>터미널 하단에 고정 표시되는 커스텀 상태 바</strong>입니다. Claude Code가 사용자가 지정한 셸 명령을 실행하고, 그 stdout 출력을 그대로 statusline에 렌더링하는 방식으로 동작합니다.</p><h3><span id="dongjag-mekeonijeum">동작 메커니즘</span><a href="#dongjag-mekeonijeum" class="header-anchor">#</a></h3><p>Claude Code는 스크립트를 실행할 때 <strong>stdin으로 현재 세션 상태를 JSON 형태로 전달</strong>합니다. 스크립트는 이 JSON을 파싱해 원하는 값을 추출하고, stdout에 출력하면 그 내용이 statusline에 표시됩니다.</p><p><strong>갱신 시점:</strong></p><ul><li>어시스턴트 메시지 수신 후</li><li><code>/compact</code> 완료 후</li><li>권한 모드 변경 시</li><li>vim 모드 토글 시</li></ul><p>빠른 연속 변경은 300ms 디바운스로 묶어서 한 번만 실행합니다. 스크립트가 아직 실행 중에 새 갱신이 오면 진행 중인 실행을 취소하고 새로 시작합니다.</p><blockquote><p><strong>중요</strong>: statusline은 <strong>로컬에서 실행</strong>됩니다. API 토큰을 전혀 소비하지 않습니다.</p></blockquote><h3><span id="stdineuro-jeondaldoeneun-juyo-json-pildeu">stdin으로 전달되는 주요 JSON 필드</span><a href="#stdineuro-jeondaldoeneun-juyo-json-pildeu" class="header-anchor">#</a></h3><table><thead><tr><th>필드</th><th>설명</th></tr></thead><tbody><tr><td><code>model.id</code></td><td>모델 ID (예: <code>claude-sonnet-4-6</code>)</td></tr><tr><td><code>model.display_name</code></td><td>모델 표시명 (예: <code>Sonnet 4.6</code>)</td></tr><tr><td><code>workspace.current_dir</code></td><td>현재 작업 디렉토리</td></tr><tr><td><code>context_window.used_percentage</code></td><td>컨텍스트 윈도우 사용률 (%)</td></tr><tr><td><code>context_window.total_input_tokens</code></td><td>현재 컨텍스트의 입력 토큰 수</td></tr><tr><td><code>context_window.total_output_tokens</code></td><td>현재 컨텍스트의 출력 토큰 수</td></tr><tr><td><code>cost.total_cost_usd</code></td><td>세션 누적 비용 (USD, 클라이언트 추정치)</td></tr><tr><td><code>cost.total_duration_ms</code></td><td>세션 경과 시간 (ms)</td></tr><tr><td><code>rate_limits.five_hour.used_percentage</code></td><td>5시간 rate limit 사용률</td></tr><tr><td><code>rate_limits.five_hour.resets_at</code></td><td>5시간 윈도우 리셋 시각 (Unix epoch)</td></tr><tr><td><code>rate_limits.seven_day.used_percentage</code></td><td>7일 rate limit 사용률</td></tr><tr><td><code>rate_limits.seven_day.resets_at</code></td><td>7일 윈도우 리셋 시각 (Unix epoch)</td></tr><tr><td><code>session_id</code></td><td>세션 고유 ID (캐싱 키로 활용 가능)</td></tr></tbody></table><p><code>rate_limits</code>는 Claude.ai Pro&#x2F;Max 구독자에게만 첫 API 응답 이후 제공됩니다. 없을 수 있는 필드는 <code>// empty</code>나 <code>// 0</code> 폴백으로 방어해야 합니다.</p><hr><h2><span id="seoljeong-bangbeob-inrain-vs-seukeuribteu-pail">설정 방법: 인라인 vs 스크립트 파일</span><a href="#seoljeong-bangbeob-inrain-vs-seukeuribteu-pail" class="header-anchor">#</a></h2><p>statusline 설정은 <code>~/.claude/settings.json</code>의 <code>statusLine</code> 필드로 합니다. 중요한 점은 <strong><code>type</code>은 항상 <code>&quot;command&quot;</code> 하나뿐</strong>이라는 것입니다. “직접 작성 모드”와 “스크립트 모드”가 별도로 있는 게 아니라, <code>command</code> 필드에 무엇을 쓰느냐의 차이입니다.</p><h3><span id="bangbeob-a-inrain-one-liner">방법 A: 인라인 one-liner</span><a href="#bangbeob-a-inrain-one-liner" class="header-anchor">#</a></h3><p><code>command</code>에 셸 명령을 직접 적습니다. <code>jq</code>를 활용하면 간단한 표시는 한 줄로 충분합니다.</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;statusLine&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;command&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;command&quot;</span><span class="punctuation">:</span> <span class="string">&quot;jq -r &#x27;[\&quot;[\&quot;, .model.display_name, \&quot;] \&quot;, (.context_window.used_percentage // 0 | floor | tostring), \&quot;% context\&quot;] | join(\&quot;\&quot;)&#x27;&quot;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><strong>장점</strong>: 파일을 따로 만들 필요 없이 바로 시작할 수 있습니다.<br><strong>단점</strong>: 명령이 길어지면 settings.json이 지저분해지고, 색상 처리나 조건 분기를 넣기 어렵습니다.</p><h3><span id="bangbeob-b-seukeuribteu-pail">방법 B: 스크립트 파일</span><a href="#bangbeob-b-seukeuribteu-pail" class="header-anchor">#</a></h3><p><code>command</code>에 스크립트 경로를 적고, 로직은 별도 파일에 작성합니다.</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;statusLine&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;command&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;command&quot;</span><span class="punctuation">:</span> <span class="string">&quot;bash ~/.claude/statusline.sh&quot;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>스크립트 파일(<code>~/.claude/statusline.sh</code>)을 만들고 실행 권한을 부여합니다:</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">chmod</span> +x ~/.claude/statusline.sh</span><br></pre></td></tr></table></figure><p><strong>장점</strong>: 로직이 길어도 관리하기 좋고, 색상·조건 분기·여러 섹션을 깔끔하게 구성할 수 있습니다. Git으로 버전 관리도 됩니다. 샘플 JSON으로 독립적인 테스트도 가능합니다.<br><strong>단점</strong>: 파일을 별도로 만들고 관리해야 합니다.</p><p>섹션이 8개 이상인 경우라면 스크립트 방식이 거의 유일한 선택지입니다.</p><h3><span id="x2f-statusline-jadong-saengseong-helpeo">&#x2F;statusline 자동 생성 헬퍼</span><a href="#x2f-statusline-jadong-saengseong-helpeo" class="header-anchor">#</a></h3><p>자연어로 설명하면 Claude Code가 스크립트를 자동 생성하고 settings.json까지 업데이트해 줍니다:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">/statusline show model name and context percentage with a progress bar</span><br></pre></td></tr></table></figure><p>이것은 별개의 설정 방식이 아니라, 위의 “스크립트 파일 방식”을 자동화해 주는 헬퍼입니다.</p><hr><h2><span id="nae-statusline-guseong">내 statusline 구성</span><a href="#nae-statusline-guseong" class="header-anchor">#</a></h2><h3><span id="coejong-culryeog-hyeongtae">최종 출력 형태</span><a href="#coejong-culryeog-hyeongtae" class="header-anchor">#</a></h3><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">fable 5 ~/L/M/i/Documents/PKM ⎇ main 24.8k [░░░░░░░░] 2% · ↻3h50m ↑32%  ↻4d9h ↑3% $10.32</span><br></pre></td></tr></table></figure><table><thead><tr><th>항목</th><th>예시</th><th>색상</th></tr></thead><tbody><tr><td>모델명 (축약)</td><td><code>fable 5</code></td><td>cyan</td></tr><tr><td>현재 경로 (fish 스타일 축약)</td><td><code>~/L/M/i/Documents/PKM</code></td><td>yellow</td></tr><tr><td>Git 브랜치</td><td><code>⎇ main</code></td><td>green</td></tr><tr><td>토큰 사용량</td><td><code>24.8k</code></td><td>blue</td></tr><tr><td>컨텍스트 사용률 바 + %</td><td><code>[░░░░░░░░] 2%</code></td><td>사용률에 따라 green&#x2F;yellow&#x2F;red</td></tr><tr><td>현재 세션 한도</td><td><code>↻3h50m ↑32%</code></td><td>리셋시간 cyan, 사용률 구간별 색</td></tr><tr><td>주간 한도</td><td><code>↻4d9h ↑3%</code></td><td>리셋시간 yellow, 사용률 구간별 색</td></tr><tr><td>세션 비용</td><td><code>$10.32</code></td><td>magenta</td></tr></tbody></table><h3><span id="segsyeonbyeol-guhyeon-yido">섹션별 구현 의도</span><a href="#segsyeonbyeol-guhyeon-yido" class="header-anchor">#</a></h3><h4><span id="modelmyeong-cugyag">모델명 축약</span><a href="#modelmyeong-cugyag" class="header-anchor">#</a></h4><p><code>model.id</code>를 <code>sed</code> 정규식으로 <code>claude-sonnet-4-6</code> → <code>sonnet-4.6</code> 형태로 줄입니다. 패턴 매칭에 실패하면(새 네이밍 등) <code>display_name</code>을 소문자로 변환해 폴백합니다.</p><figure class="highlight sh"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">abbr=$(<span class="built_in">echo</span> <span class="string">&quot;<span class="variable">$model_id</span>&quot;</span> | sed -E \</span><br><span class="line">  <span class="string">&#x27;s/claude-([a-z]+)-([0-9]+)-([0-9]+).*/\1-\2.\3/; \</span></span><br><span class="line"><span class="string">   s/claude-([a-z]+-[a-z]+)-([0-9]+)-([0-9]+).*/\1-\2.\3/&#x27;</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> [ -z <span class="string">&quot;<span class="variable">$abbr</span>&quot;</span> ] || [ <span class="string">&quot;<span class="variable">$abbr</span>&quot;</span> = <span class="string">&quot;<span class="variable">$model_id</span>&quot;</span> ]; <span class="keyword">then</span></span><br><span class="line">  abbr=$(<span class="built_in">echo</span> <span class="string">&quot;<span class="variable">$model_display</span>&quot;</span> | sed -E \</span><br><span class="line">    <span class="string">&#x27;s/Claude //; s/ ([0-9]+)\.([0-9]+).*/\1.\2/&#x27;</span> | \</span><br><span class="line">    <span class="built_in">tr</span> <span class="string">&#x27;[:upper:]&#x27;</span> <span class="string">&#x27;[:lower:]&#x27;</span>)</span><br><span class="line"><span class="keyword">fi</span></span><br></pre></td></tr></table></figure><p>모델명이 길면 statusline이 금방 꽉 차므로 최대한 짧게 유지하는 것이 목표입니다. <code>claude-fable-5</code>처럼 새 네이밍은 폴백 경로를 타서 <code>fable 5</code>로 표시됩니다.</p><h4><span id="fish-shell-seutail-gyeongro-cugyag">fish shell 스타일 경로 축약</span><a href="#fish-shell-seutail-gyeongro-cugyag" class="header-anchor">#</a></h4><p><code>$HOME</code>을 <code>~</code>로 치환한 뒤, awk로 <strong>마지막 2개 세그먼트만 전체 표시하고 나머지는 첫 글자만</strong> 남깁니다.</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">~/Library/Mobile Documents/iCloud~md~obsidian/Documents/PKM</span><br><span class="line">→ ~/L/M/i/Documents/PKM</span><br></pre></td></tr></table></figure><p>iCloud 볼트처럼 경로가 긴 환경에서 공간을 아끼기 위한 처리입니다. fish shell에서 영감을 받았습니다.</p><h4><span id="git-beuraenci">Git 브랜치</span><a href="#git-beuraenci" class="header-anchor">#</a></h4><figure class="highlight sh"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">git_branch=$(git -C <span class="string">&quot;<span class="variable">$cwd</span>&quot;</span> -c core.hooksPath=/dev/null \</span><br><span class="line">  symbolic-ref --short HEAD 2&gt;/dev/null)</span><br></pre></td></tr></table></figure><p><code>-c core.hooksPath=/dev/null</code>을 추가해 git 훅 실행을 차단합니다. statusline은 자주 호출되는데, 훅이 실행되면 의도치 않은 부작용이 생길 수 있습니다. detached HEAD 상태면 <code>rev-parse --short HEAD</code>로 커밋 해시를 대신 표시합니다. git 저장소가 아닌 디렉토리라면 섹션 자체를 생략합니다.</p><h4><span id="tokeun-sayongryang">토큰 사용량</span><a href="#tokeun-sayongryang" class="header-anchor">#</a></h4><p>입력 + 출력 토큰을 합산해 1,000 이상이면 <code>24.8k</code>처럼 k 단위 소수점 1자리로 포맷합니다.</p><figure class="highlight sh"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">total_tokens=$(( total_in + total_out ))</span><br><span class="line"><span class="keyword">if</span> [ <span class="string">&quot;<span class="variable">$total_tokens</span>&quot;</span> -ge 1000 ]; <span class="keyword">then</span></span><br><span class="line">  token_str=$(awk <span class="string">&quot;BEGIN &#123;printf \&quot;%.1fk\&quot;, <span class="variable">$total_tokens</span>/1000&#125;&quot;</span>)</span><br><span class="line"><span class="keyword">else</span></span><br><span class="line">  token_str=<span class="string">&quot;<span class="variable">$&#123;total_tokens&#125;</span>&quot;</span></span><br><span class="line"><span class="keyword">fi</span></span><br></pre></td></tr></table></figure><h4><span id="keontegseuteu-sayongryul-peurogeureseu-ba">컨텍스트 사용률 프로그레스 바</span><a href="#keontegseuteu-sayongryul-peurogeureseu-ba" class="header-anchor">#</a></h4><p><code>used_percentage</code>를 8칸 블록 바로 변환합니다. 색상은 사용률 구간별로 다르게 줍니다:</p><ul><li>0~60%: green <code>[████████]</code></li><li>61~80%: yellow</li><li>81%+: red</li></ul><figure class="highlight sh"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> [ <span class="string">&quot;<span class="variable">$pct_int</span>&quot;</span> -le 60 ]; <span class="keyword">then</span></span><br><span class="line">  bar_color=<span class="string">&quot;<span class="variable">$C_GREEN</span>&quot;</span></span><br><span class="line"><span class="keyword">elif</span> [ <span class="string">&quot;<span class="variable">$pct_int</span>&quot;</span> -le 80 ]; <span class="keyword">then</span></span><br><span class="line">  bar_color=<span class="string">&quot;<span class="variable">$C_YELLOW</span>&quot;</span></span><br><span class="line"><span class="keyword">else</span></span><br><span class="line">  bar_color=<span class="string">&quot;<span class="variable">$C_RED</span>&quot;</span></span><br><span class="line"><span class="keyword">fi</span></span><br></pre></td></tr></table></figure><h4><span id="rate-limit-hyeonjae-sesyeon-jugan">Rate limit (현재 세션·주간)</span><a href="#rate-limit-hyeonjae-sesyeon-jugan" class="header-anchor">#</a></h4><p><code>resets_at</code>(Unix epoch)에서 현재 시각을 빼 남은 시간을 <code>↻3h50m</code>, <code>↻4d9h</code> 형태로 표시하고, 사용률을 <code>↑32%</code>로 붙입니다.</p><figure class="highlight sh"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="title">format_remaining</span></span>() &#123;</span><br><span class="line">  _rem=$(( <span class="variable">$1</span> - $(date +%s) ))</span><br><span class="line">  [ <span class="string">&quot;<span class="variable">$_rem</span>&quot;</span> -lt 0 ] &amp;&amp; _rem=0</span><br><span class="line">  <span class="keyword">if</span>   [ <span class="string">&quot;<span class="variable">$_rem</span>&quot;</span> -ge 86400 ]; <span class="keyword">then</span> <span class="built_in">printf</span> <span class="string">&#x27;%dd%dh&#x27;</span> $(( _rem/<span class="number">86400</span> )) $(( _rem%<span class="number">86400</span>/<span class="number">3600</span> ))</span><br><span class="line">  <span class="keyword">elif</span> [ <span class="string">&quot;<span class="variable">$_rem</span>&quot;</span> -ge 3600  ]; <span class="keyword">then</span> <span class="built_in">printf</span> <span class="string">&#x27;%dh%02dm&#x27;</span> $(( _rem/<span class="number">3600</span> )) $(( _rem%<span class="number">3600</span>/<span class="number">60</span> ))</span><br><span class="line">  <span class="keyword">else</span>                             <span class="built_in">printf</span> <span class="string">&#x27;%dm&#x27;</span> $(( _rem/<span class="number">60</span> ))</span><br><span class="line">  <span class="keyword">fi</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>사용률 색상은 컨텍스트 바와 동일한 60&#x2F;80% 기준을 <code>usage_color</code> 함수로 공유해 일관성을 유지합니다. <code>rate_limits</code> 필드 자체가 없을 수 있으므로(API 키 직접 사용 시 등) <code>// empty</code>로 처리하고 필드가 없으면 rate limit 섹션을 통째로 생략합니다.</p><h4><span id="sesyeon-biyong">세션 비용</span><a href="#sesyeon-biyong" class="header-anchor">#</a></h4><p><code>cost.total_cost_usd</code>를 소수점 2자리로 포맷해 <code>$10.32</code> 형태로 표시합니다. magenta 색상. 비용 필드가 없으면 섹션을 생략합니다.</p><figure class="highlight sh"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">cost_usd=$(<span class="built_in">echo</span> <span class="string">&quot;<span class="variable">$input</span>&quot;</span> | jq -r <span class="string">&#x27;.cost.total_cost_usd // empty&#x27;</span>)</span><br><span class="line"><span class="keyword">if</span> [ -n <span class="string">&quot;<span class="variable">$cost_usd</span>&quot;</span> ]; <span class="keyword">then</span></span><br><span class="line">  cost_str=$(awk <span class="string">&quot;BEGIN &#123;printf \&quot;%.2f\&quot;, <span class="variable">$cost_usd</span>&#125;&quot;</span>)</span><br><span class="line">  cost_part=$(<span class="built_in">printf</span> <span class="string">&quot; <span class="variable">$&#123;C_MAGENTA&#125;</span>\$%s<span class="variable">$&#123;C_RESET&#125;</span>&quot;</span> <span class="string">&quot;<span class="variable">$cost_str</span>&quot;</span>)</span><br><span class="line"><span class="keyword">fi</span></span><br></pre></td></tr></table></figure><h3><span id="seolgye-weoncig">설계 원칙</span><a href="#seolgye-weoncig" class="header-anchor">#</a></h3><ol><li><strong>가볍게</strong>: statusline은 자주 실행됩니다. 외부 의존성은 <code>jq</code>, <code>awk</code>, <code>sed</code>, <code>git</code> 정도로 제한합니다.</li><li><strong>방어적으로</strong>: 모든 필드 추출에 <code>// empty</code> 또는 <code>// 0</code> 폴백을 둡니다. 필드가 없는 환경(버전 차이, API 키 직접 사용 등)에서도 오류 없이 동작하고, 해당 섹션만 자연스럽게 생략됩니다.</li><li><strong>일관성</strong>: 색상 구간 규칙(60%&#x2F;80% 경계)을 컨텍스트 바와 rate limit이 <code>usage_color</code> 함수로 공유합니다.</li></ol><h3><span id="jeonce-seukeuribteu">전체 스크립트</span><a href="#jeonce-seukeuribteu" class="header-anchor">#</a></h3><figure class="highlight sh"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br><span class="line">82</span><br><span class="line">83</span><br><span class="line">84</span><br><span class="line">85</span><br><span class="line">86</span><br><span class="line">87</span><br><span class="line">88</span><br><span class="line">89</span><br><span class="line">90</span><br><span class="line">91</span><br><span class="line">92</span><br><span class="line">93</span><br><span class="line">94</span><br><span class="line">95</span><br><span class="line">96</span><br><span class="line">97</span><br><span class="line">98</span><br><span class="line">99</span><br><span class="line">100</span><br><span class="line">101</span><br><span class="line">102</span><br><span class="line">103</span><br><span class="line">104</span><br><span class="line">105</span><br><span class="line">106</span><br><span class="line">107</span><br><span class="line">108</span><br><span class="line">109</span><br><span class="line">110</span><br><span class="line">111</span><br><span class="line">112</span><br><span class="line">113</span><br><span class="line">114</span><br><span class="line">115</span><br><span class="line">116</span><br><span class="line">117</span><br><span class="line">118</span><br><span class="line">119</span><br><span class="line">120</span><br><span class="line">121</span><br><span class="line">122</span><br><span class="line">123</span><br><span class="line">124</span><br><span class="line">125</span><br><span class="line">126</span><br><span class="line">127</span><br><span class="line">128</span><br><span class="line">129</span><br><span class="line">130</span><br><span class="line">131</span><br><span class="line">132</span><br><span class="line">133</span><br><span class="line">134</span><br><span class="line">135</span><br><span class="line">136</span><br><span class="line">137</span><br><span class="line">138</span><br><span class="line">139</span><br><span class="line">140</span><br><span class="line">141</span><br><span class="line">142</span><br><span class="line">143</span><br><span class="line">144</span><br><span class="line">145</span><br><span class="line">146</span><br><span class="line">147</span><br><span class="line">148</span><br><span class="line">149</span><br><span class="line">150</span><br><span class="line">151</span><br><span class="line">152</span><br><span class="line">153</span><br><span class="line">154</span><br><span class="line">155</span><br><span class="line">156</span><br><span class="line">157</span><br><span class="line">158</span><br><span class="line">159</span><br><span class="line">160</span><br><span class="line">161</span><br><span class="line">162</span><br><span class="line">163</span><br><span class="line">164</span><br><span class="line">165</span><br><span class="line">166</span><br><span class="line">167</span><br><span class="line">168</span><br><span class="line">169</span><br><span class="line">170</span><br><span class="line">171</span><br><span class="line">172</span><br><span class="line">173</span><br><span class="line">174</span><br><span class="line">175</span><br><span class="line">176</span><br><span class="line">177</span><br><span class="line">178</span><br><span class="line">179</span><br><span class="line">180</span><br><span class="line">181</span><br><span class="line">182</span><br><span class="line">183</span><br><span class="line">184</span><br><span class="line">185</span><br><span class="line">186</span><br><span class="line">187</span><br><span class="line">188</span><br><span class="line">189</span><br><span class="line">190</span><br><span class="line">191</span><br><span class="line">192</span><br><span class="line">193</span><br><span class="line">194</span><br><span class="line">195</span><br><span class="line">196</span><br><span class="line">197</span><br><span class="line">198</span><br><span class="line">199</span><br><span class="line">200</span><br><span class="line">201</span><br><span class="line">202</span><br><span class="line">203</span><br><span class="line">204</span><br><span class="line">205</span><br><span class="line">206</span><br><span class="line">207</span><br><span class="line">208</span><br><span class="line">209</span><br><span class="line">210</span><br><span class="line">211</span><br><span class="line">212</span><br><span class="line">213</span><br><span class="line">214</span><br><span class="line">215</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">#!/bin/sh</span></span><br><span class="line"><span class="comment"># Claude Code status line script — developer edition</span></span><br><span class="line"></span><br><span class="line">input=$(<span class="built_in">cat</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment"># ANSI color codes</span></span><br><span class="line">C_CYAN=<span class="string">&#x27;\033[36m&#x27;</span></span><br><span class="line">C_YELLOW=<span class="string">&#x27;\033[33m&#x27;</span></span><br><span class="line">C_GREEN=<span class="string">&#x27;\033[32m&#x27;</span></span><br><span class="line">C_RED=<span class="string">&#x27;\033[31m&#x27;</span></span><br><span class="line">C_BLUE=<span class="string">&#x27;\033[34m&#x27;</span></span><br><span class="line">C_MAGENTA=<span class="string">&#x27;\033[35m&#x27;</span></span><br><span class="line">C_RESET=<span class="string">&#x27;\033[0m&#x27;</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># ── 1. Model name (abbreviated) ──────────────────────────────────────────────</span></span><br><span class="line">model_id=$(<span class="built_in">echo</span> <span class="string">&quot;<span class="variable">$input</span>&quot;</span> | jq -r <span class="string">&#x27;.model.id // &quot;&quot;&#x27;</span>)</span><br><span class="line">model_display=$(<span class="built_in">echo</span> <span class="string">&quot;<span class="variable">$input</span>&quot;</span> | jq -r <span class="string">&#x27;.model.display_name // &quot;&quot;&#x27;</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment"># Build abbreviated name: extract family + version number</span></span><br><span class="line"><span class="comment"># Examples: claude-sonnet-4-5 → sonnet-4.5, claude-opus-4-6 → opus-4.6</span></span><br><span class="line">abbr=$(<span class="built_in">echo</span> <span class="string">&quot;<span class="variable">$model_id</span>&quot;</span> | sed -E \</span><br><span class="line">  <span class="string">&#x27;s/claude-([a-z]+)-([0-9]+)-([0-9]+).*/\1-\2.\3/; \</span></span><br><span class="line"><span class="string">   s/claude-([a-z]+-[a-z]+)-([0-9]+)-([0-9]+).*/\1-\2.\3/&#x27;</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment"># Fallback to display name if abbr didn&#x27;t change or is empty</span></span><br><span class="line"><span class="keyword">if</span> [ -z <span class="string">&quot;<span class="variable">$abbr</span>&quot;</span> ] || [ <span class="string">&quot;<span class="variable">$abbr</span>&quot;</span> = <span class="string">&quot;<span class="variable">$model_id</span>&quot;</span> ]; <span class="keyword">then</span></span><br><span class="line">  abbr=$(<span class="built_in">echo</span> <span class="string">&quot;<span class="variable">$model_display</span>&quot;</span> | sed -E \</span><br><span class="line">    <span class="string">&#x27;s/Claude //; s/ ([0-9]+)\.([0-9]+).*/\1.\2/&#x27;</span> | \</span><br><span class="line">    <span class="built_in">tr</span> <span class="string">&#x27;[:upper:]&#x27;</span> <span class="string">&#x27;[:lower:]&#x27;</span>)</span><br><span class="line"><span class="keyword">fi</span></span><br><span class="line">[ -z <span class="string">&quot;<span class="variable">$abbr</span>&quot;</span> ] &amp;&amp; abbr=<span class="string">&quot;unknown&quot;</span></span><br><span class="line"></span><br><span class="line">model_part=$(<span class="built_in">printf</span> <span class="string">&quot;<span class="variable">$&#123;C_CYAN&#125;</span>%s<span class="variable">$&#123;C_RESET&#125;</span>&quot;</span> <span class="string">&quot;<span class="variable">$abbr</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment"># ── 2. Path (fish shell style: last 2 full, rest first-char only) ─────────────</span></span><br><span class="line">cwd=$(<span class="built_in">echo</span> <span class="string">&quot;<span class="variable">$input</span>&quot;</span> | jq -r <span class="string">&#x27;.workspace.current_dir // .cwd // &quot;&quot;&#x27;</span>)</span><br><span class="line"><span class="keyword">if</span> [ -n <span class="string">&quot;<span class="variable">$cwd</span>&quot;</span> ]; <span class="keyword">then</span></span><br><span class="line">  home=<span class="string">&quot;<span class="variable">$HOME</span>&quot;</span></span><br><span class="line">  cwd_display=<span class="string">&quot;<span class="variable">$&#123;cwd#$home&#125;</span>&quot;</span></span><br><span class="line">  <span class="keyword">if</span> [ <span class="string">&quot;<span class="variable">$cwd_display</span>&quot;</span> != <span class="string">&quot;<span class="variable">$cwd</span>&quot;</span> ]; <span class="keyword">then</span></span><br><span class="line">    cwd_display=<span class="string">&quot;~<span class="variable">$cwd_display</span>&quot;</span></span><br><span class="line">  <span class="keyword">fi</span></span><br><span class="line"></span><br><span class="line">  abbreviated=$(<span class="built_in">echo</span> <span class="string">&quot;<span class="variable">$cwd_display</span>&quot;</span> | awk <span class="string">&#x27;</span></span><br><span class="line"><span class="string">  &#123;</span></span><br><span class="line"><span class="string">    n = split($0, segs, &quot;/&quot;)</span></span><br><span class="line"><span class="string">    # Identify prefix and actual segments</span></span><br><span class="line"><span class="string">    prefix = &quot;&quot;</span></span><br><span class="line"><span class="string">    start = 1</span></span><br><span class="line"><span class="string">    if (segs[1] == &quot;&quot;) &#123; prefix = &quot;/&quot;; start = 2 &#125;</span></span><br><span class="line"><span class="string"></span></span><br><span class="line"><span class="string">    # Collect non-empty segments</span></span><br><span class="line"><span class="string">    count = 0</span></span><br><span class="line"><span class="string">    for (i = start; i &lt;= n; i++) &#123;</span></span><br><span class="line"><span class="string">      if (segs[i] != &quot;&quot;) &#123;</span></span><br><span class="line"><span class="string">        count++</span></span><br><span class="line"><span class="string">        parts[count] = segs[i]</span></span><br><span class="line"><span class="string">      &#125;</span></span><br><span class="line"><span class="string">    &#125;</span></span><br><span class="line"><span class="string"></span></span><br><span class="line"><span class="string">    out = prefix</span></span><br><span class="line"><span class="string">    for (i = 1; i &lt;= count; i++) &#123;</span></span><br><span class="line"><span class="string">      # Keep last 2 segments full; abbreviate the rest to first char</span></span><br><span class="line"><span class="string">      if (count - i &gt;= 2) &#123;</span></span><br><span class="line"><span class="string">        seg = substr(parts[i], 1, 1)</span></span><br><span class="line"><span class="string">      &#125; else &#123;</span></span><br><span class="line"><span class="string">        seg = parts[i]</span></span><br><span class="line"><span class="string">      &#125;</span></span><br><span class="line"><span class="string">      if (out == &quot;&quot; || out == &quot;/&quot;) out = out seg</span></span><br><span class="line"><span class="string">      else out = out &quot;/&quot; seg</span></span><br><span class="line"><span class="string">    &#125;</span></span><br><span class="line"><span class="string">    print out</span></span><br><span class="line"><span class="string">  &#125;&#x27;</span>)</span><br><span class="line"></span><br><span class="line">  path_part=$(<span class="built_in">printf</span> <span class="string">&quot; <span class="variable">$&#123;C_YELLOW&#125;</span>%s<span class="variable">$&#123;C_RESET&#125;</span>&quot;</span> <span class="string">&quot;<span class="variable">$abbreviated</span>&quot;</span>)</span><br><span class="line"><span class="keyword">else</span></span><br><span class="line">  path_part=<span class="string">&quot;&quot;</span></span><br><span class="line"><span class="keyword">fi</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># ── 3. Git branch ────────────────────────────────────────────────────────────</span></span><br><span class="line">git_branch=<span class="string">&quot;&quot;</span></span><br><span class="line"><span class="keyword">if</span> [ -n <span class="string">&quot;<span class="variable">$cwd</span>&quot;</span> ]; <span class="keyword">then</span></span><br><span class="line">  git_root=$(git -C <span class="string">&quot;<span class="variable">$cwd</span>&quot;</span> -c core.hooksPath=/dev/null \</span><br><span class="line">    rev-parse --show-toplevel 2&gt;/dev/null)</span><br><span class="line">  <span class="keyword">if</span> [ -n <span class="string">&quot;<span class="variable">$git_root</span>&quot;</span> ]; <span class="keyword">then</span></span><br><span class="line">    git_branch=$(git -C <span class="string">&quot;<span class="variable">$cwd</span>&quot;</span> -c core.hooksPath=/dev/null \</span><br><span class="line">      symbolic-ref --short HEAD 2&gt;/dev/null)</span><br><span class="line">    <span class="keyword">if</span> [ -z <span class="string">&quot;<span class="variable">$git_branch</span>&quot;</span> ]; <span class="keyword">then</span></span><br><span class="line">      git_branch=$(git -C <span class="string">&quot;<span class="variable">$cwd</span>&quot;</span> -c core.hooksPath=/dev/null \</span><br><span class="line">        rev-parse --short HEAD 2&gt;/dev/null)</span><br><span class="line">    <span class="keyword">fi</span></span><br><span class="line">    <span class="keyword">if</span> [ -n <span class="string">&quot;<span class="variable">$git_branch</span>&quot;</span> ]; <span class="keyword">then</span></span><br><span class="line">      GIT_ICON=$(<span class="built_in">printf</span> <span class="string">&#x27;\xe2\x8e\x87&#x27;</span>)</span><br><span class="line">      branch_part=$(<span class="built_in">printf</span> <span class="string">&quot; <span class="variable">$&#123;C_GREEN&#125;</span>%s %s<span class="variable">$&#123;C_RESET&#125;</span>&quot;</span> <span class="string">&quot;<span class="variable">$GIT_ICON</span>&quot;</span> <span class="string">&quot;<span class="variable">$git_branch</span>&quot;</span>)</span><br><span class="line">    <span class="keyword">fi</span></span><br><span class="line">  <span class="keyword">fi</span></span><br><span class="line"><span class="keyword">fi</span></span><br><span class="line">[ -z <span class="string">&quot;<span class="variable">$git_branch</span>&quot;</span> ] &amp;&amp; branch_part=<span class="string">&quot;&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># ── 5. Token usage (k, 1 decimal) ────────────────────────────────────────────</span></span><br><span class="line">total_in=$(<span class="built_in">echo</span> <span class="string">&quot;<span class="variable">$input</span>&quot;</span>  | jq -r <span class="string">&#x27;.context_window.total_input_tokens // 0&#x27;</span>)</span><br><span class="line">total_out=$(<span class="built_in">echo</span> <span class="string">&quot;<span class="variable">$input</span>&quot;</span> | jq -r <span class="string">&#x27;.context_window.total_output_tokens // 0&#x27;</span>)</span><br><span class="line">total_tokens=$(( total_in + total_out ))</span><br><span class="line"><span class="keyword">if</span> [ <span class="string">&quot;<span class="variable">$total_tokens</span>&quot;</span> -ge 1000 ]; <span class="keyword">then</span></span><br><span class="line">  token_str=$(awk <span class="string">&quot;BEGIN &#123;printf \&quot;%.1fk\&quot;, <span class="variable">$total_tokens</span>/1000&#125;&quot;</span>)</span><br><span class="line"><span class="keyword">else</span></span><br><span class="line">  token_str=<span class="string">&quot;<span class="variable">$&#123;total_tokens&#125;</span>&quot;</span></span><br><span class="line"><span class="keyword">fi</span></span><br><span class="line">token_part=$(<span class="built_in">printf</span> <span class="string">&quot; <span class="variable">$&#123;C_BLUE&#125;</span>%s<span class="variable">$&#123;C_RESET&#125;</span>&quot;</span> <span class="string">&quot;<span class="variable">$token_str</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment"># ── 6. Context usage progress bar (8 blocks) + percentage ────────────────────</span></span><br><span class="line">used_pct=$(<span class="built_in">echo</span> <span class="string">&quot;<span class="variable">$input</span>&quot;</span> | jq -r <span class="string">&#x27;.context_window.used_percentage // empty&#x27;</span>)</span><br><span class="line">BLOCK_FULL=$(<span class="built_in">printf</span> <span class="string">&#x27;\xe2\x96\x88&#x27;</span>)</span><br><span class="line">BLOCK_EMPTY=$(<span class="built_in">printf</span> <span class="string">&#x27;\xe2\x96\x91&#x27;</span>)</span><br><span class="line">BARS=8</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> [ -n <span class="string">&quot;<span class="variable">$used_pct</span>&quot;</span> ]; <span class="keyword">then</span></span><br><span class="line">  pct_int=$(<span class="built_in">printf</span> <span class="string">&quot;%.0f&quot;</span> <span class="string">&quot;<span class="variable">$used_pct</span>&quot;</span>)</span><br><span class="line">  filled=$(awk <span class="string">&quot;BEGIN &#123;n=int(<span class="variable">$used_pct</span>*<span class="variable">$BARS</span>/100+0.5); if(n&gt;<span class="variable">$BARS</span>)n=<span class="variable">$BARS</span>; print n&#125;&quot;</span>)</span><br><span class="line">  empty=$(( BARS - filled ))</span><br><span class="line"></span><br><span class="line">  bar=<span class="string">&quot;&quot;</span></span><br><span class="line">  i=0</span><br><span class="line">  <span class="keyword">while</span> [ <span class="string">&quot;<span class="variable">$i</span>&quot;</span> -lt <span class="string">&quot;<span class="variable">$filled</span>&quot;</span> ]; <span class="keyword">do</span></span><br><span class="line">    bar=<span class="string">&quot;<span class="variable">$&#123;bar&#125;</span><span class="variable">$&#123;BLOCK_FULL&#125;</span>&quot;</span></span><br><span class="line">    i=$((i + <span class="number">1</span>))</span><br><span class="line">  <span class="keyword">done</span></span><br><span class="line">  i=0</span><br><span class="line">  <span class="keyword">while</span> [ <span class="string">&quot;<span class="variable">$i</span>&quot;</span> -lt <span class="string">&quot;<span class="variable">$empty</span>&quot;</span> ]; <span class="keyword">do</span></span><br><span class="line">    bar=<span class="string">&quot;<span class="variable">$&#123;bar&#125;</span><span class="variable">$&#123;BLOCK_EMPTY&#125;</span>&quot;</span></span><br><span class="line">    i=$((i + <span class="number">1</span>))</span><br><span class="line">  <span class="keyword">done</span></span><br><span class="line"></span><br><span class="line">  <span class="comment"># Color: 0-60% green, 61-80% yellow, 81%+ red</span></span><br><span class="line">  <span class="keyword">if</span> [ <span class="string">&quot;<span class="variable">$pct_int</span>&quot;</span> -le 60 ]; <span class="keyword">then</span></span><br><span class="line">    bar_color=<span class="string">&quot;<span class="variable">$C_GREEN</span>&quot;</span></span><br><span class="line">  <span class="keyword">elif</span> [ <span class="string">&quot;<span class="variable">$pct_int</span>&quot;</span> -le 80 ]; <span class="keyword">then</span></span><br><span class="line">    bar_color=<span class="string">&quot;<span class="variable">$C_YELLOW</span>&quot;</span></span><br><span class="line">  <span class="keyword">else</span></span><br><span class="line">    bar_color=<span class="string">&quot;<span class="variable">$C_RED</span>&quot;</span></span><br><span class="line">  <span class="keyword">fi</span></span><br><span class="line"></span><br><span class="line">  ctx_part=$(<span class="built_in">printf</span> <span class="string">&quot; <span class="variable">$&#123;bar_color&#125;</span>[%s]<span class="variable">$&#123;C_RESET&#125;</span> %s%%&quot;</span> <span class="string">&quot;<span class="variable">$bar</span>&quot;</span> <span class="string">&quot;<span class="variable">$pct_int</span>&quot;</span>)</span><br><span class="line"><span class="keyword">else</span></span><br><span class="line">  empty_bar=<span class="string">&quot;&quot;</span></span><br><span class="line">  i=0</span><br><span class="line">  <span class="keyword">while</span> [ <span class="string">&quot;<span class="variable">$i</span>&quot;</span> -lt <span class="string">&quot;<span class="variable">$BARS</span>&quot;</span> ]; <span class="keyword">do</span></span><br><span class="line">    empty_bar=<span class="string">&quot;<span class="variable">$&#123;empty_bar&#125;</span><span class="variable">$&#123;BLOCK_EMPTY&#125;</span>&quot;</span></span><br><span class="line">    i=$((i + <span class="number">1</span>))</span><br><span class="line">  <span class="keyword">done</span></span><br><span class="line">  ctx_part=$(<span class="built_in">printf</span> <span class="string">&quot; <span class="variable">$&#123;C_GREEN&#125;</span>[%s]<span class="variable">$&#123;C_RESET&#125;</span> --&quot;</span> <span class="string">&quot;<span class="variable">$empty_bar</span>&quot;</span>)%%</span><br><span class="line"><span class="keyword">fi</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># ── 7·8. Rate limits (5h session + 7d weekly) ────────────────────────────────</span></span><br><span class="line"><span class="function"><span class="title">usage_color</span></span>() &#123;</span><br><span class="line">  _pct=$(<span class="built_in">printf</span> <span class="string">&quot;%.0f&quot;</span> <span class="string">&quot;<span class="variable">$1</span>&quot;</span>)</span><br><span class="line">  <span class="keyword">if</span>   [ <span class="string">&quot;<span class="variable">$_pct</span>&quot;</span> -le 60 ]; <span class="keyword">then</span> <span class="built_in">printf</span> <span class="string">&#x27;%s&#x27;</span> <span class="string">&quot;<span class="variable">$C_GREEN</span>&quot;</span></span><br><span class="line">  <span class="keyword">elif</span> [ <span class="string">&quot;<span class="variable">$_pct</span>&quot;</span> -le 80 ]; <span class="keyword">then</span> <span class="built_in">printf</span> <span class="string">&#x27;%s&#x27;</span> <span class="string">&quot;<span class="variable">$C_YELLOW</span>&quot;</span></span><br><span class="line">  <span class="keyword">else</span>                           <span class="built_in">printf</span> <span class="string">&#x27;%s&#x27;</span> <span class="string">&quot;<span class="variable">$C_RED</span>&quot;</span></span><br><span class="line">  <span class="keyword">fi</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="title">format_remaining</span></span>() &#123;</span><br><span class="line">  _rem=$(( <span class="variable">$1</span> - $(date +%s) ))</span><br><span class="line">  [ <span class="string">&quot;<span class="variable">$_rem</span>&quot;</span> -lt 0 ] &amp;&amp; _rem=0</span><br><span class="line">  <span class="keyword">if</span>   [ <span class="string">&quot;<span class="variable">$_rem</span>&quot;</span> -ge 86400 ]; <span class="keyword">then</span> <span class="built_in">printf</span> <span class="string">&#x27;%dd%dh&#x27;</span> $(( _rem/<span class="number">86400</span> )) $(( _rem%<span class="number">86400</span>/<span class="number">3600</span> ))</span><br><span class="line">  <span class="keyword">elif</span> [ <span class="string">&quot;<span class="variable">$_rem</span>&quot;</span> -ge 3600  ]; <span class="keyword">then</span> <span class="built_in">printf</span> <span class="string">&#x27;%dh%02dm&#x27;</span> $(( _rem/<span class="number">3600</span> )) $(( _rem%<span class="number">3600</span>/<span class="number">60</span> ))</span><br><span class="line">  <span class="keyword">else</span>                             <span class="built_in">printf</span> <span class="string">&#x27;%dm&#x27;</span> $(( _rem/<span class="number">60</span> ))</span><br><span class="line">  <span class="keyword">fi</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">session_pct=$(<span class="built_in">echo</span> <span class="string">&quot;<span class="variable">$input</span>&quot;</span>   | jq -r <span class="string">&#x27;.rate_limits.five_hour.used_percentage // empty&#x27;</span>)</span><br><span class="line">session_reset=$(<span class="built_in">echo</span> <span class="string">&quot;<span class="variable">$input</span>&quot;</span> | jq -r <span class="string">&#x27;.rate_limits.five_hour.resets_at // empty&#x27;</span>)</span><br><span class="line">weekly_pct=$(<span class="built_in">echo</span> <span class="string">&quot;<span class="variable">$input</span>&quot;</span>    | jq -r <span class="string">&#x27;.rate_limits.seven_day.used_percentage // empty&#x27;</span>)</span><br><span class="line">weekly_reset=$(<span class="built_in">echo</span> <span class="string">&quot;<span class="variable">$input</span>&quot;</span>  | jq -r <span class="string">&#x27;.rate_limits.seven_day.resets_at // empty&#x27;</span>)</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="title">make_rate_piece</span></span>() &#123;</span><br><span class="line">  _color=<span class="string">&quot;<span class="variable">$1</span>&quot;</span> _pct=<span class="string">&quot;<span class="variable">$2</span>&quot;</span> _reset=<span class="string">&quot;<span class="variable">$3</span>&quot;</span></span><br><span class="line">  [ -z <span class="string">&quot;<span class="variable">$_pct</span>&quot;</span> ] &amp;&amp; <span class="built_in">return</span></span><br><span class="line">  _used=$(<span class="built_in">printf</span> <span class="string">&quot;%.0f&quot;</span> <span class="string">&quot;<span class="variable">$_pct</span>&quot;</span>)</span><br><span class="line">  _ucolor=$(usage_color <span class="string">&quot;<span class="variable">$_pct</span>&quot;</span>)</span><br><span class="line">  _time_str=<span class="string">&quot;&quot;</span></span><br><span class="line">  [ -n <span class="string">&quot;<span class="variable">$_reset</span>&quot;</span> ] &amp;&amp; _time_str=$(<span class="built_in">printf</span> <span class="string">&quot;<span class="variable">$&#123;_color&#125;</span>↻%s &quot;</span> <span class="string">&quot;<span class="subst">$(format_remaining <span class="string">&quot;<span class="variable">$_reset</span>&quot;</span>)</span>&quot;</span>)</span><br><span class="line">  <span class="built_in">printf</span> <span class="string">&quot;%s<span class="variable">$&#123;_ucolor&#125;</span>↑%d%%<span class="variable">$&#123;C_RESET&#125;</span>&quot;</span> <span class="string">&quot;<span class="variable">$_time_str</span>&quot;</span> <span class="string">&quot;<span class="variable">$_used</span>&quot;</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">sess_piece=$(make_rate_piece <span class="string">&quot;<span class="variable">$C_CYAN</span>&quot;</span>    <span class="string">&quot;<span class="variable">$session_pct</span>&quot;</span> <span class="string">&quot;<span class="variable">$session_reset</span>&quot;</span>)</span><br><span class="line">week_piece=$(make_rate_piece <span class="string">&quot;<span class="variable">$C_YELLOW</span>&quot;</span>  <span class="string">&quot;<span class="variable">$weekly_pct</span>&quot;</span>  <span class="string">&quot;<span class="variable">$weekly_reset</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line">rate_part=<span class="string">&quot;&quot;</span></span><br><span class="line"><span class="keyword">if</span> [ -n <span class="string">&quot;<span class="variable">$sess_piece</span>&quot;</span> ] &amp;&amp; [ -n <span class="string">&quot;<span class="variable">$week_piece</span>&quot;</span> ]; <span class="keyword">then</span></span><br><span class="line">  rate_part=$(<span class="built_in">printf</span> <span class="string">&quot; · %s  %s&quot;</span> <span class="string">&quot;<span class="variable">$sess_piece</span>&quot;</span> <span class="string">&quot;<span class="variable">$week_piece</span>&quot;</span>)</span><br><span class="line"><span class="keyword">elif</span> [ -n <span class="string">&quot;<span class="variable">$sess_piece</span>&quot;</span> ]; <span class="keyword">then</span></span><br><span class="line">  rate_part=$(<span class="built_in">printf</span> <span class="string">&quot; · %s&quot;</span> <span class="string">&quot;<span class="variable">$sess_piece</span>&quot;</span>)</span><br><span class="line"><span class="keyword">elif</span> [ -n <span class="string">&quot;<span class="variable">$week_piece</span>&quot;</span> ]; <span class="keyword">then</span></span><br><span class="line">  rate_part=$(<span class="built_in">printf</span> <span class="string">&quot; · %s&quot;</span> <span class="string">&quot;<span class="variable">$week_piece</span>&quot;</span>)</span><br><span class="line"><span class="keyword">fi</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># ── 9. Session cost (USD) ────────────────────────────────────────────────────</span></span><br><span class="line">cost_usd=$(<span class="built_in">echo</span> <span class="string">&quot;<span class="variable">$input</span>&quot;</span> | jq -r <span class="string">&#x27;.cost.total_cost_usd // empty&#x27;</span>)</span><br><span class="line">cost_part=<span class="string">&quot;&quot;</span></span><br><span class="line"><span class="keyword">if</span> [ -n <span class="string">&quot;<span class="variable">$cost_usd</span>&quot;</span> ]; <span class="keyword">then</span></span><br><span class="line">  cost_str=$(awk <span class="string">&quot;BEGIN &#123;printf \&quot;%.2f\&quot;, <span class="variable">$cost_usd</span>&#125;&quot;</span>)</span><br><span class="line">  cost_part=$(<span class="built_in">printf</span> <span class="string">&quot; <span class="variable">$&#123;C_MAGENTA&#125;</span>\$%s<span class="variable">$&#123;C_RESET&#125;</span>&quot;</span> <span class="string">&quot;<span class="variable">$cost_str</span>&quot;</span>)</span><br><span class="line"><span class="keyword">fi</span></span><br><span class="line"></span><br><span class="line"><span class="comment"># ── Output ────────────────────────────────────────────────────────────────────</span></span><br><span class="line"><span class="built_in">printf</span> <span class="string">&quot;%s%s%s%s%s%s%s\n&quot;</span> \</span><br><span class="line">  <span class="string">&quot;<span class="variable">$model_part</span>&quot;</span> \</span><br><span class="line">  <span class="string">&quot;<span class="variable">$path_part</span>&quot;</span> \</span><br><span class="line">  <span class="string">&quot;<span class="variable">$branch_part</span>&quot;</span> \</span><br><span class="line">  <span class="string">&quot;<span class="variable">$token_part</span>&quot;</span> \</span><br><span class="line">  <span class="string">&quot;<span class="variable">$ctx_part</span>&quot;</span> \</span><br><span class="line">  <span class="string">&quot;<span class="variable">$rate_part</span>&quot;</span> \</span><br><span class="line">  <span class="string">&quot;<span class="variable">$cost_part</span>&quot;</span></span><br></pre></td></tr></table></figure><hr><h2><span id="teseuteu-bangbeob">테스트 방법</span><a href="#teseuteu-bangbeob" class="header-anchor">#</a></h2><p>스크립트를 수정하거나 새로 만들 때는 샘플 JSON을 파이프로 넘겨서 Claude Code를 열지 않고도 검증할 수 있습니다:</p><figure class="highlight sh"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">echo</span> <span class="string">&#x27;&#123;</span></span><br><span class="line"><span class="string">  &quot;model&quot;: &#123;&quot;id&quot;: &quot;claude-sonnet-4-6&quot;, &quot;display_name&quot;: &quot;Sonnet 4.6&quot;&#125;,</span></span><br><span class="line"><span class="string">  &quot;workspace&quot;: &#123;&quot;current_dir&quot;: &quot;/Users/yun/projects/myapp&quot;&#125;,</span></span><br><span class="line"><span class="string">  &quot;context_window&quot;: &#123;&quot;total_input_tokens&quot;: 24000, &quot;total_output_tokens&quot;: 800, &quot;used_percentage&quot;: 12.5&#125;,</span></span><br><span class="line"><span class="string">  &quot;cost&quot;: &#123;&quot;total_cost_usd&quot;: 0.42&#125;,</span></span><br><span class="line"><span class="string">  &quot;rate_limits&quot;: &#123;</span></span><br><span class="line"><span class="string">    &quot;five_hour&quot;: &#123;&quot;used_percentage&quot;: 32, &quot;resets_at&quot;: 1750000000&#125;,</span></span><br><span class="line"><span class="string">    &quot;seven_day&quot;: &#123;&quot;used_percentage&quot;: 3, &quot;resets_at&quot;: 1750500000&#125;</span></span><br><span class="line"><span class="string">  &#125;,</span></span><br><span class="line"><span class="string">  &quot;session_id&quot;: &quot;test-session-abc&quot;</span></span><br><span class="line"><span class="string">&#125;&#x27;</span> | bash ~/.claude/statusline.sh</span><br></pre></td></tr></table></figure><hr><h2><span id="macimyeo">마치며</span><a href="#macimyeo" class="header-anchor">#</a></h2><p>statusline 커스텀의 핵심 효용은 <strong>컨텍스트·비용·rate limit을 Claude와 대화하면서 항상 시야에 두는 것</strong>입니다. 컨텍스트가 80%를 넘으면 바 색깔이 노란색으로 바뀌고, 90%를 넘으면 빨간색이 됩니다. <code>/compact</code>를 언제 해야 할지 판단하거나, 요금이 얼마나 나왔는지 확인하거나, rate limit 소진 속도를 체크하는 데 유용합니다.</p><p>더 다양한 statusline 구성이 궁금하다면 커뮤니티 프로젝트인 <a href="https://github.com/sirmalloc/ccstatusline">ccstatusline</a>이나 <a href="https://github.com/martinemde/starship-claude">starship-claude</a>도 참고할 만합니다.</p>]]></content:encoded>
      
      
      
      <category domain="https://cheese10yun.github.io/tags/Tools/">Tools</category>
      
      <category domain="https://cheese10yun.github.io/tags/claude/">claude</category>
      
      
      <comments>https://cheese10yun.github.io/claude-code-statusline-custom/#disqus_thread</comments>
      
    </item>
    
    <item>
      <title>테스트가 어렵다면 캡슐화를 의심하라</title>
      <link>https://cheese10yun.github.io/test-feedback-encapsulation.md/</link>
      <guid>https://cheese10yun.github.io/test-feedback-encapsulation.md/</guid>
      <pubDate>Tue, 21 Apr 2026 15:00:00 GMT</pubDate>
      
      <description>객체가 스스로 hasNext, hasPrev를 계산하게 만들기 위해 Kotlin에서 private constructor와 companion object invoke를 어떻게 조합하는지 설명합니다.</description>
      
      
      
      <content:encoded><![CDATA[<p>테스트를 작성하다 보면 어느 순간 이런 상황을 마주칠 때가 있습니다. 테스트 본문보다 준비 코드가 더 길고, 케이스를 추가할수록 동일한 계산 로직이 반복됩니다. “이게 맞나?” 싶은 찜찜함이 드는 순간입니다.</p><p>이 불편함은 테스트 코드가 미숙한 탓이 아닙니다. <strong>테스트가 어렵다는 것 자체가 설계에 대한 피드백입니다.</strong> 더 구체적으로는 캡슐화가 제대로 되지 않았다는 신호일 가능성이 높습니다.</p><p>이번 포스팅에서는 커서 기반 페이지네이션 응답 객체인 <code>CursorPageResponse</code>를 예시로, 생성자가 열려 있을 때 테스트가 어떻게 무너지는지, 그리고 <code>private constructor</code> + <code>invoke</code> 설계로 캡슐화를 강제했을 때 테스트가 어떻게 단순해지는지를 살펴봅니다.</p><h2><span id="cursorpageresponse-seolgye">CursorPageResponse 설계</span><a href="#cursorpageresponse-seolgye" class="header-anchor">#</a></h2><p><code>CursorPageResponse</code>는 커서 기반 페이지네이션의 응답을 담는 객체입니다. 코드를 보면 생성자가 <code>private</code>으로 선언되어 있습니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">data</span> <span class="keyword">class</span> <span class="title class_">CursorPageResponse</span>&lt;<span class="type">T</span>&gt; <span class="keyword">private</span> <span class="keyword">constructor</span>(</span><br><span class="line">    <span class="keyword">val</span> content: List&lt;T&gt;,</span><br><span class="line">    <span class="keyword">val</span> hasNext: <span class="built_in">Boolean</span>,</span><br><span class="line">    <span class="keyword">val</span> hasPrev: <span class="built_in">Boolean</span>,</span><br><span class="line">    <span class="keyword">val</span> nextCursor: T?,</span><br><span class="line">    <span class="keyword">val</span> prevCursor: T?,</span><br><span class="line">)</span><br></pre></td></tr></table></figure><p>외부에서 직접 생성자를 호출할 수 없고, <code>companion object</code>의 <code>invoke</code> 팩토리 함수를 통해서만 객체를 생성할 수 있습니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">companion</span> <span class="keyword">object</span> &#123;</span><br><span class="line">    <span class="keyword">operator</span> <span class="function"><span class="keyword">fun</span> <span class="type">&lt;T&gt;</span> <span class="title">invoke</span><span class="params">(</span></span></span><br><span class="line"><span class="params"><span class="function">        content: <span class="type">List</span>&lt;<span class="type">T</span>&gt;,</span></span></span><br><span class="line"><span class="params"><span class="function">        direction: <span class="type">CursorDirection</span>,</span></span></span><br><span class="line"><span class="params"><span class="function">        pageSize: <span class="type">Int</span>,</span></span></span><br><span class="line"><span class="params"><span class="function">    )</span></span>: CursorPageResponse&lt;T&gt; &#123;</span><br><span class="line">        <span class="comment">// ... 내부 로직</span></span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>Kotlin에서 <code>companion object</code>에 <code>operator fun invoke</code>를 정의하면 마치 생성자처럼 <code>CursorPageResponse(...)</code> 형태로 호출할 수 있습니다. 외부에서 보면 생성자처럼 보이지만, 실제로는 <code>invoke</code> 팩토리 함수를 통해 생성됩니다.</p><p><code>invoke</code> 내부 로직은 다음과 같습니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">operator</span> <span class="function"><span class="keyword">fun</span> <span class="type">&lt;T&gt;</span> <span class="title">invoke</span><span class="params">(</span></span></span><br><span class="line"><span class="params"><span class="function">    content: <span class="type">List</span>&lt;<span class="type">T</span>&gt;,</span></span></span><br><span class="line"><span class="params"><span class="function">    direction: <span class="type">CursorDirection</span>,</span></span></span><br><span class="line"><span class="params"><span class="function">    pageSize: <span class="type">Int</span>,</span></span></span><br><span class="line"><span class="params"><span class="function">)</span></span>: CursorPageResponse&lt;T&gt; &#123;</span><br><span class="line">    <span class="keyword">if</span> (content.isEmpty()) &#123;</span><br><span class="line">        <span class="keyword">return</span> CursorPageResponse(</span><br><span class="line">            content = emptyList(),</span><br><span class="line">            hasNext = <span class="literal">false</span>,</span><br><span class="line">            hasPrev = <span class="literal">false</span>,</span><br><span class="line">            nextCursor = <span class="literal">null</span>,</span><br><span class="line">            prevCursor = <span class="literal">null</span>,</span><br><span class="line">        )</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">return</span> <span class="keyword">if</span> (direction.isForward) &#123;</span><br><span class="line">        <span class="comment">// forward(FIRST/NEXT): DB에서 DESC로 조회한 결과를 그대로 사용</span></span><br><span class="line">        <span class="keyword">val</span> hasNext = content.size &gt; pageSize</span><br><span class="line">        <span class="keyword">val</span> actualContent = content.take(pageSize)</span><br><span class="line">        <span class="keyword">val</span> hasPrev = direction == CursorDirection.NEXT</span><br><span class="line">        CursorPageResponse(</span><br><span class="line">            content = actualContent,</span><br><span class="line">            hasNext = hasNext,</span><br><span class="line">            hasPrev = hasPrev,</span><br><span class="line">            nextCursor = <span class="keyword">if</span> (hasNext) actualContent.last() <span class="keyword">else</span> <span class="literal">null</span>,</span><br><span class="line">            prevCursor = <span class="keyword">if</span> (hasPrev) actualContent.first() <span class="keyword">else</span> <span class="literal">null</span>,</span><br><span class="line">        )</span><br><span class="line">    &#125; <span class="keyword">else</span> &#123;</span><br><span class="line">        <span class="comment">// backward(LAST/PREV): DB에서 ASC로 조회한 결과를 reversed()해서 반환</span></span><br><span class="line">        <span class="keyword">val</span> hasPrev = content.size &gt; pageSize</span><br><span class="line">        <span class="keyword">val</span> actualContent = content.take(pageSize).reversed()</span><br><span class="line">        <span class="keyword">val</span> hasNext = direction == CursorDirection.PREV</span><br><span class="line">        CursorPageResponse(</span><br><span class="line">            content = actualContent,</span><br><span class="line">            hasNext = hasNext,</span><br><span class="line">            hasPrev = hasPrev,</span><br><span class="line">            nextCursor = <span class="keyword">if</span> (hasNext) actualContent.last() <span class="keyword">else</span> <span class="literal">null</span>,</span><br><span class="line">            prevCursor = <span class="keyword">if</span> (hasPrev) actualContent.first() <span class="keyword">else</span> <span class="literal">null</span>,</span><br><span class="line">        )</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>direction별로 동작 방식이 다릅니다.</p><table><thead><tr><th align="left">direction</th><th align="left">content 처리</th><th align="left">hasNext</th><th align="left">hasPrev</th></tr></thead><tbody><tr><td align="left">FIRST</td><td align="left">그대로 사용</td><td align="left">pageSize 초과 여부</td><td align="left">항상 false</td></tr><tr><td align="left">NEXT</td><td align="left">그대로 사용</td><td align="left">pageSize 초과 여부</td><td align="left">항상 true</td></tr><tr><td align="left">LAST</td><td align="left">reversed()</td><td align="left">항상 false</td><td align="left">pageSize 초과 여부</td></tr><tr><td align="left">PREV</td><td align="left">reversed()</td><td align="left">항상 true</td><td align="left">pageSize 초과 여부</td></tr></tbody></table><p><code>applyCursorPagination</code>이 <code>pageSize + 1</code>개를 조회해서 넘기면, <code>invoke</code> 내부에서 초과분 유무로 다음&#x2F;이전 페이지 존재를 판단하고 <code>take(pageSize)</code>로 실제 노출할 데이터를 잘라냅니다. 커서(<code>nextCursor</code>, <code>prevCursor</code>)는 잘라낸 결과의 첫 번째 또는 마지막 항목을 저장합니다.</p><h2><span id="badneun-paramiteowa-jeojangdoeneun-pildeuga-dareuda">받는 파라미터와 저장되는 필드가 다르다</span><a href="#badneun-paramiteowa-jeojangdoeneun-pildeuga-dareuda" class="header-anchor">#</a></h2><p>이 설계의 핵심은 <strong><code>invoke</code>가 받는 파라미터</strong>와 <strong>data class에 저장되는 필드</strong>가 다르다는 점입니다.</p><p><code>invoke</code>가 받는 파라미터:</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">operator</span> <span class="function"><span class="keyword">fun</span> <span class="type">&lt;T&gt;</span> <span class="title">invoke</span><span class="params">(</span></span></span><br><span class="line"><span class="params"><span class="function">    content: <span class="type">List</span>&lt;<span class="type">T</span>&gt;,          <span class="comment">// 조회된 raw 데이터 (pageSize + 1 개)</span></span></span></span><br><span class="line"><span class="params"><span class="function">    direction: <span class="type">CursorDirection</span>, <span class="comment">// 페이지 이동 방향</span></span></span></span><br><span class="line"><span class="params"><span class="function">    pageSize: <span class="type">Int</span>,             <span class="comment">// 페이지 크기</span></span></span></span><br><span class="line"><span class="params"><span class="function">)</span></span></span><br></pre></td></tr></table></figure><p>실제 data class에 저장되는 필드:</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">data</span> <span class="keyword">class</span> <span class="title class_">CursorPageResponse</span>&lt;<span class="type">T</span>&gt; <span class="keyword">private</span> <span class="keyword">constructor</span>(</span><br><span class="line">    <span class="keyword">val</span> content: List&lt;T&gt;,  <span class="comment">// 실제 노출할 데이터 (pageSize 만큼 잘라낸 결과)</span></span><br><span class="line">    <span class="keyword">val</span> hasNext: <span class="built_in">Boolean</span>,  <span class="comment">// 다음 페이지 존재 여부</span></span><br><span class="line">    <span class="keyword">val</span> hasPrev: <span class="built_in">Boolean</span>,  <span class="comment">// 이전 페이지 존재 여부</span></span><br><span class="line">    <span class="keyword">val</span> nextCursor: T?,    <span class="comment">// 다음 페이지 커서</span></span><br><span class="line">    <span class="keyword">val</span> prevCursor: T?,    <span class="comment">// 이전 페이지 커서</span></span><br><span class="line">)</span><br></pre></td></tr></table></figure><p>파라미터 <code>content</code>, <code>direction</code>, <code>pageSize</code>를 받아 <code>hasNext</code>, <code>hasPrev</code>, <code>nextCursor</code>, <code>prevCursor</code>를 계산하는 <strong>핵심 로직이 <code>invoke</code> 내부에 캡슐화</strong>되어 있습니다. 예를 들어 다음과 같은 규칙들이 <code>invoke</code> 내부에서 처리됩니다.</p><ul><li><code>NEXT</code> 방향이면 <code>hasPrev</code>는 항상 <code>true</code></li><li><code>LAST</code> 방향이면 <code>hasNext</code>는 항상 <code>false</code></li><li>backward 방향(<code>LAST</code>, <code>PREV</code>)은 DB에서 ASC로 조회한 결과를 <code>reversed()</code>해서 반환</li><li><code>pageSize + 1</code>개를 조회한 결과에서 초과분으로 다음&#x2F;이전 페이지 존재 여부 판단</li></ul><p>이런 규칙들이 모두 <code>invoke</code> 안에 있기 때문에 외부에서는 이 로직을 신경 쓸 필요가 없습니다.</p><h2><span id="saengseongjaga-yeolryeo-issdamyeon-eoddeon-munjega-saenggilgga">생성자가 열려 있다면 어떤 문제가 생길까</span><a href="#saengseongjaga-yeolryeo-issdamyeon-eoddeon-munjega-saenggilgga" class="header-anchor">#</a></h2><p>만약 생성자가 <code>public</code>이었다면 외부에서 직접 이렇게 생성할 수 있습니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 생성자가 public인 경우, 외부에서 직접 생성 가능</span></span><br><span class="line"><span class="keyword">val</span> response = CursorPageResponse(</span><br><span class="line">    content = payments,</span><br><span class="line">    hasNext = ???,      <span class="comment">// 어떻게 계산해야 하지?</span></span><br><span class="line">    hasPrev = ???,      <span class="comment">// 방향에 따라 다른데...</span></span><br><span class="line">    nextCursor = ???,   <span class="comment">// 어떤 항목을 넣어야 하지?</span></span><br><span class="line">    prevCursor = ???,</span><br><span class="line">)</span><br></pre></td></tr></table></figure><p>이 상황에서 발생하는 문제는 여러 가지입니다.</p><p><strong>1. 어떤 생성자를 써야 할지 모른다</strong></p><p>생성자와 <code>invoke</code> 두 경로가 모두 열려 있다면 어느 쪽을 써야 하는지 혼란이 생깁니다. 생성자를 직접 사용하면 내부 계산 로직을 거치지 않아 잘못된 상태의 객체가 만들어질 수 있습니다.</p><p><strong>2. 객체의 핵심 로직이 외부로 새어나간다</strong></p><p><code>CursorPageResponse</code>를 생성하는 모든 곳에서 <code>hasNext</code>, <code>hasPrev</code>, <code>nextCursor</code>, <code>prevCursor</code>를 직접 계산해야 합니다. <code>hasPrev = direction == CursorDirection.NEXT</code>라는 규칙을 한 곳에서 구현했다면, 다른 곳에서도 동일한 규칙을 알고 구현해야 합니다. <code>CursorPageResponse</code>가 책임져야 할 로직이 해당 객체를 생성하는 모든 곳으로 분산됩니다.</p><p><strong>3. 규칙 위반 객체를 만들 수 있다</strong></p><p>더 이상 다음 페이지가 없는 상황임에도 <code>hasNext = true</code>로 생성하는 것을 막을 방법이 없습니다. 생성자가 열려 있으면 객체가 스스로 자신의 불변 규칙을 보장할 수 없습니다.</p><h2><span id="teseuteu-kodeuga-juneun-pideubaeg">테스트 코드가 주는 피드백</span><a href="#teseuteu-kodeuga-juneun-pideubaeg" class="header-anchor">#</a></h2><p>생성자가 열려 있을 때 발생하는 문제는 테스트를 작성하는 순간 가장 직접적으로 드러납니다. 테스트가 어렵다면, 그것은 캡슐화가 제대로 되지 않았다는 신호입니다.</p><h3><span id="teseuteu-junbi-kodeuga-god-rojig-jungbogida">테스트 준비 코드가 곧 로직 중복이다</span><a href="#teseuteu-junbi-kodeuga-god-rojig-jungbogida" class="header-anchor">#</a></h3><p>생성자가 <code>public</code>이었다면, NEXT 방향 테스트 하나를 이렇게 작성해야 합니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Test</span></span><br><span class="line"><span class="function"><span class="keyword">fun</span> `NEXT 방향으로 다음 페이지가 있는 경우`<span class="params">()</span></span> &#123;</span><br><span class="line">    <span class="keyword">val</span> payments = listOf(payment1, payment2, payment3)</span><br><span class="line">    <span class="keyword">val</span> pageSize = <span class="number">2</span></span><br><span class="line"></span><br><span class="line">    <span class="comment">// 테스트 작성자가 직접 계산 로직을 구현해야 한다</span></span><br><span class="line">    <span class="keyword">val</span> hasNext = payments.size &gt; pageSize</span><br><span class="line">    <span class="keyword">val</span> actualContent = payments.take(pageSize)</span><br><span class="line">    <span class="keyword">val</span> hasPrev = <span class="literal">true</span> <span class="comment">// NEXT 방향이므로</span></span><br><span class="line">    <span class="keyword">val</span> response = CursorPageResponse(</span><br><span class="line">        content = actualContent,</span><br><span class="line">        hasNext = hasNext,</span><br><span class="line">        hasPrev = hasPrev,</span><br><span class="line">        nextCursor = actualContent.last().id.toString(),</span><br><span class="line">        prevCursor = actualContent.first().id.toString(),</span><br><span class="line">    )</span><br><span class="line"></span><br><span class="line">    then(response.hasNext).isTrue()</span><br><span class="line">    <span class="comment">// ...</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>테스트를 작성하다 보면 자연스럽게 이런 생각이 듭니다.</p><blockquote><p>“이 <code>hasNext</code> 계산 로직을 내가 왜 여기서 하고 있지?”<br>“이건 <code>CursorPageResponse</code>가 알아야 하는 거 아닌가?”<br>“테스트마다 이 로직을 반복해서 짜는 게 맞나?”</p></blockquote><p>문제는 하나의 테스트로 끝나지 않는다는 데 있습니다. FIRST, NEXT, PREV, LAST 각 방향별로, 다음 페이지 있음&#x2F;없음, 이전 페이지 있음&#x2F;없음 등의 경우를 모두 테스트해야 합니다. 그 결과 테스트마다 동일한 계산 로직이 반복됩니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Test</span></span><br><span class="line"><span class="function"><span class="keyword">fun</span> `FIRST 방향으로 다음 페이지가 있는 경우`<span class="params">()</span></span> &#123;</span><br><span class="line">    <span class="keyword">val</span> hasNext = payments.size &gt; pageSize       <span class="comment">// 중복</span></span><br><span class="line">    <span class="keyword">val</span> actualContent = payments.take(pageSize)  <span class="comment">// 중복</span></span><br><span class="line">    <span class="keyword">val</span> hasPrev = <span class="literal">false</span>                          <span class="comment">// FIRST 방향이므로</span></span><br><span class="line">    <span class="keyword">val</span> response = CursorPageResponse(content = actualContent, hasNext = hasNext, ...)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="meta">@Test</span></span><br><span class="line"><span class="function"><span class="keyword">fun</span> `PREV 방향으로 이전 페이지가 있는 경우`<span class="params">()</span></span> &#123;</span><br><span class="line">    <span class="keyword">val</span> hasPrev = payments.size &gt; pageSize               <span class="comment">// 중복</span></span><br><span class="line">    <span class="keyword">val</span> actualContent = payments.take(pageSize).reversed() <span class="comment">// 중복</span></span><br><span class="line">    <span class="keyword">val</span> hasNext = <span class="literal">true</span>                                   <span class="comment">// PREV 방향이므로</span></span><br><span class="line">    <span class="keyword">val</span> response = CursorPageResponse(content = actualContent, hasPrev = hasPrev, ...)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>이 로직 중복은 단순한 코드 반복이 아닙니다. <strong><code>CursorPageResponse</code> 내부에 있어야 할 규칙이 테스트 코드 전반에 분산된 것</strong>입니다. 이 상태에서는 <code>CursorPageResponse</code>를 테스트하는 게 아니라, 테스트 작성자가 직접 그 규칙을 재구현하고 있는 셈입니다.</p><h3><span id="rojigi-byeongyeongdoemyeon-teseuteudo-hamgge-ggaejinda">로직이 변경되면 테스트도 함께 깨진다</span><a href="#rojigi-byeongyeongdoemyeon-teseuteudo-hamgge-ggaejinda" class="header-anchor">#</a></h3><p>캡슐화가 깨진 설계의 가장 큰 문제는 <strong>변경에 취약하다</strong>는 점입니다.</p><p>예를 들어 <code>nextCursor</code>를 항목 자체(<code>T</code>)가 아니라 항목의 ID(<code>String</code>)로 인코딩하는 방식으로 스펙이 바뀐다고 가정합니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 변경 전: 항목 자체를 커서로</span></span><br><span class="line">nextCursor = actualContent.last()</span><br><span class="line"></span><br><span class="line"><span class="comment">// 변경 후: ID를 문자열로 인코딩</span></span><br><span class="line">nextCursor = actualContent.last().id.toString()</span><br></pre></td></tr></table></figure><p><code>private constructor</code> + <code>invoke</code> 설계라면 <code>invoke</code> 내부 한 곳만 수정하면 됩니다. 하지만 생성자가 열려 있어 각 테스트가 커서를 직접 계산하고 있다면, 이 규칙이 반영된 모든 테스트를 찾아 수정해야 합니다. 수정이 누락된 테스트는 잘못된 기댓값을 갖게 되어 <strong>테스트가 통과해도 실제 동작이 틀릴 수 있는 상황</strong>이 만들어집니다.</p><h3><span id="kaebsyulhwa-hu-teseuteuneun-eoddeohge-baggwineunga">캡슐화 후 테스트는 어떻게 바뀌는가</span><a href="#kaebsyulhwa-hu-teseuteuneun-eoddeohge-baggwineunga" class="header-anchor">#</a></h3><p><code>private constructor</code> + <code>invoke</code> 설계에서는 테스트가 이렇게 바뀝니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Test</span></span><br><span class="line"><span class="function"><span class="keyword">fun</span> `NEXT - content가 pageSize 초과이면 hasNext <span class="literal">true</span>, hasPrev <span class="literal">true</span>`<span class="params">()</span></span> &#123;</span><br><span class="line">    <span class="comment">// given</span></span><br><span class="line">    <span class="keyword">val</span> content = listOf(<span class="number">1</span>, <span class="number">2</span>, <span class="number">3</span>, <span class="number">4</span>) <span class="comment">// pageSize + 1</span></span><br><span class="line">    <span class="keyword">val</span> pageSize = <span class="number">3</span></span><br><span class="line"></span><br><span class="line">    <span class="comment">// when</span></span><br><span class="line">    <span class="keyword">val</span> response = CursorPageResponse(content, CursorDirection.NEXT, pageSize)</span><br><span class="line"></span><br><span class="line">    <span class="comment">// then</span></span><br><span class="line">    then(response.hasNext).isTrue()</span><br><span class="line">    then(response.hasPrev).isTrue()</span><br><span class="line">    then(response.nextCursor).isEqualTo(<span class="number">3</span>)</span><br><span class="line">    then(response.prevCursor).isEqualTo(<span class="number">1</span>)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="meta">@Test</span></span><br><span class="line"><span class="function"><span class="keyword">fun</span> `PREV - content가 pageSize 초과이면 hasPrev <span class="literal">true</span>, 결과는 reversed`<span class="params">()</span></span> &#123;</span><br><span class="line">    <span class="comment">// given</span></span><br><span class="line">    <span class="keyword">val</span> content = listOf(<span class="number">1</span>, <span class="number">2</span>, <span class="number">3</span>, <span class="number">4</span>) <span class="comment">// ASC로 조회된 결과 (pageSize + 1)</span></span><br><span class="line">    <span class="keyword">val</span> pageSize = <span class="number">3</span></span><br><span class="line"></span><br><span class="line">    <span class="comment">// when</span></span><br><span class="line">    <span class="keyword">val</span> response = CursorPageResponse(content, CursorDirection.PREV, pageSize)</span><br><span class="line"></span><br><span class="line">    <span class="comment">// then</span></span><br><span class="line">    then(response.hasPrev).isTrue()</span><br><span class="line">    then(response.hasNext).isTrue()</span><br><span class="line">    then(response.content).isEqualTo(listOf(<span class="number">3</span>, <span class="number">2</span>, <span class="number">1</span>)) <span class="comment">// reversed</span></span><br><span class="line">    then(response.prevCursor).isEqualTo(<span class="number">3</span>)</span><br><span class="line">    then(response.nextCursor).isEqualTo(<span class="number">1</span>)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>각 테스트는 <code>content</code>, <code>direction</code>, <code>pageSize</code>만 넘기면 됩니다. 계산 로직은 <code>invoke</code> 안에 있고, 테스트는 <strong>입력과 기대 결과만 서술하는 구조</strong>가 됩니다. <code>nextCursor</code> 인코딩 방식이 바뀌어도 <code>invoke</code> 내부만 수정하면 테스트는 기댓값만 고치면 됩니다.</p><p>테스트 작성자는 <code>CursorPageResponse</code>의 내부 로직을 몰라도 됩니다. <code>content</code>, <code>direction</code>, <code>pageSize</code>를 넘기면 올바른 상태의 객체가 만들어진다는 것만 알면 됩니다.</p><h2><span id="gyeolron">결론</span><a href="#gyeolron" class="header-anchor">#</a></h2><p><strong>테스트가 어렵다면 캡슐화를 의심하라.</strong> 테스트 준비 코드가 길어지거나 테스트마다 같은 계산 로직이 반복된다면, 그것은 테스트 코드의 문제가 아니라 해당 로직이 올바른 위치에 있지 않다는 신호입니다.</p><p><code>CursorPageResponse</code>의 설계를 통해 확인한 것처럼, 캡슐화가 잘 된 객체는 두 가지를 보장합니다.</p><ol><li><p><strong>객체는 자신의 상태를 스스로 결정한다.</strong> <code>hasNext</code>, <code>hasPrev</code>, <code>nextCursor</code>, <code>prevCursor</code>를 외부에서 주입받지 않고, <code>invoke</code> 내부에서 <code>direction</code>과 <code>pageSize</code>를 기반으로 스스로 계산합니다.</p></li><li><p><strong>생성 방법을 하나로 강제한다.</strong> <code>private constructor</code> + <code>invoke</code> 조합으로 외부에서는 오직 올바른 방법으로만 객체를 생성할 수 있게 합니다. 두 가지 생성 경로가 열려 있으면 어느 것을 써야 할지 혼란이 생기고, 잘못된 상태의 객체가 만들어질 여지가 생깁니다.</p></li></ol><p>객체를 설계할 때 “이 로직이 정말 이 객체의 책임인가?”를 스스로에게 물어보는 것도 좋지만, 테스트를 작성하면서 느끼는 불편함 자체가 더 직접적인 피드백입니다. 테스트가 짧고 “입력 → 결과” 구조로 단순하게 작성된다면 설계가 올바른 방향이라는 증거이고, 반대라면 캡슐화를 의심해볼 시점입니다.</p>]]></content:encoded>
      
      
      
      <category domain="https://cheese10yun.github.io/tags/Kotlin/">Kotlin</category>
      
      <category domain="https://cheese10yun.github.io/tags/OOP/">OOP</category>
      
      <category domain="https://cheese10yun.github.io/tags/Test/">Test</category>
      
      <category domain="https://cheese10yun.github.io/tags/Guide/">Guide</category>
      
      
      <comments>https://cheese10yun.github.io/test-feedback-encapsulation.md/#disqus_thread</comments>
      
    </item>
    
    <item>
      <title>Querydsl 커서 기반 페이지네이션 - offset의 구조적 한계와 커서 방식 전환</title>
      <link>https://cheese10yun.github.io/querydsl-cursor-pagination/</link>
      <guid>https://cheese10yun.github.io/querydsl-cursor-pagination/</guid>
      <pubDate>Sat, 18 Apr 2026 15:00:00 GMT</pubDate>
      
      <description>limit/offset의 성능 저하 원인을 실행 계획으로 분석하고, 커서 기반 페이지네이션으로 위치에 무관하게 일정한 조회 성능을 유지하는 방법을 다룹니다.</description>
      
      
      
      <content:encoded><![CDATA[<h2><span id="gwanryeon-poseuting">관련 포스팅</span><a href="#gwanryeon-poseuting" class="header-anchor">#</a></h2><ul><li><a href="https://cheese10yun.github.io/querydsl-support/">Querydsl Repository Support 활용</a></li><li><a href="https://cheese10yun.github.io/page-performance/">JPA 페이징 Performance</a></li><li><a href="https://cheese10yun.github.io/spring-batch-reader-performance/">Spring Batch Reader 성능 분석</a></li></ul><hr><p>이전 포스팅에서 <code>Querydsl4RepositorySupport</code>를 이용한 두 가지 페이지네이션 방법을 소개했습니다. 이번 포스팅에서는 해당 방법들의 한계를 짚어보고, 이를 해결하는 커서 기반 페이지네이션(<code>applyCursorPagination</code>)을 소개합니다.</p><h2><span id="gijon-peijineisyeon-bangsig-bogseub">기존 페이지네이션 방식 복습</span><a href="#gijon-peijineisyeon-bangsig-bogseub" class="header-anchor">#</a></h2><p>전통적인 페이지네이션은 <code>limit</code>, <code>offset</code>으로 부분 데이터를 가져오고, 하단의 네비게이션을 구성하기 위해 전체 카운트 쿼리를 별도로 실행하는 방식입니다.</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">-- 데이터 조회</span></span><br><span class="line"><span class="keyword">SELECT</span> <span class="operator">*</span></span><br><span class="line"><span class="keyword">FROM</span> payment</span><br><span class="line"><span class="keyword">ORDER</span> <span class="keyword">BY</span> id <span class="keyword">DESC</span></span><br><span class="line">LIMIT <span class="number">10</span> <span class="keyword">OFFSET</span> <span class="number">100</span>;</span><br><span class="line"></span><br><span class="line"><span class="comment">-- 전체 카운트</span></span><br><span class="line"><span class="keyword">SELECT</span> <span class="built_in">COUNT</span>(<span class="operator">*</span>)</span><br><span class="line"><span class="keyword">FROM</span> payment;</span><br></pre></td></tr></table></figure><p>이 방식의 카운트 쿼리는 테이블 전체를 스캔하기 때문에 데이터가 많아질수록 부담이 커집니다.</p><h3><span id="applyslicepagination-kaunteu-kweori-eobsneun-slice">applySlicePagination - 카운트 쿼리 없는 Slice</span><a href="#applyslicepagination-kaunteu-kweori-eobsneun-slice" class="header-anchor">#</a></h3><p>그렇다면 카운트 쿼리가 반드시 필요한가? 라는 질문을 해볼 수 있습니다. 실제로 페이지 네비게이션이 있는 화면이라도, 사용자가 26페이지에 원하는 데이터가 있을 것이라고 예상하고 바로 넘어가는 경우는 거의 없습니다. 대부분 다음 페이지로 넘어가는 방식으로 탐색합니다. 이런 경우에는 굳이 무거운 카운트 쿼리 없이, 다음 데이터가 있는지 여부만 내려주는 Slice 방식이 더 효율적입니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">protected</span> <span class="function"><span class="keyword">fun</span> <span class="type">&lt;T&gt;</span> <span class="title">applySlicePagination</span><span class="params">(</span></span></span><br><span class="line"><span class="params"><span class="function">    pageable: <span class="type">Pageable</span>,</span></span></span><br><span class="line"><span class="params"><span class="function">    query: <span class="type">Function</span>&lt;<span class="type">JPAQueryFactory</span>, JPAQuery&lt;T&gt;&gt;</span></span></span><br><span class="line"><span class="params"><span class="function">)</span></span>: Slice&lt;T&gt; &#123;</span><br><span class="line">    <span class="keyword">val</span> jpaContentQuery = query.apply(queryFactory)</span><br><span class="line">    <span class="keyword">val</span> content = querydsl!!.applyPagination(pageable, jpaContentQuery).fetch()</span><br><span class="line">    <span class="keyword">val</span> hasNext = content.size &gt;= pageable.pageSize</span><br><span class="line">    <span class="keyword">return</span> SliceImpl(content, pageable, hasNext)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>pageSize + 1</code>개를 조회해서 다음 데이터 존재 여부(<code>hasNext</code>)만 판별하고, 카운트 쿼리는 실행하지 않습니다.</p><h3><span id="applypagination-korutin-byeongryeol-johoe">applyPagination - 코루틴 병렬 조회</span><a href="#applypagination-korutin-byeongryeol-johoe" class="header-anchor">#</a></h3><p>하단 전체 페이지 네비게이션이 반드시 필요한 경우라면 데이터 조회 쿼리와 카운트 쿼리를 코루틴으로 동시에 실행해서 성능을 개선할 수 있습니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">protected</span> <span class="function"><span class="keyword">fun</span> <span class="type">&lt;T&gt;</span> <span class="title">applyPagination</span><span class="params">(</span></span></span><br><span class="line"><span class="params"><span class="function">    pageable: <span class="type">Pageable</span>,</span></span></span><br><span class="line"><span class="params"><span class="function">    contentQuery: <span class="type">Function</span>&lt;<span class="type">JPAQueryFactory</span>, JPAQuery&lt;T&gt;&gt;,</span></span></span><br><span class="line"><span class="params"><span class="function">    countQuery: <span class="type">Function</span>&lt;<span class="type">JPAQueryFactory</span>, JPAQuery&lt;<span class="built_in">Long</span>&gt;&gt;</span></span></span><br><span class="line"><span class="params"><span class="function">)</span></span>: Page&lt;T&gt; = runBlocking &#123;</span><br><span class="line">    <span class="keyword">val</span> jpaContentQuery = contentQuery.apply(queryFactory)</span><br><span class="line">    <span class="keyword">val</span> content = async(Dispatchers.IO) &#123; querydsl!!.applyPagination(pageable, jpaContentQuery).fetch() <span class="keyword">as</span> List&lt;T&gt; &#125;</span><br><span class="line">    <span class="keyword">val</span> count = async(Dispatchers.IO) &#123; countQuery.apply(queryFactory).fetchFirst() &#125;</span><br><span class="line"></span><br><span class="line">    PageImpl(content.await(), pageable, count.await())</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>데이터 조회에 500ms, 카운트 조회에 500ms가 걸린다면, 동기식으로는 1,000ms가 소요됩니다. 코루틴으로 병렬 실행하면 이론적으로 500ms로 단축됩니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">fun</span> <span class="title">findBy</span><span class="params">(amount: <span class="type">BigDecimal</span>, pageable: <span class="type">Pageable</span>)</span></span>: Page&lt;Payment&gt; &#123;</span><br><span class="line">    <span class="keyword">return</span> applyPagination(</span><br><span class="line">        pageable = pageable,</span><br><span class="line">        contentQuery = &#123; selectFrom(qPayment).<span class="keyword">where</span>(qPayment.amount.gt(amount)) &#125;,</span><br><span class="line">        countQuery = &#123; select(qPayment.count()).from(qPayment).<span class="keyword">where</span>(qPayment.amount.gt(amount)) &#125;</span><br><span class="line">    )</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2><span id="limit-offsetyi-geunbonjeogin-hangye">limit, offset의 근본적인 한계</span><a href="#limit-offsetyi-geunbonjeogin-hangye" class="header-anchor">#</a></h2><p>두 방식 모두 <code>limit</code>, <code>offset</code> 기반이라는 점에서 공통적인 한계가 있습니다. <strong>offset이 커질수록 조회 성능이 선형적으로 저하됩니다.</strong></p><h3><span id="wae-offseti-neuryeojineunga">왜 offset이 느려지는가?</span><a href="#wae-offseti-neuryeojineunga" class="header-anchor">#</a></h3><blockquote><p>보다 자세한 내용은 <a href="https://cheese10yun.github.io/spring-batch-reader-performance/">Spring Batch Reader 성능 분석</a>을 참고해 주세요.</p></blockquote><p><code>JpaPagingItemReader</code>(offset 방식)와 <code>QueryDslNoOffsetPagingReader</code>(No Offset 방식)의 실측 성능을 비교하면 offset의 한계가 명확하게 드러납니다.</p><table><thead><tr><th>Reader</th><th>rows</th><th>소요 시간</th></tr></thead><tbody><tr><td>JpaPagingItemReader</td><td>10,000</td><td>778 ms</td></tr><tr><td>JpaPagingItemReader</td><td>100,000</td><td>8,912 ms</td></tr><tr><td>JpaPagingItemReader</td><td>500,000</td><td>205,469 ms</td></tr><tr><td>JpaPagingItemReader</td><td>1,000,000</td><td>1,048,979 ms</td></tr><tr><td>QueryDslNoOffsetPagingReader</td><td>10,000</td><td>658 ms</td></tr><tr><td>QueryDslNoOffsetPagingReader</td><td>100,000</td><td>3,523 ms</td></tr><tr><td>QueryDslNoOffsetPagingReader</td><td>500,000</td><td>15,501 ms</td></tr><tr><td>QueryDslNoOffsetPagingReader</td><td>1,000,000</td><td>28,732 ms</td></tr></tbody></table><p><code>JpaPagingItemReader</code> 기준으로 10만 건과 50만 건의 차이가 단순히 5배가 아니라 23배 이상 차이가 납니다. 반면 <code>QueryDslNoOffsetPagingReader</code>는 rows가 늘어나도 선형에 가까운 증가를 보입니다.</p><p><img src="https://raw.githubusercontent.com/cheese10yun/blog-sample/master/batch-study/docs/img/reader-performance-1.png"></p><p>그래프를 보면 <code>JpaPagingItemReader</code>(파란색)의 수치가 너무 커서 <code>QueryDslNoOffsetPagingReader</code>(빨간색)의 선이 거의 보이지 않을 정도입니다. 두 리더의 성능 차이가 그만큼 크다는 것을 의미합니다. <code>JpaPagingItemReader</code>를 제외하고 두 리더만 비교한 그래프에서 <code>QueryDslNoOffsetPagingReader</code>가 얼마나 안정적인지 확인할 수 있습니다.</p><p>원인을 실행 계획으로 확인해 보겠습니다.</p><h4><span id="jpapagingitemreader-ceos-beonjjae-ceongkeu-offset-0">JpaPagingItemReader - 첫 번째 청크 (offset 0)</span><a href="#jpapagingitemreader-ceos-beonjjae-ceongkeu-offset-0" class="header-anchor">#</a></h4><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">SELECT</span> <span class="operator">*</span></span><br><span class="line"><span class="keyword">FROM</span> payment</span><br><span class="line"><span class="keyword">WHERE</span> created_at <span class="operator">&gt;=</span> ?</span><br><span class="line"><span class="keyword">ORDER</span> <span class="keyword">BY</span> created_at <span class="keyword">DESC</span></span><br><span class="line">LIMIT <span class="number">1000</span>;</span><br></pre></td></tr></table></figure><table><thead><tr><th>type</th><th>key</th><th>Extra</th></tr></thead><tbody><tr><td>range</td><td>IDX_created_at</td><td>Using index condition</td></tr></tbody></table><p><img src="https://raw.githubusercontent.com/cheese10yun/blog-sample/master/batch-study/docs/img/explain_1.png"></p><p><code>type: range</code>로 인덱스가 정상적으로 동작합니다.</p><h4><span id="jpapagingitemreader-majimag-ceongkeu-offset-4-999-000">JpaPagingItemReader - 마지막 청크 (offset 4,999,000)</span><a href="#jpapagingitemreader-majimag-ceongkeu-offset-4-999-000" class="header-anchor">#</a></h4><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">SELECT</span> <span class="operator">*</span></span><br><span class="line"><span class="keyword">FROM</span> payment</span><br><span class="line"><span class="keyword">WHERE</span> created_at <span class="operator">&gt;=</span> ?</span><br><span class="line"><span class="keyword">ORDER</span> <span class="keyword">BY</span> created_at <span class="keyword">DESC</span></span><br><span class="line">LIMIT <span class="number">4999000</span>, <span class="number">1000</span>;</span><br></pre></td></tr></table></figure><table><thead><tr><th>type</th><th>key</th><th>Extra</th></tr></thead><tbody><tr><td>ALL</td><td>NULL</td><td>Using where; Using filesort</td></tr></tbody></table><p><img src="https://raw.githubusercontent.com/cheese10yun/blog-sample/master/batch-study/docs/img/explan_2.png"></p><p><code>type: ALL</code>, 즉 <strong>풀 테이블 스캔</strong>이 발생합니다. 인덱스도 사용하지 않습니다.</p><h4><span id="wae-pul-seukaeni-balsaenghaneunga">왜 풀 스캔이 발생하는가?</span><a href="#wae-pul-seukaeni-balsaenghaneunga" class="header-anchor">#</a></h4><p><code>offset</code>의 본질적인 문제를 이해하려면 DB가 offset을 처리하는 방식을 알아야 합니다. <code>LIMIT 4999000, 1000</code>은 “4,999,000번째 행부터 1,000건을 가져와라”라는 의미인데, 데이터베이스는 4,999,000번째 행이 어디 있는지 바로 알 수 없습니다. <strong>버스에서 10번째 줄에 누가 앉아 있는지 확인하려면 앞에서부터 9줄을 직접 세어가며 확인해야 하는 것처럼, offset도 그 위치까지 도달하기 위해 앞의 데이터를 모두 읽고 버려야 합니다.</strong></p><p>즉, <code>LIMIT 4999000, 1000</code> 쿼리는 결국 4,999,000건을 읽고 전부 버린 뒤, 그 다음 1,000건만 돌려주는 동작을 합니다. 실제로 반환되는 데이터는 1,000건이지만 내부적으로는 500만 건 가까운 데이터를 스캔하는 셈입니다.</p><p>여기서 옵티마이저의 비용 판단이 개입합니다. <strong>인덱스를 통해 레코드 1건을 읽는 것은 테이블에서 직접 1건을 읽는 것보다 4~5배 비용이 발생합니다.</strong> 데이터 모수가 적을 때는 인덱스를 타는 것이 훨씬 효율적이지만, 읽어야 할 범위가 테이블 전체의 20~25%를 넘어서면 옵티마이저는 인덱스를 통해 한 건씩 찾아가는 것보다 테이블을 통째로 풀 스캔하는 것이 더 낫다고 판단합니다. 결과적으로 후반 청크에서는 인덱스가 사라지고 풀 스캔이 선택됩니다.</p><p><img src="https://raw.githubusercontent.com/cheese10yun/blog-sample/master/batch-study/docs/img/limit_3.png"></p><h4><span id="querydslnooffsetpagingreader-ceos-beonjjae-x2f-majimag-ceongkeu-bigyo">QueryDslNoOffsetPagingReader - 첫 번째 &#x2F; 마지막 청크 비교</span><a href="#querydslnooffsetpagingreader-ceos-beonjjae-x2f-majimag-ceongkeu-bigyo" class="header-anchor">#</a></h4><p>No Offset 방식은 <code>WHERE id &lt; 마지막 조회 ID</code> 조건으로 offset 없이 다음 데이터를 찾습니다.</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">-- 첫 번째 청크</span></span><br><span class="line"><span class="keyword">SELECT</span> <span class="operator">*</span></span><br><span class="line"><span class="keyword">FROM</span> payment</span><br><span class="line"><span class="keyword">WHERE</span> created_at <span class="operator">&gt;=</span> ?</span><br><span class="line">  <span class="keyword">AND</span> id <span class="operator">&gt;=</span> <span class="number">1</span></span><br><span class="line">  <span class="keyword">AND</span> id <span class="operator">&lt;=</span> <span class="number">1000</span></span><br><span class="line"><span class="keyword">ORDER</span> <span class="keyword">BY</span> id <span class="keyword">ASC</span></span><br><span class="line">LIMIT <span class="number">1000</span>;</span><br><span class="line"></span><br><span class="line"><span class="comment">-- 마지막 청크</span></span><br><span class="line"><span class="keyword">SELECT</span> <span class="operator">*</span></span><br><span class="line"><span class="keyword">FROM</span> payment</span><br><span class="line"><span class="keyword">WHERE</span> created_at <span class="operator">&gt;=</span> ?</span><br><span class="line">  <span class="keyword">AND</span> id <span class="operator">&gt;=</span> <span class="number">4999001</span></span><br><span class="line">  <span class="keyword">AND</span> id <span class="operator">&lt;=</span> <span class="number">5000000</span></span><br><span class="line"><span class="keyword">ORDER</span> <span class="keyword">BY</span> id <span class="keyword">ASC</span></span><br><span class="line">LIMIT <span class="number">1000</span>;</span><br></pre></td></tr></table></figure><table><thead><tr><th>type</th><th>key</th><th>Extra</th></tr></thead><tbody><tr><td>range</td><td>PRIMARY</td><td>Using where</td></tr></tbody></table><p><img src="https://raw.githubusercontent.com/cheese10yun/blog-sample/master/batch-study/docs/img/explain_3.png"></p><p><strong>첫 번째 청크와 마지막 청크의 실행 계획이 동일합니다.</strong> PK 인덱스(<code>PRIMARY</code>)를 기준으로 범위 조회하기 때문에 offset이 누적되어도 스캔 비용이 일정하게 유지됩니다.</p><p>정리하면, <strong>offset&#x2F;limit 방식은 offset이 커질수록 그 위치까지 도달하기 위한 스캔 비용이 누적되어 결국 풀 스캔으로 전환되는 구조적 한계를 가집니다.</strong> No Offset 방식은 이 문제를 커서 조건으로 원천 차단합니다.</p><h2><span id="applycursorpagination-keoseo-giban-peiji-johoe">applyCursorPagination - 커서 기반 페이지 조회</span><a href="#applycursorpagination-keoseo-giban-peiji-johoe" class="header-anchor">#</a></h2><p>앞서 소개한 <code>QueryDslNoOffsetPagingReader</code>도 커서 기반 발상을 활용하지만, 그 용도는 <strong>배치 Item Reader</strong>에 특화되어 있습니다. 데이터를 처음부터 끝까지 한 방향으로 순차 처리하는 것이 목적이기 때문에 “다음 청크”만 지원하면 충분합니다.</p><p>반면 <strong>REST API로 페이지 탐색 기능을 제공</strong>할 때는 요구사항이 다릅니다. 사용자 또는 어드민이 화면에서 다음과 같은 탐색을 요청할 수 있습니다.</p><ul><li><strong>첫 번째 페이지로 이동</strong> (FIRST)</li><li><strong>마지막 페이지로 이동</strong> (LAST)</li><li><strong>현재 페이지에서 다음 페이지로</strong> (NEXT)</li><li><strong>현재 페이지에서 이전 페이지로</strong> (PREV)</li></ul><p><code>applyCursorPagination</code>은 이 네 가지 방향을 모두 지원하는 <strong>양방향 커서 탐색 API</strong>를 제공하기 위해 설계되었습니다.</p><h3><span id="keoseo-giban-peijineisyeoniran">커서 기반 페이지네이션이란</span><a href="#keoseo-giban-peijineisyeoniran" class="header-anchor">#</a></h3><p>커서 기반 페이지네이션의 핵심 아이디어는 <code>offset</code>을 없애는 것입니다. <code>offset</code> 대신 <strong>마지막으로 조회한 데이터의 ID를 커서(cursor)로 삼아 <code>WHERE id &lt; :cursor</code> 조건으로 다음 데이터를 가져오는 방식</strong>입니다.</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">-- offset 방식 (뒤로 갈수록 느려짐)</span></span><br><span class="line"><span class="keyword">SELECT</span> <span class="operator">*</span></span><br><span class="line"><span class="keyword">FROM</span> payment</span><br><span class="line"><span class="keyword">ORDER</span> <span class="keyword">BY</span> id <span class="keyword">DESC</span></span><br><span class="line">LIMIT <span class="number">10</span> <span class="keyword">OFFSET</span> <span class="number">4990</span>;</span><br><span class="line"></span><br><span class="line"><span class="comment">-- cursor 방식 (항상 동일한 실행 계획)</span></span><br><span class="line"><span class="keyword">SELECT</span> <span class="operator">*</span></span><br><span class="line"><span class="keyword">FROM</span> payment</span><br><span class="line"><span class="keyword">WHERE</span> id <span class="operator">&lt;</span> :lastId</span><br><span class="line"><span class="keyword">ORDER</span> <span class="keyword">BY</span> id <span class="keyword">DESC</span></span><br><span class="line">LIMIT <span class="number">10</span>;</span><br></pre></td></tr></table></figure><p><code>WHERE id &lt; :lastId</code>는 PK 인덱스를 그대로 활용하기 때문에, 조회 위치가 어디든 실행 계획이 동일하게 유지됩니다.</p><p>클라이언트는 응답으로 받은 <code>nextCursor</code> 또는 <code>prevCursor</code> 값을 다음 요청의 <code>cursorKey</code>로 전달하는 방식으로 탐색을 이어갑니다. 서버는 커서 값과 방향(direction)만으로 다음 조회 범위를 결정하기 때문에, 전체 데이터 크기나 현재 페이지 번호를 유지할 필요가 없습니다.</p><h3><span id="dongjag-weonri">동작 원리</span><a href="#dongjag-weonri" class="header-anchor">#</a></h3><p>데이터가 8건(id: 1~8)이고 pageSize&#x3D;2인 경우를 예로 들겠습니다.</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">전체 데이터: [8, 7, 6, 5, 4, 3, 2, 1]  (DESC 기준)</span><br></pre></td></tr></table></figure><p><strong>FIRST</strong> - 첫 페이지 (커서 없음):</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">SELECT</span> <span class="operator">*</span></span><br><span class="line"><span class="keyword">FROM</span> payment</span><br><span class="line"><span class="keyword">ORDER</span> <span class="keyword">BY</span> id <span class="keyword">DESC</span></span><br><span class="line">LIMIT <span class="number">3</span>;</span><br><span class="line"><span class="comment">-- pageSize + 1</span></span><br><span class="line"><span class="comment">-- 결과: [8, 7, 6]</span></span><br></pre></td></tr></table></figure><ul><li><code>actualContent</code>: [8, 7] (pageSize만큼 자름)</li><li><code>hasNext</code>: true (3건 &gt; pageSize 2)</li><li><code>nextCursor</code>: “7” (마지막 항목 ID)</li><li><code>hasPrev</code>: false,</li><li><code>prevCursor</code>: null</li></ul><p><strong>NEXT</strong> (cursor&#x3D;7):</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">SELECT</span> <span class="operator">*</span></span><br><span class="line"><span class="keyword">FROM</span> payment</span><br><span class="line"><span class="keyword">WHERE</span> id <span class="operator">&lt;</span> <span class="number">7</span></span><br><span class="line"><span class="keyword">ORDER</span> <span class="keyword">BY</span> id <span class="keyword">DESC</span></span><br><span class="line">LIMIT <span class="number">3</span>;</span><br><span class="line"><span class="comment">-- 결과: [6, 5, 4]</span></span><br></pre></td></tr></table></figure><ul><li><code>actualContent</code>: [6, 5]</li><li><code>hasNext</code>: true</li><li><code>nextCursor</code>: “5”,</li><li><code>prevCursor</code>: “6”</li><li><code>hasPrev</code>: true</li></ul><p><strong>NEXT</strong> (cursor&#x3D;3) - 마지막 페이지:</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">SELECT</span> <span class="operator">*</span></span><br><span class="line"><span class="keyword">FROM</span> payment</span><br><span class="line"><span class="keyword">WHERE</span> id <span class="operator">&lt;</span> <span class="number">3</span></span><br><span class="line"><span class="keyword">ORDER</span> <span class="keyword">BY</span> id <span class="keyword">DESC</span></span><br><span class="line">LIMIT <span class="number">3</span>;</span><br><span class="line"><span class="comment">-- 결과: [2, 1]</span></span><br></pre></td></tr></table></figure><ul><li><code>actualContent</code>: [2, 1]</li><li><code>hasNext</code>: false (2건 ≤ pageSize 2)</li><li><code>nextCursor</code>: null</li><li><code>hasPrev</code>: true,</li><li><code>prevCursor</code>: “2”</li></ul><h3><span id="guhyeon">구현</span><a href="#guhyeon" class="header-anchor">#</a></h3><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">fun</span> <span class="type">&lt;T&gt;</span> <span class="title">applyCursorPagination</span><span class="params">(</span></span></span><br><span class="line"><span class="params"><span class="function">    cursorRequest: <span class="type">CursorRequest</span>,</span></span></span><br><span class="line"><span class="params"><span class="function">    cursorPath: <span class="type">NumberPath</span>&lt;<span class="type">Long</span>&gt;,</span></span></span><br><span class="line"><span class="params"><span class="function">    cursorSelector: (<span class="type">T</span>) -&gt; <span class="type">String</span>,</span></span></span><br><span class="line"><span class="params"><span class="function">    contentQuery: <span class="type">Function</span>&lt;<span class="type">JPAQueryFactory</span>, JPAQuery&lt;T&gt;&gt;,</span></span></span><br><span class="line"><span class="params"><span class="function">)</span></span>: CursorPageResponse&lt;T&gt; &#123;</span><br><span class="line">    <span class="keyword">val</span> direction = cursorRequest.direction</span><br><span class="line">    <span class="keyword">val</span> pageSize = cursorRequest.pageSize</span><br><span class="line">    <span class="keyword">val</span> cursorValue = cursorRequest.cursorKey?.toLong()</span><br><span class="line"></span><br><span class="line">    <span class="keyword">val</span> query = contentQuery.apply(queryFactory)</span><br><span class="line"></span><br><span class="line">    <span class="keyword">when</span> (direction) &#123;</span><br><span class="line">        CursorDirection.FIRST, CursorDirection.LAST -&gt; <span class="built_in">Unit</span></span><br><span class="line">        CursorDirection.NEXT -&gt; &#123;</span><br><span class="line">            requireNotNull(cursorValue) &#123; <span class="string">&quot;Cursor key must be provided for NEXT direction&quot;</span> &#125;</span><br><span class="line">            query.<span class="keyword">where</span>(cursorPath.lt(cursorValue))</span><br><span class="line">        &#125;</span><br><span class="line">        CursorDirection.PREV -&gt; &#123;</span><br><span class="line">            requireNotNull(cursorValue) &#123; <span class="string">&quot;Cursor key must be provided for PREV direction&quot;</span> &#125;</span><br><span class="line">            query.<span class="keyword">where</span>(cursorPath.gt(cursorValue))</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    query.orderBy(</span><br><span class="line">        <span class="keyword">when</span> &#123;</span><br><span class="line">            direction.isForward -&gt; cursorPath.desc()</span><br><span class="line">            <span class="keyword">else</span> -&gt; cursorPath.asc()</span><br><span class="line">        &#125;</span><br><span class="line">    )</span><br><span class="line">    query.limit((pageSize + <span class="number">1</span>).toLong())</span><br><span class="line">    <span class="keyword">val</span> content = query.fetch()</span><br><span class="line">    <span class="keyword">return</span> CursorPageResponse(</span><br><span class="line">        content = content,</span><br><span class="line">        direction = direction,</span><br><span class="line">        pageSize = pageSize,</span><br><span class="line">        encodeCursor = cursorSelector,</span><br><span class="line">    )</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>contentQuery</code>에는 기본 조회 쿼리만 전달하면 됩니다. <code>WHERE</code> 커서 조건, <code>ORDER BY</code>, <code>LIMIT</code> 처리는 <code>applyCursorPagination</code>이 담당합니다.</p><p>실제 Repository에서는 다음과 같이 사용합니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">fun</span> <span class="title">findByCursor</span><span class="params">(cursorRequest: <span class="type">CursorRequest</span>)</span></span>: CursorPageResponse&lt;Payment&gt; &#123;</span><br><span class="line">    <span class="keyword">return</span> applyCursorPagination(</span><br><span class="line">        cursorRequest = cursorRequest,</span><br><span class="line">        cursorPath = qPayment.id,</span><br><span class="line">        cursorSelector = &#123; it.id.toString() &#125;,</span><br><span class="line">        contentQuery = &#123; selectFrom(qPayment) &#125;</span><br><span class="line">    )</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3><span id="cursorpageresponse">CursorPageResponse</span><a href="#cursorpageresponse" class="header-anchor">#</a></h3><p>응답 객체는 팩토리 함수(<code>invoke</code>)를 통해 방향별 커서와 플래그를 자동으로 계산합니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">data</span> <span class="keyword">class</span> <span class="title class_">CursorPageResponse</span>&lt;<span class="type">T</span>&gt; <span class="keyword">private</span> <span class="keyword">constructor</span>(</span><br><span class="line">    <span class="keyword">val</span> content: List&lt;T&gt;,</span><br><span class="line">    <span class="keyword">val</span> hasNext: <span class="built_in">Boolean</span>,</span><br><span class="line">    <span class="keyword">val</span> hasPrev: <span class="built_in">Boolean</span>,</span><br><span class="line">    <span class="keyword">val</span> nextCursor: String?,</span><br><span class="line">    <span class="keyword">val</span> prevCursor: String?,</span><br><span class="line">    ...</span><br><span class="line">)</span><br></pre></td></tr></table></figure><table><thead><tr><th>direction</th><th>content</th><th>hasNext</th><th>hasPrev</th><th>nextCursor</th><th>prevCursor</th></tr></thead><tbody><tr><td>FIRST</td><td>take(pageSize)</td><td>초과 여부</td><td>false</td><td>마지막 항목 or null</td><td>null</td></tr><tr><td>NEXT</td><td>take(pageSize)</td><td>초과 여부</td><td>true</td><td>마지막 항목 or null</td><td>첫 번째 항목</td></tr><tr><td>PREV</td><td>take(pageSize).reversed()</td><td>true</td><td>초과 여부</td><td>마지막 항목</td><td>첫 번째 항목 or null</td></tr><tr><td>LAST</td><td>take(pageSize).reversed()</td><td>false</td><td>초과 여부</td><td>null</td><td>첫 번째 항목 or null</td></tr></tbody></table><p>backward(PREV&#x2F;LAST) 방향은 DB에서 ASC로 조회한 결과를 <code>reversed()</code>로 뒤집어 표시 순서를 맞춥니다.</p><h2><span id="juyi-sahang">주의 사항</span><a href="#juyi-sahang" class="header-anchor">#</a></h2><h3><span id="contentquerye-jeongryeol-jogeoneul-neohji-maseyo">contentQuery에 정렬 조건을 넣지 마세요</span><a href="#contentquerye-jeongryeol-jogeoneul-neohji-maseyo" class="header-anchor">#</a></h3><p><code>applyCursorPagination</code>은 내부에서 <code>cursorPath</code>를 기준으로 <code>ORDER BY</code>를 자동으로 추가합니다. <code>contentQuery</code>에 별도의 정렬 조건을 넣으면 충돌이 발생할 수 있습니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// Bad - contentQuery에 orderBy 추가</span></span><br><span class="line">contentQuery = &#123; selectFrom(qPayment).orderBy(qPayment.createdAt.desc()) &#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// Good - 기본 조회 쿼리만</span></span><br><span class="line">contentQuery = &#123; selectFrom(qPayment) &#125;</span><br></pre></td></tr></table></figure><h3><span id="keoseo-giban-johoega-jeoghabhaji-anheun-gyeongu">커서 기반 조회가 적합하지 않은 경우</span><a href="#keoseo-giban-johoega-jeoghabhaji-anheun-gyeongu" class="header-anchor">#</a></h3><p>커서 기반 조회는 ID처럼 <strong>순차적이고 유니크한 값을 기준</strong>으로 동작합니다. 다음 경우에는 적합하지 않을 수 있습니다.</p><p><strong>1. GROUP BY가 포함된 쿼리</strong></p><p>GROUP BY 결과에서 ID 기준으로 커서를 잡기 애매합니다. 예를 들어 카테고리별 합계를 조회하는 경우, 집계 결과의 행에는 단일 ID가 존재하지 않아 커서의 연속성이 깨집니다.</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">-- ID 커서를 적용하기 어려운 쿼리</span></span><br><span class="line"><span class="keyword">SELECT</span> category, <span class="built_in">SUM</span>(amount)</span><br><span class="line"><span class="keyword">FROM</span> payment</span><br><span class="line"><span class="keyword">GROUP</span> <span class="keyword">BY</span> category;</span><br></pre></td></tr></table></figure><p><strong>2. ID와 다른 기준으로 정렬하는 경우</strong></p><p>커서 기반 조회는 ID의 대소 비교로 페이지를 나눕니다. 정렬 기준이 ID와 다른 경우(예: <code>amount DESC</code>) 커서가 정렬 순서와 일치하지 않아 데이터 누락이나 중복이 발생할 수 있습니다.</p><p><strong>3. 임의 페이지 이동이 필요한 경우</strong></p><p>“26페이지로 바로 이동” 같은 임의 페이지 이동은 커서 기반으로는 지원하기 어렵습니다. 커서는 이전 페이지의 결과에서 연속적으로 이어지는 구조이기 때문입니다.</p><p>정리하면, 커서 기반 조회는 <strong>시계열 데이터를 ID 기준으로 순차 탐색</strong>하는 패턴에 가장 잘 맞습니다. 피드, 알림 목록, 거래 내역처럼 최신순으로 스크롤하는 화면이 대표적인 적합 사례입니다.</p><h2><span id="seongneung-teseuteu-deiteo-seseob">성능 테스트 데이터 셋업</span><a href="#seongneung-teseuteu-deiteo-seseob" class="header-anchor">#</a></h2><p>성능 측정을 위해 payment 테이블에 100만 건 데이터를 셋업합니다. <strong>Doubling 방식</strong>(INSERT INTO … SELECT)으로 초기 1행에서 20번 반복하면 2^20 &#x3D; 1,048,576건을 빠르게 생성할 수 있습니다.</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">-- 1. 기존 데이터 초기화</span></span><br><span class="line"><span class="keyword">TRUNCATE</span> <span class="keyword">TABLE</span> payment;</span><br><span class="line"></span><br><span class="line"><span class="comment">-- 2. 기준 1행 삽입</span></span><br><span class="line"><span class="keyword">INSERT INTO</span> payment (amount, created_at, updated_at)</span><br><span class="line"><span class="keyword">VALUES</span> (<span class="number">500.00</span>, <span class="string">&#x27;2023-01-01 00:00:00&#x27;</span>, NOW());</span><br><span class="line"></span><br><span class="line"><span class="comment">-- 3. 아래 INSERT를 20번 반복 실행 (매 실행마다 행 수 2배 증가)</span></span><br><span class="line"><span class="comment">-- 1회: 2건 / 5회: 32건 / 10회: 1,024건 / 15회: 32,768건 / 20회: 1,048,576건</span></span><br><span class="line"><span class="keyword">INSERT INTO</span> payment (amount, created_at, updated_at)</span><br><span class="line"><span class="keyword">SELECT</span> ROUND(RAND() <span class="operator">*</span> <span class="number">1000</span>, <span class="number">2</span>),</span><br><span class="line">       DATE_ADD(<span class="string">&#x27;2023-01-01 00:00:00&#x27;</span>, <span class="type">INTERVAL</span> <span class="built_in">FLOOR</span>(RAND() <span class="operator">*</span> <span class="number">730</span> <span class="operator">*</span> <span class="number">24</span> <span class="operator">*</span> <span class="number">3600</span>) <span class="keyword">SECOND</span>),</span><br><span class="line">       NOW()</span><br><span class="line"><span class="keyword">FROM</span> payment;</span><br><span class="line"></span><br><span class="line"><span class="comment">-- 4. 검증</span></span><br><span class="line"><span class="keyword">SELECT</span> <span class="built_in">COUNT</span>(<span class="operator">*</span>)</span><br><span class="line"><span class="keyword">FROM</span> payment;</span><br><span class="line"></span><br><span class="line"><span class="keyword">SELECT</span> DATE_FORMAT(created_at, <span class="string">&#x27;%Y-%m&#x27;</span>) <span class="keyword">AS</span> <span class="keyword">month</span>,</span><br><span class="line">       <span class="built_in">COUNT</span>(<span class="operator">*</span>)                         <span class="keyword">AS</span> cnt</span><br><span class="line"><span class="keyword">FROM</span> payment</span><br><span class="line"><span class="keyword">GROUP</span> <span class="keyword">BY</span> <span class="keyword">month</span></span><br><span class="line"><span class="keyword">ORDER</span> <span class="keyword">BY</span> <span class="keyword">month</span>;</span><br></pre></td></tr></table></figure><h2><span id="seongneung-bigyo">성능 비교</span><a href="#seongneung-bigyo" class="header-anchor">#</a></h2><p>데이터 셋업(1,048,576건, created_at 2023~2024년 분산)을 기준으로 limit&#x2F;offset 방식과 커서 방식의 SQL을 각각 첫 번째·중간·마지막 페이지로 비교합니다. limit size는 100으로 고정합니다.</p><h3><span id="limit-x2f-offset-bangsig">limit &#x2F; offset 방식</span><a href="#limit-x2f-offset-bangsig" class="header-anchor">#</a></h3><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">-- 첫 번째 페이지 (offset 0), 소요 597 ms</span></span><br><span class="line"><span class="keyword">SELECT</span> <span class="operator">*</span></span><br><span class="line"><span class="keyword">FROM</span> payment</span><br><span class="line"><span class="keyword">ORDER</span> <span class="keyword">BY</span> id <span class="keyword">DESC</span></span><br><span class="line">LIMIT <span class="number">100</span> <span class="keyword">OFFSET</span> <span class="number">0</span>;</span><br><span class="line"></span><br><span class="line"><span class="comment">-- 약 5,241 페이지 (offset ~524,000, 전체의 약 50% 지점), 소요 1,198 ms</span></span><br><span class="line"><span class="keyword">SELECT</span> <span class="operator">*</span></span><br><span class="line"><span class="keyword">FROM</span> payment</span><br><span class="line"><span class="keyword">ORDER</span> <span class="keyword">BY</span> id <span class="keyword">DESC</span></span><br><span class="line">LIMIT <span class="number">100</span> <span class="keyword">OFFSET</span> <span class="number">524000</span>;</span><br><span class="line"></span><br><span class="line"><span class="comment">-- 약 8,241 페이지 (offset ~824,000, 전체의 약 79% 지점), 소요 1,639 ms</span></span><br><span class="line"><span class="keyword">SELECT</span> <span class="operator">*</span></span><br><span class="line"><span class="keyword">FROM</span> payment</span><br><span class="line"><span class="keyword">ORDER</span> <span class="keyword">BY</span> id <span class="keyword">DESC</span></span><br><span class="line">LIMIT <span class="number">100</span> <span class="keyword">OFFSET</span> <span class="number">824000</span>;</span><br><span class="line"></span><br><span class="line"><span class="comment">-- 마지막 페이지 (offset ~1,048,476, 전체 끝 지점), 소요 1,995 ms</span></span><br><span class="line"><span class="keyword">SELECT</span> <span class="operator">*</span></span><br><span class="line"><span class="keyword">FROM</span> payment</span><br><span class="line"><span class="keyword">ORDER</span> <span class="keyword">BY</span> id <span class="keyword">DESC</span></span><br><span class="line">LIMIT <span class="number">100</span> <span class="keyword">OFFSET</span> <span class="number">1048476</span>;</span><br></pre></td></tr></table></figure><h3><span id="keoseo-bangsig">커서 방식</span><a href="#keoseo-bangsig" class="header-anchor">#</a></h3><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">-- 첫 번째 페이지 (커서 없음, id 최댓값 1,310,693부터 시작), 소요 589 ms</span></span><br><span class="line"><span class="keyword">SELECT</span> <span class="operator">*</span></span><br><span class="line"><span class="keyword">FROM</span> payment</span><br><span class="line"><span class="keyword">ORDER</span> <span class="keyword">BY</span> id <span class="keyword">DESC</span></span><br><span class="line">LIMIT <span class="number">100</span>;</span><br><span class="line"></span><br><span class="line"><span class="comment">-- 약 50% 지점 (id 약 655,000 기준), 소요 596 ms</span></span><br><span class="line"><span class="keyword">SELECT</span> <span class="operator">*</span></span><br><span class="line"><span class="keyword">FROM</span> payment</span><br><span class="line"><span class="keyword">WHERE</span> id <span class="operator">&lt;</span> <span class="number">655000</span></span><br><span class="line"><span class="keyword">ORDER</span> <span class="keyword">BY</span> id <span class="keyword">DESC</span></span><br><span class="line">LIMIT <span class="number">100</span>;</span><br><span class="line"></span><br><span class="line"><span class="comment">-- 약 79% 지점 (id 약 281,000 기준), 소요 577 ms</span></span><br><span class="line"><span class="keyword">SELECT</span> <span class="operator">*</span></span><br><span class="line"><span class="keyword">FROM</span> payment</span><br><span class="line"><span class="keyword">WHERE</span> id <span class="operator">&lt;</span> <span class="number">281000</span></span><br><span class="line"><span class="keyword">ORDER</span> <span class="keyword">BY</span> id <span class="keyword">DESC</span></span><br><span class="line">LIMIT <span class="number">100</span>;</span><br><span class="line"></span><br><span class="line"><span class="comment">-- 마지막 페이지 (id 약 100 기준, 끝 지점), 소요 595 ms</span></span><br><span class="line"><span class="keyword">SELECT</span> <span class="operator">*</span></span><br><span class="line"><span class="keyword">FROM</span> payment</span><br><span class="line"><span class="keyword">WHERE</span> id <span class="operator">&lt;</span> <span class="number">100</span></span><br><span class="line"><span class="keyword">ORDER</span> <span class="keyword">BY</span> id <span class="keyword">DESC</span></span><br><span class="line">LIMIT <span class="number">100</span>;</span><br></pre></td></tr></table></figure><h3><span id="seongneung-ceugjeong-gyeolgwa">성능 측정 결과</span><a href="#seongneung-ceugjeong-gyeolgwa" class="header-anchor">#</a></h3><p><img src="https://raw.githubusercontent.com/cheese10yun/blog-sample/master/query-dsl/docs/images/page-Performance.svg" alt="Update Performance"></p><table><thead><tr><th>구간</th><th>limit&#x2F;offset</th><th>cursor</th><th>차이</th></tr></thead><tbody><tr><td>첫 번째</td><td>597 ms</td><td>589 ms</td><td>+1.4%</td></tr><tr><td>약 50% 지점</td><td>1,198 ms</td><td>596 ms</td><td>+101.0%</td></tr><tr><td>약 79% 지점</td><td>1,639 ms</td><td>577 ms</td><td>+184.1%</td></tr><tr><td>마지막</td><td>1,995 ms</td><td>595 ms</td><td>+235.3%</td></tr></tbody></table><p>첫 번째 페이지에서는 두 방식의 차이가 거의 없습니다. 하지만 뒤로 갈수록 격차가 급격히 벌어져 마지막 지점에서는 limit&#x2F;offset이 cursor보다 <strong>3.4배</strong> 느립니다.</p><p>이 차이는 단발성 API 호출에서도 체감되지만, <strong>전체 데이터를 순차적으로 읽어 리포트를 생성하는 배치 처리</strong>에서 더욱 치명적입니다. 예를 들어 100만 건의 결제 데이터를 페이지 단위로 모두 읽어 월별 매출 집계를 산출하는 배치를 생각해보면, limit&#x2F;offset 방식은 청크가 뒤로 넘어갈수록 쿼리 1건당 소요 시간이 계속 누적되어 증가합니다. 반면 cursor 방식은 청크 위치와 무관하게 매 쿼리가 일정한 비용으로 동작하기 때문에 전체 처리 시간이 선형에 가깝게 유지됩니다.</p><p>결국 <strong>“어느 위치를 조회하든 동일한 실행 계획”</strong> 이라는 cursor의 특성이, 반복 호출이 누적되는 배치 환경에서 성능 격차를 더욱 크게 만드는 핵심 이유입니다.</p><h2><span id="mamuri">마무리</span><a href="#mamuri" class="header-anchor">#</a></h2><table><thead><tr><th>방식</th><th>카운트 쿼리</th><th>후반부 성능 저하</th><th>임의 페이지 이동</th><th>배치 처리 적합성</th><th>적합한 화면</th></tr></thead><tbody><tr><td>applyPagination</td><td>O (병렬)</td><td>offset 증가 시 최대 3.4배 이상 저하</td><td>O</td><td>X</td><td>전체 페이지 네비게이션</td></tr><tr><td>applySlicePagination</td><td>X</td><td>offset 증가 시 최대 3.4배 이상 저하</td><td>X</td><td>X</td><td>무한 스크롤, 더보기</td></tr><tr><td>applyCursorPagination</td><td>X</td><td>없음 (위치 무관 일정)</td><td>X</td><td>O</td><td>피드, 알림, 거래 내역, 배치</td></tr></tbody></table><p><code>applyPagination</code>과 <code>applySlicePagination</code>은 offset 기반이므로 뒤로 갈수록 쿼리 비용이 선형 이상으로 증가합니다. 실측 기준으로 첫 페이지 대비 마지막 페이지에서 <strong>3.4배</strong> 이상 느려졌고, 이 차이는 배치처럼 전체 데이터를 반복 순회하는 환경에서 청크마다 누적되어 전체 처리 시간에 직접적인 영향을 줍니다.</p><p><code>applyCursorPagination</code>은 <code>WHERE id &lt; :cursor</code> 조건으로 PK 인덱스를 직접 활용하기 때문에 조회 위치와 무관하게 실행 계획이 동일하게 유지됩니다. 단건 API뿐 아니라 전체 데이터를 순차적으로 읽어야 하는 배치 리포트에도 적합한 방식입니다.</p><p>세 가지 방식 모두 <code>Querydsl4RepositorySupport</code>를 통해 공통 함수로 제공됩니다. 임의 페이지 이동이 필요하면 <code>applyPagination</code>, 다음&#x2F;이전 탐색만 필요하면 <code>applySlicePagination</code>, 대용량 순차 조회라면 <code>applyCursorPagination</code>을 선택하면 됩니다.</p>]]></content:encoded>
      
      
      
      <category domain="https://cheese10yun.github.io/tags/Performance/">Performance</category>
      
      <category domain="https://cheese10yun.github.io/tags/JPA/">JPA</category>
      
      <category domain="https://cheese10yun.github.io/tags/ORM/">ORM</category>
      
      <category domain="https://cheese10yun.github.io/tags/Querydsl/">Querydsl</category>
      
      
      <comments>https://cheese10yun.github.io/querydsl-cursor-pagination/#disqus_thread</comments>
      
    </item>
    
    <item>
      <title>Querydsl을 이용한 Batch Update 성능 개선</title>
      <link>https://cheese10yun.github.io/querydsl-batch-update/</link>
      <guid>https://cheese10yun.github.io/querydsl-batch-update/</guid>
      <pubDate>Mon, 13 Apr 2026 15:00:00 GMT</pubDate>
      
      <description>JPA 환경에서 Querydsl-SQL을 활용해 대량 데이터 업데이트 성능을 획기적으로 개선하는 방법을 소개합니다.</description>
      
      
      
      <content:encoded><![CDATA[<p>JPA를 사용하다 보면 대량의 데이터를 수정해야 하는 상황에서 Dirty Checking 방식의 성능 한계에 부딪히게 됩니다. 이번 포스팅에서는 JPA Dirty Checking의 성능 이슈를 살펴보고, Querydsl의 <code>SQLQueryFactory</code>를 활용한 Batch Update로 성능을 획기적으로 개선하는 방법을 소개합니다.</p><h2><span id="gaeyo">개요</span><a href="#gaeyo" class="header-anchor">#</a></h2><p>대량의 데이터를 수정해야 할 때, 일반적으로 JPA의 Dirty Checking 방식을 사용합니다. 엔티티를 조회하여 영속성 컨텍스트에 올린 뒤 필드를 변경하면 트랜잭션 커밋 시점에 변경 감지가 일어나 UPDATE 쿼리가 실행되는 방식입니다. 하지만 데이터의 양이 늘어날수록 이 방식의 처리 속도는 급격히 느려질 수 있습니다. 엔티티 수만큼 개별적인 UPDATE 쿼리가 발생하기 때문입니다.</p><p>이전 포스팅(<a href="https://cheese10yun.github.io/querydsl-batch-insert/">Querydsl을 이용한 Batch Insert 성능 개선</a>)에서 Insert 성능 개선 방법을 소개한 적이 있습니다. Update도 동일한 접근으로 해결할 수 있습니다.</p><p>이미 프로젝트에서 JPA와 Querydsl을 사용하고 있다면, 추가적인 라이브러리 도입 없이 <strong>Querydsl-SQL</strong> 모듈을 활용하여 Type-Safe하게 Batch Update를 구현할 수 있습니다. 이번 포스팅에서는 그 방법을 소개합니다.</p><h2><span id="jpa-dirty-checkingyi-seongneung-isyu">JPA Dirty Checking의 성능 이슈</span><a href="#jpa-dirty-checkingyi-seongneung-isyu" class="header-anchor">#</a></h2><p>JPA의 일반적인 Update 패턴은 엔티티를 조회하여 영속성 컨텍스트에 올린 뒤, 필드를 변경하고 트랜잭션이 커밋될 때 변경 감지(Dirty Checking)를 통해 UPDATE 쿼리를 실행하는 방식입니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Transactional</span></span><br><span class="line"><span class="function"><span class="keyword">fun</span> <span class="title">updateWriters</span><span class="params">(ids: <span class="type">List</span>&lt;<span class="type">Long</span>&gt;)</span></span> &#123;</span><br><span class="line">    <span class="keyword">val</span> writers = writerRepository.findAllById(ids)</span><br><span class="line">    <span class="keyword">for</span> (writer <span class="keyword">in</span> writers) &#123;</span><br><span class="line">        writer.name = <span class="string">&quot;updated&quot;</span></span><br><span class="line">        writer.score = <span class="number">100</span></span><br><span class="line">        <span class="comment">// dirty checking → 트랜잭션 커밋 시 건별 UPDATE 발생</span></span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>위 코드는 사용하기 편리하지만, 1,000개의 데이터를 수정하면 1,000번의 UPDATE 쿼리가 데이터베이스로 전송됩니다. 여기에 <code>findAllById</code>로 인한 SELECT 쿼리까지 더하면, 대량 수정 작업에서는 심각한 성능 저하의 원인이 됩니다.</p><h2><span id="querydsl-batch-update-guhyeon">Querydsl Batch Update 구현</span><a href="#querydsl-batch-update-guhyeon" class="header-anchor">#</a></h2><p>Querydsl-SQL 모듈을 사용하면 JPA 엔티티가 아닌 JDBC 레벨에서 직접 SQL을 구성하여 실행할 수 있습니다. 이를 통해 <code>addBatch</code> 기능을 활용한 Bulk Update를 구현할 수 있습니다.</p><h3><span id="guhyeon-kodeu-yesi">구현 코드 예시</span><a href="#guhyeon-kodeu-yesi" class="header-anchor">#</a></h3><p><code>SQLQueryFactory</code>를 사용하여 Batch Update를 구현하는 방법은 다음과 같습니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Service</span></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">BatchInsertService</span>(</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">val</span> dataSource: DataSource</span><br><span class="line">) &#123;</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">val</span> sqlQueryFactory: SQLQueryFactory <span class="keyword">by</span> lazy &#123;</span><br><span class="line">        SQLQueryFactory(Configuration(MySQLTemplates()), dataSource)</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Transactional</span></span><br><span class="line">    <span class="function"><span class="keyword">fun</span> <span class="title">batchUpdate</span><span class="params">(writers: <span class="type">List</span>&lt;<span class="type">Writer</span>&gt;)</span></span>: <span class="built_in">Long</span> &#123;</span><br><span class="line">        <span class="comment">// 1. 테이블 메타데이터 정의</span></span><br><span class="line">        <span class="keyword">val</span> writerTable = RelationalPathBase(Writer::<span class="keyword">class</span>.java, <span class="string">&quot;writer&quot;</span>, <span class="literal">null</span>, <span class="string">&quot;writer&quot;</span>)</span><br><span class="line">        <span class="comment">// 2. SQLQueryFactory (공유 인스턴스 사용)</span></span><br><span class="line">        <span class="keyword">val</span> update = sqlQueryFactory.update(writerTable)</span><br><span class="line">        <span class="comment">// 3. 데이터를 Batch에 추가</span></span><br><span class="line">        <span class="keyword">for</span> (writer <span class="keyword">in</span> writers) &#123;</span><br><span class="line">            <span class="keyword">val</span> id = requireNotNull(writer.id) &#123; <span class="string">&quot;Writer id must not be null&quot;</span> &#125;</span><br><span class="line">            update</span><br><span class="line">                .<span class="keyword">set</span>(QWriter.writer.name, writer.name)</span><br><span class="line">                .<span class="keyword">set</span>(QWriter.writer.email, writer.email)</span><br><span class="line">                .<span class="keyword">set</span>(QWriter.writer.score, writer.score)</span><br><span class="line">                .<span class="keyword">set</span>(QWriter.writer.reputation, writer.reputation)</span><br><span class="line">                .<span class="keyword">set</span>(QWriter.writer.active, writer.active)</span><br><span class="line">                .<span class="keyword">where</span>(QWriter.writer.id.eq(id))</span><br><span class="line">                .addBatch() <span class="comment">// 메모리에 쿼리 적재</span></span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 4. 일괄 실행</span></span><br><span class="line">        <span class="keyword">return</span> update.execute()</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>코드 설명</strong></p><ul><li><strong>RelationalPathBase</strong>: SQL 쿼리 작성을 위해 대상 테이블의 메타데이터를 정의합니다. <code>SQLQueryFactory</code>는 <code>RelationalPath</code> 타입을 요구하기 때문에, JPA 엔티티 기반의 <code>QWriter</code>(<code>EntityPathBase</code>)를 테이블 참조로 직접 사용할 수 없어 별도로 정의합니다.</li><li><strong>QWriter.writer.</strong>*: 컬럼 참조에는 기존에 생성된 Q클래스의 path를 그대로 활용합니다.</li><li><strong>addBatch()</strong>: 루프를 돌며 데이터를 즉시 UPDATE 하지 않고, JDBC의 Batch 기능을 활용하기 위해 메모리에 쿼리 파라미터들을 쌓아둡니다.</li><li><strong>execute()</strong>: 쌓여있는 Batch 쿼리를 데이터베이스로 한 번에 전송하여 실행합니다.</li></ul><h3><span id="insertwayi-gujojeog-cai">Insert와의 구조적 차이</span><a href="#insertwayi-gujojeog-cai" class="header-anchor">#</a></h3><p>Batch Insert와 Batch Update는 동일한 <code>addBatch</code> + <code>execute()</code> 패턴을 사용하지만, 중요한 구조적 차이가 있습니다.</p><table><thead><tr><th align="left">항목</th><th align="left">Batch Insert</th><th align="left">Batch Update</th></tr></thead><tbody><tr><td align="left"><code>where</code> 조건</td><td align="left">불필요</td><td align="left"><strong>필수</strong></td></tr><tr><td align="left">테이블 참조</td><td align="left"><code>RelationalPathBase</code></td><td align="left"><code>RelationalPathBase</code> (동일)</td></tr><tr><td align="left">컬럼 참조</td><td align="left"><code>QWriter.writer.*</code></td><td align="left"><code>QWriter.writer.*</code> (동일)</td></tr><tr><td align="left">id 처리</td><td align="left">없음</td><td align="left"><code>requireNotNull</code> 필요</td></tr></tbody></table><p>가장 중요한 차이는 <strong><code>where</code> 조건</strong>입니다. <code>where</code> 없이 <code>addBatch()</code>를 호출하면 해당 UPDATE는 테이블 전체를 대상으로 실행되어 의도치 않은 전체 UPDATE가 발생할 수 있습니다. 반드시 <code>where(QWriter.writer.id.eq(id))</code>와 같이 대상 row를 특정해야 합니다.</p><p>또한 <code>EntityAuditing</code>을 통해 상속받는 <code>id</code> 필드는 <code>Long?</code> (nullable) 타입이므로, <code>requireNotNull</code>로 null을 방어한 뒤 사용해야 컴파일 타입 안전성이 보장됩니다.</p><h3><span id="batch-dongjag-geomjeung-bangbeob">Batch 동작 검증 방법</span><a href="#batch-dongjag-geomjeung-bangbeob" class="header-anchor">#</a></h3><p><code>addBatch</code>를 사용하면 SQL 내용 자체는 dirty checking과 동일하게 <code>UPDATE writer SET ... WHERE id = ?</code> 형태입니다. 쿼리 내용만으로는 실제로 batch가 동작하는지 구분하기 어렵습니다.</p><p>실제 전송 방식의 차이는 JDBC URL에 <code>profileSQL=true</code>를 추가하면 로그로 확인할 수 있습니다.</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">jdbc:mysql://localhost:3306/mydb?rewriteBatchedStatements=true&amp;logger=Slf4JLogger&amp;profileSQL=true</span><br></pre></td></tr></table></figure><p><strong>케이스 1 — dirty checking (N번 통신)</strong></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line">[QUERY] update writer set active=1,email=&#x27;email-2&#x27;,name=&#x27;updated&#x27;... where id=2</span><br><span class="line">        [at ProxyPreparedStatement.executeUpdate]</span><br><span class="line">[FETCH] [at ProxyPreparedStatement.executeUpdate]</span><br><span class="line">[QUERY] update writer set active=1,email=&#x27;email-3&#x27;,name=&#x27;updated&#x27;... where id=3</span><br><span class="line">        [at ProxyPreparedStatement.executeUpdate]</span><br><span class="line">[FETCH] [at ProxyPreparedStatement.executeUpdate]</span><br><span class="line">[QUERY] update writer set active=1,email=&#x27;email-4&#x27;,name=&#x27;updated&#x27;... where id=4</span><br><span class="line">        [at ProxyPreparedStatement.executeUpdate]</span><br><span class="line">[FETCH] [at ProxyPreparedStatement.executeUpdate]</span><br><span class="line">...</span><br></pre></td></tr></table></figure><p><code>executeUpdate</code>가 건마다 호출되어 <code>[QUERY] + [FETCH]</code> 쌍이 건수만큼 반복됩니다. 10건이면 DB 서버로 <strong>10번 왕복</strong>합니다.</p><p><strong>케이스 2 — addBatch (1번 통신)</strong></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line">[QUERY] update writer</span><br><span class="line">set name = &#x27;new&#x27;</span><br><span class="line">where writer.id = 1;update writer</span><br><span class="line">set name = &#x27;new&#x27;</span><br><span class="line">where writer.id = 2;update writer</span><br><span class="line">set name = &#x27;new&#x27;</span><br><span class="line">where writer.id = 3;</span><br><span class="line">...</span><br><span class="line">update writer</span><br><span class="line">set name = &#x27;new&#x27;</span><br><span class="line">where writer.id = 28;</span><br></pre></td></tr></table></figure><p><code>[QUERY]</code> 로그가 <strong>1개</strong>만 출력됩니다. 세미콜론으로 구분된 모든 쿼리가 하나의 패킷으로 전송되며, 28건이든 10,000건이든 DB 서버로 <strong>1번만 왕복</strong>합니다.</p><p><strong>구분 포인트 요약</strong></p><table><thead><tr><th align="left">구분 기준</th><th align="left">dirty checking</th><th align="left">addBatch</th></tr></thead><tbody><tr><td align="left">메서드명</td><td align="left"><code>executeUpdate</code></td><td align="left"><code>executeBatch</code> (<code>[QUERY]</code> 수로 판단)</td></tr><tr><td align="left"><code>[QUERY]</code> 로그 수</td><td align="left">N개</td><td align="left"><strong>1개</strong></td></tr><tr><td align="left"><code>[FETCH]</code> 존재</td><td align="left">건마다 존재</td><td align="left">없음</td></tr><tr><td align="left">쿼리 형태</td><td align="left">쿼리 1개씩</td><td align="left"><code>;</code>로 이어진 멀티 쿼리</td></tr></tbody></table><p><code>profileSQL=true</code>만 붙이면 로그 줄 수와 메서드명만으로 의도한 대로 batch가 동작하고 있는지 즉시 확인할 수 있습니다.</p><h2><span id="seongneung-bigyo">성능 비교</span><a href="#seongneung-bigyo" class="header-anchor">#</a></h2><h3><span id="seongneung-ceugjeong-kodeu">성능 측정 코드</span><a href="#seongneung-ceugjeong-kodeu" class="header-anchor">#</a></h3><p>정확한 성능 측정을 위해 JPA Dirty Checking 방식과 Querydsl <code>addBatch</code>의 실행 시간을 각각 측정했습니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br><span class="line">82</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Service</span></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">BatchInsertService</span>() &#123;</span><br><span class="line">    <span class="meta">@Transactional</span></span><br><span class="line">    <span class="function"><span class="keyword">fun</span> <span class="title">batchUpdate</span><span class="params">(writers: <span class="type">List</span>&lt;<span class="type">WriterUpdate</span>&gt;)</span></span>: <span class="built_in">Long</span> &#123;</span><br><span class="line">        <span class="keyword">val</span> writerTable = RelationalPathBase(Writer::<span class="keyword">class</span>.java, <span class="string">&quot;writer&quot;</span>, <span class="literal">null</span>, <span class="string">&quot;writer&quot;</span>)</span><br><span class="line">        <span class="keyword">val</span> update = sqlQueryFactory.update(writerTable)</span><br><span class="line">        <span class="comment">// 데이터를 Batch에 추가</span></span><br><span class="line">        <span class="keyword">for</span> (writer <span class="keyword">in</span> writers) &#123;</span><br><span class="line">            <span class="keyword">val</span> id = requireNotNull(writer.id) &#123; <span class="string">&quot;Writer id must not be null&quot;</span> &#125;</span><br><span class="line">            update</span><br><span class="line">                .<span class="keyword">set</span>(QWriter.writer.name, writer.name)</span><br><span class="line">                .<span class="keyword">where</span>(QWriter.writer.id.eq(id))</span><br><span class="line">                .addBatch() <span class="comment">// 메모리에 쿼리 적재</span></span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 일괄 실행</span></span><br><span class="line">        <span class="keyword">return</span> update.execute()</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="meta">@Transactional</span></span><br><span class="line">    <span class="function"><span class="keyword">fun</span> <span class="title">updateWriters</span><span class="params">(writers: <span class="type">List</span>&lt;<span class="type">Writer</span>&gt;)</span></span> &#123;</span><br><span class="line">        <span class="keyword">for</span> (writer <span class="keyword">in</span> writers) &#123;</span><br><span class="line">            writer.name = <span class="string">&quot;updated&quot;</span></span><br><span class="line">            writerRepository.save(writer) <span class="comment">// dirty checking → 트랜잭션 커밋 시 건별 UPDATE 발생</span></span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="meta">@Test</span></span><br><span class="line"><span class="function"><span class="keyword">fun</span> `dirty checking update test`<span class="params">()</span></span> &#123;</span><br><span class="line">    <span class="keyword">val</span> rowsList = listOf(<span class="number">100</span>, <span class="number">200</span>, <span class="number">500</span>, <span class="number">1_000</span>, <span class="number">2_000</span>, <span class="number">5_000</span>, <span class="number">10_000</span>)</span><br><span class="line">    <span class="keyword">val</span> iterations = <span class="number">5</span></span><br><span class="line"></span><br><span class="line">    rowsList.forEach &#123; rows -&gt;</span><br><span class="line">        <span class="comment">// 테스트 데이터 사전 삽입 (id 확보)</span></span><br><span class="line">        <span class="keyword">val</span> writers = writerRepository.saveAll(</span><br><span class="line">            (<span class="number">1.</span>.rows).map &#123; Writer(name = <span class="string">&quot;name-<span class="variable">$it</span>&quot;</span>, email = <span class="string">&quot;email-<span class="variable">$it</span>&quot;</span>) &#125;</span><br><span class="line">        ).toList()</span><br><span class="line"></span><br><span class="line">        <span class="keyword">var</span> totalTimeMillis = <span class="number">0.0</span></span><br><span class="line">        <span class="keyword">for</span> (i <span class="keyword">in</span> <span class="number">1.</span>.iterations) &#123;</span><br><span class="line">            <span class="keyword">val</span> stopWatch = StopWatch()</span><br><span class="line">            stopWatch.start()</span><br><span class="line">            writerService.updateWriters(writers)</span><br><span class="line">            stopWatch.stop()</span><br><span class="line"></span><br><span class="line">            <span class="keyword">if</span> (i &gt; <span class="number">1</span>) &#123; <span class="comment">// 첫 회차 제외</span></span><br><span class="line">                totalTimeMillis += stopWatch.totalTimeMillis</span><br><span class="line">            &#125;</span><br><span class="line">        &#125;</span><br><span class="line">        <span class="keyword">val</span> averageTimeMillis = totalTimeMillis / (iterations - <span class="number">1</span>)</span><br><span class="line">        println(<span class="string">&quot;<span class="variable">$rows</span> 건 dirty checking 평균 실행 시간: <span class="subst">$&#123;averageTimeMillis&#125;</span> ms&quot;</span>)</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="meta">@Test</span></span><br><span class="line"><span class="function"><span class="keyword">fun</span> `batchUpdate test`<span class="params">()</span></span> &#123;</span><br><span class="line">    <span class="keyword">val</span> rowsList = listOf(<span class="number">100</span>, <span class="number">200</span>, <span class="number">500</span>, <span class="number">1_000</span>, <span class="number">2_000</span>, <span class="number">5_000</span>, <span class="number">10_000</span>)</span><br><span class="line">    <span class="keyword">val</span> iterations = <span class="number">5</span></span><br><span class="line"></span><br><span class="line">    rowsList.forEach &#123; rows -&gt;</span><br><span class="line">        <span class="comment">// 테스트 데이터 사전 삽입</span></span><br><span class="line">        <span class="keyword">val</span> savedWriters = writerRepository.saveAll(</span><br><span class="line">            (<span class="number">1.</span>.rows).map &#123; Writer(name = <span class="string">&quot;name-<span class="variable">$it</span>&quot;</span>, email = <span class="string">&quot;email-<span class="variable">$it</span>&quot;</span>) &#125;</span><br><span class="line">        )</span><br><span class="line"></span><br><span class="line">        <span class="keyword">val</span> updates = savedWriters.map &#123; WriterUpdate(id = it.id!!, name = <span class="string">&quot;new&quot;</span>) &#125;</span><br><span class="line"></span><br><span class="line">        <span class="keyword">var</span> totalTimeMillis = <span class="number">0.0</span></span><br><span class="line">        <span class="keyword">for</span> (i <span class="keyword">in</span> <span class="number">1.</span>.iterations) &#123;</span><br><span class="line">            <span class="keyword">val</span> stopWatch = StopWatch()</span><br><span class="line">            stopWatch.start()</span><br><span class="line">            batchInsertService.batchUpdate(updates)</span><br><span class="line">            stopWatch.stop()</span><br><span class="line"></span><br><span class="line">            <span class="keyword">if</span> (i &gt; <span class="number">1</span>) &#123; <span class="comment">// 첫 회차 제외</span></span><br><span class="line">                totalTimeMillis += stopWatch.totalTimeMillis</span><br><span class="line">            &#125;</span><br><span class="line">        &#125;</span><br><span class="line">        <span class="keyword">val</span> averageTimeMillis = totalTimeMillis / (iterations - <span class="number">1</span>)</span><br><span class="line">        println(<span class="string">&quot;<span class="variable">$rows</span> 건 Querydsl Batch Update 평균 실행 시간: <span class="subst">$&#123;averageTimeMillis&#125;</span> ms&quot;</span>)</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>측정 방식 설명</strong></p><ul><li><strong>반복 측정</strong>: 각 데이터 구간(100건 ~ 10,000건)마다 총 <strong>5회</strong> 반복하여 측정했습니다.</li><li><strong>Warm-up 고려</strong>: 테스트 실행 시 <strong>첫 번째 회차는 결과에서 제외</strong>했습니다. 커넥션 풀 초기화 등 초기 비용이 포함되어 결과가 왜곡되는 것을 방지하기 위함입니다.</li><li><strong>평균값 산출</strong>: 첫 회차를 제외한 나머지 <strong>4회의 실행 시간</strong>을 합산하여 평균값을 산출했습니다.</li></ul><h3><span id="seongneung-ceugjeong-gyeolgwa">성능 측정 결과</span><a href="#seongneung-ceugjeong-gyeolgwa" class="header-anchor">#</a></h3><p>JPA Dirty Checking과 Querydsl <code>addBatch</code>를 사용했을 때의 성능 차이를 비교한 결과입니다.</p><p><img src="https://raw.githubusercontent.com/cheese10yun/blog-sample/master/query-dsl/docs/images/Update_Performance.svg" alt="Update Performance"></p><table><thead><tr><th align="left">rows</th><th align="left">dirty checking (ms)</th><th align="left">add batch (ms)</th><th align="left">성능 개선율</th></tr></thead><tbody><tr><td align="left">100</td><td align="left">265.5</td><td align="left">28.5</td><td align="left">89.3%</td></tr><tr><td align="left">200</td><td align="left">368.5</td><td align="left">45.5</td><td align="left">87.7%</td></tr><tr><td align="left">500</td><td align="left">808.75</td><td align="left">103.5</td><td align="left">87.2%</td></tr><tr><td align="left">1,000</td><td align="left">1,647.25</td><td align="left">191.5</td><td align="left">88.4%</td></tr><tr><td align="left">2,000</td><td align="left">3,315.0</td><td align="left">392.0</td><td align="left">88.2%</td></tr><tr><td align="left">5,000</td><td align="left">8,593.5</td><td align="left">928.25</td><td align="left">89.2%</td></tr><tr><td align="left">10,000</td><td align="left">16,530.75</td><td align="left">2,063.0</td><td align="left">87.5%</td></tr></tbody></table><ul><li><strong>bar (dirty checking)</strong>: JPA <code>save(writer)</code>를 루프에서 건별 호출 — UPDATE N번 개별 전송</li><li><strong>line (add batch)</strong>: Querydsl SQLQueryFactory의 addBatch — N개의 UPDATE를 1번의 네트워크 왕복으로 전송</li></ul><blockquote><p><strong>참고</strong>: 이 측정은 애플리케이션 서버와 데이터베이스가 **동일한 로컬 환경(loopback)**에서 수행된 결과입니다. Loopback 통신은 실제 네트워크 대비 레이턴시가 거의 없는 이상적인 조건임에도 불구하고 이 정도의 성능 차이가 발생합니다. 실제 운영 환경처럼 애플리케이션 서버와 DB 서버가 <strong>별도의 네트워크</strong>에 위치한다면, 건별로 전송하는 dirty checking 방식은 네트워크 왕복 비용이 쿼리 수만큼 누적되어 성능 차이가 훨씬 더 크게 벌어질 수 있습니다.</p></blockquote><h2><span id="gyeolron">결론</span><a href="#gyeolron" class="header-anchor">#</a></h2><p>대량의 데이터를 수정해야 하는 배치성 작업에서는 JPA의 Dirty Checking 방식보다 JDBC Batch Update를 사용하는 것이 성능 측면에서 훨씬 유리합니다.</p><p>Insert와 마찬가지로, 오직 Batch Update 성능 개선만을 위해 별도의 라이브러리를 도입하는 것은 프로젝트의 복잡도를 높일 수 있습니다. 이미 JPA와 Querydsl을 사용 중인 환경이라면, <strong>Querydsl-SQL</strong>을 활용하는 것이 추가적인 학습 곡선이나 설정의 번거로움 없이 Type-Safe하게 성능을 극대화할 수 있는 가장 효율적인 대안입니다.</p><p>Insert와 Update 모두 동일한 <code>addBatch</code> + <code>execute()</code> 패턴을 사용하지만, Update에서는 반드시 <code>where</code> 조건으로 대상 row를 특정해야 한다는 점을 기억하세요.</p>]]></content:encoded>
      
      
      
      <category domain="https://cheese10yun.github.io/tags/Performance/">Performance</category>
      
      <category domain="https://cheese10yun.github.io/tags/JPA/">JPA</category>
      
      <category domain="https://cheese10yun.github.io/tags/ORM/">ORM</category>
      
      <category domain="https://cheese10yun.github.io/tags/Querydsl/">Querydsl</category>
      
      
      <comments>https://cheese10yun.github.io/querydsl-batch-update/#disqus_thread</comments>
      
    </item>
    
    <item>
      <title>필드 단위 변경 이력(History) 추적 시스템</title>
      <link>https://cheese10yun.github.io/diff-history-part-1/</link>
      <guid>https://cheese10yun.github.io/diff-history-part-1/</guid>
      <pubDate>Sun, 15 Feb 2026 15:00:00 GMT</pubDate>
      
      <description>객체의 필드 변경 이력을 자동으로 추적해, 무엇이 어떻게 바뀌었는지 명확히 기록합니다.</description>
      
      
      
      <content:encoded><![CDATA[<h2><span id="deuleogamyeo">들어가며</span><a href="#deuleogamyeo" class="header-anchor">#</a></h2><p>운영 환경에서 데이터 변경 이력을 추적해야 하는 경우가 자주 발생합니다. 특히 주문 정보 수정, 가맹점 수수료율 변경 등 중요한 데이터가 어떻게 변경되었는지 필드 단위로 명확하게 기록하고 확인할 수 있어야 합니다.</p><p>예를 들어, 운영자가 주문 내역에서 배송 주소만 변경했을 때, 전체 주문 데이터를 다시 저장하는 것보다 “어떤 필드가 어떻게 변경되었는지”를 명확히 기록하면 다음과 같은 이점이 있습니다:</p><ul><li>변경 이력 추적이 명확해집니다</li><li>승인 프로세스에서 변경 내용 검토가 용이합니다</li><li>디버깅 및 감사(Audit) 목적으로 활용할 수 있습니다</li><li>데이터 롤백 시 정확한 변경 지점을 파악할 수 있습니다</li></ul><p>이번 포스트에서는 Kotlin과 Jackson을 활용하여 복잡한 중첩 객체의 변경사항을 자동으로 추적하는 시스템을 구현하는 방법을 알아보겠습니다.</p><h2><span id="munje-sanghwang">문제 상황</span><a href="#munje-sanghwang" class="header-anchor">#</a></h2><p>다음과 같은 주문(Order) 데이터가 있다고 가정해봅시다.</p><h3><span id="byeongyeong-jeon-deiteo">변경 전 데이터</span><a href="#byeongyeong-jeon-deiteo" class="header-anchor">#</a></h3><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;order_id&quot;</span><span class="punctuation">:</span> <span class="string">&quot;ORD123456&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;customer&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;customer_id&quot;</span><span class="punctuation">:</span> <span class="string">&quot;CUST7890&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;name&quot;</span><span class="punctuation">:</span> <span class="string">&quot;홍길동&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;contact&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;email&quot;</span><span class="punctuation">:</span> <span class="string">&quot;hong@example.com&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;phone&quot;</span><span class="punctuation">:</span> <span class="string">&quot;010-1234-5678&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;address&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">        <span class="attr">&quot;street&quot;</span><span class="punctuation">:</span> <span class="string">&quot;서울특별시 종로구&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;city&quot;</span><span class="punctuation">:</span> <span class="string">&quot;서울&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;zip_code&quot;</span><span class="punctuation">:</span> <span class="string">&quot;03000&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;country&quot;</span><span class="punctuation">:</span> <span class="string">&quot;KR&quot;</span></span><br><span class="line">      <span class="punctuation">&#125;</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;items&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span></span><br><span class="line">    <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;product&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">        <span class="attr">&quot;product_id&quot;</span><span class="punctuation">:</span> <span class="string">&quot;PROD001&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;product_name&quot;</span><span class="punctuation">:</span> <span class="string">&quot;노트북&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;category&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">          <span class="attr">&quot;main_category&quot;</span><span class="punctuation">:</span> <span class="string">&quot;전자제품&quot;</span><span class="punctuation">,</span></span><br><span class="line">          <span class="attr">&quot;sub_category&quot;</span><span class="punctuation">:</span> <span class="string">&quot;컴퓨터&quot;</span></span><br><span class="line">        <span class="punctuation">&#125;</span></span><br><span class="line">      <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;quantity&quot;</span><span class="punctuation">:</span> <span class="number">1</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;price&quot;</span><span class="punctuation">:</span> <span class="number">1500000</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">]</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;payment&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;method&quot;</span><span class="punctuation">:</span> <span class="string">&quot;신용카드&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;transaction_id&quot;</span><span class="punctuation">:</span> <span class="string">&quot;TXN987654321&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;status&quot;</span><span class="punctuation">:</span> <span class="string">&quot;완료&quot;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><h3><span id="byeongyeong-hu-deiteo">변경 후 데이터</span><a href="#byeongyeong-hu-deiteo" class="header-anchor">#</a></h3><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;order_id&quot;</span><span class="punctuation">:</span> <span class="string">&quot;ORD123456&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;customer&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;customer_id&quot;</span><span class="punctuation">:</span> <span class="string">&quot;CUST7890&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;name&quot;</span><span class="punctuation">:</span> <span class="string">&quot;홍길동&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;contact&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;email&quot;</span><span class="punctuation">:</span> <span class="string">&quot;hong@example.com&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;phone&quot;</span><span class="punctuation">:</span> <span class="string">&quot;010-1234-5678&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;address&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">        <span class="attr">&quot;street&quot;</span><span class="punctuation">:</span> <span class="string">&quot;서울특별시 강남구&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;city&quot;</span><span class="punctuation">:</span> <span class="string">&quot;서울&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;zip_code&quot;</span><span class="punctuation">:</span> <span class="string">&quot;03000&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;country&quot;</span><span class="punctuation">:</span> <span class="string">&quot;KR&quot;</span></span><br><span class="line">      <span class="punctuation">&#125;</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;items&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span></span><br><span class="line">    <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;product&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">        <span class="attr">&quot;product_id&quot;</span><span class="punctuation">:</span> <span class="string">&quot;PROD001&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;product_name&quot;</span><span class="punctuation">:</span> <span class="string">&quot;노트북&quot;</span><span class="punctuation">,</span></span><br><span class="line">        <span class="attr">&quot;category&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">          <span class="attr">&quot;main_category&quot;</span><span class="punctuation">:</span> <span class="string">&quot;전자제품&quot;</span><span class="punctuation">,</span></span><br><span class="line">          <span class="attr">&quot;sub_category&quot;</span><span class="punctuation">:</span> <span class="string">&quot;컴퓨터&quot;</span></span><br><span class="line">        <span class="punctuation">&#125;</span></span><br><span class="line">      <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;quantity&quot;</span><span class="punctuation">:</span> <span class="number">1</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;price&quot;</span><span class="punctuation">:</span> <span class="number">1400000</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">]</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;payment&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;method&quot;</span><span class="punctuation">:</span> <span class="string">&quot;신용카드&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;transaction_id&quot;</span><span class="punctuation">:</span> <span class="string">&quot;TXN987654322&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;status&quot;</span><span class="punctuation">:</span> <span class="string">&quot;완료&quot;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>위 두 데이터를 비교하면 다음 필드들이 변경되었습니다:</p><ul><li><code>customer.contact.address.street</code>: “서울특별시 종로구” → “서울특별시 강남구”</li><li><code>items[0].price</code>: 1500000 → 1400000</li><li><code>payment.transaction_id</code>: “TXN987654321” → “TXN987654322”</li></ul><p>이러한 변경사항을 자동으로 감지하고 추적하려면 어떻게 해야 할까요?</p><h2><span id="intellijyi-diff-gineungceoreom">IntelliJ의 Diff 기능처럼</span><a href="#intellijyi-diff-gineungceoreom" class="header-anchor">#</a></h2><p>IntelliJ IDE를 사용해보신 분들은 아시겠지만, 두 파일을 비교할 때 매우 직관적으로 변경 사항을 표시해줍니다.</p><p><img src="https://raw.githubusercontent.com/cheese10yun/blog-sample/refs/heads/master/boot3-mongo/docs/json-diff-1.png"></p><p>우리가 구현하려는 시스템도 이와 유사하게 두 객체를 비교하여 변경된 필드만 추출하는 것입니다.</p><h2><span id="guhyeon-bangbeob">구현 방법</span><a href="#guhyeon-bangbeob" class="header-anchor">#</a></h2><h3><span id="1-haegsim-raibeureori-zjsonpatch">1. 핵심 라이브러리: zjsonpatch</span><a href="#1-haegsim-raibeureori-zjsonpatch" class="header-anchor">#</a></h3><p>JSON 객체 간의 차이를 계산하기 위해 <code>zjsonpatch</code> 라이브러리를 사용합니다. 이 라이브러리는 RFC 6902 JSON Patch 표준을 구현하여 두 JSON 문서의 차이를 효과적으로 계산합니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// build.gradle.kts</span></span><br><span class="line">dependencies &#123;</span><br><span class="line">    implementation(<span class="string">&quot;com.flipkart.zjsonpatch:zjsonpatch:0.4.14&quot;</span>)</span><br><span class="line">    implementation(<span class="string">&quot;com.fasterxml.jackson.module:jackson-module-kotlin&quot;</span>)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3><span id="2-diffcomparisonmanager-guhyeon">2. DiffComparisonManager 구현</span><a href="#2-diffcomparisonmanager-guhyeon" class="header-anchor">#</a></h3><p>전체 코드는 다음과 같습니다:</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br><span class="line">57</span><br><span class="line">58</span><br><span class="line">59</span><br><span class="line">60</span><br><span class="line">61</span><br><span class="line">62</span><br><span class="line">63</span><br><span class="line">64</span><br><span class="line">65</span><br><span class="line">66</span><br><span class="line">67</span><br><span class="line">68</span><br><span class="line">69</span><br><span class="line">70</span><br><span class="line">71</span><br><span class="line">72</span><br><span class="line">73</span><br><span class="line">74</span><br><span class="line">75</span><br><span class="line">76</span><br><span class="line">77</span><br><span class="line">78</span><br><span class="line">79</span><br><span class="line">80</span><br><span class="line">81</span><br><span class="line">82</span><br><span class="line">83</span><br><span class="line">84</span><br><span class="line">85</span><br><span class="line">86</span><br><span class="line">87</span><br><span class="line">88</span><br><span class="line">89</span><br><span class="line">90</span><br><span class="line">91</span><br><span class="line">92</span><br><span class="line">93</span><br><span class="line">94</span><br><span class="line">95</span><br><span class="line">96</span><br><span class="line">97</span><br><span class="line">98</span><br><span class="line">99</span><br><span class="line">100</span><br><span class="line">101</span><br><span class="line">102</span><br><span class="line">103</span><br><span class="line">104</span><br><span class="line">105</span><br><span class="line">106</span><br><span class="line">107</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">package</span> com.example.boot3mongo</span><br><span class="line"></span><br><span class="line"><span class="keyword">import</span> com.fasterxml.jackson.core.JsonGenerator</span><br><span class="line"><span class="keyword">import</span> com.fasterxml.jackson.databind.JsonNode</span><br><span class="line"><span class="keyword">import</span> com.fasterxml.jackson.databind.JsonSerializer</span><br><span class="line"><span class="keyword">import</span> com.fasterxml.jackson.databind.PropertyNamingStrategies</span><br><span class="line"><span class="keyword">import</span> com.fasterxml.jackson.databind.SerializerProvider</span><br><span class="line"><span class="keyword">import</span> com.fasterxml.jackson.databind.module.SimpleModule</span><br><span class="line"><span class="keyword">import</span> com.fasterxml.jackson.module.kotlin.jacksonObjectMapper</span><br><span class="line"><span class="keyword">import</span> com.flipkart.zjsonpatch.JsonDiff</span><br><span class="line"><span class="keyword">import</span> org.bson.types.ObjectId</span><br><span class="line"></span><br><span class="line"><span class="keyword">typealias</span> DiffValueTracker = Map&lt;String, DiffValue&lt;String, String&gt;&gt;</span><br><span class="line"><span class="keyword">typealias</span> DiffTriple = Triple&lt;String, String, String&gt;</span><br><span class="line"></span><br><span class="line"><span class="keyword">object</span> DiffComparisonManager &#123;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">val</span> diffMapper = jacksonObjectMapper()</span><br><span class="line">        .apply &#123;</span><br><span class="line">            registerModules(</span><br><span class="line">                SimpleModule().apply &#123;</span><br><span class="line">                    propertyNamingStrategy = PropertyNamingStrategies.SNAKE_CASE</span><br><span class="line">                    addSerializer(ObjectId::<span class="keyword">class</span>.java, ObjectIdSerializer())</span><br><span class="line">                &#125;</span><br><span class="line">            )</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">    <span class="function"><span class="keyword">fun</span> <span class="type">&lt;T&gt;</span> <span class="title">calculateDifference</span><span class="params">(</span></span></span><br><span class="line"><span class="params"><span class="function">        originItem: <span class="type">T</span>,</span></span></span><br><span class="line"><span class="params"><span class="function">        newItem: <span class="type">T</span></span></span></span><br><span class="line"><span class="params"><span class="function">    )</span></span>: DiffValueTracker &#123;</span><br><span class="line">        <span class="keyword">val</span> originalNode = diffMapper.valueToTree&lt;JsonNode&gt;(originItem)</span><br><span class="line">        <span class="keyword">val</span> newNode = diffMapper.valueToTree&lt;JsonNode&gt;(newItem)</span><br><span class="line">        <span class="keyword">val</span> diff = JsonDiff.asJson(originalNode, newNode)</span><br><span class="line">        <span class="keyword">return</span> <span class="keyword">when</span> &#123;</span><br><span class="line">            diff.size() &gt; <span class="number">0</span> -&gt; &#123;</span><br><span class="line">                diff.mapNotNull &#123; diffNode -&gt;</span><br><span class="line">                    <span class="keyword">val</span> (path, originValue, newValue) = extractDiffValue(diffNode, originalNode, newNode)</span><br><span class="line">                    Pair(</span><br><span class="line">                        first = path,</span><br><span class="line">                        second = DiffValue(originValue, newValue)</span><br><span class="line">                    )</span><br><span class="line">                &#125;</span><br><span class="line">                    .toMap()</span><br><span class="line">            &#125;</span><br><span class="line">            <span class="keyword">else</span> -&gt; emptyMap()</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="function"><span class="keyword">fun</span> <span class="type">&lt;T, K, S&gt;</span> <span class="title">calculateDifferences</span><span class="params">(</span></span></span><br><span class="line"><span class="params"><span class="function">        originItems: <span class="type">List</span>&lt;<span class="type">T</span>&gt;,</span></span></span><br><span class="line"><span class="params"><span class="function">        newItems: <span class="type">List</span>&lt;<span class="type">T</span>&gt;,</span></span></span><br><span class="line"><span class="params"><span class="function">        associateByKey: (<span class="type">T</span>) -&gt; <span class="type">K</span>,</span></span></span><br><span class="line"><span class="params"><span class="function">        groupByKey: (<span class="type">T</span>) -&gt; <span class="type">S</span></span></span></span><br><span class="line"><span class="params"><span class="function">    )</span></span>: Map&lt;S, DiffValueTracker&gt; &#123;</span><br><span class="line">        <span class="keyword">val</span> originalAssociate = originItems.associateBy(associateByKey)</span><br><span class="line">        <span class="keyword">val</span> newAssociate = newItems.associateBy(associateByKey)</span><br><span class="line">        <span class="keyword">val</span> changes = newAssociate.flatMap &#123; (id, newItem) -&gt;</span><br><span class="line">            <span class="keyword">val</span> originalItem = originalAssociate[id]</span><br><span class="line">            <span class="keyword">when</span> &#123;</span><br><span class="line">                originalItem != <span class="literal">null</span> -&gt; &#123;</span><br><span class="line">                    <span class="keyword">val</span> originalNode = diffMapper.valueToTree&lt;JsonNode&gt;(originalItem)</span><br><span class="line">                    <span class="keyword">val</span> newNode = diffMapper.valueToTree&lt;JsonNode&gt;(newItem)</span><br><span class="line">                    <span class="keyword">val</span> diffNode = JsonDiff.asJson(originalNode, newNode)</span><br><span class="line"></span><br><span class="line">                    <span class="keyword">when</span> &#123;</span><br><span class="line">                        diffNode.size() &gt; <span class="number">0</span> -&gt; &#123;</span><br><span class="line">                            diffNode.mapNotNull &#123; node -&gt;</span><br><span class="line">                                <span class="keyword">val</span> (path, originValue, newValue) = extractDiffValue(node, originalNode, newNode)</span><br><span class="line"></span><br><span class="line">                                Triple(</span><br><span class="line">                                    first = groupByKey(newItem),</span><br><span class="line">                                    second = path,</span><br><span class="line">                                    third = DiffValue(origin = originValue, new = newValue)</span><br><span class="line">                                )</span><br><span class="line">                            &#125;</span><br><span class="line">                        &#125;</span><br><span class="line">                        <span class="keyword">else</span> -&gt; emptyList()</span><br><span class="line">                    &#125;</span><br><span class="line">                &#125;</span><br><span class="line">                <span class="keyword">else</span> -&gt; emptyList()</span><br><span class="line">            &#125;</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="keyword">return</span> changes</span><br><span class="line">            .groupBy(&#123; it.first &#125;, &#123; it.second to it.third &#125;)</span><br><span class="line">            .mapValues &#123; (_, value) -&gt; value.toMap() &#125;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">private</span> <span class="function"><span class="keyword">fun</span> <span class="title">extractDiffValue</span><span class="params">(node: <span class="type">JsonNode</span>, originalNode: <span class="type">JsonNode</span>, newNode: <span class="type">JsonNode</span>)</span></span>: DiffTriple &#123;</span><br><span class="line">        <span class="keyword">val</span> path = node.<span class="keyword">get</span>(<span class="string">&quot;path&quot;</span>).asText().removePrefix(<span class="string">&quot;/&quot;</span>)</span><br><span class="line">        <span class="keyword">val</span> originValue = originalNode.at(<span class="string">&quot;/<span class="variable">$path</span>&quot;</span>).asText()</span><br><span class="line">        <span class="keyword">val</span> newValue = newNode.at(<span class="string">&quot;/<span class="variable">$path</span>&quot;</span>).asText()</span><br><span class="line">        <span class="keyword">return</span> DiffTriple(path, originValue, newValue)</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">data</span> <span class="keyword">class</span> <span class="title class_">DiffValue</span>&lt;<span class="type">out A, out B</span>&gt;(</span><br><span class="line">    <span class="keyword">val</span> origin: A,</span><br><span class="line">    <span class="keyword">val</span> new: B</span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">ObjectIdSerializer</span> : <span class="type">JsonSerializer</span>&lt;<span class="type">ObjectId</span>&gt;() &#123;</span><br><span class="line">    <span class="keyword">override</span> <span class="function"><span class="keyword">fun</span> <span class="title">serialize</span><span class="params">(value: <span class="type">ObjectId</span>, gen: <span class="type">JsonGenerator</span>, serializers: <span class="type">SerializerProvider</span>)</span></span> &#123;</span><br><span class="line">        gen.writeString(value.toString())</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3><span id="3-kodeu-sangse-seolmyeong">3. 코드 상세 설명</span><a href="#3-kodeu-sangse-seolmyeong" class="header-anchor">#</a></h3><h4><span id="3-1-jackson-objectmapper-seoljeong">3.1 Jackson ObjectMapper 설정</span><a href="#3-1-jackson-objectmapper-seoljeong" class="header-anchor">#</a></h4><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">private</span> <span class="keyword">val</span> diffMapper = jacksonObjectMapper()</span><br><span class="line">    .apply &#123;</span><br><span class="line">        registerModules(</span><br><span class="line">            SimpleModule().apply &#123;</span><br><span class="line">                propertyNamingStrategy = PropertyNamingStrategies.SNAKE_CASE</span><br><span class="line">                addSerializer(ObjectId::<span class="keyword">class</span>.java, ObjectIdSerializer())</span><br><span class="line">            &#125;</span><br><span class="line">        )</span><br><span class="line">    &#125;</span><br></pre></td></tr></table></figure><ul><li><strong>Snake Case 변환</strong>: Kotlin의 camelCase 필드명을 JSON의 snake_case로 자동 변환합니다</li><li><strong>ObjectId 직렬화</strong>: MongoDB의 ObjectId를 문자열로 변환하는 커스텀 Serializer를 등록합니다</li><li>이를 통해 <code>productName</code> → <code>product_name</code>으로 자동 변환되어 일관된 필드명으로 추적할 수 있습니다</li></ul><h4><span id="3-2-calculatedifference-hamsu">3.2 calculateDifference 함수</span><a href="#3-2-calculatedifference-hamsu" class="header-anchor">#</a></h4><p>단일 객체 간의 차이를 계산하는 핵심 함수입니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">fun</span> <span class="type">&lt;T&gt;</span> <span class="title">calculateDifference</span><span class="params">(</span></span></span><br><span class="line"><span class="params"><span class="function">    originItem: <span class="type">T</span>,</span></span></span><br><span class="line"><span class="params"><span class="function">    newItem: <span class="type">T</span></span></span></span><br><span class="line"><span class="params"><span class="function">)</span></span>: DiffValueTracker &#123;</span><br><span class="line">    <span class="comment">// 1. Kotlin 객체를 JsonNode로 변환</span></span><br><span class="line">    <span class="keyword">val</span> originalNode = diffMapper.valueToTree&lt;JsonNode&gt;(originItem)</span><br><span class="line">    <span class="keyword">val</span> newNode = diffMapper.valueToTree&lt;JsonNode&gt;(newItem)</span><br><span class="line">    </span><br><span class="line">    <span class="comment">// 2. JsonDiff로 차이 계산</span></span><br><span class="line">    <span class="keyword">val</span> diff = JsonDiff.asJson(originalNode, newNode)</span><br><span class="line">    </span><br><span class="line">    <span class="comment">// 3. 차이가 있으면 변경 정보 추출</span></span><br><span class="line">    <span class="keyword">return</span> <span class="keyword">when</span> &#123;</span><br><span class="line">        diff.size() &gt; <span class="number">0</span> -&gt; &#123;</span><br><span class="line">            diff.mapNotNull &#123; diffNode -&gt;</span><br><span class="line">                <span class="keyword">val</span> (path, originValue, newValue) = extractDiffValue(diffNode, originalNode, newNode)</span><br><span class="line">                Pair(</span><br><span class="line">                    first = path,</span><br><span class="line">                    second = DiffValue(originValue, newValue)</span><br><span class="line">                )</span><br><span class="line">            &#125;.toMap()</span><br><span class="line">        &#125;</span><br><span class="line">        <span class="keyword">else</span> -&gt; emptyMap()</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>동작 과정:</strong></p><ol><li><strong>객체를 JsonNode로 변환</strong>: Kotlin 객체를 Jackson의 JsonNode로 변환하여 JSON 구조로 다룰 수 있게 합니다</li><li><strong>JsonDiff 계산</strong>: <code>JsonDiff.asJson()</code>을 사용하여 두 JsonNode 간의 차이를 계산합니다</li><li><strong>변경 정보 추출</strong>: 각 diff node에서 경로(path), 이전 값(originValue), 새 값(newValue)을 추출합니다</li><li><strong>결과 반환</strong>: <code>Map&lt;String, DiffValue&gt;</code> 형태로 반환합니다</li></ol><ul><li>Key: 필드 경로 (예: <code>customer/contact/address/street</code>)</li><li>Value: <code>DiffValue(origin, new)</code> 객체</li></ul><h4><span id="3-3-extractdiffvalue-hamsu">3.3 extractDiffValue 함수</span><a href="#3-3-extractdiffvalue-hamsu" class="header-anchor">#</a></h4><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">private</span> <span class="function"><span class="keyword">fun</span> <span class="title">extractDiffValue</span><span class="params">(node: <span class="type">JsonNode</span>, originalNode: <span class="type">JsonNode</span>, newNode: <span class="type">JsonNode</span>)</span></span>: DiffTriple &#123;</span><br><span class="line">    <span class="keyword">val</span> path = node.<span class="keyword">get</span>(<span class="string">&quot;path&quot;</span>).asText().removePrefix(<span class="string">&quot;/&quot;</span>)</span><br><span class="line">    <span class="keyword">val</span> originValue = originalNode.at(<span class="string">&quot;/<span class="variable">$path</span>&quot;</span>).asText()</span><br><span class="line">    <span class="keyword">val</span> newValue = newNode.at(<span class="string">&quot;/<span class="variable">$path</span>&quot;</span>).asText()</span><br><span class="line">    <span class="keyword">return</span> DiffTriple(path, originValue, newValue)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><ul><li><strong>path 추출</strong>: diff node에서 변경된 필드의 경로를 추출합니다 (예: <code>/customer/contact/address/street</code>)</li><li><strong>슬래시 제거</strong>: 경로 앞의 <code>/</code>를 제거하여 깔끔한 key로 만듭니다</li><li><strong>값 추출</strong>: JsonNode의 <code>at()</code> 메서드로 해당 경로의 값을 추출합니다</li><li><strong>Triple 반환</strong>: (경로, 이전값, 새값)을 하나의 Triple로 반환합니다</li></ul><h4><span id="3-4-calculatedifferences-hamsu-bogsu-gaegce-ceori">3.4 calculateDifferences 함수 (복수 객체 처리)</span><a href="#3-4-calculatedifferences-hamsu-bogsu-gaegce-ceori" class="header-anchor">#</a></h4><p>여러 객체를 비교할 때 사용하는 함수입니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">fun</span> <span class="type">&lt;T, K, S&gt;</span> <span class="title">calculateDifferences</span><span class="params">(</span></span></span><br><span class="line"><span class="params"><span class="function">    originItems: <span class="type">List</span>&lt;<span class="type">T</span>&gt;,</span></span></span><br><span class="line"><span class="params"><span class="function">    newItems: <span class="type">List</span>&lt;<span class="type">T</span>&gt;,</span></span></span><br><span class="line"><span class="params"><span class="function">    associateByKey: (<span class="type">T</span>) -&gt; <span class="type">K</span>,      <span class="comment">// 매칭용 키 (예: ID)</span></span></span></span><br><span class="line"><span class="params"><span class="function">    groupByKey: (<span class="type">T</span>) -&gt; <span class="type">S</span>            <span class="comment">// 그룹화용 키</span></span></span></span><br><span class="line"><span class="params"><span class="function">)</span></span>: Map&lt;S, DiffValueTracker&gt;</span><br></pre></td></tr></table></figure><ul><li><strong>associateByKey</strong>: 원본과 새로운 데이터를 매칭하기 위한 키 (예: orderId, productId)</li><li><strong>groupByKey</strong>: 결과를 그룹화하기 위한 키</li><li>여러 객체를 한 번에 처리하고 각 객체별 변경사항을 그룹화하여 반환합니다</li></ul><h2><span id="teseuteu-kodeuro-geomjeunghagi">테스트 코드로 검증하기</span><a href="#teseuteu-kodeuro-geomjeunghagi" class="header-anchor">#</a></h2><p>다양한 케이스를 테스트하여 구현이 올바르게 동작하는지 확인했습니다.</p><h3><span id="1-danil-pildeu-byeongyeong-gamji">1. 단일 필드 변경 감지</span><a href="#1-danil-pildeu-byeongyeong-gamji" class="header-anchor">#</a></h3><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Test</span></span><br><span class="line"><span class="function"><span class="keyword">fun</span> `calculateDifference - 단일 객체의 변경 사항을 감지한다`<span class="params">()</span></span> &#123;</span><br><span class="line">    <span class="comment">// Given</span></span><br><span class="line">    <span class="keyword">val</span> originalProduct = Product(</span><br><span class="line">        productId = <span class="string">&quot;PROD001&quot;</span>,</span><br><span class="line">        productName = <span class="string">&quot;노트북&quot;</span>,</span><br><span class="line">        category = Category(<span class="string">&quot;전자제품&quot;</span>, <span class="string">&quot;컴퓨터&quot;</span>)</span><br><span class="line">    )</span><br><span class="line">    <span class="keyword">val</span> newProduct = Product(</span><br><span class="line">        productId = <span class="string">&quot;PROD001&quot;</span>,</span><br><span class="line">        productName = <span class="string">&quot;울트라 노트북&quot;</span>,</span><br><span class="line">        category = Category(<span class="string">&quot;전자제품&quot;</span>, <span class="string">&quot;컴퓨터&quot;</span>)</span><br><span class="line">    )</span><br><span class="line"></span><br><span class="line">    <span class="comment">// When</span></span><br><span class="line">    <span class="keyword">val</span> result = DiffComparisonManager.calculateDifference(originalProduct, newProduct)</span><br><span class="line"></span><br><span class="line">    <span class="comment">// Then</span></span><br><span class="line">    then(result).hasSize(<span class="number">1</span>)</span><br><span class="line">    then(result[<span class="string">&quot;product_name&quot;</span>]).isNotNull</span><br><span class="line">    then(result[<span class="string">&quot;product_name&quot;</span>]?.origin).isEqualTo(<span class="string">&quot;노트북&quot;</span>)</span><br><span class="line">    then(result[<span class="string">&quot;product_name&quot;</span>]?.new).isEqualTo(<span class="string">&quot;울트라 노트북&quot;</span>)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>결과:</strong></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">&#123;</span><br><span class="line">  &quot;product_name&quot;: &#123;</span><br><span class="line">    &quot;origin&quot;: &quot;노트북&quot;,</span><br><span class="line">    &quot;new&quot;: &quot;울트라 노트북&quot;</span><br><span class="line">  &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3><span id="2-jungceobdoen-gaegceyi-byeongyeong-gamji">2. 중첩된 객체의 변경 감지</span><a href="#2-jungceobdoen-gaegceyi-byeongyeong-gamji" class="header-anchor">#</a></h3><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Test</span></span><br><span class="line"><span class="function"><span class="keyword">fun</span> `calculateDifference - 중첩된 객체의 변경 사항을 감지한다`<span class="params">()</span></span> &#123;</span><br><span class="line">    <span class="comment">// Given</span></span><br><span class="line">    <span class="keyword">val</span> originalProduct = Product(</span><br><span class="line">        productId = <span class="string">&quot;PROD001&quot;</span>,</span><br><span class="line">        productName = <span class="string">&quot;노트북&quot;</span>,</span><br><span class="line">        category = Category(<span class="string">&quot;전자제품&quot;</span>, <span class="string">&quot;컴퓨터&quot;</span>)</span><br><span class="line">    )</span><br><span class="line">    <span class="keyword">val</span> newProduct = Product(</span><br><span class="line">        productId = <span class="string">&quot;PROD001&quot;</span>,</span><br><span class="line">        productName = <span class="string">&quot;노트북&quot;</span>,</span><br><span class="line">        category = Category(<span class="string">&quot;전자제품&quot;</span>, <span class="string">&quot;노트북&quot;</span>)</span><br><span class="line">    )</span><br><span class="line"></span><br><span class="line">    <span class="comment">// When</span></span><br><span class="line">    <span class="keyword">val</span> result = DiffComparisonManager.calculateDifference(originalProduct, newProduct)</span><br><span class="line"></span><br><span class="line">    <span class="comment">// Then</span></span><br><span class="line">    then(result).hasSize(<span class="number">1</span>)</span><br><span class="line">    then(result[<span class="string">&quot;category/sub_category&quot;</span>]).isNotNull</span><br><span class="line">    then(result[<span class="string">&quot;category/sub_category&quot;</span>]?.origin).isEqualTo(<span class="string">&quot;컴퓨터&quot;</span>)</span><br><span class="line">    then(result[<span class="string">&quot;category/sub_category&quot;</span>]?.new).isEqualTo(<span class="string">&quot;노트북&quot;</span>)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>결과:</strong></p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;category/sub_category&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;origin&quot;</span><span class="punctuation">:</span> <span class="string">&quot;컴퓨터&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;new&quot;</span><span class="punctuation">:</span> <span class="string">&quot;노트북&quot;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>중첩 객체의 필드는 <code>category/sub_category</code> 형태로 경로가 명확히 표시됩니다.<br>슬래시(<code>/</code>)를 구분자로 사용하여 객체의 계층 구조를 표현하므로, 어떤 깊이의 중첩 객체라도 경로만으로 정확한 위치를 파악할 수 있습니다.</p><h3><span id="3-yeoreo-pildeu-dongsi-byeongyeong-gamji">3. 여러 필드 동시 변경 감지</span><a href="#3-yeoreo-pildeu-dongsi-byeongyeong-gamji" class="header-anchor">#</a></h3><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Test</span></span><br><span class="line"><span class="function"><span class="keyword">fun</span> `calculateDifference - 여러 필드의 변경 사항을 감지한다`<span class="params">()</span></span> &#123;</span><br><span class="line">    <span class="comment">// Given</span></span><br><span class="line">    <span class="keyword">val</span> originalItem = Item(</span><br><span class="line">        product = Product(<span class="string">&quot;PROD001&quot;</span>, <span class="string">&quot;노트북&quot;</span>, Category(<span class="string">&quot;전자제품&quot;</span>, <span class="string">&quot;컴퓨터&quot;</span>)),</span><br><span class="line">        quantity = <span class="number">2</span>,</span><br><span class="line">        price = <span class="number">1500000</span></span><br><span class="line">    )</span><br><span class="line">    <span class="keyword">val</span> newItem = Item(</span><br><span class="line">        product = Product(<span class="string">&quot;PROD001&quot;</span>, <span class="string">&quot;울트라 노트북&quot;</span>, Category(<span class="string">&quot;전자제품&quot;</span>, <span class="string">&quot;컴퓨터&quot;</span>)),</span><br><span class="line">        quantity = <span class="number">3</span>,</span><br><span class="line">        price = <span class="number">1400000</span></span><br><span class="line">    )</span><br><span class="line"></span><br><span class="line">    <span class="comment">// When</span></span><br><span class="line">    <span class="keyword">val</span> result = DiffComparisonManager.calculateDifference(originalItem, newItem)</span><br><span class="line"></span><br><span class="line">    <span class="comment">// Then</span></span><br><span class="line">    then(result).hasSize(<span class="number">3</span>)</span><br><span class="line">    then(result[<span class="string">&quot;product/product_name&quot;</span>]?.origin).isEqualTo(<span class="string">&quot;노트북&quot;</span>)</span><br><span class="line">    then(result[<span class="string">&quot;product/product_name&quot;</span>]?.new).isEqualTo(<span class="string">&quot;울트라 노트북&quot;</span>)</span><br><span class="line">    then(result[<span class="string">&quot;quantity&quot;</span>]?.origin).isEqualTo(<span class="string">&quot;2&quot;</span>)</span><br><span class="line">    then(result[<span class="string">&quot;quantity&quot;</span>]?.new).isEqualTo(<span class="string">&quot;3&quot;</span>)</span><br><span class="line">    then(result[<span class="string">&quot;price&quot;</span>]?.origin).isEqualTo(<span class="string">&quot;1500000&quot;</span>)</span><br><span class="line">    then(result[<span class="string">&quot;price&quot;</span>]?.new).isEqualTo(<span class="string">&quot;1400000&quot;</span>)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>결과:</strong></p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;product/product_name&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;origin&quot;</span><span class="punctuation">:</span> <span class="string">&quot;노트북&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;new&quot;</span><span class="punctuation">:</span> <span class="string">&quot;울트라 노트북&quot;</span></span><br><span class="line">  <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;quantity&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;origin&quot;</span><span class="punctuation">:</span> <span class="string">&quot;2&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;new&quot;</span><span class="punctuation">:</span> <span class="string">&quot;3&quot;</span></span><br><span class="line">  <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;price&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;origin&quot;</span><span class="punctuation">:</span> <span class="string">&quot;1500000&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;new&quot;</span><span class="punctuation">:</span> <span class="string">&quot;1400000&quot;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>한 번의 비교로 여러 필드의 변경사항을 모두 추적할 수 있으며, 각 필드별로 이전 값과 새로운 값이 명확하게 구분됩니다.</p><h3><span id="4-gipeun-jungceob-gujo-byeongyeong-gamji">4. 깊은 중첩 구조 변경 감지</span><a href="#4-gipeun-jungceob-gujo-byeongyeong-gamji" class="header-anchor">#</a></h3><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Test</span></span><br><span class="line"><span class="function"><span class="keyword">fun</span> `calculateDifference - 깊게 중첩된 객체의 변경 사항을 감지한다`<span class="params">()</span></span> &#123;</span><br><span class="line">    <span class="comment">// Given</span></span><br><span class="line">    <span class="keyword">val</span> originalOrder = Order(</span><br><span class="line">        orderId = <span class="string">&quot;ORD123&quot;</span>,</span><br><span class="line">        customer = Customer(</span><br><span class="line">            customerId = <span class="string">&quot;CUST001&quot;</span>,</span><br><span class="line">            name = <span class="string">&quot;홍길동&quot;</span>,</span><br><span class="line">            contact = Contact(</span><br><span class="line">                email = <span class="string">&quot;hong@example.com&quot;</span>,</span><br><span class="line">                phone = <span class="string">&quot;010-1234-5678&quot;</span>,</span><br><span class="line">                address = Address(<span class="string">&quot;서울특별시 종로구&quot;</span>, <span class="string">&quot;서울&quot;</span>, <span class="string">&quot;03001&quot;</span>, <span class="string">&quot;대한민국&quot;</span>)</span><br><span class="line">            )</span><br><span class="line">        ),</span><br><span class="line">        items = emptyList(),</span><br><span class="line">        payment = Payment(<span class="string">&quot;신용카드&quot;</span>, <span class="string">&quot;TXN001&quot;</span>, <span class="string">&quot;완료&quot;</span>)</span><br><span class="line">    )</span><br><span class="line">    <span class="keyword">val</span> newOrder = Order(</span><br><span class="line">        orderId = <span class="string">&quot;ORD123&quot;</span>,</span><br><span class="line">        customer = Customer(</span><br><span class="line">            customerId = <span class="string">&quot;CUST001&quot;</span>,</span><br><span class="line">            name = <span class="string">&quot;홍길동&quot;</span>,</span><br><span class="line">            contact = Contact(</span><br><span class="line">                email = <span class="string">&quot;hong@example.com&quot;</span>,</span><br><span class="line">                phone = <span class="string">&quot;010-1234-5678&quot;</span>,</span><br><span class="line">                address = Address(<span class="string">&quot;서울특별시 강남구&quot;</span>, <span class="string">&quot;서울&quot;</span>, <span class="string">&quot;06001&quot;</span>, <span class="string">&quot;대한민국&quot;</span>)</span><br><span class="line">            )</span><br><span class="line">        ),</span><br><span class="line">        items = emptyList(),</span><br><span class="line">        payment = Payment(<span class="string">&quot;신용카드&quot;</span>, <span class="string">&quot;TXN001&quot;</span>, <span class="string">&quot;완료&quot;</span>)</span><br><span class="line">    )</span><br><span class="line"></span><br><span class="line">    <span class="comment">// When</span></span><br><span class="line">    <span class="keyword">val</span> result = DiffComparisonManager.calculateDifference(originalOrder, newOrder)</span><br><span class="line"></span><br><span class="line">    <span class="comment">// Then</span></span><br><span class="line">    then(result).hasSize(<span class="number">2</span>)</span><br><span class="line">    then(result[<span class="string">&quot;customer/contact/address/street&quot;</span>]?.origin).isEqualTo(<span class="string">&quot;서울특별시 종로구&quot;</span>)</span><br><span class="line">    then(result[<span class="string">&quot;customer/contact/address/street&quot;</span>]?.new).isEqualTo(<span class="string">&quot;서울특별시 강남구&quot;</span>)</span><br><span class="line">    then(result[<span class="string">&quot;customer/contact/address/zip_code&quot;</span>]?.origin).isEqualTo(<span class="string">&quot;03001&quot;</span>)</span><br><span class="line">    then(result[<span class="string">&quot;customer/contact/address/zip_code&quot;</span>]?.new).isEqualTo(<span class="string">&quot;06001&quot;</span>)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>결과:</strong></p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;customer/contact/address/street&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;origin&quot;</span><span class="punctuation">:</span> <span class="string">&quot;서울특별시 종로구&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;new&quot;</span><span class="punctuation">:</span> <span class="string">&quot;서울특별시 강남구&quot;</span></span><br><span class="line">  <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;customer/contact/address/zip_code&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;origin&quot;</span><span class="punctuation">:</span> <span class="string">&quot;03001&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;new&quot;</span><span class="punctuation">:</span> <span class="string">&quot;06001&quot;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>4단계 깊이의 중첩 구조(<code>customer/contact/address/street</code>)도 정확히 추적합니다.</p><h3><span id="5-null-ceori">5. Null 처리</span><a href="#5-null-ceori" class="header-anchor">#</a></h3><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Test</span></span><br><span class="line"><span class="function"><span class="keyword">fun</span> `calculateDifference - <span class="literal">null</span>에서 값으로 변경을 감지한다`<span class="params">()</span></span> &#123;</span><br><span class="line">    <span class="comment">// Given</span></span><br><span class="line">    <span class="keyword">data</span> <span class="keyword">class</span> <span class="title class_">TestData</span>(<span class="keyword">val</span> name: String, <span class="keyword">val</span> description: String?)</span><br><span class="line">    <span class="keyword">val</span> original = TestData(<span class="string">&quot;테스트&quot;</span>, <span class="literal">null</span>)</span><br><span class="line">    <span class="keyword">val</span> new = TestData(<span class="string">&quot;테스트&quot;</span>, <span class="string">&quot;설명 추가&quot;</span>)</span><br><span class="line"></span><br><span class="line">    <span class="comment">// When</span></span><br><span class="line">    <span class="keyword">val</span> result = DiffComparisonManager.calculateDifference(original, new)</span><br><span class="line"></span><br><span class="line">    <span class="comment">// Then</span></span><br><span class="line">    then(result).hasSize(<span class="number">1</span>)</span><br><span class="line">    then(result[<span class="string">&quot;description&quot;</span>]?.origin).isEmpty()</span><br><span class="line">    then(result[<span class="string">&quot;description&quot;</span>]?.new).isEqualTo(<span class="string">&quot;설명 추가&quot;</span>)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>결과:</strong></p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;description&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;origin&quot;</span><span class="punctuation">:</span> <span class="string">&quot;&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;new&quot;</span><span class="punctuation">:</span> <span class="string">&quot;설명 추가&quot;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><h3><span id="5-2-gabseseo-nullro-byeongyeong">5-2. 값에서 null로 변경</span><a href="#5-2-gabseseo-nullro-byeongyeong" class="header-anchor">#</a></h3><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Test</span></span><br><span class="line"><span class="function"><span class="keyword">fun</span> `calculateDifference - 값에서 <span class="literal">null</span>로 변경을 감지한다`<span class="params">()</span></span> &#123;</span><br><span class="line">    <span class="comment">// Given</span></span><br><span class="line">    <span class="keyword">data</span> <span class="keyword">class</span> <span class="title class_">TestData</span>(<span class="keyword">val</span> name: String, <span class="keyword">val</span> description: String?)</span><br><span class="line">    <span class="keyword">val</span> original = TestData(<span class="string">&quot;테스트&quot;</span>, <span class="string">&quot;기존 설명&quot;</span>)</span><br><span class="line">    <span class="keyword">val</span> new = TestData(<span class="string">&quot;테스트&quot;</span>, <span class="literal">null</span>)</span><br><span class="line"></span><br><span class="line">    <span class="comment">// When</span></span><br><span class="line">    <span class="keyword">val</span> result = DiffComparisonManager.calculateDifference(original, new)</span><br><span class="line"></span><br><span class="line">    <span class="comment">// Then</span></span><br><span class="line">    then(result).hasSize(<span class="number">1</span>)</span><br><span class="line">    then(result[<span class="string">&quot;description&quot;</span>]?.origin).isEqualTo(<span class="string">&quot;기존 설명&quot;</span>)</span><br><span class="line">    then(result[<span class="string">&quot;description&quot;</span>]?.new).isEmpty()</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>결과:</strong></p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;description&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;origin&quot;</span><span class="punctuation">:</span> <span class="string">&quot;기존 설명&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;new&quot;</span><span class="punctuation">:</span> <span class="string">&quot;&quot;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>null 값의 변경도 정확하게 추적되며, null은 빈 문자열로 표시됩니다.</p><h3><span id="6-byeongyeong-eobsneun-gyeongu">6. 변경 없는 경우</span><a href="#6-byeongyeong-eobsneun-gyeongu" class="header-anchor">#</a></h3><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Test</span></span><br><span class="line"><span class="function"><span class="keyword">fun</span> `calculateDifference - 동일한 객체는 변경 사항이 없다`<span class="params">()</span></span> &#123;</span><br><span class="line">    <span class="comment">// Given</span></span><br><span class="line">    <span class="keyword">val</span> product = Product(</span><br><span class="line">        productId = <span class="string">&quot;PROD001&quot;</span>,</span><br><span class="line">        productName = <span class="string">&quot;노트북&quot;</span>,</span><br><span class="line">        category = Category(<span class="string">&quot;전자제품&quot;</span>, <span class="string">&quot;컴퓨터&quot;</span>)</span><br><span class="line">    )</span><br><span class="line"></span><br><span class="line">    <span class="comment">// When</span></span><br><span class="line">    <span class="keyword">val</span> result = DiffComparisonManager.calculateDifference(product, product)</span><br><span class="line"></span><br><span class="line">    <span class="comment">// Then</span></span><br><span class="line">    then(result).isEmpty()</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>결과:</strong></p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>동일한 객체를 비교하면 빈 Map이 반환되어, 불필요한 변경 이력이 저장되지 않습니다.</p><h3><span id="7-silje-jumun-deiteo-byeongyeong-cujeog">7. 실제 주문 데이터 변경 추적</span><a href="#7-silje-jumun-deiteo-byeongyeong-cujeog" class="header-anchor">#</a></h3><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Test</span></span><br><span class="line"><span class="function"><span class="keyword">fun</span> `주문 데이터의 필드 변경을 확인한다`<span class="params">()</span></span> &#123;</span><br><span class="line">    <span class="comment">// Given</span></span><br><span class="line">    <span class="keyword">val</span> originalOrder: Order = diffMapper.readValue(readFile(<span class="string">&quot;/diff-origin.json&quot;</span>))</span><br><span class="line">    <span class="keyword">val</span> newOrder: Order = diffMapper.readValue(readFile(<span class="string">&quot;/diff-new.json&quot;</span>))</span><br><span class="line"></span><br><span class="line">    <span class="comment">// When</span></span><br><span class="line">    <span class="keyword">val</span> result = DiffComparisonManager.calculateDifferences(</span><br><span class="line">        originItems = listOf(originalOrder),</span><br><span class="line">        newItems = listOf(newOrder),</span><br><span class="line">        associateByKey = &#123; it.orderId &#125;,</span><br><span class="line">        groupByKey = &#123; it.orderId &#125;</span><br><span class="line">    )</span><br><span class="line"></span><br><span class="line">    <span class="comment">// Then</span></span><br><span class="line">    <span class="keyword">val</span> differences = result[<span class="string">&quot;ORD123456&quot;</span>]</span><br><span class="line">    then(differences).isNotNull</span><br><span class="line">    then(differences!!.size).isEqualTo(<span class="number">3</span>)</span><br><span class="line">    then(differences[<span class="string">&quot;customer/contact/address/street&quot;</span>]?.origin).isEqualTo(<span class="string">&quot;서울특별시 종로구&quot;</span>)</span><br><span class="line">    then(differences[<span class="string">&quot;customer/contact/address/street&quot;</span>]?.new).isEqualTo(<span class="string">&quot;서울특별시 강남구&quot;</span>)</span><br><span class="line">    then(differences[<span class="string">&quot;items/0/price&quot;</span>]?.origin).isEqualTo(<span class="string">&quot;1500000&quot;</span>)</span><br><span class="line">    then(differences[<span class="string">&quot;items/0/price&quot;</span>]?.new).isEqualTo(<span class="string">&quot;1400000&quot;</span>)</span><br><span class="line">    then(differences[<span class="string">&quot;payment/transaction_id&quot;</span>]?.origin).isEqualTo(<span class="string">&quot;TXN987654321&quot;</span>)</span><br><span class="line">    then(differences[<span class="string">&quot;payment/transaction_id&quot;</span>]?.new).isEqualTo(<span class="string">&quot;TXN987654322&quot;</span>)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>결과:</strong></p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;ORD123456&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;customer/contact/address/street&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;origin&quot;</span><span class="punctuation">:</span> <span class="string">&quot;서울특별시 종로구&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;new&quot;</span><span class="punctuation">:</span> <span class="string">&quot;서울특별시 강남구&quot;</span></span><br><span class="line">    <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;items/0/price&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;origin&quot;</span><span class="punctuation">:</span> <span class="string">&quot;1500000&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;new&quot;</span><span class="punctuation">:</span> <span class="string">&quot;1400000&quot;</span></span><br><span class="line">    <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;payment/transaction_id&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;origin&quot;</span><span class="punctuation">:</span> <span class="string">&quot;TXN987654321&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;new&quot;</span><span class="punctuation">:</span> <span class="string">&quot;TXN987654322&quot;</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>실제 JSON 파일에서 읽어온 복잡한 주문 데이터도 정확하게 변경사항을 추적합니다. <code>calculateDifferences</code> 함수는 여러 객체를 처리하고 그룹화된 결과를 반환합니다.</p><h2><span id="hwalyong-bangan">활용 방안</span><a href="#hwalyong-bangan" class="header-anchor">#</a></h2><h3><span id="1-seungin-peuroseseue-hwalyong">1. 승인 프로세스에 활용</span><a href="#1-seungin-peuroseseue-hwalyong" class="header-anchor">#</a></h3><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">data</span> <span class="keyword">class</span> <span class="title class_">ApprovalRequest</span>(</span><br><span class="line">    <span class="meta">@Id</span></span><br><span class="line">    <span class="keyword">val</span> id: ObjectId = ObjectId(),</span><br><span class="line">    <span class="keyword">val</span> requestType: String,</span><br><span class="line">    <span class="keyword">val</span> targetId: String,</span><br><span class="line">    <span class="keyword">val</span> changes: DiffValueTracker,       <span class="comment">// 어떤 필드가 어떻게 변경될지</span></span><br><span class="line">    <span class="keyword">val</span> requestedBy: String,</span><br><span class="line">    <span class="keyword">val</span> status: ApprovalStatus = ApprovalStatus.PENDING</span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="comment">// 승인 요청 생성</span></span><br><span class="line"><span class="function"><span class="keyword">fun</span> <span class="title">createFeeChangeApproval</span><span class="params">(merchantId: <span class="type">String</span>, currentFee: <span class="type">MerchantFee</span>, newFee: <span class="type">MerchantFee</span>, userId: <span class="type">String</span>)</span></span> &#123;</span><br><span class="line">    <span class="keyword">val</span> changes = DiffComparisonManager.calculateDifference(currentFee, newFee)</span><br><span class="line">    </span><br><span class="line">    <span class="keyword">val</span> approvalRequest = ApprovalRequest(</span><br><span class="line">        requestType = <span class="string">&quot;MERCHANT_FEE_CHANGE&quot;</span>,</span><br><span class="line">        targetId = merchantId,</span><br><span class="line">        changes = changes,</span><br><span class="line">        requestedBy = userId</span><br><span class="line">    )</span><br><span class="line">    approvalRequestRepository.save(approvalRequest)</span><br><span class="line">    </span><br><span class="line">    <span class="comment">// 승인권자에게 알림 전송</span></span><br><span class="line">    notifyApprovers(approvalRequest)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3><span id="2-gamsa-rogeu-mic-moniteoring">2. 감사 로그 및 모니터링</span><a href="#2-gamsa-rogeu-mic-moniteoring" class="header-anchor">#</a></h3><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 중요 필드 변경 모니터링</span></span><br><span class="line"><span class="function"><span class="keyword">fun</span> <span class="title">monitorCriticalChanges</span><span class="params">(changes: <span class="type">DiffValueTracker</span>, entityType: <span class="type">String</span>)</span></span> &#123;</span><br><span class="line">    <span class="keyword">val</span> criticalFields = setOf(</span><br><span class="line">        <span class="string">&quot;payment/method&quot;</span>,</span><br><span class="line">        <span class="string">&quot;customer/contact/address/street&quot;</span>,</span><br><span class="line">        <span class="string">&quot;items/0/price&quot;</span></span><br><span class="line">    )</span><br><span class="line">    </span><br><span class="line">    changes.keys.filter &#123; it <span class="keyword">in</span> criticalFields &#125;</span><br><span class="line">        .forEach &#123; field -&gt;</span><br><span class="line">            <span class="keyword">val</span> change = changes[field]!!</span><br><span class="line">            logger.warn(</span><br><span class="line">                <span class="string">&quot;Critical field changed in <span class="variable">$entityType</span>: <span class="variable">$field</span> - &quot;</span> +</span><br><span class="line">                <span class="string">&quot;from &#x27;<span class="subst">$&#123;change.origin&#125;</span>&#x27; to &#x27;<span class="subst">$&#123;change.new&#125;</span>&#x27;&quot;</span></span><br><span class="line">            )</span><br><span class="line">            <span class="comment">// 알림 전송, 메트릭 기록 등</span></span><br><span class="line">        &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2><span id="jangjeomgwa-goryeosahang">장점과 고려사항</span><a href="#jangjeomgwa-goryeosahang" class="header-anchor">#</a></h2><h3><span id="jangjeom">장점</span><a href="#jangjeom" class="header-anchor">#</a></h3><ol><li><strong>자동화</strong>: 수동으로 변경 필드를 비교할 필요 없이 자동으로 추적합니다</li><li><strong>타입 안정성</strong>: Kotlin의 제네릭을 활용하여 타입 안전하게 구현됩니다</li><li><strong>중첩 객체 지원</strong>: 깊은 중첩 구조도 경로로 명확히 표시합니다</li><li><strong>저장소 독립성</strong>: 특정 데이터베이스에 의존하지 않는 순수한 로직으로 구현되어, MongoDB, PostgreSQL, MySQL 등 어떤 저장소에도 저장할 수 있습니다</li><li><strong>가독성</strong>: 변경 내역이 명확한 key-value 형태로 저장됩니다</li></ol><h3><span id="goryeosahang">고려사항</span><a href="#goryeosahang" class="header-anchor">#</a></h3><ol><li><strong>성능</strong>: 큰 객체나 대량의 데이터를 비교할 때는 성능을 고려해야 합니다</li><li><strong>배열 처리</strong>: 배열의 순서가 바뀌면 전체가 변경된 것으로 인식될 수 있습니다</li><li><strong>저장 공간</strong>: 모든 변경 이력을 저장하면 데이터가 빠르게 증가할 수 있습니다</li><li><strong>민감 정보</strong>: 비밀번호 등 민감한 정보는 이력에서 제외하는 로직이 필요합니다</li></ol><h2><span id="macimyeo">마치며</span><a href="#macimyeo" class="header-anchor">#</a></h2><p>이번 포스트에서는 Kotlin과 Jackson, zjsonpatch를 활용하여 필드 단위 변경 이력 추적 시스템을 구현하는 방법을 알아보았습니다.</p><p>복잡한 중첩 객체의 변경사항을 자동으로 추적하고 명확한 경로로 표시하는 이 시스템은 다음과 같은 상황에서 유용하게 활용할 수 있습니다:</p><ul><li>주문&#x2F;결제 정보 변경 이력 추적</li><li>가맹점 정보 변경 승인 프로세스</li><li>감사(Audit) 로그 시스템</li><li>데이터 동기화 및 충돌 감지</li></ul><p>실제 프로덕션 환경에 적용할 때는 성능과 저장 공간, 민감 정보 처리 등을 충분히 고려하여 상황에 맞게 커스터마이징하시기 바랍니다.</p>]]></content:encoded>
      
      
      
      <category domain="https://cheese10yun.github.io/tags/Kotlin/">Kotlin</category>
      
      <category domain="https://cheese10yun.github.io/tags/Guide/">Guide</category>
      
      
      <comments>https://cheese10yun.github.io/diff-history-part-1/#disqus_thread</comments>
      
    </item>
    
    <item>
      <title>Querydsl을 이용한 Batch Insert 성능 개선</title>
      <link>https://cheese10yun.github.io/querydsl-batch-insert/</link>
      <guid>https://cheese10yun.github.io/querydsl-batch-insert/</guid>
      <pubDate>Sat, 17 Jan 2026 15:54:48 GMT</pubDate>
      
      <description>JPA 환경에서 Querydsl-SQL을 활용해 대량 데이터 삽입 성능을 획기적으로 개선하는 방법을 소개합니다.</description>
      
      
      
      <content:encoded><![CDATA[<p>JPA를 사용하다 보면 대량의 데이터를 삽입해야 하는 상황에서 <code>saveAll</code>의 성능 한계에 부딪히게 됩니다. 이번 포스팅에서는 JPA <code>saveAll</code>의 성능 이슈를 살펴보고, Querydsl의 <code>SQLQueryFactory</code>를 활용한 Batch Insert로 성능을 획기적으로 개선하는 방법을 소개합니다.</p><h2><span id="gaeyo">개요</span><a href="#gaeyo" class="header-anchor">#</a></h2><p>대량의 데이터를 데이터베이스에 저장해야 할 때, 일반적으로 JPA의 <code>saveAll</code> 메서드를 사용합니다. 하지만 데이터의 양이 늘어날수록 <code>saveAll</code>의 처리 속도는 급격히 느려질 수 있습니다. 특히 ID 생성 전략이 <code>IDENTITY</code>인 경우, JPA는 Batch Insert를 지원하지 않아 단건으로 Insert 쿼리가 실행되는 문제가 있습니다.</p><p>이전 포스팅(<a href="https://cheese10yun.github.io/spring-batch-batch-insert/">Spring Batch에서 Exposed를 이용한 Batch Insert</a>)에서 Exposed를 활용한 성능 개선 방법을 소개한 적이 있습니다. 하지만 오직 Batch Insert만을 위해 JPA 환경에 Exposed라는 새로운 ORM을 도입하고 혼합해서 사용하는 것은 설정의 복잡함이나 학습 곡선 측면에서 비효율적일 수 있습니다.</p><p>만약 이미 프로젝트에서 JPA와 Querydsl을 사용하고 있다면, 추가적인 ORM 도입 없이 <strong>Querydsl-SQL</strong> 모듈을 활용하여 Type-Safe하게 Batch Insert를 구현할 수 있습니다. 이번 포스팅에서는 그 방법을 소개합니다.</p><h2><span id="jpa-saveallyi-seongneung-isyu">JPA saveAll의 성능 이슈</span><a href="#jpa-saveallyi-seongneung-isyu" class="header-anchor">#</a></h2><p>JPA(Hibernate)는 엔티티의 ID 생성 전략이 <code>@GeneratedValue(strategy = GenerationType.IDENTITY)</code>로 설정되어 있을 때, JDBC 레벨의 Batch Insert를 비활성화합니다. 이는 영속성 컨텍스트가 엔티티를 관리하기 위해 Insert 즉시 ID 값을 알아야 하기 때문입니다. 결과적으로 1,000개의 데이터를 저장하면 1,000번의 Insert 쿼리가 데이터베이스로 전송되어 성능 저하의 주원인이 됩니다.</p><p>일반적인 JPA의 <code>saveAll</code> 사용 코드는 다음과 같습니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Transactional</span></span><br><span class="line"><span class="function"><span class="keyword">fun</span> <span class="title">saveAllWriters</span><span class="params">(writers: <span class="type">List</span>&lt;<span class="type">Writer</span>&gt;)</span></span> &#123;</span><br><span class="line">    writerRepository.saveAll(writers)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>위 코드는 사용하기 매우 편리하지만, 대량의 데이터를 처리할 때는 각 엔티티마다 개별적인 Insert 쿼리가 발생하여 네트워크 오버헤드와 데이터베이스 처리 비용이 증가하게 됩니다.</p><h2><span id="querydsl-batch-insert-guhyeon">Querydsl Batch Insert 구현</span><a href="#querydsl-batch-insert-guhyeon" class="header-anchor">#</a></h2><p>Querydsl-SQL 모듈을 사용하면 JPA 엔티티가 아닌 JDBC 레벨에서 직접 SQL을 구성하여 실행할 수 있습니다. 이를 통해 <code>addBatch</code> 기능을 활용한 Bulk Insert를 구현할 수 있습니다.</p><h3><span id="yijonseong-seoljeong">의존성 설정</span><a href="#yijonseong-seoljeong" class="header-anchor">#</a></h3><p>Querydsl-SQL을 사용하기 위해 <code>build.gradle.kts</code>에 아래 의존성을 추가합니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">dependencies &#123;</span><br><span class="line">    <span class="comment">// Querydsl JPA (기본 사용)</span></span><br><span class="line">    implementation(<span class="string">&quot;com.querydsl:querydsl-jpa:5.1.0:jakarta&quot;</span>)</span><br><span class="line"></span><br><span class="line">    <span class="comment">// Querydsl SQL (Batch Insert를 위해 필요)</span></span><br><span class="line">    implementation(<span class="string">&quot;com.querydsl:querydsl-sql:5.1.0&quot;</span>)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>Querydsl-SQL 소개:</strong><br>Querydsl-SQL은 JPA 엔티티 모델이 아닌 데이터베이스 스키마를 기반으로 쿼리를 작성할 수 있게 해주는 모듈입니다. JPA가 제공하지 않는 세밀한 SQL 제어(예: Batch Insert, 특정 벤더 전용 구문 등)가 필요할 때 유용하게 사용할 수 있습니다. <code>SQLQueryFactory</code>를 통해 JDBC 레벨의 기능을 Type-Safe하게 사용할 수 있도록 도와줍니다.</p><h3><span id="guhyeon-kodeu-yesi">구현 코드 예시</span><a href="#guhyeon-kodeu-yesi" class="header-anchor">#</a></h3><p><code>SQLQueryFactory</code>를 사용하여 Batch Insert를 구현하는 방법은 다음과 같습니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Service</span></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">BatchInsertService</span>(</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">val</span> dataSource: DataSource</span><br><span class="line">) &#123;</span><br><span class="line">    <span class="meta">@Transactional</span></span><br><span class="line">    <span class="function"><span class="keyword">fun</span> <span class="title">executeBulkInsertWritersWithSql</span><span class="params">(writers: <span class="type">List</span>&lt;<span class="type">Writer</span>&gt;)</span></span>: <span class="built_in">Long</span> &#123;</span><br><span class="line">        <span class="comment">// 1. 테이블 메타데이터 정의</span></span><br><span class="line">        <span class="keyword">val</span> writerTable = RelationalPathBase(Writer::<span class="keyword">class</span>.java, <span class="string">&quot;writer&quot;</span>, <span class="literal">null</span>, <span class="string">&quot;writer&quot;</span>)</span><br><span class="line">        <span class="comment">// 2. SQLQueryFactory 생성 (MySQL 템플릿 사용)</span></span><br><span class="line">        <span class="keyword">val</span> sqlQueryFactory = SQLQueryFactory(Configuration(MySQLTemplates()), dataSource)</span><br><span class="line">        <span class="keyword">val</span> insert = sqlQueryFactory.insert(writerTable)</span><br><span class="line">        <span class="comment">// 3. 데이터를 Batch에 추가</span></span><br><span class="line">        <span class="keyword">for</span> (writer <span class="keyword">in</span> writers) &#123;</span><br><span class="line">            insert.<span class="keyword">set</span>(QWriter.writer.name, writer.name)</span><br><span class="line">            insert.<span class="keyword">set</span>(QWriter.writer.email, writer.email)</span><br><span class="line">            insert.<span class="keyword">set</span>(QWriter.writer.score, <span class="number">1</span>)</span><br><span class="line">            insert.<span class="keyword">set</span>(QWriter.writer.reputation, <span class="number">1.</span>toDouble())</span><br><span class="line">            insert.<span class="keyword">set</span>(QWriter.writer.active, <span class="literal">true</span>)</span><br><span class="line">            insert.addBatch() <span class="comment">// 메모리에 쿼리 적재</span></span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 4. 일괄 실행</span></span><br><span class="line">        <span class="keyword">return</span> insert.execute()</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>코드 설명</strong></p><ul><li><strong>RelationalPathBase</strong>: SQL 쿼리 작성을 위해 대상 테이블의 메타데이터를 정의합니다.</li><li><strong>addBatch()</strong>: 루프를 돌며 데이터를 즉시 Insert 하지 않고, JDBC의 Batch 기능을 활용하기 위해 메모리에 쿼리 파라미터들을 쌓아둡니다.</li><li><strong>execute()</strong>: 쌓여있는 Batch 쿼리를 데이터베이스로 한 번에 전송하여 실행합니다.</li></ul><h3><span id="mysql-coejeoghwa-obsyeon-rewritebatchedstatements">MySQL 최적화 옵션: rewriteBatchedStatements</span><a href="#mysql-coejeoghwa-obsyeon-rewritebatchedstatements" class="header-anchor">#</a></h3><p>MySQL을 사용하는 경우, JDBC 연결 URL에 <code>rewriteBatchedStatements=true</code> 옵션을 반드시 추가해야 합니다.</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">jdbc:mysql://localhost:3306/mydb?rewriteBatchedStatements=true</span><br></pre></td></tr></table></figure><p><strong>이 옵션이 필요한 이유:</strong><br>기본적으로 MySQL JDBC 드라이버는 <code>addBatch()</code>로 들어온 쿼리들을 개별적인 Insert 구문으로 전송합니다. 하지만 이 옵션을 활성화하면 드라이버 레벨에서 여러 개의 Insert 구문을 하나의 <code>INSERT INTO ... VALUES (...), (...), (...)</code> 형태의 <strong>Multi-Value Insert</strong> 구문으로 재작성(Rewrite)하여 전송합니다. 이를 통해 네트워크 패킷 수를 획기적으로 줄이고 데이터베이스의 파싱 비용을 절감하여 성능을 극대화할 수 있습니다.</p><p><strong>처리 흐름:</strong></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line">addBatch() 호출 (여러 번)</span><br><span class="line">    ↓</span><br><span class="line">execute() 호출</span><br><span class="line">    ↓</span><br><span class="line">MySQL JDBC 드라이버가 rewriteBatchedStatements=true 감지</span><br><span class="line">    ↓</span><br><span class="line">개별 INSERT들을 Multi-Value INSERT로 재작성</span><br><span class="line">    ↓</span><br><span class="line">INSERT INTO writer (name, email) VALUES (&#x27;a&#x27;,&#x27;a@a.com&#x27;), (&#x27;b&#x27;,&#x27;b@b.com&#x27;), (&#x27;c&#x27;,&#x27;c@c.com&#x27;)</span><br><span class="line">    ↓</span><br><span class="line">DB로 전송</span><br></pre></td></tr></table></figure><p><strong>옵션 유무에 따른 차이:</strong></p><table><thead><tr><th align="left">상황</th><th align="left">DB로 전달되는 SQL</th></tr></thead><tbody><tr><td align="left"><code>addBatch()</code> 단독</td><td align="left"><code>INSERT ... VALUES (a)</code> × N번</td></tr><tr><td align="left"><code>addBatch()</code> + <code>rewriteBatchedStatements=true</code></td><td align="left"><code>INSERT ... VALUES (a),(b),(c)</code> × 1번</td></tr></tbody></table><p><code>rewriteBatchedStatements=true</code>는 애플리케이션 코드 변경 없이 JDBC 드라이버 레벨에서 자동으로 변환해주므로, 옵션 하나만으로 Multi-Value INSERT의 성능 이점을 얻을 수 있습니다.</p><h3><span id="silje-jeonsong-kweori-hwagin-profilesql-x3d-true">실제 전송 쿼리 확인: profileSQL&#x3D;true</span><a href="#silje-jeonsong-kweori-hwagin-profilesql-x3d-true" class="header-anchor">#</a></h3><p>실제로 DB에 어떤 쿼리가 전송되는지 확인하려면 JDBC URL에 <code>profileSQL=true</code> 옵션을 추가합니다.</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">jdbc:mysql://localhost:3306/mydb?rewriteBatchedStatements=true&amp;profileSQL=true</span><br></pre></td></tr></table></figure><p><strong><code>rewriteBatchedStatements=true</code> 일 때</strong> — 10건이 하나의 Multi-Value INSERT로 전송됩니다.</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">[QUERY] insert into writer (name, email, score, reputation, active)</span><br><span class="line">values (&#x27;name-5-1&#x27;, &#x27;email-5-1&#x27;, 1, 1.0, 1),(&#x27;name-5-2&#x27;, &#x27;email-5-2&#x27;, 1, 1.0, 1), ... ,(&#x27;name-5-10&#x27;, &#x27;email-5-10&#x27;, 1, 1.0, 1)</span><br></pre></td></tr></table></figure><p><strong><code>rewriteBatchedStatements=false</code> 일 때</strong> — 건별로 개별 INSERT가 반복 전송됩니다.</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">[QUERY] insert into writer (name, email, score, reputation, active) values (&#x27;name-5-6&#x27;, &#x27;email-5-6&#x27;, 1, 1.0, 1)</span><br><span class="line">[QUERY] insert into writer (name, email, score, reputation, active) values (&#x27;name-5-7&#x27;, &#x27;email-5-7&#x27;, 1, 1.0, 1)</span><br><span class="line">[QUERY] insert into writer (name, email, score, reputation, active) values (&#x27;name-5-8&#x27;, &#x27;email-5-8&#x27;, 1, 1.0, 1)</span><br><span class="line">...</span><br></pre></td></tr></table></figure><p><code>profileSQL=true</code>는 개발&#x2F;테스트 환경에서 실제 전송 쿼리를 눈으로 검증할 때 유용하며, 운영 환경에서는 로그 부하로 인해 비활성화하는 것을 권장합니다.</p><h3><span id="db-seobeo-ibjangeseoyi-cai">DB 서버 입장에서의 차이</span><a href="#db-seobeo-ibjangeseoyi-cai" class="header-anchor">#</a></h3><p>세 가지 방식이 DB 서버에서 어떻게 처리되는지 비교하면 성능 차이의 원인을 명확히 이해할 수 있습니다.</p><table><thead><tr><th align="left">항목</th><th align="left">단건 INSERT</th><th align="left">JDBC Batch INSERT</th><th align="left">Multi-Value INSERT</th></tr></thead><tbody><tr><td align="left">방식</td><td align="left">건당 1회 전송</td><td align="left">N개 구문을 1패킷으로 전송</td><td align="left">1개 구문(<code>VALUES ...,...</code>)으로 전송</td></tr><tr><td align="left">네트워크 왕복</td><td align="left">N번</td><td align="left">1번</td><td align="left">1번</td></tr><tr><td align="left">DB가 수신하는 구문 수</td><td align="left">N개</td><td align="left">N개</td><td align="left">1개</td></tr><tr><td align="left">SQL 파싱 비용</td><td align="left">N번</td><td align="left">N번</td><td align="left">1번</td></tr><tr><td align="left">트랜잭션 커밋 오버헤드</td><td align="left">N번 (auto-commit 시)</td><td align="left">1번</td><td align="left">1번</td></tr><tr><td align="left">DB 내부 잠금(Lock) 획득</td><td align="left">N번</td><td align="left">N번</td><td align="left">1번</td></tr><tr><td align="left">인덱스 재계산</td><td align="left">건마다 발생</td><td align="left">건마다 발생</td><td align="left">삽입 완료 후 일괄 처리 가능</td></tr></tbody></table><ul><li><strong>단건 INSERT</strong>: <code>saveAll</code>처럼 건마다 개별 커넥션 요청을 보내는 방식</li><li><strong>JDBC Batch INSERT</strong>: <code>addBatch()</code> + <code>executeBatch()</code>로 N개의 구문을 하나의 패킷에 묶어 전송하지만, DB는 여전히 N개의 구문을 각각 파싱·실행</li><li><strong>Multi-Value INSERT</strong>: <code>rewriteBatchedStatements=true</code>로 드라이버가 <code>INSERT INTO ... VALUES (...),(...),...</code> 형태의 단일 구문으로 변환하여 전송</li></ul><p>JDBC Batch INSERT는 네트워크 왕복을 줄이는 효과가 있지만, DB 서버의 파싱·실행 횟수는 줄지 않습니다. Multi-Value INSERT는 <strong>네트워크 왕복과 DB 파싱·실행을 모두 1번으로 줄이기</strong> 때문에 데이터 양이 많을수록 차이가 커집니다. 아래 성능 측정 결과(10,000건 기준 96.5% 개선)가 바로 이 차이에서 비롯됩니다.</p><h2><span id="seongneung-bigyo">성능 비교</span><a href="#seongneung-bigyo" class="header-anchor">#</a></h2><h3><span id="seongneung-ceugjeong-kodeu">성능 측정 코드</span><a href="#seongneung-ceugjeong-kodeu" class="header-anchor">#</a></h3><p>정확한 성능 측정을 위해 <code>saveAll</code>과 Querydsl <code>addBatch</code>의 실행 시간을 각각 측정했습니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br></pre></td><td class="code"><pre><span class="line">    <span class="meta">@Test</span></span><br><span class="line"><span class="function"><span class="keyword">fun</span> `saveAll test`<span class="params">()</span></span> &#123;</span><br><span class="line">    <span class="keyword">val</span> rowsList = listOf(<span class="number">100</span>, <span class="number">200</span>, <span class="number">500</span>, <span class="number">1_000</span>, <span class="number">2_000</span>, <span class="number">5_000</span>, <span class="number">10_000</span>)</span><br><span class="line">    <span class="keyword">val</span> iterations = <span class="number">5</span></span><br><span class="line"></span><br><span class="line">    rowsList.forEach &#123; rows -&gt;</span><br><span class="line">        <span class="keyword">var</span> totalTimeMillis = <span class="number">0.0</span></span><br><span class="line">        <span class="keyword">for</span> (i <span class="keyword">in</span> <span class="number">1.</span>.iterations) &#123;</span><br><span class="line">            <span class="keyword">val</span> uniqueWriters = (<span class="number">1.</span>.rows).map &#123;</span><br><span class="line">                Writer(name = <span class="string">&quot;name-<span class="variable">$i</span>-<span class="variable">$it</span>&quot;</span>, email = <span class="string">&quot;email-<span class="variable">$i</span>-<span class="variable">$it</span>&quot;</span>)</span><br><span class="line">            &#125;</span><br><span class="line"></span><br><span class="line">            <span class="keyword">val</span> stopWatch = StopWatch()</span><br><span class="line">            stopWatch.start()</span><br><span class="line">            writerRepository.saveAll(uniqueWriters)</span><br><span class="line">            stopWatch.stop()</span><br><span class="line"></span><br><span class="line">            <span class="keyword">if</span> (i &gt; <span class="number">1</span>) &#123; <span class="comment">// 첫 회차 제외</span></span><br><span class="line">                totalTimeMillis += stopWatch.totalTimeMillis</span><br><span class="line">            &#125;</span><br><span class="line">        &#125;</span><br><span class="line">        <span class="keyword">val</span> averageTimeMillis = totalTimeMillis / (iterations - <span class="number">1</span>)</span><br><span class="line">        println(<span class="string">&quot;<span class="variable">$rows</span> 건 saveAll 평균 실행 시간: <span class="subst">$&#123;averageTimeMillis&#125;</span> ms&quot;</span>)</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="meta">@Test</span></span><br><span class="line"><span class="function"><span class="keyword">fun</span> `executeBulkInsertWritersWithSql test`<span class="params">()</span></span> &#123;</span><br><span class="line">    <span class="keyword">val</span> rowsList = listOf(<span class="number">100</span>, <span class="number">200</span>, <span class="number">500</span>, <span class="number">1_000</span>, <span class="number">2_000</span>, <span class="number">5_000</span>, <span class="number">10_000</span>)</span><br><span class="line">    <span class="keyword">val</span> iterations = <span class="number">5</span></span><br><span class="line"></span><br><span class="line">    rowsList.forEach &#123; rows -&gt;</span><br><span class="line">        <span class="keyword">var</span> totalTimeMillis = <span class="number">0.0</span></span><br><span class="line">        <span class="keyword">for</span> (i <span class="keyword">in</span> <span class="number">1.</span>.iterations) &#123;</span><br><span class="line">            <span class="keyword">val</span> uniqueWriters = (<span class="number">1.</span>.rows).map &#123;</span><br><span class="line">                Writer(name = <span class="string">&quot;name-<span class="variable">$i</span>-<span class="variable">$it</span>&quot;</span>, email = <span class="string">&quot;email-<span class="variable">$i</span>-<span class="variable">$it</span>&quot;</span>)</span><br><span class="line">            &#125;</span><br><span class="line"></span><br><span class="line">            <span class="keyword">val</span> stopWatch = StopWatch()</span><br><span class="line">            stopWatch.start()</span><br><span class="line">            batchInsertService.executeBulkInsertWritersWithSql(uniqueWriters)</span><br><span class="line">            stopWatch.stop()</span><br><span class="line"></span><br><span class="line">            <span class="keyword">if</span> (i &gt; <span class="number">1</span>) &#123; <span class="comment">// 첫 회차 제외</span></span><br><span class="line">                totalTimeMillis += stopWatch.totalTimeMillis</span><br><span class="line">            &#125;</span><br><span class="line">        &#125;</span><br><span class="line">        <span class="keyword">val</span> averageTimeMillis = totalTimeMillis / (iterations - <span class="number">1</span>)</span><br><span class="line">        println(<span class="string">&quot;<span class="variable">$rows</span> 건 Querydsl Batch Insert 평균 실행 시간: <span class="subst">$&#123;averageTimeMillis&#125;</span> ms&quot;</span>)</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>측정 방식 설명</strong></p><ul><li><strong>반복 측정</strong>: 각 데이터 구간(100건 ~ 10,000건)마다 총 <strong>5회</strong> 반복하여 측정했습니다.</li><li><strong>Warm-up 고려</strong>: 테스트 실행 시 <strong>첫 번째 회차는 결과에서 제외</strong>했습니다. 이는 데이터베이스 커넥션 풀(Connection Pool)에서 커넥션을 처음 생성하는 초기 비용 등 초기화 작업에 소요되는 시간이 포함되어 결과가 왜곡되는 것을 방지하기 위함입니다.</li><li><strong>평균값 산출</strong>: 첫 회차를 제외한 나머지 <strong>4회의 실행 시간</strong>을 합산하여 평균값을 산출함으로써 보다 신뢰성 있는 성능 데이터를 얻었습니다.</li></ul><h3><span id="seongneung-ceugjeong-gyeolgwa">성능 측정 결과</span><a href="#seongneung-ceugjeong-gyeolgwa" class="header-anchor">#</a></h3><p><img src="https://raw.githubusercontent.com/cheese10yun/blog-sample/master/query-dsl/docs/images/Insert-Performance.png"></p><p>JPA <code>saveAll</code>과 Querydsl <code>addBatch</code>를 사용했을 때의 성능 차이를 비교한 결과입니다. 데이터 개수가 늘어날수록 성능 차이가 확연하게 벌어지는 것을 확인할 수 있습니다.</p><table><thead><tr><th align="left">rows</th><th align="left">saveAll (ms)</th><th align="left">add batch (ms)</th><th align="left">성능 개선율</th></tr></thead><tbody><tr><td align="left">100</td><td align="left">104</td><td align="left">12</td><td align="left">88.50%</td></tr><tr><td align="left">200</td><td align="left">174.5</td><td align="left">16</td><td align="left">90.80%</td></tr><tr><td align="left">500</td><td align="left">370.25</td><td align="left">27.5</td><td align="left">92.60%</td></tr><tr><td align="left">1,000</td><td align="left">695</td><td align="left">56</td><td align="left">91.90%</td></tr><tr><td align="left">2,000</td><td align="left">1,574</td><td align="left">68</td><td align="left">95.70%</td></tr><tr><td align="left">5,000</td><td align="left">3,778</td><td align="left">140</td><td align="left">96.30%</td></tr><tr><td align="left">10,000</td><td align="left">7,505</td><td align="left">265</td><td align="left">96.50%</td></tr></tbody></table><ul><li><strong>saveAll</strong>: JPA Repository의 saveAll 메서드 사용</li><li><strong>add batch</strong>: Querydsl SQLQueryFactory의 addBatch 사용</li></ul><p>10,000건 기준으로 약 <strong>96.5</strong>%의 성능 개선 효과가 있었습니다. <code>saveAll</code>이 약 7.5초 걸리는 작업을 Batch Insert로는 0.26초 만에 처리할 수 있습니다.</p><blockquote><p><strong>참고</strong>: 이 측정은 애플리케이션 서버와 데이터베이스가 **동일한 로컬 환경(loopback)**에서 수행된 결과입니다. Loopback 통신은 실제 네트워크 대비 레이턴시가 거의 없는 이상적인 조건임에도 불구하고 이 정도의 성능 차이가 발생합니다. 실제 운영 환경처럼 애플리케이션 서버와 DB 서버가 <strong>별도의 네트워크</strong>에 위치한다면, 건별로 Insert 쿼리를 전송하는 <code>saveAll</code> 방식은 네트워크 왕복 비용이 Insert 건수만큼 누적되어 성능 차이가 훨씬 더 크게 벌어질 수 있습니다.</p></blockquote><h2><span id="gyeolron">결론</span><a href="#gyeolron" class="header-anchor">#</a></h2><p>대량의 데이터를 처리해야 하는 배치성 작업이나 초기 데이터 적재 시에는 JPA의 <code>saveAll</code>보다는 JDBC Batch Insert를 사용하는 것이 필수적입니다.</p><p>특히, 오직 Batch Insert 성능 개선만을 위해 Exposed와 같은 새로운 ORM을 도입하는 것은 프로젝트의 복잡도를 높일 수 있습니다. 이미 JPA와 Querydsl을 사용 중인 환경이라면, <strong>Querydsl-SQL</strong>을 활용하는 것이 추가적인 학습 곡선이나 설정의 번거로움 없이 Type-Safe하게 성능을 극대화할 수 있는 가장 효율적인 대안입니다.</p><p>프로젝트에서 대량 Insert가 필요한 구간이 있다면, 별도의 라이브러리 추가 없이 Querydsl-SQL을 통해 성능과 생산성을 동시에 챙겨보시기를 권장합니다.</p>]]></content:encoded>
      
      
      
      <category domain="https://cheese10yun.github.io/tags/Performance/">Performance</category>
      
      <category domain="https://cheese10yun.github.io/tags/JPA/">JPA</category>
      
      <category domain="https://cheese10yun.github.io/tags/ORM/">ORM</category>
      
      <category domain="https://cheese10yun.github.io/tags/Querydsl/">Querydsl</category>
      
      
      <comments>https://cheese10yun.github.io/querydsl-batch-insert/#disqus_thread</comments>
      
    </item>
    
    <item>
      <title>실무를 위한 Spring Batch의 모든 것 - 성능 개선부터 프로젝트까지</title>
      <link>https://cheese10yun.github.io/fastcampus-spirng-batch/</link>
      <guid>https://cheese10yun.github.io/fastcampus-spirng-batch/</guid>
      <pubDate>Tue, 06 May 2025 15:00:00 GMT</pubDate>
      
        
        
      <description>&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/cheese10yun/TIL/refs/heads/master/assets/fastcampus-spring-batch.jpg&quot; alt&gt;&lt;/p&gt;
&lt;p&gt;안녕하세요. 이번에 </description>
        
      
      
      
      <content:encoded><![CDATA[<p><img src="https://raw.githubusercontent.com/cheese10yun/TIL/refs/heads/master/assets/fastcampus-spring-batch.jpg" alt></p><p>안녕하세요. 이번에 패스트캠퍼스에서 ‘실무를 위한 Spring Batch의 모든 것: 성능 개선부터 프로젝트까지’라는 주제로 강의를 진행하게 되어 홍보 포스팅을 올립니다.</p><p>저는 백엔드 개발 경력 9년, 대규모 배치 시스템 운영 경험 6년 이상을 바탕으로, 현업에서 직접 부딪히며 쌓은 스프링 배치 실무 노하우를 이번 강의에 모두 담았습니다. 이 강의는 스프링 배치를 처음 접하시는 분은 물론, 이미 배치 시스템을 다뤄보았지만 실무에서 마주치는 다양한 문제로 어려움을 겪고 계신 분들께 적극 추천드립니다.</p><p><strong>스프링 배치의 기본 개념부터 배치 시스템 설계·운영에 필요한 핵심 개념과 실전 팁을 제공합니다. 특히 대용량 데이터 처리 성능 개선과 장애 복구 전략을 깊이 다루어, 현업에서의 시행착오를 최소화하고 스프링 배치를 자신 있게 다룰 수 있도록 도와드리겠습니다.</strong></p><p>강의에서 뵙고 함께 성장할 수 있기를 기대합니다!</p><br><ul><li>25% 할인코드 : 김남윤스프링배치</li><li>링크 : <a href="https://buly.kr/8elKcDb">https://buly.kr/8elKcDb</a></li><li>사용기간 : 5/7 ~ 5/31</li></ul>]]></content:encoded>
      
      
      
      <category domain="https://cheese10yun.github.io/tags/Essay/">Essay</category>
      
      
      <comments>https://cheese10yun.github.io/fastcampus-spirng-batch/#disqus_thread</comments>
      
    </item>
    
    <item>
      <title>코루틴 Dispatchers.IO로 블록킹 문제 해결하기</title>
      <link>https://cheese10yun.github.io/coroutine-io/</link>
      <guid>https://cheese10yun.github.io/coroutine-io/</guid>
      <pubDate>Sun, 23 Mar 2025 15:00:00 GMT</pubDate>
      
      <description>Kotlin 코루틴의 Dispatchers.IO를 활용하면 블록킹 I/O 문제를 극복하고, 병렬 처리로 애플리케이션의 성능과 반응성을 크게 향상시킬 수 있습니다.</description>
      
      
      
      <content:encoded><![CDATA[<h2><span id="seoron">서론</span><a href="#seoron" class="header-anchor">#</a></h2><p>Kotlin 코루틴은 복잡한 스레드 관리와 동시성 문제를 간단하게 해결할 수 있는 강력한 도구입니다. 이 글에서는 특히 I/O 작업에 최적화된 <code>Dispatchers.IO</code>에 집중하여, 이 디스패처가 어떻게 스레드 블록킹 문제를 해결하고 전체 애플리케이션의 성능과 반응성을 개선할 수 있는지 구체적인 코드 예제와 실행 로그를 통해 살펴봅니다. 또한, JDBC 드라이버나 파일 입출력, 동기식 네트워크 호출 등 블록킹이 발생할 수 있는 실제 상황에서 <code>Dispatchers.IO</code>를 적용하는 방법과 그 효과를 자세히 설명하여, 개발자가 실제 프로젝트에서 효율적인 비동기 처리를 구현할 수 있도록 돕는 것을 주요 목적으로 합니다.</p><h2><span id="dispatchers-jongryu-bigyo">Dispatchers 종류 비교</span><a href="#dispatchers-jongryu-bigyo" class="header-anchor">#</a></h2><p>아래 테이블은 각 디스패처의 특징과 사용 용도를 간략하게 정리한 것입니다.</p><table><thead><tr><th><strong>디스패처</strong></th><th><strong>설명</strong></th><th><strong>사용 시나리오</strong></th><th><strong>실행 스레드 예시</strong></th></tr></thead><tbody><tr><td>Dispatchers.Main</td><td>UI 스레드에서 실행되며, 메인 스레드 업데이트를 담당합니다.</td><td>UI 업데이트, 사용자 이벤트 처리</td><td>Android의 메인 스레드 등</td></tr><tr><td>Dispatchers.Default</td><td>CPU 집약적인 작업에 적합하며, 공용 스레드 풀을 사용합니다.</td><td>복잡한 계산, 데이터 처리</td><td>일반적인 백그라운드 작업 스레드</td></tr><tr><td>Dispatchers.Unconfined</td><td>호출한 컨텍스트에 묶이지 않고, 일시적으로 다른 스레드에서 실행될 수 있습니다.</td><td>빠른 초기 작업, 스레드 전환 없이 동작 확인 시</td><td>컨텍스트에 따라 달라짐</td></tr><tr><td><a href="http://Dispatchers.IO">Dispatchers.IO</a></td><td>I/O 작업에 최적화된 별도의 스레드 풀을 사용하며, 블록킹 작업에 대응할 수 있습니다.</td><td>파일 I/O, 네트워크, DB 접근 등 블록킹이 발생하는 작업</td><td>“DefaultDispatcher-worker-#” 와 같은 I/O 전용 스레드</td></tr></tbody></table><p><code>Dispatchers.IO</code>는 특히 I/O 작업에서 스레드가 블록킹(blocking) 되는 상황에 적합하여, 별도의 스레드 풀을 사용함으로써 전체 애플리케이션의 응답성을 유지하는 데 큰 역할을 합니다.<br>이번 포스팅에서는 특히 <code>Dispatchers.IO</code>에 대해 자세히 알아보고, 이를 통해 효율적인 비동기 처리와 블록킹 문제 해결 방안을 알아보겠습니다.</p><h2><span id="dispatchers-io-sayong-sinario"> 사용 시나리오</span><a href="#dispatchers-io-sayong-sinario" class="header-anchor">#</a></h2><p><code>Dispatchers.IO</code>를 도입해야 하는 대표적인 경우는 <strong>스레드가 블록킹될 때</strong>입니다. 예를 들어</p><ul><li><strong>JDBC 드라이버:</strong> JDBC 드라이버는 네트워크 I/O나 DB 쿼리 실행 중 스레드를 블록킹합니다.</li><li><strong>파일 입출력:</strong> 파일을 읽거나 쓰는 작업 역시 블록킹 될 수 있습니다.</li><li><strong>네트워크 요청:</strong> 동기 방식의 네트워크 호출 역시 블록킹을 유발할 수 있습니다.</li></ul><p>이와 같이 블록킹 작업이 발생하면, 동일 스레드에서 순차적으로 실행될 경우 전체 애플리케이션의 성능 저하로 이어질 수 있으므로, <strong><code>Dispatchers.IO</code>를 통해 별도의 스레드 풀에서 작업을 처리하여 동시 실행(parallel execution)을 보장하는 것이 중요합니다.</strong></p><p>아래는 async()를 호출할 때 별도의 디스패처를 지정하지 않은 경우의 코드 예제와 그에 따른 실행 로그를 바탕으로 병렬 실행이 어떻게 이루어지는지 구체적으로 설명한 내용입니다.</p><h2><span id="dispatchers-io-kodeu-yeje-bunseog"> 코드 예제 분석</span><a href="#dispatchers-io-kodeu-yeje-bunseog" class="header-anchor">#</a></h2><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Test</span></span><br><span class="line"><span class="function"><span class="keyword">fun</span> `동시성 테스트`<span class="params">()</span></span> &#123;</span><br><span class="line">    runBlocking &#123;</span><br><span class="line">        println(<span class="string">&quot;Main 시작 - 실행 스레드: <span class="subst">$&#123;Thread.currentThread().name&#125;</span>&quot;</span>)</span><br><span class="line">        <span class="keyword">val</span> stopWatch = StopWatch()</span><br><span class="line">        stopWatch.start()</span><br><span class="line"></span><br><span class="line">        <span class="keyword">val</span> deferred1 = async &#123; doSomething(<span class="string">&quot;deferred1&quot;</span>) &#125;</span><br><span class="line">        <span class="keyword">val</span> deferred2 = async &#123; doSomething(<span class="string">&quot;deferred2&quot;</span>) &#125;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// 결과 대기</span></span><br><span class="line">        <span class="keyword">val</span> resultDefault = deferred1.await()</span><br><span class="line">        println(<span class="string">&quot;deferred1 결과: <span class="variable">$resultDefault</span> - 호출 스레드: <span class="subst">$&#123;Thread.currentThread().name&#125;</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line">        <span class="keyword">val</span> resultIO = deferred2.await()</span><br><span class="line">        println(<span class="string">&quot;deferred2 결과: <span class="variable">$resultIO</span> - 호출 스레드: <span class="subst">$&#123;Thread.currentThread().name&#125;</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line">        stopWatch.stop()</span><br><span class="line">        println(<span class="string">&quot;소요 시간 : <span class="subst">$&#123;stopWatch.totalTimeMillis&#125;</span> ms&quot;</span>)</span><br><span class="line">        println(<span class="string">&quot;Main 종료 - 실행 스레드: <span class="subst">$&#123;Thread.currentThread().name&#125;</span>&quot;</span>)</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">private</span> <span class="function"><span class="keyword">fun</span> <span class="title">doSomething</span><span class="params">(dispatchersName: <span class="type">String</span>)</span></span>: String &#123;</span><br><span class="line">    println(<span class="string">&quot;[<span class="variable">$dispatchersName</span>] 시작 - 실행 스레드: <span class="subst">$&#123;Thread.currentThread().name&#125;</span>&quot;</span>)</span><br><span class="line">    <span class="comment">// 2,000 ms 대기</span></span><br><span class="line">    runBlocking &#123; delay(<span class="number">2000</span>) &#125;</span><br><span class="line">    println(<span class="string">&quot;[<span class="variable">$dispatchersName</span>] 완료 - 실행 스레드: <span class="subst">$&#123;Thread.currentThread().name&#125;</span>&quot;</span>)</span><br><span class="line">    <span class="keyword">return</span> <span class="string">&quot;Result from <span class="variable">$dispatchersName</span>&quot;</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>async()</code>를 호출할 때 별도의 디스패처를 지정하지 않으면, 해당 코루틴은 상위 코루틴의 컨텍스트(여기서는 runBlocking의 컨텍스트)를 그대로 상속받게 됩니다. 이 예제에서는 <code>doSomething()</code> 함수가 두 번 호출되며, 각각 2,000ms의 지연(delay)을 포함한 블록킹 작업을 수행한다고 가정합니다. 이 경우, 두 작업은 <code>async</code>를 통해 병렬로 실행되므로 이론상 전체 소요 시간은 2,000ms 내외여야 합니다.</p><p>실제 실행 로그는 다음과 같습니다.</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">Main 시작 - 실행 스레드: Test worker @coroutine#1</span><br><span class="line">[deferred1] 시작 - 실행 스레드: Test worker @coroutine#2</span><br><span class="line">[deferred2] 시작 - 실행 스레드: Test worker @coroutine#3</span><br><span class="line">[deferred2] 완료 - 실행 스레드: Test worker @coroutine#3</span><br><span class="line">[deferred1] 완료 - 실행 스레드: Test worker @coroutine#2</span><br><span class="line">deferred1 결과: Result from deferred1 - 호출 스레드: Test worker @coroutine#1</span><br><span class="line">deferred2 결과: Result from deferred2 - 호출 스레드: Test worker @coroutine#1</span><br><span class="line">소요 시간 : 2020 ms</span><br><span class="line">Main 종료 - 실행 스레드: Test worker @coroutine#1</span><br></pre></td></tr></table></figure><p>로그를 분석해보면, Main 코루틴은 “Test worker @coroutine#1” 스레드에서 시작되고, async로 생성된 두 자식 코루틴은 각각 &quot;Test worker @coroutine#2&quot;와 “Test worker @coroutine#3” 스레드에서 실행됩니다. 두 코루틴은 독립적으로 동시에 실행되기 때문에, <code>doSomething()</code> 함수 내에서 2,000ms의 대기가 발생하더라도 두 작업이 병렬로 처리되어 전체 소요 시간은 약 2,000ms(실제 2020ms)로 측정됩니다.</p><p><strong>즉, <code>async()</code>를 통해 생성된 두 코루틴이 부모의 컨텍스트를 상속받더라도, 각각의 코루틴이 별도의 스레드에서 실행되어 병렬 처리가 이루어지는 것을 확인할 수 있습니다.</strong> 이는 동일한 스레드에서 순차적으로 처리될 경우(예: 동기 호출 시 4,000ms 소요)와 비교했을 때, 전체 실행 시간을 크게 단축시키는 효과가 있음을 보여줍니다.</p><h3><span id="gibon-async-sayong-si-dongjag-thread-sleep-sayong">기본 async() 사용 시 동작 (Thread.sleep 사용)</span><a href="#gibon-async-sayong-si-dongjag-thread-sleep-sayong" class="header-anchor">#</a></h3><p>아래 코드는 <code>doSomething()</code> 함수 내부에서 <code>delay(2000)</code> 대신 <code>Thread.sleep(2000)</code>을 사용한 경우입니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">private</span> <span class="function"><span class="keyword">fun</span> <span class="title">doSomething</span><span class="params">(dispatchersName: <span class="type">String</span>)</span></span>: String &#123;</span><br><span class="line">    println(<span class="string">&quot;[<span class="variable">$dispatchersName</span>] 시작 - 실행 스레드: <span class="subst">$&#123;Thread.currentThread().name&#125;</span>&quot;</span>)</span><br><span class="line">    <span class="comment">// 2,000 ms 대기 (Thread.sleep 사용)</span></span><br><span class="line">    Thread.sleep(<span class="number">2000</span>)</span><br><span class="line">    println(<span class="string">&quot;[<span class="variable">$dispatchersName</span>] 완료 - 실행 스레드: <span class="subst">$&#123;Thread.currentThread().name&#125;</span>&quot;</span>)</span><br><span class="line">    <span class="keyword">return</span> <span class="string">&quot;Result from <span class="variable">$dispatchersName</span>&quot;</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>이 경우 실행 로그는 다음과 같이 나타납니다.</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">Main 시작 - 실행 스레드: Test worker @coroutine#1</span><br><span class="line">[deferred1] 시작 - 실행 스레드: Test worker @coroutine#2</span><br><span class="line">[deferred1] 완료 - 실행 스레드: Test worker @coroutine#2</span><br><span class="line">[deferred2] 시작 - 실행 스레드: Test worker @coroutine#3</span><br><span class="line">[deferred2] 완료 - 실행 스레드: Test worker @coroutine#3</span><br><span class="line">deferred1 결과: Result from deferred1 - 호출 스레드: Test worker @coroutine#1</span><br><span class="line">deferred2 결과: Result from deferred2 - 호출 스레드: Test worker @coroutine#1</span><br><span class="line">소요 시간 : 4021 ms</span><br><span class="line">Main 종료 - 실행 스레드: Test worker @coroutine#1</span><br></pre></td></tr></table></figure><p>기본 async() 사용 시 동작(<code>Thread.sleep</code> 사용)에서는, 별도의 디스패처를 지정하지 않아 상위 코루틴의 컨텍스트를 그대로 상속받게 됩니다. 이 경우, runBlocking 내부의 메인 스레드인 Test worker가 블로킹되기 때문에, <code>doSomething()</code> 함수 내의 <code>Thread.sleep(2000)</code>이 호출되면 해당 스레드가 2,000ms 동안 점유되고, 첫 번째 코루틴(deferred1)이 완료되어야만 두 번째 코루틴(deferred2)이 실행될 수 있습니다. 그 결과, 두 작업이 실제로 순차적으로 실행되어 전체 소요 시간이 약 4021ms로 측정되는 것입니다.</p><h3><span id="dispatchers-ioreul-jeogyonghan-gyeongu">Dispatchers.IO를 적용한 경우</span><a href="#dispatchers-ioreul-jeogyonghan-gyeongu" class="header-anchor">#</a></h3><p>이 문제를 해결하기 위해 async() 호출 시 <code>Dispatchers.IO</code>를 지정하면, 각 코루틴이 I/O 전용 스레드 풀에서 실행되므로 별도의 스레드에서 동시에 작업이 수행됩니다. 수정된 코드는 다음과 같습니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">val</span> deferred1 = async(Dispatchers.IO) &#123; doSomething(<span class="string">&quot;deferred1&quot;</span>) &#125;</span><br><span class="line"><span class="keyword">val</span> deferred2 = async(Dispatchers.IO) &#123; doSomething(<span class="string">&quot;deferred2&quot;</span>) &#125;</span><br></pre></td></tr></table></figure><p>이때 실행 로그는 아래와 같이 나타납니다.</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">Main 시작 - 실행 스레드: Test worker @coroutine#1</span><br><span class="line">[deferred2] 시작 - 실행 스레드: DefaultDispatcher-worker-3 @coroutine#3</span><br><span class="line">[deferred1] 시작 - 실행 스레드: DefaultDispatcher-worker-1 @coroutine#2</span><br><span class="line">[deferred2] 완료 - 실행 스레드: DefaultDispatcher-worker-3 @coroutine#3</span><br><span class="line">[deferred1] 완료 - 실행 스레드: DefaultDispatcher-worker-1 @coroutine#2</span><br><span class="line">deferred1 결과: Result from deferred1 - 호출 스레드: Test worker @coroutine#1</span><br><span class="line">deferred2 결과: Result from deferred2 - 호출 스레드: Test worker @coroutine#1</span><br><span class="line">소요 시간 : 2018 ms</span><br><span class="line">Main 종료 - 실행 스레드: Test worker @coroutine#1</span><br></pre></td></tr></table></figure><p>여기서 확인할 수 있듯이, Main 코루틴은 Test worker에서 시작하지만, deferred1과 deferred2 코루틴은 각각 DefaultDispatcher-worker-1과 DefaultDispatcher-worker-3과 같이 별도의 스레드에서 실행됩니다. 각 코루틴이 독립된 스레드에서 실행되기 때문에, <code>doSomething()</code> 함수 내부의 <code>Thread.sleep(2000)</code>과 같은 블록킹 호출이 해당 코루틴의 스레드에만 영향을 미치며, 다른 코루틴의 실행에는 영향을 주지 않습니다. 그 결과, 두 작업이 동시에 병렬로 실행되어 전체 소요 시간이 약 2018ms로 단축되는 효과를 얻을 수 있습니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">private</span> <span class="function"><span class="keyword">fun</span> <span class="title">doSomething</span><span class="params">(dispatchersName: <span class="type">String</span>)</span></span>: String &#123;</span><br><span class="line">    <span class="comment">// ...</span></span><br><span class="line">    <span class="comment">// runBlocking &#123; delay(2000) &#125; // delay 에서 Thread sleep 으로 대체</span></span><br><span class="line">    Thread.sleep(<span class="number">2000</span>)</span><br><span class="line">    <span class="comment">// ...</span></span><br><span class="line">    <span class="keyword">return</span> <span class="string">&quot;Result from <span class="variable">$dispatchersName</span>&quot;</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>기존 delay에서 <code>Thread.sleep(2000)</code> 으로 변경 했을 경우에 로그를 보자</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">Main 시작 - 실행 스레드: Test worker @coroutine#1</span><br><span class="line">[deferred1] 시작 - 실행 스레드: Test worker @coroutine#2</span><br><span class="line">[deferred1] 완료 - 실행 스레드: Test worker @coroutine#2</span><br><span class="line">[deferred2] 시작 - 실행 스레드: Test worker @coroutine#3</span><br><span class="line">[deferred2] 완료 - 실행 스레드: Test worker @coroutine#3</span><br><span class="line">deferred1 결과: Result from deferred1 - 호출 스레드: Test worker @coroutine#1</span><br><span class="line">deferred2 결과: Result from deferred1 - 호출 스레드: Test worker @coroutine#1</span><br><span class="line">소요 시간 : 4021 ms</span><br><span class="line">Main 종료 - 실행 스레드: Test worker @coroutine#1</span><br></pre></td></tr></table></figure><p><code>doSomething()</code> 함수가 @coroutine#2와 @coroutine#3에서 각각 실행되었음에도 불구하고, 전체 소요 시간이 4021ms로 측정된다는 것은 <code>async()</code>를 사용할 때 의도한 병렬 실행이 이루어지지 않았음을 의미합니다. 그 이유는, <code>async()</code>에 별도의 디스패처를 지정하지 않아 상위 코루틴(runBlocking)의 컨텍스트를 그대로 상속받게 되면서, Main 스레드인 Test worker가 deferred1의 실행으로 인해 블록킹되고, deferred2도 동일한 Test worker에서 실행되기 때문입니다. 즉, Test worker 스레드가 블록킹이 해제될 때까지 전체 작업이 순차적으로 실행됩니다.</p><p>이 문제를 해결하기 위해, 각 <code>async()</code> 호출 시 <code>Dispatchers.IO</code>와 같이 별도의 I/O 전용 스레드 풀을 지정하면, 각 코루틴이 상위 컨텍스트를 상속받지 않고 독립적인 스레드에서 실행되게 됩니다. 다음과 같이 코드를 수정하면, deferred1과 deferred2가 각각 다른 스레드에서 동시에 블록킹 작업을 수행하므로, 전체 소요 시간이 약 2000ms 내외로 단축됩니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">val</span> deferred1 = async(Dispatchers.IO) &#123; doSomething(<span class="string">&quot;deferred1&quot;</span>) &#125;</span><br><span class="line"><span class="keyword">val</span> deferred2 = async(Dispatchers.IO) &#123; doSomething(<span class="string">&quot;deferred2&quot;</span>) &#125;</span><br></pre></td></tr></table></figure><p>이렇게 수정한 후 로그를 확인하면, Main 코루틴은 여전히 Test worker에서 실행되지만, deferred1은 DefaultDispatcher-worker-1, deferred2는 DefaultDispatcher-worker-3과 같이 서로 다른 스레드에서 실행되어 병렬 처리가 이루어지는 것을 확인할 수 있습니다.</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">Main 시작 - 실행 스레드: Test worker @coroutine#1</span><br><span class="line">[deferred2] 시작 - 실행 스레드: DefaultDispatcher-worker-3 @coroutine#3</span><br><span class="line">[deferred1] 시작 - 실행 스레드: DefaultDispatcher-worker-1 @coroutine#2</span><br><span class="line">[deferred2] 완료 - 실행 스레드: DefaultDispatcher-worker-3 @coroutine#3</span><br><span class="line">[deferred1] 완료 - 실행 스레드: DefaultDispatcher-worker-1 @coroutine#2</span><br><span class="line">deferred1 결과: Result from deferred1 - 호출 스레드: Test worker @coroutine#1</span><br><span class="line">deferred2 결과: Result from deferred1 - 호출 스레드: Test worker @coroutine#1</span><br><span class="line">소요 시간 : 2018 ms</span><br><span class="line">Main 종료 - 실행 스레드: Test worker @coroutine#1</span><br></pre></td></tr></table></figure><p>Test worker 으로 메인 스레드가 시작하는 것을 확인할 수 있고 deferred1, deferred2의 수행 스레드가 DefaultDispatcher-worker-1, DefaultDispatcher-worker-3 으로 각기 다른 스레드를 통해서 수행되는 것을 확인할 수 있습니다.</p><p>각기 다른 스레드로 동작하기 때문에 각각의 스레드가 블록킹 당해도 동시에 수행이 가능하며 소요 시간이 2018 ms 으로 동작하는 것을 확인할 수 있습니다. 이 처럼 스레드가 블록킹 당하는 경우라면 <code>Dispatchers.IO</code> 가 적절한 대안이 될 수 있습니다.</p><h2><span id="silje-sarye-jdbc-deuraibeowa-beulrogking-munje">실제 사례: JDBC 드라이버와 블록킹 문제</span><a href="#silje-sarye-jdbc-deuraibeowa-beulrogking-munje" class="header-anchor">#</a></h2><p>다음은 JDBC 드라이버를 사용하는 페이징 쿼리 예제입니다. 아래 코드에서는 content와 totalCount 두 쿼리를 동시에 실행하도록 작성되었지만, <code>async()</code>에 별도의 디스패처를 지정하지 않아 모든 코루틴이 runBlocking의 컨텍스트를 상속받아 동일한 스레드에서 순차적으로 실행됩니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">override</span> <span class="function"><span class="keyword">fun</span> <span class="title">findPagingBy</span><span class="params">(pageable: <span class="type">Pageable</span>, address: <span class="type">String</span>)</span></span>: Page&lt;Order&gt; = runBlocking &#123;</span><br><span class="line">  log.info(<span class="string">&quot;findPagingBy thread : <span class="subst">$&#123;Thread.currentThread()&#125;</span>&quot;</span>)</span><br><span class="line">  <span class="keyword">val</span> content: Deferred&lt;List&lt;Order&gt;&gt; = async() &#123;</span><br><span class="line">    log.info(<span class="string">&quot;content thread : <span class="subst">$&#123;Thread.currentThread()&#125;</span>&quot;</span>)</span><br><span class="line">    from(order)</span><br><span class="line">      .select(order)</span><br><span class="line">      .innerJoin(user).on(order.userId.eq(user.id))</span><br><span class="line">      .leftJoin(coupon).on(order.couponId.eq(coupon.id))</span><br><span class="line">      .<span class="keyword">where</span>(order.address.eq(address))</span><br><span class="line">      .run &#123;</span><br><span class="line">        querydsl.applyPagination(pageable, <span class="keyword">this</span>).fetch()</span><br><span class="line">      &#125;</span><br><span class="line">  &#125;</span><br><span class="line">  <span class="keyword">val</span> totalCount: Deferred&lt;<span class="built_in">Long</span>&gt; = async() &#123;</span><br><span class="line">    log.info(<span class="string">&quot;count thread : <span class="subst">$&#123;Thread.currentThread()&#125;</span>&quot;</span>)</span><br><span class="line">    from(order)</span><br><span class="line">      .select(order.count())</span><br><span class="line">      .<span class="keyword">where</span>(order.address.eq(address))</span><br><span class="line">      .fetchFirst()</span><br><span class="line">  &#125;</span><br><span class="line">  PageImpl(content.await(), pageable, totalCount.await())</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>아래 이미지는 위 코드가 실행되었을 때의 로그를 보여줍니다.</p><p><img src="https://raw.githubusercontent.com/cheese10yun/blog-sample/refs/heads/master/kotlin-coroutine/images/00001.png" alt></p><p>여기서 특히 강조해야 할 점은, <strong>JDBC 드라이버가 기본적으로 블록킹 I/O를 수행한다는 것입니다.</strong> JDBC 드라이버는 데이터베이스와의 통신 과정에서 네트워크 I/O 및 쿼리 실행을 진행하는 동안 스레드를 블록킹합니다. 이로 인해 동일한 스레드에서 쿼리가 순차적으로 실행될 경우, 한 쿼리의 블록킹이 다른 쿼리의 실행까지 지연시키게 됩니다. 위 예제에서는 <code>async()</code>에 별도의 디스패처를 지정하지 않아, content와 totalCount 쿼리가 모두 runBlocking의 컨텍스트인 <strong>동일한 스레드에서 실행됩니다. 이로 인해 한 쿼리의 작업이 완료되어야만 다음 쿼리가 실행되므로 전체 성능 저하와 응답성 저하가 발생할 수 있습니다.</strong></p><p>이를 해결하기 위해, 아래와 같이 <code>async()</code> 호출 시 <code>Dispatchers.IO</code>와 같은 I/O 전용 스레드 풀을 지정하면, 각 코루틴이 독립된 별도의 스레드에서 실행됩니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">val</span> content: Deferred&lt;List&lt;Order&gt;&gt; = async(Dispatchers.IO) &#123; ... &#125;</span><br><span class="line"><span class="keyword">val</span> totalCount: Deferred&lt;<span class="built_in">Long</span>&gt; = async(Dispatchers.IO) &#123; ... &#125;</span><br></pre></td></tr></table></figure><p>이렇게 하면 JDBC 드라이버의 블록킹 특성에도 불구하고, <strong>각 쿼리가 독립적으로 다른 스레드에서 병렬로 실행되므로, 한 쿼리의 블록킹이 다른 쿼리의 실행에 영향을 주지 않습니다.</strong> 실제로 이 방식으로 실행할 경우, 전체 성능이 크게 향상되어 병렬 처리의 이점을 얻을 수 있습니다.</p><p>새롭게 추가된 로그 이미지를 보면, Main 코루틴은 여전히 동일한 스레드에서 실행되지만, content와 totalCount 코루틴은 각각 “DefaultDispatcher-worker-1” 및 &quot;DefaultDispatcher-worker-3&quot;과 같은 별도의 스레드에서 실행되어, 동시에 작업을 수행할 수 있습니다. <strong>이와 같이, 블록킹 I/O가 발생하는 환경에서는 <code>Dispatchers.IO</code>를 활용하여 각 작업을 독립적인 스레드에서 처리하는 것이 전체 시스템의 응답성과 성능 개선에 효과적입니다.</strong></p><h2><span id="gyeolron">결론</span><a href="#gyeolron" class="header-anchor">#</a></h2><p>이번 포스팅에서는 Kotlin 코루틴의 <code>Dispatchers.IO</code>가 어떻게 블록킹 I/O 작업, 특히 JDBC 드라이버나 파일 입출력과 같은 상황에서 전체 애플리케이션의 성능과 반응성을 개선하는 데 기여하는지 살펴보았습니다. 기본적으로 <code>async()</code>를 호출할 때 별도의 디스패처를 지정하지 않으면, 상위 코루틴의 컨텍스트를 그대로 상속받아 동일한 스레드에서 순차적으로 실행되기 때문에, 블록킹 작업이 발생할 경우 전체 실행 시간이 크게 늘어나는 문제가 발생합니다. 그러나, <code>Dispatchers.IO</code>를 지정하면 각 코루틴이 I/O 전용 스레드 풀의 독립된 스레드에서 실행되어, 한 작업의 블록킹이 다른 작업에 영향을 주지 않고 병렬 처리가 가능해집니다. 이를 통해 실제 작업에서는 전체 처리 시간이 단축되고, 시스템의 응답성이 크게 향상됨을 확인할 수 있습니다.</p><p>이와 같이 <code>Dispatchers.IO</code>를 적절히 활용하면, 블록킹 I/O로 인한 성능 저하를 효과적으로 극복할 수 있으며, 효율적인 비동기 처리와 병렬 실행을 통해 더욱 안정적이고 반응성이 뛰어난 애플리케이션을 구현할 수 있습니다.</p>]]></content:encoded>
      
      
      
      <category domain="https://cheese10yun.github.io/tags/Coroutines/">Coroutines</category>
      
      
      <comments>https://cheese10yun.github.io/coroutine-io/#disqus_thread</comments>
      
    </item>
    
    <item>
      <title>Spring Data MongoDB로 배열의 특정 요소 업데이트하기</title>
      <link>https://cheese10yun.github.io/spring-data-mongodb-update-arrayFilters/</link>
      <guid>https://cheese10yun.github.io/spring-data-mongodb-update-arrayFilters/</guid>
      <pubDate>Mon, 24 Feb 2025 15:00:00 GMT</pubDate>
      
      <description>Spring Data MongoDB에서 arrayFilters를 활용해 배열의 특정 요소만 조건에 맞게 벌크 업데이트하여 대량 데이터 처리 성능을 최적화할 수 있습니다.</description>
      
      
      
      <content:encoded><![CDATA[<p>Spring Data MongoDB를 활용해 한 도큐먼트(한 row)의 특정 배열 요소만 선택적으로 업데이트하는 방법을 알아보겠습니다.</p><p>일반적으로 find로 데이터를 조회한 후 save로 업데이트하는 방식은 편리하지만, <strong>대량 데이터를 처리할 때는 updateOne 또는 updateMany를 이용한 벌크 업데이트가 성능 면에서 큰 이점을 제공합니다.</strong><br>이번 포스팅에서는 MongoDB의 arrayFilters 옵션과 Spring Data MongoDB를 사용해 배열의 특정 요소만 업데이트하는 방법을 살펴봅니다.</p><blockquote><p>이전 포스팅 <a href="https://cheese10yun.github.io/spring-data-mongodb-update-performance/">MongoDB Update 성능 측정 및 분석</a> 을 통해 일반 업데이트와 벌크 업데이트의 성능 차이를 확인할 수 있습니다.</p></blockquote><h2><span id="munje-jeongyi">문제 정의</span><a href="#munje-jeongyi" class="header-anchor">#</a></h2><p>예를 들어, 다음과 같은 도큐먼트가 있다고 가정합니다.</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;_id&quot;</span><span class="punctuation">:</span> <span class="string">&quot;67bc734b9aa3007af5a704af&quot;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;items&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span></span><br><span class="line">    <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;name&quot;</span><span class="punctuation">:</span> <span class="string">&quot;나이키 에어 포스&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;category&quot;</span><span class="punctuation">:</span> <span class="string">&quot;신발&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;price&quot;</span><span class="punctuation">:</span> <span class="number">100.00</span></span><br><span class="line">    <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;name&quot;</span><span class="punctuation">:</span> <span class="string">&quot;나이키 후드&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;category&quot;</span><span class="punctuation">:</span> <span class="string">&quot;상의&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;price&quot;</span><span class="punctuation">:</span> <span class="number">200.00</span></span><br><span class="line">    <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;name&quot;</span><span class="punctuation">:</span> <span class="string">&quot;나이키 반바지&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;category&quot;</span><span class="punctuation">:</span> <span class="string">&quot;하의&quot;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;price&quot;</span><span class="punctuation">:</span> <span class="number">300.00</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">]</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>이 중에서 배열 <code>items</code>에 있는 특정 요소의 <code>price</code>만 업데이트하고 싶을 때,<br>예를 들어 &quot;나이키 에어 포스&quot;와 &quot;나이키 후드&quot;의 가격을 각각 222, 333으로 변경한다고 해보겠습니다.</p><h2><span id="mongodb-update-kweori">MongoDB Update 쿼리</span><a href="#mongodb-update-kweori" class="header-anchor">#</a></h2><p>MongoDB에서는 arrayFilters 옵션을 사용하여 배열의 각 요소에 대해 조건을 지정할 수 있습니다. 아래 쿼리는 items 배열에서 이름이 &quot;나이키 에어 포스&quot;인 요소와 &quot;나이키 후드&quot;인 요소의 price를 업데이트하는 예시입니다.</p><figure class="highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line">db.<span class="property">order_item</span>.<span class="title function_">updateOne</span>(</span><br><span class="line">    &#123;</span><br><span class="line">        <span class="string">&quot;_id&quot;</span>: <span class="title class_">ObjectId</span>(<span class="string">&quot;67bc7db1f407ca76116d9e35&quot;</span>)</span><br><span class="line">    &#125;,</span><br><span class="line">    &#123;</span><br><span class="line">        <span class="string">&quot;$set&quot;</span>: &#123;</span><br><span class="line">            <span class="string">&quot;items.$[elem0].price&quot;</span>: <span class="title class_">NumberDecimal</span>(<span class="number">222</span>),</span><br><span class="line">            <span class="string">&quot;items.$[elem1].price&quot;</span>: <span class="title class_">NumberDecimal</span>(<span class="number">333</span>)</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;,</span><br><span class="line">    &#123;</span><br><span class="line">        <span class="attr">arrayFilters</span>: [</span><br><span class="line">            &#123;<span class="string">&quot;elem0.name&quot;</span>: <span class="string">&quot;나이키 에어 포스&quot;</span>&#125;,</span><br><span class="line">            &#123;<span class="string">&quot;elem1.name&quot;</span>: <span class="string">&quot;나이키 후드&quot;</span>&#125;</span><br><span class="line">        ]</span><br><span class="line">    &#125;</span><br><span class="line">)</span><br></pre></td></tr></table></figure><p>위 쿼리는 조건에 맞는 배열 요소만 골라 업데이트를 진행합니다.</p><p>여기서 <code>$[elem0]</code>와 <code>$[elem1]</code>는 자리표현자로, 실제 업데이트 시에는 arrayFilters 옵션에 지정된 조건을 만족하는 배열 요소의 실제 인덱스와 매핑됩니다. 예를 들어, <code>&quot;elem0.name&quot;: &quot;나이키 에어 포스&quot;</code> 조건을 통해 MongoDB는 items 배열에서 이름이 &quot;나이키 에어 포스&quot;인 요소를 찾아 그 인덱스에 해당하는 위치에 <code>$[elem0]</code>를 매핑하여 업데이트를 적용합니다. 동일한 방식으로 <code>&quot;elem1.name&quot;: &quot;나이키 후드&quot;</code> 조건도 적용됩니다.</p><h2><span id="spring-data-mongodbeseo-eobdeiteu-jeogyonghagi">Spring Data MongoDB에서 업데이트 적용하기</span><a href="#spring-data-mongodbeseo-eobdeiteu-jeogyonghagi" class="header-anchor">#</a></h2><p>Spring Data MongoDB에서도 위와 같이 arrayFilters 옵션을 사용할 수 있습니다. 일반적으로 아래와 같이 엔티티와 업데이트 쿼리 객체를 정의합니다.</p><h3><span id="domein-keulraeseu">도메인 클래스</span><a href="#domein-keulraeseu" class="header-anchor">#</a></h3><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Document(collection = <span class="string">&quot;order_item&quot;</span>)</span></span><br><span class="line"><span class="keyword">data</span> <span class="keyword">class</span> <span class="title class_">OrderItem</span>(</span><br><span class="line">    <span class="meta">@Field(<span class="string">&quot;items&quot;</span>)</span></span><br><span class="line">    <span class="keyword">val</span> items: List&lt;Item&gt; = emptyList()</span><br><span class="line">) : Auditable()</span><br><span class="line"></span><br><span class="line"><span class="keyword">data</span> <span class="keyword">class</span> <span class="title class_">Item</span>(</span><br><span class="line">    <span class="meta">@Field(<span class="string">&quot;name&quot;</span>)</span></span><br><span class="line">    <span class="keyword">val</span> name: String,</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Field(<span class="string">&quot;category&quot;</span>)</span></span><br><span class="line">    <span class="keyword">val</span> category: String,</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Field(<span class="string">&quot;price&quot;</span>)</span></span><br><span class="line">    <span class="keyword">val</span> price: BigDecimal</span><br><span class="line">)</span><br></pre></td></tr></table></figure><h3><span id="eobdeiteu-kweorie-sayonghal-gaegce">업데이트 쿼리에 사용할 객체</span><a href="#eobdeiteu-kweorie-sayonghal-gaegce" class="header-anchor">#</a></h3><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">object</span> OrderItemQueryForm &#123;</span><br><span class="line">    <span class="keyword">data</span> <span class="keyword">class</span> <span class="title class_">UpdateItem</span>(</span><br><span class="line">        <span class="keyword">val</span> orderItem: ObjectId,</span><br><span class="line">        <span class="keyword">val</span> items: List&lt;UpdateItemForm&gt;</span><br><span class="line">    )</span><br><span class="line"></span><br><span class="line">    <span class="keyword">data</span> <span class="keyword">class</span> <span class="title class_">UpdateItemForm</span>(</span><br><span class="line">        <span class="keyword">val</span> name: String,</span><br><span class="line">        <span class="keyword">val</span> category: String,</span><br><span class="line">        <span class="keyword">val</span> price: BigDecimal</span><br><span class="line">    )</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><blockquote><p>이전 포스팅 <a href="https://cheese10yun.github.io/spring-data-mongo-update-guide-1/#eobdeiteu-kweorie-sayonghal-gaegce-jeongyi">Spring Data MongoDB에서의 Update 전략과 경험 - 업데이트 쿼리에 사용할 객체 정의</a>에서 업데이트 쿼리에 사용할 객체를 별도로 관리하는 방법에 대해 자세히 다루었습니다.</p></blockquote><h3><span id="dansun-eobdeiteu-kweori-guhyeon-gibon-bangbeob">단순 업데이트 쿼리 구현 (기본 방법)</span><a href="#dansun-eobdeiteu-kweori-guhyeon-gibon-bangbeob" class="header-anchor">#</a></h3><p>아래 코드는 Spring Data MongoDB의 <code>Update</code>와 <code>filterArray</code> 메서드를 활용하여, 배열의 각 요소에 대해 조건을 지정하는 업데이트 쿼리를 작성한 예시입니다.</p><p>이 경우, form.items 리스트의 각 항목마다 고유한 자리표현자(예: elem0, elem1)를 할당하여 해당 요소의 price를 업데이트하고, arrayFilters 조건으로 name만 적용합니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">class</span> <span class="title class_">OrderItemCustomRepositoryImpl</span>(</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">val</span> mongoTemplate: MongoTemplate</span><br><span class="line">) : OrderItemCustomRepository &#123;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">override</span> <span class="function"><span class="keyword">fun</span> <span class="title">updateItems</span><span class="params">(form: <span class="type">OrderItemQueryForm</span>.<span class="type">UpdateItem</span>)</span></span> &#123;</span><br><span class="line">        <span class="keyword">val</span> query = Query(Criteria.<span class="keyword">where</span>(<span class="string">&quot;_id&quot;</span>).`<span class="keyword">is</span>`(form.orderItem))</span><br><span class="line">        <span class="keyword">val</span> update = Update()</span><br><span class="line"></span><br><span class="line">        form.items.forEachIndexed &#123; index, item -&gt;</span><br><span class="line">            update</span><br><span class="line">                .<span class="keyword">set</span>(<span class="string">&quot;items.\$[elem<span class="subst">$&#123;index&#125;</span>].price&quot;</span>, item.price)</span><br><span class="line">                .filterArray(<span class="string">&quot;elem<span class="subst">$&#123;index&#125;</span>.name&quot;</span>, item.name)</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        mongoTemplate.updateFirst(query, update, OrderItem::<span class="keyword">class</span>.java)</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><blockquote><p>이전 포스팅 <a href="https://cheese10yun.github.io/spring-data-mongo-repository/">Spring Data MongoDB Repository 확장</a>에서 Repository를 확장하여 커스텀 메서드를 구현하는 방법에 대해 자세히 다루었습니다.</p></blockquote><p>이 코드는 내부적으로 아래와 같은 MongoDB 업데이트 명령을 생성합니다.</p><ul><li><strong>업데이트 문서 ($set):</strong><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;$set&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;items.$[elem0].price&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span><span class="attr">&quot;$numberDecimal&quot;</span><span class="punctuation">:</span> <span class="string">&quot;222&quot;</span><span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;items.$[elem1].price&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span><span class="attr">&quot;$numberDecimal&quot;</span><span class="punctuation">:</span> <span class="string">&quot;333&quot;</span><span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure></li><li><strong>업데이트 옵션 (arrayFilters):</strong><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;arrayFilters&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span></span><br><span class="line">    <span class="punctuation">&#123;</span> <span class="attr">&quot;elem0.name&quot;</span><span class="punctuation">:</span> <span class="string">&quot;나이키 에어 포스&quot;</span> <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="punctuation">&#123;</span> <span class="attr">&quot;elem1.name&quot;</span><span class="punctuation">:</span> <span class="string">&quot;나이키 후드&quot;</span> <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">]</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure></li></ul><h2><span id="boghab-jogeonyi-arrayfilters-jeogyong">복합 조건의 arrayFilters 적용</span><a href="#boghab-jogeonyi-arrayfilters-jeogyong" class="header-anchor">#</a></h2><p>실제 상황에서는 배열 요소를 업데이트할 때 name뿐 아니라 category 등 복합 조건으로 필터링해야 하는 경우가 있습니다. 예를 들어, 아래 MongoDB 쿼리는 배열의 조건을 복합 키로 찾아 &quot;나이키 에어 포스&quot;는 신발, &quot;나이키 후드&quot;는 상의인 경우에만 업데이트하도록 합니다.</p><figure class="highlight javascript"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line">db.<span class="property">order_item</span>.<span class="title function_">updateOne</span>(</span><br><span class="line">    &#123;</span><br><span class="line">        <span class="string">&quot;_id&quot;</span>: <span class="title class_">ObjectId</span>(<span class="string">&quot;67bc7db1f407ca76116d9e35&quot;</span>)</span><br><span class="line">    &#125;,</span><br><span class="line">    &#123;</span><br><span class="line">        <span class="string">&quot;$set&quot;</span>: &#123;</span><br><span class="line">            <span class="string">&quot;items.$[elem0].price&quot;</span>: <span class="title class_">NumberDecimal</span>(<span class="number">333</span>),</span><br><span class="line">            <span class="string">&quot;items.$[elem1].price&quot;</span>: <span class="title class_">NumberDecimal</span>(<span class="number">333</span>)</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;,</span><br><span class="line">    &#123;</span><br><span class="line">        <span class="attr">arrayFilters</span>: [</span><br><span class="line">            &#123;<span class="string">&quot;elem0.name&quot;</span>: <span class="string">&quot;나이키 에어 포스&quot;</span>, <span class="string">&quot;elem0.category&quot;</span>: <span class="string">&quot;신발&quot;</span>&#125;,</span><br><span class="line">            &#123;<span class="string">&quot;elem1.name&quot;</span>: <span class="string">&quot;나이키 후드&quot;</span>, <span class="string">&quot;elem1.category&quot;</span>: <span class="string">&quot;상의&quot;</span>&#125;</span><br><span class="line">        ]</span><br><span class="line">    &#125;</span><br><span class="line">)</span><br></pre></td></tr></table></figure><p>위와 같이 복합 조건으로 업데이트하려면 단순하게 filterArray()를 여러 번 호출하는 방법은 동작하지 않습니다. 예를 들어, 아래와 같이 작성하면</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">override</span> <span class="function"><span class="keyword">fun</span> <span class="title">updateItems</span><span class="params">(form: <span class="type">OrderItemQueryForm</span>.<span class="type">UpdateItem</span>)</span></span> &#123;</span><br><span class="line">    <span class="keyword">val</span> query = Query(Criteria.<span class="keyword">where</span>(<span class="string">&quot;_id&quot;</span>).`<span class="keyword">is</span>`(form.orderItem))</span><br><span class="line">    <span class="keyword">val</span> update = Update()</span><br><span class="line"></span><br><span class="line">    form.items.forEachIndexed &#123; index, item -&gt;</span><br><span class="line">        update</span><br><span class="line">            .<span class="keyword">set</span>(<span class="string">&quot;items.\$[elem<span class="subst">$&#123;index&#125;</span>].price&quot;</span>, item.price)</span><br><span class="line">            .filterArray(<span class="string">&quot;elem<span class="subst">$&#123;index&#125;</span>.name&quot;</span>, item.name)</span><br><span class="line">            .filterArray(<span class="string">&quot;elem<span class="subst">$&#123;index&#125;</span>.category&quot;</span>, item.category)</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    mongoTemplate.updateFirst(query, update, documentClass)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>Spring Data MongoDB에서는 동일한 자리표현자(예: elem0)에 대해 <strong>두 번의 filterArray 호출이 이루어지면</strong>, 내부적으로 각각 별도의 Document가 생성되어 중복된 최상위 키가 되어 버립니다. <strong>결과적으로 원하는 복합 조건의 arrayFilters가 아닌,</strong></p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">[</span></span><br><span class="line">  <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;elem0.name&quot;</span><span class="punctuation">:</span> <span class="string">&quot;나이키 에어 포스&quot;</span></span><br><span class="line">  <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;elem0.category&quot;</span><span class="punctuation">:</span> <span class="string">&quot;신발&quot;</span></span><br><span class="line">  <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;elem1.name&quot;</span><span class="punctuation">:</span> <span class="string">&quot;나이키 후드&quot;</span></span><br><span class="line">  <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;elem1.category&quot;</span><span class="punctuation">:</span> <span class="string">&quot;상의&quot;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">]</span></span><br></pre></td></tr></table></figure><p>와 같이 분리되어 전달되며, <strong>원하는 쿼리가 아니며 정상적으로 인덱스를 찾아 업데이트하지 못하게 됩니다.</strong></p><h3><span id="haegyeol-bangbeob-keoseuteom-updatedefinition-hwalyong">해결 방법 – 커스텀 UpdateDefinition 활용</span><a href="#haegyeol-bangbeob-keoseuteom-updatedefinition-hwalyong" class="header-anchor">#</a></h3><p>복합 조건을 하나의 Document로 결합하여 업데이트 옵션으로 분리해서 전달하기 위해, 커스텀 UpdateDefinition을 사용할 수 있습니다. 예를 들어, 아래와 같이 UpdateWithArrayFilters 클래스를 정의합니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> org.bson.Document</span><br><span class="line"><span class="keyword">import</span> org.springframework.<span class="keyword">data</span>.mongodb.core.query.Update</span><br><span class="line"><span class="keyword">import</span> org.springframework.<span class="keyword">data</span>.mongodb.core.query.UpdateDefinition</span><br><span class="line"></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">UpdateWithArrayFilters</span>(</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">val</span> update: Update,</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">val</span> arrayFilters: List&lt;Document&gt;</span><br><span class="line">) : UpdateDefinition &#123;</span><br><span class="line">    <span class="comment">// 업데이트 문서는 내부 update의 updateObject만 반환 (즉, $set 부분만 포함)</span></span><br><span class="line">    <span class="keyword">override</span> <span class="function"><span class="keyword">fun</span> <span class="title">getUpdateObject</span><span class="params">()</span></span>: Document = update.updateObject</span><br><span class="line"></span><br><span class="line">    <span class="comment">// getArrayFilters()에서는 List&lt;Document&gt;로 보관된 조건들을</span></span><br><span class="line">    <span class="comment">// UpdateDefinition.ArrayFilter 타입으로 변환하여 반환합니다.</span></span><br><span class="line">    <span class="keyword">override</span> <span class="function"><span class="keyword">fun</span> <span class="title">getArrayFilters</span><span class="params">()</span></span>: List&lt;UpdateDefinition.ArrayFilter&gt; &#123;</span><br><span class="line">        <span class="keyword">return</span> arrayFilters</span><br><span class="line">            .map &#123; doc -&gt; UpdateDefinition.ArrayFilter &#123; doc &#125; &#125;</span><br><span class="line">            .toList()</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 나머지 메서드는 내부 update에 위임</span></span><br><span class="line">    <span class="keyword">override</span> <span class="function"><span class="keyword">fun</span> <span class="title">isIsolated</span><span class="params">()</span></span>: <span class="built_in">Boolean</span> = update.isIsolated</span><br><span class="line">    <span class="keyword">override</span> <span class="function"><span class="keyword">fun</span> <span class="title">modifies</span><span class="params">(key: <span class="type">String</span>)</span></span>: <span class="built_in">Boolean</span> = update.modifies(key)</span><br><span class="line">    <span class="keyword">override</span> <span class="function"><span class="keyword">fun</span> <span class="title">inc</span><span class="params">(key: <span class="type">String</span>)</span></span> = update.inc(key)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>이 클래스를 사용한 업데이트 쿼리 구현은 아래와 같습니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">override</span> <span class="function"><span class="keyword">fun</span> <span class="title">updateItems</span><span class="params">(form: <span class="type">OrderItemQueryForm</span>.<span class="type">UpdateItem</span>)</span></span> &#123;</span><br><span class="line">    <span class="keyword">val</span> query = Query(Criteria.<span class="keyword">where</span>(<span class="string">&quot;_id&quot;</span>).`<span class="keyword">is</span>`(form.orderItem))</span><br><span class="line">    <span class="keyword">val</span> update = Update()</span><br><span class="line">    <span class="keyword">val</span> arrayFilters = mutableListOf&lt;Document&gt;()</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 각 항목마다 자리표현자(elem0, elem1, …)를 생성하여 업데이트할 필드와 조건 Document를 구성</span></span><br><span class="line">    form.items.forEachIndexed &#123; index, item -&gt;</span><br><span class="line">        <span class="comment">// 예: &quot;items.$[elem0].price&quot;: item.price</span></span><br><span class="line">        update.<span class="keyword">set</span>(<span class="string">&quot;items.\$[elem<span class="variable">$index</span>].price&quot;</span>, item.price)</span><br><span class="line">        <span class="comment">// 복합 조건 Document: &#123; &quot;elem0.name&quot;: &quot;나이키 에어 포스&quot;, &quot;elem0.category&quot;: &quot;신발&quot; &#125;</span></span><br><span class="line">        arrayFilters.add(</span><br><span class="line">            Document(<span class="string">&quot;elem<span class="subst">$&#123;index&#125;</span>.name&quot;</span>, item.name)</span><br><span class="line">                .append(<span class="string">&quot;elem<span class="subst">$&#123;index&#125;</span>.category&quot;</span>, item.category)</span><br><span class="line">        )</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 커스텀 UpdateDefinition 생성 – 업데이트 문서와 arrayFilters 옵션을 분리하여 전달합니다.</span></span><br><span class="line">    <span class="keyword">val</span> updateApplyArrayFilters = UpdateWithArrayFilters(update, arrayFilters.toList())</span><br><span class="line">    mongoTemplate.updateFirst(query, updateApplyArrayFilters, documentClass)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>이 방식으로 업데이트를 수행하면 최종적으로 MongoDB에 전송되는 명령은 다음과 같습니다.</p><ul><li><strong>업데이트 문서 ($set 부분):</strong><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;$set&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;items.$[elem0].price&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span><span class="attr">&quot;$numberDecimal&quot;</span><span class="punctuation">:</span> <span class="string">&quot;333&quot;</span><span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;items.$[elem1].price&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span><span class="attr">&quot;$numberDecimal&quot;</span><span class="punctuation">:</span> <span class="string">&quot;333&quot;</span><span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure></li><li><strong>업데이트 옵션 (arrayFilters):</strong><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;arrayFilters&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span></span><br><span class="line">    <span class="punctuation">&#123;</span> <span class="attr">&quot;elem0.name&quot;</span><span class="punctuation">:</span> <span class="string">&quot;나이키 에어 포스&quot;</span><span class="punctuation">,</span> <span class="attr">&quot;elem0.category&quot;</span><span class="punctuation">:</span> <span class="string">&quot;신발&quot;</span> <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="punctuation">&#123;</span> <span class="attr">&quot;elem1.name&quot;</span><span class="punctuation">:</span> <span class="string">&quot;나이키 후드&quot;</span><span class="punctuation">,</span> <span class="attr">&quot;elem1.category&quot;</span><span class="punctuation">:</span> <span class="string">&quot;상의&quot;</span> <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">]</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure></li></ul><p>즉, getUpdateObject()에서는 $set 부분만 반환하고, getArrayFilters()에서 반환한 조건들이 별도의 업데이트 옵션으로 전달되어 MongoDB가 올바르게 인식하게 됩니다.</p><h3><span id="mongocustomrepositorysupporteul-tonghan-bulkops-gineung-jegong">MongoCustomRepositorySupport을 통한 bulkOps 기능 제공</span><a href="#mongocustomrepositorysupporteul-tonghan-bulkops-gineung-jegong" class="header-anchor">#</a></h3><p><img src="https://raw.githubusercontent.com/cheese10yun/blog-sample/5fc6127a0800ca9bce5de5a6c73931b2025b0791/mongo-study/images/performance-update.png" alt></p><p><strong>bulkOps를 사용하면 대량 데이터 처리 시 업데이트 성능이 크게 향상됩니다.</strong> 위 그림에서도 볼 수 있듯이, 단일 업데이트에 비해 벌크 업데이트를 적용할 경우 처리 속도가 현저히 개선됩니다.</p><p>아래 코드는 BulkOperations를 활용하여 여러 도큐먼트에 대해 벌크 업데이트를 수행하는 편의 기능을 제공합니다.<br>MongoCustomRepositorySupport 추상 클래스는 <code>bulkUpdateDefinition</code> 메서드를 통해, Query와 UpdateDefinition 생성자를 담은 람다 리스트를 받아 BulkOperations 객체에 각 업데이트를 추가한 후 일괄 실행합니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">abstract</span> <span class="keyword">class</span> <span class="title class_">MongoCustomRepositorySupport</span>&lt;<span class="type">T</span>&gt;(</span><br><span class="line">    <span class="keyword">protected</span> <span class="keyword">val</span> documentClass: Class&lt;T&gt;,</span><br><span class="line">    <span class="keyword">protected</span> <span class="keyword">val</span> mongoTemplate: MongoTemplate</span><br><span class="line">) &#123;</span><br><span class="line">    <span class="keyword">protected</span> <span class="function"><span class="keyword">fun</span> <span class="title">bulkUpdateDefinition</span><span class="params">(</span></span></span><br><span class="line"><span class="params"><span class="function">        operations: <span class="type">List</span>&lt;<span class="type">Pair</span>&lt;() -&gt; <span class="type">Query</span>, ()</span></span> -&gt; UpdateDefinition&gt;&gt;, <span class="comment">// Query와 Update 생성자를 위한 람다 리스트</span></span><br><span class="line">        bulkMode: BulkOperations.BulkMode = BulkOperations.BulkMode.UNORDERED,</span><br><span class="line">    ): BulkWriteResult &#123;</span><br><span class="line">        <span class="comment">// BulkOperations 객체를 생성합니다.</span></span><br><span class="line">        <span class="keyword">val</span> bulkOps = mongoTemplate.bulkOps(bulkMode, documentClass)</span><br><span class="line">        <span class="comment">// 제공된 리스트를 반복하면서 bulk 연산에 각 update를 추가합니다.</span></span><br><span class="line">        operations.forEach &#123; (queryCreator, updateCreator) -&gt;</span><br><span class="line">            bulkOps.updateOne(queryCreator.invoke(), updateCreator.invoke())</span><br><span class="line">        &#125;</span><br><span class="line">        <span class="comment">// 모든 업데이트를 실행합니다.</span></span><br><span class="line">        <span class="keyword">return</span> bulkOps.execute()</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>OrderItemCustomRepositoryImpl에서는 bulkUpdateDefinition 메서드를 사용하여, 여러 업데이트 폼을 반복 처리합니다.<br>각 폼마다 _id 조건의 Query와, Update 및 복합 조건의 arrayFilters를 적용한 커스텀 UpdateDefinition(UpdateWithArrayFilters)을 생성하여 BulkOperations에 추가합니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">class</span> <span class="title class_">OrderItemCustomRepositoryImpl</span>(mongoTemplate: MongoTemplate) : OrderItemCustomRepository, MongoCustomRepositorySupport&lt;OrderItem&gt;(</span><br><span class="line">    OrderItem::<span class="keyword">class</span>.java,</span><br><span class="line">    mongoTemplate</span><br><span class="line">) &#123;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">override</span> <span class="function"><span class="keyword">fun</span> <span class="title">updateItems</span><span class="params">(forms: <span class="type">List</span>&lt;<span class="type">OrderItemQueryForm</span>.<span class="type">UpdateItem</span>&gt;)</span></span> &#123;</span><br><span class="line">        bulkUpdateDefinition(</span><br><span class="line">            forms.map &#123; form -&gt;</span><br><span class="line">                Pair(</span><br><span class="line">                    first = &#123; Query(Criteria.<span class="keyword">where</span>(<span class="string">&quot;_id&quot;</span>).`<span class="keyword">is</span>`(form.orderItem)) &#125;,</span><br><span class="line">                    second = &#123;</span><br><span class="line">                        <span class="keyword">val</span> update = Update()</span><br><span class="line">                        <span class="keyword">val</span> arrayFilters = mutableListOf&lt;Document&gt;()</span><br><span class="line">                        form.items.forEachIndexed &#123; index, item -&gt;</span><br><span class="line">                            update.<span class="keyword">set</span>(<span class="string">&quot;items.\$[elem<span class="variable">$index</span>].price&quot;</span>, item.price)</span><br><span class="line">                            arrayFilters.add(</span><br><span class="line">                                Document(<span class="string">&quot;elem<span class="subst">$&#123;index&#125;</span>.name&quot;</span>, item.name)</span><br><span class="line">                                    .append(<span class="string">&quot;elem<span class="subst">$&#123;index&#125;</span>.category&quot;</span>, item.category)</span><br><span class="line">                            )</span><br><span class="line">                        &#125;</span><br><span class="line">                        <span class="keyword">val</span> customUpdate = UpdateWithArrayFilters(update, arrayFilters.toList())</span><br><span class="line">                        customUpdate</span><br><span class="line">                    &#125;</span><br><span class="line">                )</span><br><span class="line">            &#125;</span><br><span class="line">        )</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>이 구조를 사용하면, 대량 업데이트가 필요한 경우 여러 도큐먼트에 대해 Query와 UpdateDefinition을 한 번에 처리할 수 있어, 벌크 업데이트의 성능 이점을 효과적으로 활용할 수 있습니다.</p><blockquote><p>이전 포스팅 <a href="https://cheese10yun.github.io/spring-data-mongodb-update-performance/#bulkops-pyeonyi-gineung-jegong">MongoDB Update 성능 측정 및 분석 - bulkOps 편의 기능 제공</a>에서 MongoCustomRepositorySupport를 활용한 bulkOps 기능을 구현하는 방법에 대해 자세히 다루었습니다.</p></blockquote><h2><span id="teseuteu-kodeu-yeje">테스트 코드 예제</span><a href="#teseuteu-kodeu-yeje" class="header-anchor">#</a></h2><p>다음은 위 업데이트 쿼리가 올바르게 동작하는지 확인하기 위한 테스트 코드 예시입니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Test</span></span><br><span class="line"><span class="function"><span class="keyword">fun</span> `updateItems`<span class="params">()</span></span> &#123;</span><br><span class="line">    <span class="comment">// given: 초기 데이터 삽입</span></span><br><span class="line">    <span class="keyword">val</span> orderItem = mongoTemplate.insert(</span><br><span class="line">        OrderItem(</span><br><span class="line">            items = listOf(</span><br><span class="line">                Item(name = <span class="string">&quot;나이키 에어 포스&quot;</span>, category = <span class="string">&quot;신발&quot;</span>, price = <span class="number">100.00</span>.toBigDecimal()),</span><br><span class="line">                Item(name = <span class="string">&quot;나이키 후드&quot;</span>, category = <span class="string">&quot;상의&quot;</span>, price = <span class="number">200.00</span>.toBigDecimal()),</span><br><span class="line">                Item(name = <span class="string">&quot;나이키 반바지&quot;</span>, category = <span class="string">&quot;하의&quot;</span>, price = <span class="number">300.00</span>.toBigDecimal())</span><br><span class="line">            )</span><br><span class="line">        )</span><br><span class="line">    )</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 업데이트 폼 생성 (두 항목 업데이트)</span></span><br><span class="line">    <span class="keyword">val</span> form = OrderItemQueryForm.UpdateItem(</span><br><span class="line">        orderItem = orderItem.id!!,</span><br><span class="line">        items = listOf(</span><br><span class="line">            OrderItemQueryForm.UpdateItemForm(name = <span class="string">&quot;나이키 에어 포스&quot;</span>, category = <span class="string">&quot;신발&quot;</span>, price = <span class="number">4000.00</span>.toBigDecimal()),</span><br><span class="line">            OrderItemQueryForm.UpdateItemForm(name = <span class="string">&quot;나이키 후드&quot;</span>, category = <span class="string">&quot;상의&quot;</span>, price = <span class="number">5000.00</span>.toBigDecimal())</span><br><span class="line">        )</span><br><span class="line">    )</span><br><span class="line"></span><br><span class="line">    <span class="comment">// when: 업데이트 실행</span></span><br><span class="line">    orderItemRepository.updateItems(form)</span><br><span class="line"></span><br><span class="line">    <span class="comment">// then: 결과 검증</span></span><br><span class="line">    <span class="keyword">val</span> result = mongoTemplate.findOne(Query(Criteria.<span class="keyword">where</span>(<span class="string">&quot;_id&quot;</span>).`<span class="keyword">is</span>`(orderItem.id)), OrderItem::<span class="keyword">class</span>.java)!!</span><br><span class="line">    result.items.forEach &#123; item -&gt;</span><br><span class="line">        <span class="keyword">when</span> (item.name) &#123;</span><br><span class="line">            <span class="string">&quot;나이키 에어 포스&quot;</span> -&gt; assertThat(item.price).isEqualByComparingTo(<span class="number">4000.00</span>.toBigDecimal())</span><br><span class="line">            <span class="string">&quot;나이키 후드&quot;</span> -&gt; assertThat(item.price).isEqualByComparingTo(<span class="number">5000.00</span>.toBigDecimal())</span><br><span class="line">            <span class="string">&quot;나이키 반바지&quot;</span> -&gt; assertThat(item.price).isEqualByComparingTo(<span class="number">300.00</span>.toBigDecimal()) <span class="comment">// 변경 없음</span></span><br><span class="line">            <span class="keyword">else</span> -&gt; <span class="keyword">throw</span> IllegalStateException(<span class="string">&quot;검증되지 않은 항목&quot;</span>)</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>위 테스트는 총 3개의 Item 중 2개만 업데이트되고, 나머지 항목은 그대로 남는 것을 확인할 수 있습니다.</p><h2><span id="mamuri">마무리</span><a href="#mamuri" class="header-anchor">#</a></h2><p>Spring Data MongoDB에서 배열의 특정 요소만 업데이트하기 위해서는 updateOne/updateMany와 arrayFilters 옵션을 활용하는 것이 성능 면에서 매우 유리합니다.</p><p>특히, 배열 요소를 복합 조건(예: name과 category)으로 필터링해야 하는 경우, 단순한 filterArray 호출로는 원하는 결과를 얻기 어려우므로 커스텀 UpdateDefinition(예: UpdateWithArrayFilters)을 활용하여 업데이트 문서와 arrayFilters 옵션을 분리해 전달하는 방법을 사용할 수 있습니다.</p><p>본 포스팅에서는 MongoDB 업데이트 쿼리와 이를 Spring Data MongoDB에서 구현하는 방법을 살펴보았으며, 실제 테스트 코드까지 확인해 보았습니다. <strong>대량 데이터 처리나 업데이트가 빈번한 애플리케이션에서는 find 후 save 방식 대신 벌크 업데이트를 적극 활용하여 성능 최적화를 고려해 보시기 바랍니다.</strong></p>]]></content:encoded>
      
      
      
      <category domain="https://cheese10yun.github.io/tags/Mongo/">Mongo</category>
      
      
      <comments>https://cheese10yun.github.io/spring-data-mongodb-update-arrayFilters/#disqus_thread</comments>
      
    </item>
    
    <item>
      <title>Spring Data MongoDB Repository 확장 - Aggregation 기반 페이징 처리</title>
      <link>https://cheese10yun.github.io/spring-data-mongo-repository-2/</link>
      <guid>https://cheese10yun.github.io/spring-data-mongo-repository-2/</guid>
      <pubDate>Thu, 30 Jan 2025 01:00:00 GMT</pubDate>
      
      <description>기존 Spring Data MongoDB Repository 확장 포스팅에서는 복잡한 쿼리 로직을 분리하여 상위 레벨에서는 구현 디테일을 신경 쓰지 않고, 데이터 접근 로직을 단순화할 수 있는 방법을 다루었습니다.</description>
      
      
      
      <content:encoded><![CDATA[<p>기존 <a href="https://cheese10yun.github.io/spring-data-mongo-repository/">Spring Data MongoDB Repository 확장</a> 포스팅에서는 복잡한 쿼리 로직을 분리하여 상위 레벨에서는 구현 디테일을 신경 쓰지 않고, 데이터 접근 로직을 단순화할 수 있는 방법을 다루었습니다.  특히, <strong>Slice 기반 및 Page 기반의 페이징 처리</strong>를 적용하여 다음과 같은 장점을 얻을 수 있었습니다.</p><ul><li><strong>Page 기반 페이징 처리</strong>:<ul><li>카운트 쿼리와 컨텐츠 쿼리를 <strong>병렬로 실행</strong>하여 성능 최적화</li></ul></li><li><strong>Slice 기반 페이징 처리</strong>:<ul><li><code>hasNext</code> 처리를 위임하여 반복적인 코드 없이 페이징을 처리</li></ul></li></ul><p>그러나 이러한 방식은 <strong>단순히 도큐먼트 객체(<code>T</code>) 타입</strong>을 기준으로 설계되어, <strong>복잡한 데이터 변환</strong>, <strong>조인</strong>, <strong>그룹화</strong> 등 다양한 데이터 처리 작업을 다루는 데 한계가 있었습니다. 특히, <strong>프로젝션</strong>을 활용한 <strong>데이터 조회</strong>나 <strong>복잡한 쿼리</strong>는 <code>Aggregation</code>을 사용하여 해결해야 하기 때문에, <strong><code>Aggregation</code>을 기반으로 하는 페이징 처리</strong>가 필요합니다.<br>따라서, 이번 포스팅에서는 <strong><code>MongoTemplate</code>을 기반으로 <code>Aggregation</code>을 활용한 페이징 처리</strong> 방법을 확장하여 이 문제를 해결하는 방법을 다루겠습니다.</p><h2><span id="gijon-query-giban-paginationgwa-slice-ceori">기존 Query 기반 Pagination과 Slice 처리</span><a href="#gijon-query-giban-paginationgwa-slice-ceori" class="header-anchor">#</a></h2><p>기존 <code>applyPagination</code>과 <code>applySlice</code>는 <code>MongoTemplate</code>을 활용하여 <code>Query</code> 기반으로 페이징을 처리하는 방식이었습니다.</p><h3><span id="query-giban-pagination-mic-slice-cusanghwa">Query 기반 Pagination 및 Slice 추상화</span><a href="#query-giban-pagination-mic-slice-cusanghwa" class="header-anchor">#</a></h3><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">protected</span> <span class="function"><span class="keyword">fun</span> <span class="type">&lt;S : T&gt;</span> <span class="title">applyPagination</span><span class="params">(</span></span></span><br><span class="line"><span class="params"><span class="function">    pageable: <span class="type">Pageable</span>,</span></span></span><br><span class="line"><span class="params"><span class="function">    contentQuery: (<span class="type">Query</span>) -&gt; <span class="type">List</span>&lt;<span class="type">S</span>&gt;,</span></span></span><br><span class="line"><span class="params"><span class="function">    countQuery: (<span class="type">Query</span>) -&gt; <span class="type">Long</span></span></span></span><br><span class="line"><span class="params"><span class="function">)</span></span> = runBlocking &#123;</span><br><span class="line">    <span class="keyword">val</span> content = async(Dispatchers.IO) &#123; contentQuery(Query().with(pageable)) &#125;</span><br><span class="line">    <span class="keyword">val</span> totalCount = async(Dispatchers.IO) &#123; countQuery(Query()) &#125;</span><br><span class="line">    PageImpl(content.await(), pageable, totalCount.await())</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">protected</span> <span class="function"><span class="keyword">fun</span> <span class="type">&lt;S : T&gt;</span> <span class="title">applySlice</span><span class="params">(</span></span></span><br><span class="line"><span class="params"><span class="function">    pageable: <span class="type">Pageable</span>,</span></span></span><br><span class="line"><span class="params"><span class="function">    contentQuery: (<span class="type">Query</span>) -&gt; <span class="type">List</span>&lt;<span class="type">S</span>&gt;</span></span></span><br><span class="line"><span class="params"><span class="function">)</span></span>: Slice&lt;S&gt; &#123;</span><br><span class="line">    <span class="keyword">val</span> content = contentQuery(Query().with(pageable))</span><br><span class="line">    <span class="keyword">val</span> hasNext = content.size &gt;= pageable.pageSize</span><br><span class="line">    <span class="keyword">return</span> SliceImpl(content, pageable, hasNext)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3><span id="sayong-yesi">사용 예시</span><a href="#sayong-yesi" class="header-anchor">#</a></h3><p>기존 <code>applyPagination</code>과 <code>applySlice</code>를 활용하면 <code>MongoTemplate</code>을 사용하여 데이터를 간단하게 조회할 수 있습니다.</p><h4><span id="slice-johoe">Slice 조회</span><a href="#slice-johoe" class="header-anchor">#</a></h4><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">override</span> <span class="function"><span class="keyword">fun</span> <span class="title">findSlice</span><span class="params">(</span></span></span><br><span class="line"><span class="params"><span class="function">    pageable: <span class="type">Pageable</span>,</span></span></span><br><span class="line"><span class="params"><span class="function">    name: <span class="type">String</span>?,</span></span></span><br><span class="line"><span class="params"><span class="function">    email: <span class="type">String</span>?,</span></span></span><br><span class="line"><span class="params"><span class="function">    memberId: <span class="type">String</span>?</span></span></span><br><span class="line"><span class="params"><span class="function">)</span></span>: Slice&lt;Member&gt; &#123;</span><br><span class="line">    <span class="keyword">val</span> criteria = Criteria()</span><br><span class="line">        .apply &#123;</span><br><span class="line">            name?.let &#123; <span class="keyword">this</span>.and(<span class="string">&quot;name&quot;</span>).`<span class="keyword">is</span>`(it) &#125;</span><br><span class="line">            email?.let &#123; <span class="keyword">this</span>.and(<span class="string">&quot;email&quot;</span>).`<span class="keyword">is</span>`(it) &#125;</span><br><span class="line">            memberId?.let &#123; <span class="keyword">this</span>.and(<span class="string">&quot;member_id&quot;</span>).`<span class="keyword">is</span>`(it) &#125;</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">return</span> applySlice(</span><br><span class="line">        pageable = pageable,</span><br><span class="line">        contentQuery = &#123;</span><br><span class="line">            mongoTemplate.find&lt;Member&gt;(it.addCriteria(criteria))</span><br><span class="line">        &#125;</span><br><span class="line">    )</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h4><span id="page-johoe">Page 조회</span><a href="#page-johoe" class="header-anchor">#</a></h4><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">override</span> <span class="function"><span class="keyword">fun</span> <span class="title">findPage</span><span class="params">(</span></span></span><br><span class="line"><span class="params"><span class="function">    pageable: <span class="type">Pageable</span>,</span></span></span><br><span class="line"><span class="params"><span class="function">    name: <span class="type">String</span>?,</span></span></span><br><span class="line"><span class="params"><span class="function">    email: <span class="type">String</span>?,</span></span></span><br><span class="line"><span class="params"><span class="function">    memberId: <span class="type">String</span>?</span></span></span><br><span class="line"><span class="params"><span class="function">)</span></span>: Page&lt;Member&gt; &#123;</span><br><span class="line">    <span class="keyword">val</span> criteria = Criteria().apply &#123;</span><br><span class="line">        name?.let &#123; <span class="keyword">this</span>.and(<span class="string">&quot;name&quot;</span>).`<span class="keyword">is</span>`(it) &#125;</span><br><span class="line">        email?.let &#123; <span class="keyword">this</span>.and(<span class="string">&quot;email&quot;</span>).`<span class="keyword">is</span>`(it) &#125;</span><br><span class="line">        memberId?.let &#123; <span class="keyword">this</span>.and(<span class="string">&quot;member_id&quot;</span>).`<span class="keyword">is</span>`(it) &#125;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">return</span> applyPagination(</span><br><span class="line">        pageable = pageable,</span><br><span class="line">        contentQuery = &#123; mongoTemplate.find&lt;Member&gt;(it.addCriteria(criteria)) &#125;,</span><br><span class="line">        countQuery = &#123; mongoTemplate.count(it.addCriteria(criteria), documentClass) &#125;</span><br><span class="line">    )</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>이 방식은 기본적인 도큐먼트(<code>T</code>) 조회에는 적합 하지만, 프로젝션을 활용한 데이터 조회에는 적용할 수 없는 한계가 있습니다.</p><h2><span id="aggregationeul-hwalyonghan-projection-mic-pagination-hwagjang">Aggregation을 활용한 Projection 및 Pagination 확장</span><a href="#aggregationeul-hwalyonghan-projection-mic-pagination-hwagjang" class="header-anchor">#</a></h2><p>MongoDB에서는 <strong>Aggregation</strong>을 활용하여 특정 필드만 선택하거나, 데이터를 변환하는 <strong>프로젝션</strong> 외에도, <strong>조인(<code>$lookup</code>)</strong>, <strong>그룹화(<code>$group</code>)</strong>, <strong>집계(<code>$count</code>)</strong>, <strong>정렬(<code>$sort</code>)</strong> 등 다양한 작업을 수행할 수 있습니다. 이러한 복잡한 데이터 처리 작업을 효율적으로 다루기 위해서는 <strong>Aggregation</strong> 기반의 페이징을 활용하는 것이 필요합니다. 이 방식은 기존 <code>Query</code> 기반 페이징 처리 방식에 비해 더 유연하고 강력한 쿼리 작성이 가능하며, 복잡한 데이터 변환 및 집계도 손쉽게 처리할 수 있습니다.</p><h3><span id="aggregation-giban-pagination-mic-slice-cusanghwa-kodeu-seolmyeong">Aggregation 기반 Pagination 및 Slice 추상화 코드 설명</span><a href="#aggregation-giban-pagination-mic-slice-cusanghwa-kodeu-seolmyeong" class="header-anchor">#</a></h3><p>이 두 메서드는 MongoDB에서 <strong>Aggregation</strong>을 사용하여 페이징 처리 및 Slice 또는 Page 결과를 반환하는 기능을 제공합니다. 각 메서드는 <strong>Aggregation의 파이프라인</strong>을 동적으로 수정하고, <code>skip</code>과 <code>limit</code>을 추가하여 페이지네이션을 처리합니다.</p><h3><span id="applypaginationaggregation-meseodeu">applyPaginationAggregation 메서드</span><a href="#applypaginationaggregation-meseodeu" class="header-anchor">#</a></h3><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">protected</span> <span class="function"><span class="keyword">fun</span> <span class="type">&lt;S&gt;</span> <span class="title">applyPaginationAggregation</span><span class="params">(</span></span></span><br><span class="line"><span class="params"><span class="function">    pageable: <span class="type">Pageable</span>,</span></span></span><br><span class="line"><span class="params"><span class="function">    contentAggregation: <span class="type">Aggregation</span>,</span></span></span><br><span class="line"><span class="params"><span class="function">    countAggregation: <span class="type">Aggregation</span>,</span></span></span><br><span class="line"><span class="params"><span class="function">    contentQuery: (<span class="type">Aggregation</span>) -&gt; <span class="type">AggregationResults</span>&lt;<span class="type">S</span>&gt;,</span></span></span><br><span class="line"><span class="params"><span class="function">    countQuery: (<span class="type">Aggregation</span>) -&gt; <span class="type">AggregationResults</span>&lt;<span class="type">MongoCount</span>&gt;</span></span></span><br><span class="line"><span class="params"><span class="function">)</span></span>: PageImpl&lt;S&gt; = runBlocking &#123;</span><br><span class="line">    <span class="keyword">val</span> skip = pageable.pageNumber * pageable.pageSize</span><br><span class="line">    <span class="keyword">val</span> limit = pageable.pageSize</span><br><span class="line"></span><br><span class="line">    contentAggregation.pipeline.apply &#123;</span><br><span class="line">        <span class="keyword">this</span>.add(Aggregation.skip(skip.toLong()))</span><br><span class="line">        <span class="keyword">this</span>.add(Aggregation.limit(limit.toLong()))</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    countAggregation.pipeline.apply &#123;</span><br><span class="line">        <span class="keyword">this</span>.add(Aggregation.count().`<span class="keyword">as</span>`(<span class="string">&quot;count&quot;</span>))</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="comment">// Perform queries asynchronously</span></span><br><span class="line">    <span class="keyword">val</span> contentDeferred = async(Dispatchers.IO) &#123; contentQuery(contentAggregation) &#125;</span><br><span class="line">    <span class="keyword">val</span> countDeferred = async(Dispatchers.IO) &#123; countQuery(countAggregation) &#125;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">val</span> content = contentDeferred.await().mappedResults</span><br><span class="line">    <span class="keyword">val</span> totalCount = countDeferred.await().uniqueMappedResult?.count ?: <span class="number">0L</span></span><br><span class="line"></span><br><span class="line">    PageImpl(content, pageable, totalCount)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3><span id="seolmyeong">설명</span><a href="#seolmyeong" class="header-anchor">#</a></h3><ol><li><strong><code>contentAggregation</code></strong>:<ul><li>페이지네이션을 적용하기 위해 <code>skip</code>과 <code>limit</code>을 <code>contentAggregation</code>에 추가합니다. <code>skip</code>은 현재 페이지의 첫 번째 항목부터 건너뛸 수 있도록 하며, <code>limit</code>은 페이지당 보여줄 항목의 개수를 설정합니다.</li></ul></li><li><strong><code>countAggregation</code></strong>:<ul><li>카운트 쿼리를 처리하기 위해 <code>countAggregation</code>에서 <code>$count</code>를 사용하여 총 항목 수를 계산합니다. 이 단계에서는 <code>skip</code>과 <code>limit</code>을 적용하지 않고, 전체 항목 수만 계산합니다.</li></ul></li><li><strong><code>runBlocking</code></strong>:<ul><li>비동기 처리를 위해 <code>runBlocking</code>을 사용하여 <code>contentQuery</code>와 <code>countQuery</code>를 동시에 실행합니다. 이렇게 함으로써 <strong>페이징 처리 쿼리</strong>와 <strong>카운트 쿼리</strong>를 병렬로 실행하여 성능을 최적화합니다.</li></ul></li><li><strong>쿼리 실행</strong>:<ul><li><strong><code>contentQuery(contentAggregation)</code></strong>: <code>contentAggregation</code>을 기반으로 데이터를 조회합니다.</li><li><strong><code>countQuery(countAggregation)</code></strong>: <code>countAggregation</code>을 기반으로 총 개수를 조회합니다.</li></ul></li><li><strong>응답 생성</strong>:<ul><li><strong><code>content</code></strong>: 페이징된 결과 목록.</li><li><strong><code>totalCount</code></strong>: 전체 항목 수.</li><li><code>PageImpl</code> 객체를 생성하여 결과를 반환합니다.</li></ul></li></ol><h3><span id="sayongdoen-mongodb-kweori">사용된 MongoDB 쿼리</span><a href="#sayongdoen-mongodb-kweori" class="header-anchor">#</a></h3><h4><span id="peijing-kweori">페이징 쿼리</span><a href="#peijing-kweori" class="header-anchor">#</a></h4><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line">db.members.aggregate([</span><br><span class="line">  &#123;</span><br><span class="line">    &quot;$match&quot;: &#123;</span><br><span class="line">      &quot;member_id&quot;: &quot;memberId&quot;</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;,</span><br><span class="line">  &#123;</span><br><span class="line">    &quot;$project&quot;: &#123;</span><br><span class="line">      &quot;name&quot;: 1.0,</span><br><span class="line">      &quot;email&quot;: 1.0</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;,</span><br><span class="line">  &#123;</span><br><span class="line">    &quot;$skip&quot;: 0.0</span><br><span class="line">  &#125;,</span><br><span class="line">  &#123;</span><br><span class="line">    &quot;$limit&quot;: 10.0</span><br><span class="line">  &#125;</span><br><span class="line">])</span><br></pre></td></tr></table></figure><h4><span id="kaunteu-kweori">카운트 쿼리</span><a href="#kaunteu-kweori" class="header-anchor">#</a></h4><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line">db.members.aggregate([</span><br><span class="line">  &#123;</span><br><span class="line">    &quot;$match&quot;: &#123;</span><br><span class="line">      &quot;member_id&quot;: &quot;memberId&quot;</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;,</span><br><span class="line">  &#123;</span><br><span class="line">    &quot;$count&quot;: &quot;count&quot;</span><br><span class="line">  &#125;</span><br><span class="line">])</span><br></pre></td></tr></table></figure><p>이 두 쿼리는 각각 페이징 처리된 결과와 전체 항목 수를 계산하는 쿼리입니다. <code>contentAggregation</code>에서는 <code>skip</code>과 <code>limit</code>을 적용하여 데이터를 제한하고, <code>countAggregation</code>에서는 총 항목 수를 계산합니다.</p><h3><span id="applysliceaggregation-meseodeu">applySliceAggregation 메서드</span><a href="#applysliceaggregation-meseodeu" class="header-anchor">#</a></h3><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">protected</span> <span class="function"><span class="keyword">fun</span> <span class="type">&lt;S&gt;</span> <span class="title">applySliceAggregation</span><span class="params">(</span></span></span><br><span class="line"><span class="params"><span class="function">    pageable: <span class="type">Pageable</span>,</span></span></span><br><span class="line"><span class="params"><span class="function">    contentAggregation: <span class="type">Aggregation</span>,</span></span></span><br><span class="line"><span class="params"><span class="function">    contentQuery: (<span class="type">Aggregation</span>) -&gt; <span class="type">AggregationResults</span>&lt;<span class="type">S</span>&gt;</span></span></span><br><span class="line"><span class="params"><span class="function">)</span></span>: Slice&lt;S&gt; &#123;</span><br><span class="line">    <span class="keyword">val</span> skip = pageable.pageNumber * pageable.pageSize</span><br><span class="line">    <span class="keyword">val</span> limit = pageable.pageSize</span><br><span class="line">   contentAggregation.pipeline.apply &#123;</span><br><span class="line">        <span class="keyword">this</span>.add(Aggregation.skip(skip.toLong()))</span><br><span class="line">        <span class="keyword">this</span>.add(Aggregation.limit(limit.toLong()))</span><br><span class="line">    &#125;</span><br><span class="line">   <span class="keyword">val</span> results = contentQuery(contentAggregation)</span><br><span class="line">    <span class="keyword">val</span> content = results.mappedResults</span><br><span class="line">    <span class="keyword">val</span> hasNext = content.size &gt;= pageable.pageSize</span><br><span class="line">    <span class="keyword">return</span> SliceImpl(content, pageable, hasNext)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3><span id="seolmyeong"><strong>설명</strong>:</span><a href="#seolmyeong" class="header-anchor">#</a></h3><ol><li><strong><code>contentAggregation</code></strong>:<ul><li><strong><code>contentAggregation</code></strong> 는 사용자가 제공한 Aggregation 객체입니다. 이 객체에는 <code>$match</code>, <code>$project</code>와 같은 데이터 변환 및 필터링 로직이 포함됩니다.</li><li><code>contentAggregation</code>에 <strong><code>skip</code></strong> 과 <strong><code>limit</code></strong> 을 추가하여 페이징을 처리합니다. 이를 통해 주어진 <code>pageable</code>에 맞게 데이터를 조회할 수 있습니다.</li></ul></li><li><strong><code>contentQuery</code></strong>:<ul><li><code>contentAggregation</code>을 기반으로 데이터를 조회하는 <code>contentQuery</code> 함수입니다. 이 함수는 <code>Aggregation</code>을 받아서 <code>mongoTemplate.aggregate</code>를 사용해 데이터를 가져옵니다.</li></ul></li><li><strong>쿼리 실행</strong>:<ul><li><code>contentQuery(contentAggregation)</code>를 실행하여 페이징된 결과를 가져옵니다.</li><li><code>skip</code>과 <code>limit</code>을 포함한 <code>contentAggregation</code>을 전달하여 데이터를 필터링합니다.</li></ul></li><li><strong>응답 생성</strong>:<ul><li><strong><code>content</code></strong>: 페이징된 결과.</li><li><strong><code>hasNext</code></strong>: <code>content</code>의 크기가 <code>pageable.pageSize</code>보다 크거나 같으면, 더 많은 데이터가 있다는 뜻으로 <code>hasNext</code>를 설정합니다.</li><li><code>SliceImpl</code> 객체를 생성하여 결과를 반환합니다.</li></ul></li></ol><h3><span id="sayongdoen-mongodb-kweori">사용된 MongoDB 쿼리</span><a href="#sayongdoen-mongodb-kweori" class="header-anchor">#</a></h3><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br></pre></td><td class="code"><pre><span class="line">db.members.aggregate([</span><br><span class="line">  &#123;</span><br><span class="line">    &quot;$match&quot;: &#123;</span><br><span class="line">      &quot;name&quot;: &quot;11-name&quot;,</span><br><span class="line">      &quot;email&quot;: &quot;11-asd@asd.com&quot;,</span><br><span class="line">      &quot;member_id&quot;: &quot;memberId&quot;</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;,</span><br><span class="line">  &#123;</span><br><span class="line">    &quot;$project&quot;: &#123;</span><br><span class="line">      &quot;name&quot;: 1.0,</span><br><span class="line">      &quot;email&quot;: 1.0</span><br><span class="line">    &#125;</span><br><span class="line">  &#125;,</span><br><span class="line">  &#123;</span><br><span class="line">    &quot;$skip&quot;: 0.0</span><br><span class="line">  &#125;,</span><br><span class="line">  &#123;</span><br><span class="line">    &quot;$limit&quot;: 10.0</span><br><span class="line">  &#125;</span><br><span class="line">])</span><br></pre></td></tr></table></figure><h3><span id="aggregation-giban-page-mic-slice-johoe-yesi">Aggregation 기반 <code>Page</code> 및 <code>Slice</code> 조회 예시</span><a href="#aggregation-giban-page-mic-slice-johoe-yesi" class="header-anchor">#</a></h3><h4><span id="slice-johoe">Slice 조회</span><a href="#slice-johoe" class="header-anchor">#</a></h4><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">override</span> <span class="function"><span class="keyword">fun</span> <span class="title">findSliceAggregation</span><span class="params">(</span></span></span><br><span class="line"><span class="params"><span class="function">    pageable: <span class="type">Pageable</span>,</span></span></span><br><span class="line"><span class="params"><span class="function">    name: <span class="type">String</span>?,</span></span></span><br><span class="line"><span class="params"><span class="function">    email: <span class="type">String</span>?,</span></span></span><br><span class="line"><span class="params"><span class="function">    memberId: <span class="type">String</span>?</span></span></span><br><span class="line"><span class="params"><span class="function">)</span></span>: Slice&lt;MemberProjection&gt; &#123;</span><br><span class="line">    <span class="keyword">val</span> match = Aggregation.match(</span><br><span class="line">        Criteria().apply &#123;</span><br><span class="line">            name?.let &#123; <span class="keyword">this</span>.and(<span class="string">&quot;name&quot;</span>).`<span class="keyword">is</span>`(it) &#125;</span><br><span class="line">            email?.let &#123; <span class="keyword">this</span>.and(<span class="string">&quot;email&quot;</span>).`<span class="keyword">is</span>`(it) &#125;</span><br><span class="line">            memberId?.let &#123; <span class="keyword">this</span>.and(<span class="string">&quot;member_id&quot;</span>).`<span class="keyword">is</span>`(it) &#125;</span><br><span class="line">        &#125;</span><br><span class="line">    )</span><br><span class="line">    <span class="keyword">val</span> projection = Aggregation.project()</span><br><span class="line">        .andInclude(<span class="string">&quot;name&quot;</span>)</span><br><span class="line">        .andInclude(<span class="string">&quot;email&quot;</span>)</span><br><span class="line"></span><br><span class="line">    <span class="keyword">return</span> <span class="keyword">this</span>.applySliceAggregation(</span><br><span class="line">        pageable = pageable,</span><br><span class="line">       contentAggregation = Aggregation.newAggregation(match, projection),</span><br><span class="line">        contentQuery = &#123; mongoTemplate.aggregate(it, Member.DOCUMENT_NAME, MemberProjection::<span class="keyword">class</span>.java) &#125;</span><br><span class="line">    )</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h4><span id="page-johoe">Page 조회</span><a href="#page-johoe" class="header-anchor">#</a></h4><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">override</span> <span class="function"><span class="keyword">fun</span> <span class="title">findPageAggregation</span><span class="params">(</span></span></span><br><span class="line"><span class="params"><span class="function">    pageable: <span class="type">Pageable</span>,</span></span></span><br><span class="line"><span class="params"><span class="function">    name: <span class="type">String</span>?,</span></span></span><br><span class="line"><span class="params"><span class="function">    email: <span class="type">String</span>?,</span></span></span><br><span class="line"><span class="params"><span class="function">    memberId: <span class="type">String</span>?</span></span></span><br><span class="line"><span class="params"><span class="function">)</span></span>: Page&lt;MemberProjection&gt; &#123;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">val</span> match = Aggregation.match(</span><br><span class="line">        Criteria().apply &#123;</span><br><span class="line">            name?.let &#123; <span class="keyword">this</span>.and(<span class="string">&quot;name&quot;</span>).`<span class="keyword">is</span>`(it) &#125;</span><br><span class="line">            email?.let &#123; <span class="keyword">this</span>.and(<span class="string">&quot;email&quot;</span>).`<span class="keyword">is</span>`(it) &#125;</span><br><span class="line">            memberId?.let &#123; <span class="keyword">this</span>.and(<span class="string">&quot;member_id&quot;</span>).`<span class="keyword">is</span>`(it) &#125;</span><br><span class="line">        &#125;</span><br><span class="line">    )</span><br><span class="line">    <span class="keyword">val</span> projection = Aggregation.project()</span><br><span class="line">        .andInclude(<span class="string">&quot;name&quot;</span>)</span><br><span class="line">        .andInclude(<span class="string">&quot;email&quot;</span>)</span><br><span class="line"></span><br><span class="line">    <span class="keyword">return</span> applyPaginationAggregation(</span><br><span class="line">        pageable = pageable,</span><br><span class="line">        contentAggregation = Aggregation.newAggregation(match, projection),</span><br><span class="line">        countAggregation = Aggregation.newAggregation(match),</span><br><span class="line">        contentQuery = &#123; mongoTemplate.aggregate(it, Member.DOCUMENT_NAME, MemberProjection::<span class="keyword">class</span>.java) &#125;,</span><br><span class="line">        countQuery = &#123; mongoTemplate.aggregate(it, Member.DOCUMENT_NAME, MongoCount::<span class="keyword">class</span>.java) &#125;</span><br><span class="line">    )</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2><span id="gyeolron"><strong>결론</strong></span><a href="#gyeolron" class="header-anchor">#</a></h2><ul><li>기존 <code>Query</code> 기반 페이징에서 <code>T</code> 타입 한계를 벗어나 <strong>Projection을 지원</strong>하도록 확장</li><li><strong><code>Aggregation</code>을 활용한 페이징 처리</strong>로 다양한 데이터 변환 및 성능 최적화 가능</li><li><strong>비동기(<code>async</code>) 처리로 성능 최적화</strong>하며, 코드 재사용성을 높임</li></ul><p>이제 Spring Data MongoDB에서 <strong>확장 가능한 Projection 기반 Pagination을 활용한 Repository를 구축</strong>할 수 있습니다.</p>]]></content:encoded>
      
      
      
      <category domain="https://cheese10yun.github.io/tags/Mongo/">Mongo</category>
      
      <category domain="https://cheese10yun.github.io/tags/Guide/">Guide</category>
      
      
      <comments>https://cheese10yun.github.io/spring-data-mongo-repository-2/#disqus_thread</comments>
      
    </item>
    
    <item>
      <title>Spring Data MongoDB에서의 Update 전략과 경험</title>
      <link>https://cheese10yun.github.io/spring-data-mongo-update-guide-1/</link>
      <guid>https://cheese10yun.github.io/spring-data-mongo-update-guide-1/</guid>
      <pubDate>Fri, 17 Jan 2025 15:00:00 GMT</pubDate>
      
      <description>이 글에서는 Spring Data MongoDB에서 업데이트 전략을 중심으로 개발 경험에서 얻은 인사이트를 공유하며, 각 메서드의 동작 방식과 적절한 사용 방법에 대해 논의합니다.</description>
      
      
      
      <content:encoded><![CDATA[<p>Spring Data MongoDB를 활용한 애플리케이션 개발 과정에서, 데이터를 업데이트하는 방법은 프로젝트의 설계와 성능에 큰 영향을 미칩니다. 특히, <code>mongoRepository.save</code>, <code>mongoTemplate.save</code>, 그리고 <code>mongoTemplate.updateFirst</code>와 같은 메서드들은 각각의 특성과 적합한 상황이 다릅니다. 이 글에서는 Spring Data MongoDB에서 <strong>업데이트 전략</strong>을 중심으로 개발 경험에서 얻은 인사이트를 공유하며, 각 메서드의 동작 방식과 적절한 사용 방법에 대해 논의합니다.</p><h2><span id="update-meseodeu-bigyo">Update 메서드 비교</span><a href="#update-meseodeu-bigyo" class="header-anchor">#</a></h2><p>Spring Data MongoDB에서 사용되는 주요 업데이트 메서드들은 아래와 같이 동작 방식과 적합한 시나리오에서 차이가 있습니다:</p><table><thead><tr><th><strong>특징</strong></th><th><strong>mongoRepository.save</strong></th><th><strong>mongoTemplate.save</strong></th><th><strong>mongoTemplate.updateFirst</strong></th></tr></thead><tbody><tr><td><strong>작업 대상</strong></td><td>단일 문서</td><td>단일 문서</td><td>단일 문서</td></tr><tr><td><strong>저장 방식</strong></td><td>전체 문서 교체</td><td>전체 문서 교체</td><td>변경된 필드만 업데이트</td></tr><tr><td><strong>문서가 없을 경우</strong></td><td>새로 삽입</td><td>새로 삽입</td><td>기본적으로 아무 작업도 수행하지 않음</td></tr><tr><td><strong>업데이트 범위</strong></td><td>전체 문서</td><td>전체 문서</td><td>필드 단위</td></tr><tr><td><strong>조건 지정</strong></td><td><code>_id</code> 기준</td><td><code>_id</code> 기준</td><td>사용자 정의 쿼리</td></tr><tr><td><strong>Spring Data 통합</strong></td><td>페이징, 정렬 등 지원</td><td>미지원</td><td>미지원</td></tr><tr><td><strong>적합한 상황</strong></td><td>간단한 CRUD 작업</td><td>전체 문서 교체 또는 삽입</td><td>조건에 맞는 단일 문서 필드 수정</td></tr></tbody></table><h3><span id="mongotemplate-save">mongoTemplate.save</span><a href="#mongotemplate-save" class="header-anchor">#</a></h3><p>문서 전체 교체(Replace)를 수행합니다.</p><h4><span id="dongjag-bangsig">동작 방식</span><a href="#dongjag-bangsig" class="header-anchor">#</a></h4><ul><li><code>_id</code> 필드를 기준으로 MongoDB에서 문서를 검색.</li><li>문서가 존재하면 <strong>전체 문서를 교체</strong>합니다.</li><li>문서가 존재하지 않으면 새로 삽입합니다.</li><li>저장 객체에 없는 필드는 기존 문서에서 삭제됩니다.</li></ul><h4><span id="yeje">예제</span><a href="#yeje" class="header-anchor">#</a></h4><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">val</span> user = User(id = <span class="string">&quot;123&quot;</span>, name = <span class="string">&quot;John Doe&quot;</span>, age = <span class="number">30</span>)</span><br><span class="line">mongoTemplate.save(user)</span><br></pre></td></tr></table></figure><h4><span id="gyeolgwa">결과</span><a href="#gyeolgwa" class="header-anchor">#</a></h4><ul><li>기존 문서: <code>&#123; &quot;_id&quot;: &quot;123&quot;, &quot;name&quot;: &quot;Alice&quot;, &quot;age&quot;: 25, &quot;email&quot;: &quot;alice@example.com&quot; &#125;</code></li><li>업데이트 후: <code>&#123; &quot;_id&quot;: &quot;123&quot;, &quot;name&quot;: &quot;John Doe&quot;, &quot;age&quot;: 30 &#125;</code></li><li>변경 사항: <code>email</code> 필드가 삭제됨.</li></ul><h3><span id="mongorepository-save">mongoRepository.save</span><a href="#mongorepository-save" class="header-anchor">#</a></h3><p>문서 전체 교체(Replace)를 수행합니다.</p><h4><span id="dongjag-bangsig">동작 방식</span><a href="#dongjag-bangsig" class="header-anchor">#</a></h4><ul><li><code>_id</code> 필드를 기준으로 MongoDB에서 문서를 검색.</li><li>문서가 존재하면 <strong>전체 문서를 교체</strong>합니다.</li><li>문서가 존재하지 않으면 새로 삽입합니다.</li></ul><h4><span id="yeje">예제</span><a href="#yeje" class="header-anchor">#</a></h4><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">val</span> user = User(id = <span class="string">&quot;123&quot;</span>, name = <span class="string">&quot;John Doe&quot;</span>)</span><br><span class="line">userRepository.save(user)</span><br></pre></td></tr></table></figure><h4><span id="gyeolgwa">결과</span><a href="#gyeolgwa" class="header-anchor">#</a></h4><ul><li>기존 문서: <code>&#123; &quot;_id&quot;: &quot;123&quot;, &quot;name&quot;: &quot;Alice&quot;, &quot;age&quot;: 25, &quot;email&quot;: &quot;alice@example.com&quot; &#125;</code></li><li>업데이트 후: <code>&#123; &quot;_id&quot;: &quot;123&quot;, &quot;name&quot;: &quot;John Doe&quot;, &quot;age&quot;: 25, &quot;email&quot;: &quot;alice@example.com&quot; &#125;</code></li><li>변경 사항: <code>name</code> 필드만 업데이트, 나머지 필드는 유지됨.</li></ul><h3><span id="mongotemplate-updatefirst">mongoTemplate.updateFirst</span><a href="#mongotemplate-updatefirst" class="header-anchor">#</a></h3><p>MongoDB의 <strong><code>updateFirst</code></strong> 명령어를 실행하여 <strong>단일 문서를 부분 업데이트</strong>합니다.</p><h4><span id="dongjag-bangsig">동작 방식</span><a href="#dongjag-bangsig" class="header-anchor">#</a></h4><ul><li>조건을 지정하여 MongoDB에서 문서를 검색.</li><li>첫 번째로 매칭된 문서의 <strong>일부 필드만 업데이트</strong>합니다.</li><li>문서가 존재하지 않으면 기본적으로 아무 작업도 수행하지 않습니다(삽입하지 않음).</li><li><code>$set</code>과 같은 MongoDB 연산자를 사용하여 지정된 필드만 업데이트합니다.</li></ul><h4><span id="yeje">예제</span><a href="#yeje" class="header-anchor">#</a></h4><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">val</span> query = Query(Criteria.<span class="keyword">where</span>(<span class="string">&quot;name&quot;</span>).`<span class="keyword">is</span>`(<span class="string">&quot;Alice&quot;</span>))</span><br><span class="line"><span class="keyword">val</span> update = Update().<span class="keyword">set</span>(<span class="string">&quot;age&quot;</span>, <span class="number">30</span>)</span><br><span class="line">mongoTemplate.updateFirst(query, update, User::<span class="keyword">class</span>.java)</span><br></pre></td></tr></table></figure><h4><span id="gyeolgwa">결과</span><a href="#gyeolgwa" class="header-anchor">#</a></h4><ul><li>기존 문서: <code>&#123; &quot;_id&quot;: &quot;123&quot;, &quot;name&quot;: &quot;Alice&quot;, &quot;age&quot;: 25, &quot;email&quot;: &quot;alice@example.com&quot; &#125;</code></li><li>업데이트 후: <code>&#123; &quot;_id&quot;: &quot;123&quot;, &quot;name&quot;: &quot;Alice&quot;, &quot;age&quot;: 30, &quot;email&quot;: &quot;alice@example.com&quot; &#125;</code></li><li>변경 사항: <code>age</code> 필드만 업데이트, 나머지 필드는 유지됨.</li></ul><h2><span id="hyoyuljeogin-mongodb-eobdeiteu-jeonryag">효율적인 MongoDB 업데이트 전략</span><a href="#hyoyuljeogin-mongodb-eobdeiteu-jeonryag" class="header-anchor">#</a></h2><p><code>mongoTemplate.save</code>는 문서 전체를 교체하기 때문에 일반적인 경우에는 거의 사용되지 않습니다. 반면, <code>mongoRepository.save</code>는 더 직관적이며, 특히 Spring Data JPA 경험이 있는 개발자에게는 익숙하고 이해하기 쉬운 방식입니다. 그럼에도 불구하고, 저는 업데이트 작업에 <code>mongoTemplate</code>기반의 업데이트만을 사용하고 있습니다. 그 이유는 다음과 같습니다.</p><h3><span id="daeryang-ceorieseoyi-seongneung-cai">대량 처리에서의 성능 차이</span><a href="#daeryang-ceorieseoyi-seongneung-cai" class="header-anchor">#</a></h3><p><a href="https://cheese10yun.github.io/spring-data-mongodb-update-performance/">MongoDB Update 성능 측정 및 분석</a>에서 업데이트 성능을 측정한 결과를 참고할 수 있습니다.</p><p><img src="https://raw.githubusercontent.com/cheese10yun/blog-sample/5fc6127a0800ca9bce5de5a6c73931b2025b0791/mongo-study/images/performance-update.png" alt></p><table><thead><tr><th><strong>Rows</strong></th><th><strong>saveAll</strong></th><th><strong>updateFirst</strong></th><th><strong>bulkOps (UNORDERED)</strong></th><th><strong>bulkOps (ORDERED)</strong></th></tr></thead><tbody><tr><td>100</td><td>1,052 ms</td><td>1,176 ms</td><td>46 ms</td><td>79 ms</td></tr><tr><td>200</td><td>2,304 ms</td><td>2,196 ms</td><td>103 ms</td><td>124 ms</td></tr><tr><td>500</td><td>5,658 ms</td><td>5,250 ms</td><td>309 ms</td><td>257 ms</td></tr><tr><td>1,000</td><td>11,106 ms</td><td>10,846 ms</td><td>418 ms</td><td>412 ms</td></tr><tr><td>2,000</td><td>22,592 ms</td><td>21,427 ms</td><td>1,060 ms</td><td>1,004 ms</td></tr><tr><td>5,000</td><td>54,407 ms</td><td>52,075 ms</td><td>2,663 ms</td><td>2,292 ms</td></tr><tr><td>10,000</td><td>107,651 ms</td><td>110,884 ms</td><td>4,514 ms</td><td>4,496 ms</td></tr></tbody></table><p><code>saveAll</code> 방식은 각 문서의 <code>_id</code>를 확인하여 데이터베이스에 해당 문서가 존재하는지 판단합니다. 존재하는 경우에는 이를 업데이트로 인식하고, 내부적으로 반복문을 돌면서 <code>save</code>를 개별적으로 호출합니다. 이로 인해 각 문서에 대해 별도의 데이터베이스 요청이 발생하며, 처리 성능이 문서 수에 비례하여 저하될 수 있습니다. 특히, 요청 수가 많아질수록 이러한 방식은 응답 시간이 급격히 증가하는 원인이 됩니다.</p><p>반면, <code>bulkOps</code>는 여러 업데이트 작업을 한 번의 연산으로 묶어서 실행하므로 대량 처리에서 훨씬 효율적입니다. 이를 통해 처리 시간을 크게 단축할 수 있지만, <code>save</code>와 <code>saveAll</code> 방식으로는 <code>bulkOps</code>를 활용할 수 없다는 한계가 있습니다. 이러한 이유로 저는 대량 처리 작업에서 <code>updateFirst</code>와 함께 <code>bulkOps</code>를 활용하는 방식을 선호합니다.</p><p>또한, 대량 데이터를 업데이트할 때 <strong><code>where in</code> 절</strong>을 활용하면 효과적입니다. 이 경우, <code>mongoTemplate.updateMulti</code>를 사용하면 <code>bulkOps</code> 방식과 유사한 성능을 얻을 수 있습니다. <code>saveAll</code>을 사용하면 성능이 급격히 저하되므로, 대량 데이터를 업데이트할 때는 반드시 <code>mongoTemplate</code>을 사용하는 것이 좋습니다. 이러한 접근 방식은 대량 처리의 효율성과 성능 최적화를 보장하며, 대량 데이터를 다루는 애플리케이션에서 더욱 유용합니다.</p><h3><span id="myeonghwaghan-byeongyeong-sahang-cujeog">명확한 변경 사항 추적</span><a href="#myeonghwaghan-byeongyeong-sahang-cujeog" class="header-anchor">#</a></h3><p><code>mongoRepository.save</code>를 사용하여 데이터를 업데이트할 경우, 정확히 어떤 필드가 변경되었는지 추적하기 어렵습니다. MongoDB는 비정형 데이터베이스로, 다양한 필드와 그 필드들이 다루는 컨텍스트가 매우 다양합니다. 이런 상황에서 <code>mongoRepository.save</code>를 통해 업데이트가 이루어지면, 어떤 필드가 어떤 조건에서 업데이트되었는지 명확히 파악하기 어렵기 때문에 데이터 변경 사항을 추적하고 관리하는 데 어려움이 발생할 수 있습니다.</p><p>반면, <code>mongoTemplate</code>을 기반으로 업데이트 쿼리를 작성하면 특정 필드에 대해 명확히 정의된 업데이트를 수행할 수 있습니다. 각 업데이트가 어디에서 이루어졌는지, 어떤 필드가 변경되었는지를 코드 레벨에서 명확히 확인할 수 있어 추적이 용이합니다. 특히 프로젝트가 복잡해지거나 엄격한 변경 관리가 요구될수록, 이러한 명확성은 유지보수와 협업 측면에서 큰 장점으로 작용합니다. 이를 통해 데이터 업데이트의 불확실성을 줄이고, 코드의 가독성과 신뢰성을 높일 수 있습니다.</p><h2><span id="silje-sayong-yesi">실제 사용 예시</span><a href="#silje-sayong-yesi" class="header-anchor">#</a></h2><h3><span id="document-jeongyi">Document 정의</span><a href="#document-jeongyi" class="header-anchor">#</a></h3><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Document(collection = <span class="string">&quot;members&quot;</span>)</span></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">Member</span>(</span><br><span class="line">    <span class="meta">@Field(name = <span class="string">&quot;name&quot;</span>)</span></span><br><span class="line">    <span class="keyword">val</span> name: String,</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Field(name = <span class="string">&quot;address&quot;</span>)</span></span><br><span class="line">    <span class="keyword">val</span> address: Address,</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Field(name = <span class="string">&quot;member_id&quot;</span>)</span></span><br><span class="line">    <span class="keyword">val</span> memberId: String,</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Field(name = <span class="string">&quot;email&quot;</span>)</span></span><br><span class="line">    <span class="keyword">val</span> email: String,</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Field(name = <span class="string">&quot;status&quot;</span>)</span></span><br><span class="line">    <span class="keyword">val</span> status: MemberStatus</span><br><span class="line">) : Auditable()</span><br></pre></td></tr></table></figure><p>위 예시와 같이 <code>Member</code> 도큐먼트가 정의되어 있다고 가정하겠습니다. 이 도큐먼트는 <code>MongoRepository</code>를 사용하여 업데이트하지 않기 때문에, 필드들이 <code>val</code>로 지정되어 있습니다. 필드를 <code>val</code>로 지정하면 도큐먼트의 특정 필드를 변경하기 위해 객체를 직접 수정한 뒤 <code>save</code>를 호출하는 방식이 불가능합니다. 이렇게 필드를 <code>val</code>로 지정하면 도큐먼트의 불변성을 보장하며, 특정 필드의 변경을 엄격히 관리할 수 있습니다.</p><h3><span id="repository-jeongyi">Repository 정의</span><a href="#repository-jeongyi" class="header-anchor">#</a></h3><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">interface</span> <span class="title class_">MemberRepository</span> : <span class="type">MongoRepository</span>&lt;<span class="type">Member, ObjectId</span>&gt;, <span class="type">MemberCustomRepository</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">interface</span> <span class="title class_">MemberCustomRepository</span> &#123;</span><br><span class="line">    <span class="function"><span class="keyword">fun</span> <span class="title">updateName</span><span class="params">(targets: <span class="type">List</span>&lt;<span class="type">MemberQueryForm</span>.<span class="type">UpdateName</span>&gt;)</span></span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">MemberCustomRepositoryImpl</span>(mongoTemplate: MongoTemplate) : MemberCustomRepository, MongoCustomRepositorySupport&lt;Member&gt;(Member::<span class="keyword">class</span>.java, mongoTemplate) &#123;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">override</span> <span class="function"><span class="keyword">fun</span> <span class="title">updateName</span><span class="params">(targets: <span class="type">List</span>&lt;<span class="type">MemberQueryForm</span>.<span class="type">UpdateName</span>&gt;)</span></span> &#123;</span><br><span class="line">        bulkUpdate(</span><br><span class="line">            targets.map &#123;</span><br><span class="line">                Pair(</span><br><span class="line">                    &#123; Query(Criteria.<span class="keyword">where</span>(<span class="string">&quot;id&quot;</span>).`<span class="keyword">is</span>`(it.id)) &#125;,</span><br><span class="line">                    &#123; Update().<span class="keyword">set</span>(<span class="string">&quot;name&quot;</span>, it.name) &#125;</span><br><span class="line">                )</span><br><span class="line">            &#125;</span><br><span class="line">        )</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>MongoCustomRepositorySupport</code>를 상속받아 <code>bulkUpdate</code> 메서드를 통해 <code>bulkOps</code>를 사용한 대량 업데이트를 수행합니다. 이를 활용하면 대량 데이터를 효율적으로 처리할 수 있으며, 단일 업데이트만 필요한 경우 <code>updateFirst</code>를 사용하여 업데이트를 수행할 수도 있습니다. 그러나 특별한 이유가 없다면 <code>MongoCustomRepositorySupport</code> 기반으로 대량 업데이트를 지원하는 <code>bulkUpdate</code>를 사용하는 것을 권장합니다.</p><p>이 방식에 대한 자세한 구현 방법은 이전 포스팅 <a href="https://cheese10yun.github.io/spring-data-mongodb-update-performance/">MongoDB Update 성능 측정 및 분석 - MongoCustomRepositorySupport을 통한 bulkOps 기능 제공</a>에서 확인할 수 있습니다.</p><h3><span id="eobdeiteu-kweorie-sayonghal-gaegce-jeongyi">업데이트 쿼리에 사용할 객체 정의</span><a href="#eobdeiteu-kweorie-sayonghal-gaegce-jeongyi" class="header-anchor">#</a></h3><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">object</span> MemberQueryForm &#123;</span><br><span class="line">    <span class="keyword">data</span> <span class="keyword">class</span> <span class="title class_">UpdateName</span>(</span><br><span class="line">        <span class="keyword">val</span> id: ObjectId,</span><br><span class="line">        <span class="keyword">val</span> name: String</span><br><span class="line">    )</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>MemberQueryForm</code> 객체를 정의하여 쿼리에 필요한 필드와 데이터를 명확하게 관리합니다. 이를 통해 업데이트 작업에서 어떤 필드가 업데이트되는지 명확히 파악할 수 있습니다. 만약 <code>MemberQueryForm</code>에 정의되지 않은 필드가 있다면, 해당 필드는 현재 업데이트 대상이 아니거나 정책적으로 업데이트되지 않는 필드라고 간주할 수 있습니다.</p><h3><span id="teseuteu-kodeu-yesi">테스트 코드 예시</span><a href="#teseuteu-kodeu-yesi" class="header-anchor">#</a></h3><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@MongoTestSupport</span></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">MemberRepositoryTest</span>(</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">val</span> memberRepository: MemberRepository</span><br><span class="line">) : MongoStudyApplicationTests() &#123;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Test</span></span><br><span class="line">    <span class="function"><span class="keyword">fun</span> `updateName test`<span class="params">()</span></span> &#123;</span><br><span class="line">        <span class="comment">// given</span></span><br><span class="line">        <span class="keyword">val</span> members = (<span class="number">1.</span><span class="number">.20</span>).map &#123;</span><br><span class="line">            Member(</span><br><span class="line">                name = <span class="string">&quot;name&quot;</span>,</span><br><span class="line">                ...</span><br><span class="line">            )</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="keyword">val</span> targets = mongoTemplate</span><br><span class="line">            .insertAll(members).map &#123;</span><br><span class="line">                MemberQueryForm.UpdateName(</span><br><span class="line">                    id = it.id!!,</span><br><span class="line">                    name = <span class="string">&quot;newName&quot;</span></span><br><span class="line">                )</span><br><span class="line">            &#125;</span><br><span class="line"></span><br><span class="line">        <span class="comment">// when</span></span><br><span class="line">        memberRepository.updateName(targets)</span><br><span class="line"></span><br><span class="line">        <span class="comment">// then</span></span><br><span class="line">        <span class="keyword">val</span> results = mongoTemplate.findAll&lt;Member&gt;()</span><br><span class="line"></span><br><span class="line">        then(results).hasSize(<span class="number">20</span>)</span><br><span class="line">        then(results).allSatisfy &#123;</span><br><span class="line">            then(it.name).isEqualTo(<span class="string">&quot;newName&quot;</span>)</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>해당 테스트 코드는 <code>MemberRepository</code>의 <code>updateName</code> 메서드를 검증합니다.</p><ol><li>먼저 <code>Member</code> 객체를 생성하고 MongoDB에 저장한 뒤, 저장된 데이터를 조회하여 <code>UpdateName</code> 객체를 생성합니다.</li><li>이후 <code>updateName</code> 메서드를 호출하여 업데이트를 수행합니다.</li><li>마지막으로 MongoDB에서 데이터를 다시 조회해, 업데이트가 성공적으로 이루어졌는지 확인합니다.</li></ol><p>이처럼 <code>MemberQueryForm</code> 객체를 사용해 업데이트 대상 필드를 명확히 정의함으로써, 변경 작업의 범위를 명확히 관리하고 추적할 수 있습니다. 테스트 코드 역시 이러한 명확성을 기반으로 업데이트 로직을 확인하도록 작성되었습니다.</p><h2><span id="jeongri">정리</span><a href="#jeongri" class="header-anchor">#</a></h2><p>제가 담당하는 도메인은 특정 필드마다 업데이트 권한이 다르게 설정되어 있어, 업데이트 필드를 보다 명확하고 엄격하게 관리해야 하는 상황입니다. 또한, 대량의 데이터를 처리해야 하며, 빠른 처리를 보장해야 하는 요구사항도 있습니다. 이러한 이유로, 위에서 설명한 방식의 업데이트 전략을 선택했습니다. 각자의 상황과 요구사항에 맞는 적절한 방법을 선택하는 것이 가장 중요합니다.</p>]]></content:encoded>
      
      
      
      <category domain="https://cheese10yun.github.io/tags/Mongo/">Mongo</category>
      
      <category domain="https://cheese10yun.github.io/tags/Performance/">Performance</category>
      
      
      <comments>https://cheese10yun.github.io/spring-data-mongo-update-guide-1/#disqus_thread</comments>
      
    </item>
    
    <item>
      <title>Hikari와 비교하며 알아보는 Redis Lettuce 커넥션 풀의 특징</title>
      <link>https://cheese10yun.github.io/redis-lettuce-connection/</link>
      <guid>https://cheese10yun.github.io/redis-lettuce-connection/</guid>
      <pubDate>Sun, 17 Nov 2024 07:13:12 GMT</pubDate>
      
      <description>Redis의 싱글 스레드 특성에도 불구하고, Lettuce 커넥션 풀은 비동기 및 논블로킹 방식을 통해 빠른 응답과 효율적인 자원 관리를 구현하는 방법을 Hikari와 비교하여 설명합니다.</description>
      
      
      
      <content:encoded><![CDATA[<p>Redis는 싱글 스레드로 동작하는 인메모리 데이터베이스로, 모든 요청을 순차적으로 처리합니다. 이러한 특성 때문에 많은 개발자들은 Redis 클라이언트인 Lettuce에서 제공하는 <strong>커넥션 풀의 필요성과 이점</strong>에 대해 의문을 가질 수 있습니다. 특히, &quot;<strong>Redis는 싱글 스레드로 동작하기 때문에 여러 개의 커넥션을 사용해도 동시성을 처리할 수 없을 텐데, 이런 커넥션 풀이 무슨 의미가 있을까?</strong>&quot;라는 생각을 할 수 있습니다.</p><p>Redis 클라이언트에서 커넥션 풀을 사용하는 이유는 서버의 동작 방식과 클라이언트 애플리케이션에서의 요구사항이 다르기 때문입니다. Redis는 서버 레벨에서 요청을 순차적으로 처리하지만, 클라이언트 애플리케이션은 <strong>동시성과 성능을 최적화하기 위해 비동기 및 넌블로킹 I/O를 지원하는 Lettuce</strong>와 같은 클라이언트를 통해 여러 요청을 효과적으로 관리합니다. Lettuce는 이러한 특성을 활용해 적은 수의 커넥션으로도 높은 효율을 발휘할 수 있도록 설계되었습니다.</p><p>이번 글에서는 Hikari Connection Pool과 비교하며 Lettuce 커넥션 풀이 애플리케이션 성능에 미치는 영향을 실제 사례를 통해 구체적으로 살펴보겠습니다.</p><h2><span id="jdbc-hikari-connection-poolyi-dongjag-bangsig">JDBC Hikari Connection Pool의 동작 방식</span><a href="#jdbc-hikari-connection-poolyi-dongjag-bangsig" class="header-anchor">#</a></h2><p>Redis Lettuce 커넥션 풀의 역할을 이해하기 위해, 먼저 전통적인 데이터베이스 커넥션 풀의 대표적인 예인 <strong>JDBC Hikari Connection Pool</strong>을 살펴보겠습니다.</p><img class="kroki" src="data:image/svg+xml;base64,PHN2ZyBpZD0iY29udGFpbmVyIiB3aWR0aD0iNTU4IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjY3IiB2aWV3Qm94PSItNTAgLTEwIDU1OCAyNjciIHJvbGU9ImdyYXBoaWNzLWRvY3VtZW50IGRvY3VtZW50IiBhcmlhLXJvbGVkZXNjcmlwdGlvbj0ic2VxdWVuY2UiPjxnPjxyZWN0IHg9IjMwOCIgeT0iMTgxIiBmaWxsPSIjZWFlYWVhIiBzdHJva2U9IiM2NjYiIHdpZHRoPSIxNTAiIGhlaWdodD0iNjUiIG5hbWU9Ik15U1FMIiByeD0iMyIgcnk9IjMiIGNsYXNzPSJhY3RvciBhY3Rvci1ib3R0b20iPjwvcmVjdD48dGV4dCB4PSIzODMiIHk9IjIxMy41IiBkb21pbmFudC1iYXNlbGluZT0iY2VudHJhbCIgYWxpZ25tZW50LWJhc2VsaW5lPSJjZW50cmFsIiBjbGFzcz0iYWN0b3IgYWN0b3ItYm94IiBzdHlsZT0idGV4dC1hbmNob3I6IG1pZGRsZTsgZm9udC1zaXplOiAxNnB4OyBmb250LXdlaWdodDogNDAwOyI+PHRzcGFuIHg9IjM4MyIgZHk9IjAiPk15U1FMPC90c3Bhbj48L3RleHQ+PC9nPjxnPjxyZWN0IHg9IjAiIHk9IjE4MSIgZmlsbD0iI2VhZWFlYSIgc3Ryb2tlPSIjNjY2IiB3aWR0aD0iMTUwIiBoZWlnaHQ9IjY1IiBuYW1lPSJTZXJ2ZXIiIHJ4PSIzIiByeT0iMyIgY2xhc3M9ImFjdG9yIGFjdG9yLWJvdHRvbSI+PC9yZWN0Pjx0ZXh0IHg9Ijc1IiB5PSIyMTMuNSIgZG9taW5hbnQtYmFzZWxpbmU9ImNlbnRyYWwiIGFsaWdubWVudC1iYXNlbGluZT0iY2VudHJhbCIgY2xhc3M9ImFjdG9yIGFjdG9yLWJveCIgc3R5bGU9InRleHQtYW5jaG9yOiBtaWRkbGU7IGZvbnQtc2l6ZTogMTZweDsgZm9udC13ZWlnaHQ6IDQwMDsiPjx0c3BhbiB4PSI3NSIgZHk9IjAiPlNlcnZlcjwvdHNwYW4+PC90ZXh0PjwvZz48Zz48bGluZSBpZD0iYWN0b3IxIiB4MT0iMzgzIiB5MT0iNjUiIHgyPSIzODMiIHkyPSIxODEiIGNsYXNzPSJhY3Rvci1saW5lIDIwMCIgc3Ryb2tlLXdpZHRoPSIwLjVweCIgc3Ryb2tlPSIjOTk5IiBuYW1lPSJNeVNRTCI+PC9saW5lPjxnIGlkPSJyb290LTEiPjxyZWN0IHg9IjMwOCIgeT0iMCIgZmlsbD0iI2VhZWFlYSIgc3Ryb2tlPSIjNjY2IiB3aWR0aD0iMTUwIiBoZWlnaHQ9IjY1IiBuYW1lPSJNeVNRTCIgcng9IjMiIHJ5PSIzIiBjbGFzcz0iYWN0b3IgYWN0b3ItdG9wIj48L3JlY3Q+PHRleHQgeD0iMzgzIiB5PSIzMi41IiBkb21pbmFudC1iYXNlbGluZT0iY2VudHJhbCIgYWxpZ25tZW50LWJhc2VsaW5lPSJjZW50cmFsIiBjbGFzcz0iYWN0b3IgYWN0b3ItYm94IiBzdHlsZT0idGV4dC1hbmNob3I6IG1pZGRsZTsgZm9udC1zaXplOiAxNnB4OyBmb250LXdlaWdodDogNDAwOyI+PHRzcGFuIHg9IjM4MyIgZHk9IjAiPk15U1FMPC90c3Bhbj48L3RleHQ+PC9nPjwvZz48Zz48bGluZSBpZD0iYWN0b3IwIiB4MT0iNzUiIHkxPSI2NSIgeDI9Ijc1IiB5Mj0iMTgxIiBjbGFzcz0iYWN0b3ItbGluZSAyMDAiIHN0cm9rZS13aWR0aD0iMC41cHgiIHN0cm9rZT0iIzk5OSIgbmFtZT0iU2VydmVyIj48L2xpbmU+PGcgaWQ9InJvb3QtMCI+PHJlY3QgeD0iMCIgeT0iMCIgZmlsbD0iI2VhZWFlYSIgc3Ryb2tlPSIjNjY2IiB3aWR0aD0iMTUwIiBoZWlnaHQ9IjY1IiBuYW1lPSJTZXJ2ZXIiIHJ4PSIzIiByeT0iMyIgY2xhc3M9ImFjdG9yIGFjdG9yLXRvcCI+PC9yZWN0Pjx0ZXh0IHg9Ijc1IiB5PSIzMi41IiBkb21pbmFudC1iYXNlbGluZT0iY2VudHJhbCIgYWxpZ25tZW50LWJhc2VsaW5lPSJjZW50cmFsIiBjbGFzcz0iYWN0b3IgYWN0b3ItYm94IiBzdHlsZT0idGV4dC1hbmNob3I6IG1pZGRsZTsgZm9udC1zaXplOiAxNnB4OyBmb250LXdlaWdodDogNDAwOyI+PHRzcGFuIHg9Ijc1IiBkeT0iMCI+U2VydmVyPC90c3Bhbj48L3RleHQ+PC9nPjwvZz48c3R5bGU+I2NvbnRhaW5lcntmb250LWZhbWlseToidHJlYnVjaGV0IG1zIix2ZXJkYW5hLGFyaWFsLHNhbnMtc2VyaWY7Zm9udC1zaXplOjE2cHg7ZmlsbDojMzMzO31Aa2V5ZnJhbWVzIGVkZ2UtYW5pbWF0aW9uLWZyYW1le2Zyb217c3Ryb2tlLWRhc2hvZmZzZXQ6MDt9fUBrZXlmcmFtZXMgZGFzaHt0b3tzdHJva2UtZGFzaG9mZnNldDowO319I2NvbnRhaW5lciAuZWRnZS1hbmltYXRpb24tc2xvd3tzdHJva2UtZGFzaGFycmF5OjksNSFpbXBvcnRhbnQ7c3Ryb2tlLWRhc2hvZmZzZXQ6OTAwO2FuaW1hdGlvbjpkYXNoIDUwcyBsaW5lYXIgaW5maW5pdGU7c3Ryb2tlLWxpbmVjYXA6cm91bmQ7fSNjb250YWluZXIgLmVkZ2UtYW5pbWF0aW9uLWZhc3R7c3Ryb2tlLWRhc2hhcnJheTo5LDUhaW1wb3J0YW50O3N0cm9rZS1kYXNob2Zmc2V0OjkwMDthbmltYXRpb246ZGFzaCAyMHMgbGluZWFyIGluZmluaXRlO3N0cm9rZS1saW5lY2FwOnJvdW5kO30jY29udGFpbmVyIC5lcnJvci1pY29ue2ZpbGw6IzU1MjIyMjt9I2NvbnRhaW5lciAuZXJyb3ItdGV4dHtmaWxsOiM1NTIyMjI7c3Ryb2tlOiM1NTIyMjI7fSNjb250YWluZXIgLmVkZ2UtdGhpY2tuZXNzLW5vcm1hbHtzdHJva2Utd2lkdGg6MXB4O30jY29udGFpbmVyIC5lZGdlLXRoaWNrbmVzcy10aGlja3tzdHJva2Utd2lkdGg6My41cHg7fSNjb250YWluZXIgLmVkZ2UtcGF0dGVybi1zb2xpZHtzdHJva2UtZGFzaGFycmF5OjA7fSNjb250YWluZXIgLmVkZ2UtdGhpY2tuZXNzLWludmlzaWJsZXtzdHJva2Utd2lkdGg6MDtmaWxsOm5vbmU7fSNjb250YWluZXIgLmVkZ2UtcGF0dGVybi1kYXNoZWR7c3Ryb2tlLWRhc2hhcnJheTozO30jY29udGFpbmVyIC5lZGdlLXBhdHRlcm4tZG90dGVke3N0cm9rZS1kYXNoYXJyYXk6Mjt9I2NvbnRhaW5lciAubWFya2Vye2ZpbGw6IzMzMzMzMztzdHJva2U6IzMzMzMzMzt9I2NvbnRhaW5lciAubWFya2VyLmNyb3Nze3N0cm9rZTojMzMzMzMzO30jY29udGFpbmVyIHN2Z3tmb250LWZhbWlseToidHJlYnVjaGV0IG1zIix2ZXJkYW5hLGFyaWFsLHNhbnMtc2VyaWY7Zm9udC1zaXplOjE2cHg7fSNjb250YWluZXIgcHttYXJnaW46MDt9I2NvbnRhaW5lciAuYWN0b3J7c3Ryb2tlOmhzbCgyNTkuNjI2MTY4MjI0MywgNTkuNzc2NTM2MzEyOCUsIDg3LjkwMTk2MDc4NDMlKTtmaWxsOiNFQ0VDRkY7fSNjb250YWluZXIgdGV4dC5hY3RvciZndDt0c3BhbntmaWxsOmJsYWNrO3N0cm9rZTpub25lO30jY29udGFpbmVyIC5hY3Rvci1saW5le3N0cm9rZTpoc2woMjU5LjYyNjE2ODIyNDMsIDU5Ljc3NjUzNjMxMjglLCA4Ny45MDE5NjA3ODQzJSk7fSNjb250YWluZXIgLmlubmVyQXJje3N0cm9rZS13aWR0aDoxLjU7c3Ryb2tlLWRhc2hhcnJheTpub25lO30jY29udGFpbmVyIC5tZXNzYWdlTGluZTB7c3Ryb2tlLXdpZHRoOjEuNTtzdHJva2UtZGFzaGFycmF5Om5vbmU7c3Ryb2tlOiMzMzM7fSNjb250YWluZXIgLm1lc3NhZ2VMaW5lMXtzdHJva2Utd2lkdGg6MS41O3N0cm9rZS1kYXNoYXJyYXk6MiwyO3N0cm9rZTojMzMzO30jY29udGFpbmVyICNhcnJvd2hlYWQgcGF0aHtmaWxsOiMzMzM7c3Ryb2tlOiMzMzM7fSNjb250YWluZXIgLnNlcXVlbmNlTnVtYmVye2ZpbGw6d2hpdGU7fSNjb250YWluZXIgI3NlcXVlbmNlbnVtYmVye2ZpbGw6IzMzMzt9I2NvbnRhaW5lciAjY3Jvc3NoZWFkIHBhdGh7ZmlsbDojMzMzO3N0cm9rZTojMzMzO30jY29udGFpbmVyIC5tZXNzYWdlVGV4dHtmaWxsOiMzMzM7c3Ryb2tlOm5vbmU7fSNjb250YWluZXIgLmxhYmVsQm94e3N0cm9rZTpoc2woMjU5LjYyNjE2ODIyNDMsIDU5Ljc3NjUzNjMxMjglLCA4Ny45MDE5NjA3ODQzJSk7ZmlsbDojRUNFQ0ZGO30jY29udGFpbmVyIC5sYWJlbFRleHQsI2NvbnRhaW5lciAubGFiZWxUZXh0Jmd0O3RzcGFue2ZpbGw6YmxhY2s7c3Ryb2tlOm5vbmU7fSNjb250YWluZXIgLmxvb3BUZXh0LCNjb250YWluZXIgLmxvb3BUZXh0Jmd0O3RzcGFue2ZpbGw6YmxhY2s7c3Ryb2tlOm5vbmU7fSNjb250YWluZXIgLmxvb3BMaW5le3N0cm9rZS13aWR0aDoycHg7c3Ryb2tlLWRhc2hhcnJheToyLDI7c3Ryb2tlOmhzbCgyNTkuNjI2MTY4MjI0MywgNTkuNzc2NTM2MzEyOCUsIDg3LjkwMTk2MDc4NDMlKTtmaWxsOmhzbCgyNTkuNjI2MTY4MjI0MywgNTkuNzc2NTM2MzEyOCUsIDg3LjkwMTk2MDc4NDMlKTt9I2NvbnRhaW5lciAubm90ZXtzdHJva2U6I2FhYWEzMztmaWxsOiNmZmY1YWQ7fSNjb250YWluZXIgLm5vdGVUZXh0LCNjb250YWluZXIgLm5vdGVUZXh0Jmd0O3RzcGFue2ZpbGw6YmxhY2s7c3Ryb2tlOm5vbmU7fSNjb250YWluZXIgLmFjdGl2YXRpb24we2ZpbGw6I2Y0ZjRmNDtzdHJva2U6IzY2Njt9I2NvbnRhaW5lciAuYWN0aXZhdGlvbjF7ZmlsbDojZjRmNGY0O3N0cm9rZTojNjY2O30jY29udGFpbmVyIC5hY3RpdmF0aW9uMntmaWxsOiNmNGY0ZjQ7c3Ryb2tlOiM2NjY7fSNjb250YWluZXIgLmFjdG9yUG9wdXBNZW51e3Bvc2l0aW9uOmFic29sdXRlO30jY29udGFpbmVyIC5hY3RvclBvcHVwTWVudVBhbmVse3Bvc2l0aW9uOmFic29sdXRlO2ZpbGw6I0VDRUNGRjtib3gtc2hhZG93OjBweCA4cHggMTZweCAwcHggcmdiYSgwLDAsMCwwLjIpO2ZpbHRlcjpkcm9wLXNoYWRvdygzcHggNXB4IDJweCByZ2IoMCAwIDAgLyAwLjQpKTt9I2NvbnRhaW5lciAuYWN0b3ItbWFuIGxpbmV7c3Ryb2tlOmhzbCgyNTkuNjI2MTY4MjI0MywgNTkuNzc2NTM2MzEyOCUsIDg3LjkwMTk2MDc4NDMlKTtmaWxsOiNFQ0VDRkY7fSNjb250YWluZXIgLmFjdG9yLW1hbiBjaXJjbGUsI2NvbnRhaW5lciBsaW5le3N0cm9rZTpoc2woMjU5LjYyNjE2ODIyNDMsIDU5Ljc3NjUzNjMxMjglLCA4Ny45MDE5NjA3ODQzJSk7ZmlsbDojRUNFQ0ZGO3N0cm9rZS13aWR0aDoycHg7fSNjb250YWluZXIgOnJvb3R7LS1tZXJtYWlkLWZvbnQtZmFtaWx5OiJ0cmVidWNoZXQgbXMiLHZlcmRhbmEsYXJpYWwsc2Fucy1zZXJpZjt9PC9zdHlsZT48Zz48L2c+PGRlZnM+PHN5bWJvbCBpZD0iY29tcHV0ZXIiIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCI+PHBhdGggdHJhbnNmb3JtPSJzY2FsZSguNSkiIGQ9Ik0yIDJ2MTNoMjB2LTEzaC0yMHptMTggMTFoLTE2di05aDE2djl6bS0xMC4yMjggNmwuNDY2LTFoMy41MjRsLjQ2NyAxaC00LjQ1N3ptMTQuMjI4IDNoLTI0bDItNmgyLjEwNGwtMS4zMyA0aDE4LjQ1bC0xLjI5Ny00aDIuMDczbDIgNnptLTUtMTBoLTE0di03aDE0djd6Ij48L3BhdGg+PC9zeW1ib2w+PC9kZWZzPjxkZWZzPjxzeW1ib2wgaWQ9ImRhdGFiYXNlIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCI+PHBhdGggdHJhbnNmb3JtPSJzY2FsZSguNSkiIGQ9Ik0xMi4yNTguMDAxbC4yNTYuMDA0LjI1NS4wMDUuMjUzLjAwOC4yNTEuMDEuMjQ5LjAxMi4yNDcuMDE1LjI0Ni4wMTYuMjQyLjAxOS4yNDEuMDIuMjM5LjAyMy4yMzYuMDI0LjIzMy4wMjcuMjMxLjAyOC4yMjkuMDMxLjIyNS4wMzIuMjIzLjAzNC4yMi4wMzYuMjE3LjAzOC4yMTQuMDQuMjExLjA0MS4yMDguMDQzLjIwNS4wNDUuMjAxLjA0Ni4xOTguMDQ4LjE5NC4wNS4xOTEuMDUxLjE4Ny4wNTMuMTgzLjA1NC4xOC4wNTYuMTc1LjA1Ny4xNzIuMDU5LjE2OC4wNi4xNjMuMDYxLjE2LjA2My4xNTUuMDY0LjE1LjA2Ni4wNzQuMDMzLjA3My4wMzMuMDcxLjAzNC4wNy4wMzQuMDY5LjAzNS4wNjguMDM1LjA2Ny4wMzUuMDY2LjAzNS4wNjQuMDM2LjA2NC4wMzYuMDYyLjAzNi4wNi4wMzYuMDYuMDM3LjA1OC4wMzcuMDU4LjAzNy4wNTUuMDM4LjA1NS4wMzguMDUzLjAzOC4wNTIuMDM4LjA1MS4wMzkuMDUuMDM5LjA0OC4wMzkuMDQ3LjAzOS4wNDUuMDQuMDQ0LjA0LjA0My4wNC4wNDEuMDQuMDQuMDQxLjAzOS4wNDEuMDM3LjA0MS4wMzYuMDQxLjAzNC4wNDEuMDMzLjA0Mi4wMzIuMDQyLjAzLjA0Mi4wMjkuMDQyLjAyNy4wNDIuMDI2LjA0My4wMjQuMDQzLjAyMy4wNDMuMDIxLjA0My4wMi4wNDMuMDE4LjA0NC4wMTcuMDQzLjAxNS4wNDQuMDEzLjA0NC4wMTIuMDQ0LjAxMS4wNDUuMDA5LjA0NC4wMDcuMDQ1LjAwNi4wNDUuMDA0LjA0NS4wMDIuMDQ1LjAwMS4wNDV2MTdsLS4wMDEuMDQ1LS4wMDIuMDQ1LS4wMDQuMDQ1LS4wMDYuMDQ1LS4wMDcuMDQ1LS4wMDkuMDQ0LS4wMTEuMDQ1LS4wMTIuMDQ0LS4wMTMuMDQ0LS4wMTUuMDQ0LS4wMTcuMDQzLS4wMTguMDQ0LS4wMi4wNDMtLjAyMS4wNDMtLjAyMy4wNDMtLjAyNC4wNDMtLjAyNi4wNDMtLjAyNy4wNDItLjAyOS4wNDItLjAzLjA0Mi0uMDMyLjA0Mi0uMDMzLjA0Mi0uMDM0LjA0MS0uMDM2LjA0MS0uMDM3LjA0MS0uMDM5LjA0MS0uMDQuMDQxLS4wNDEuMDQtLjA0My4wNC0uMDQ0LjA0LS4wNDUuMDQtLjA0Ny4wMzktLjA0OC4wMzktLjA1LjAzOS0uMDUxLjAzOS0uMDUyLjAzOC0uMDUzLjAzOC0uMDU1LjAzOC0uMDU1LjAzOC0uMDU4LjAzNy0uMDU4LjAzNy0uMDYuMDM3LS4wNi4wMzYtLjA2Mi4wMzYtLjA2NC4wMzYtLjA2NC4wMzYtLjA2Ni4wMzUtLjA2Ny4wMzUtLjA2OC4wMzUtLjA2OS4wMzUtLjA3LjAzNC0uMDcxLjAzNC0uMDczLjAzMy0uMDc0LjAzMy0uMTUuMDY2LS4xNTUuMDY0LS4xNi4wNjMtLjE2My4wNjEtLjE2OC4wNi0uMTcyLjA1OS0uMTc1LjA1Ny0uMTguMDU2LS4xODMuMDU0LS4xODcuMDUzLS4xOTEuMDUxLS4xOTQuMDUtLjE5OC4wNDgtLjIwMS4wNDYtLjIwNS4wNDUtLjIwOC4wNDMtLjIxMS4wNDEtLjIxNC4wNC0uMjE3LjAzOC0uMjIuMDM2LS4yMjMuMDM0LS4yMjUuMDMyLS4yMjkuMDMxLS4yMzEuMDI4LS4yMzMuMDI3LS4yMzYuMDI0LS4yMzkuMDIzLS4yNDEuMDItLjI0Mi4wMTktLjI0Ni4wMTYtLjI0Ny4wMTUtLjI0OS4wMTItLjI1MS4wMS0uMjUzLjAwOC0uMjU1LjAwNS0uMjU2LjAwNC0uMjU4LjAwMS0uMjU4LS4wMDEtLjI1Ni0uMDA0LS4yNTUtLjAwNS0uMjUzLS4wMDgtLjI1MS0uMDEtLjI0OS0uMDEyLS4yNDctLjAxNS0uMjQ1LS4wMTYtLjI0My0uMDE5LS4yNDEtLjAyLS4yMzgtLjAyMy0uMjM2LS4wMjQtLjIzNC0uMDI3LS4yMzEtLjAyOC0uMjI4LS4wMzEtLjIyNi0uMDMyLS4yMjMtLjAzNC0uMjItLjAzNi0uMjE3LS4wMzgtLjIxNC0uMDQtLjIxMS0uMDQxLS4yMDgtLjA0My0uMjA0LS4wNDUtLjIwMS0uMDQ2LS4xOTgtLjA0OC0uMTk1LS4wNS0uMTktLjA1MS0uMTg3LS4wNTMtLjE4NC0uMDU0LS4xNzktLjA1Ni0uMTc2LS4wNTctLjE3Mi0uMDU5LS4xNjctLjA2LS4xNjQtLjA2MS0uMTU5LS4wNjMtLjE1NS0uMDY0LS4xNTEtLjA2Ni0uMDc0LS4wMzMtLjA3Mi0uMDMzLS4wNzItLjAzNC0uMDctLjAzNC0uMDY5LS4wMzUtLjA2OC0uMDM1LS4wNjctLjAzNS0uMDY2LS4wMzUtLjA2NC0uMDM2LS4wNjMtLjAzNi0uMDYyLS4wMzYtLjA2MS0uMDM2LS4wNi0uMDM3LS4wNTgtLjAzNy0uMDU3LS4wMzctLjA1Ni0uMDM4LS4wNTUtLjAzOC0uMDUzLS4wMzgtLjA1Mi0uMDM4LS4wNTEtLjAzOS0uMDQ5LS4wMzktLjA0OS0uMDM5LS4wNDYtLjAzOS0uMDQ2LS4wNC0uMDQ0LS4wNC0uMDQzLS4wNC0uMDQxLS4wNC0uMDQtLjA0MS0uMDM5LS4wNDEtLjAzNy0uMDQxLS4wMzYtLjA0MS0uMDM0LS4wNDEtLjAzMy0uMDQyLS4wMzItLjA0Mi0uMDMtLjA0Mi0uMDI5LS4wNDItLjAyNy0uMDQyLS4wMjYtLjA0My0uMDI0LS4wNDMtLjAyMy0uMDQzLS4wMjEtLjA0My0uMDItLjA0My0uMDE4LS4wNDQtLjAxNy0uMDQzLS4wMTUtLjA0NC0uMDEzLS4wNDQtLjAxMi0uMDQ0LS4wMTEtLjA0NS0uMDA5LS4wNDQtLjAwNy0uMDQ1LS4wMDYtLjA0NS0uMDA0LS4wNDUtLjAwMi0uMDQ1LS4wMDEtLjA0NXYtMTdsLjAwMS0uMDQ1LjAwMi0uMDQ1LjAwNC0uMDQ1LjAwNi0uMDQ1LjAwNy0uMDQ1LjAwOS0uMDQ0LjAxMS0uMDQ1LjAxMi0uMDQ0LjAxMy0uMDQ0LjAxNS0uMDQ0LjAxNy0uMDQzLjAxOC0uMDQ0LjAyLS4wNDMuMDIxLS4wNDMuMDIzLS4wNDMuMDI0LS4wNDMuMDI2LS4wNDMuMDI3LS4wNDIuMDI5LS4wNDIuMDMtLjA0Mi4wMzItLjA0Mi4wMzMtLjA0Mi4wMzQtLjA0MS4wMzYtLjA0MS4wMzctLjA0MS4wMzktLjA0MS4wNC0uMDQxLjA0MS0uMDQuMDQzLS4wNC4wNDQtLjA0LjA0Ni0uMDQuMDQ2LS4wMzkuMDQ5LS4wMzkuMDQ5LS4wMzkuMDUxLS4wMzkuMDUyLS4wMzguMDUzLS4wMzguMDU1LS4wMzguMDU2LS4wMzguMDU3LS4wMzcuMDU4LS4wMzcuMDYtLjAzNy4wNjEtLjAzNi4wNjItLjAzNi4wNjMtLjAzNi4wNjQtLjAzNi4wNjYtLjAzNS4wNjctLjAzNS4wNjgtLjAzNS4wNjktLjAzNS4wNy0uMDM0LjA3Mi0uMDM0LjA3Mi0uMDMzLjA3NC0uMDMzLjE1MS0uMDY2LjE1NS0uMDY0LjE1OS0uMDYzLjE2NC0uMDYxLjE2Ny0uMDYuMTcyLS4wNTkuMTc2LS4wNTcuMTc5LS4wNTYuMTg0LS4wNTQuMTg3LS4wNTMuMTktLjA1MS4xOTUtLjA1LjE5OC0uMDQ4LjIwMS0uMDQ2LjIwNC0uMDQ1LjIwOC0uMDQzLjIxMS0uMDQxLjIxNC0uMDQuMjE3LS4wMzguMjItLjAzNi4yMjMtLjAzNC4yMjYtLjAzMi4yMjgtLjAzMS4yMzEtLjAyOC4yMzQtLjAyNy4yMzYtLjAyNC4yMzgtLjAyMy4yNDEtLjAyLjI0My0uMDE5LjI0NS0uMDE2LjI0Ny0uMDE1LjI0OS0uMDEyLjI1MS0uMDEuMjUzLS4wMDguMjU1LS4wMDUuMjU2LS4wMDQuMjU4LS4wMDEuMjU4LjAwMXptLTkuMjU4IDIwLjQ5OXYuMDFsLjAwMS4wMjEuMDAzLjAyMS4wMDQuMDIyLjAwNS4wMjEuMDA2LjAyMi4wMDcuMDIyLjAwOS4wMjMuMDEuMDIyLjAxMS4wMjMuMDEyLjAyMy4wMTMuMDIzLjAxNS4wMjMuMDE2LjAyNC4wMTcuMDIzLjAxOC4wMjQuMDE5LjAyNC4wMjEuMDI0LjAyMi4wMjUuMDIzLjAyNC4wMjQuMDI1LjA1Mi4wNDkuMDU2LjA1LjA2MS4wNTEuMDY2LjA1MS4wNy4wNTEuMDc1LjA1MS4wNzkuMDUyLjA4NC4wNTIuMDg4LjA1Mi4wOTIuMDUyLjA5Ny4wNTIuMTAyLjA1MS4xMDUuMDUyLjExLjA1Mi4xMTQuMDUxLjExOS4wNTEuMTIzLjA1MS4xMjcuMDUuMTMxLjA1LjEzNS4wNS4xMzkuMDQ4LjE0NC4wNDkuMTQ3LjA0Ny4xNTIuMDQ3LjE1NS4wNDcuMTYuMDQ1LjE2My4wNDUuMTY3LjA0My4xNzEuMDQzLjE3Ni4wNDEuMTc4LjA0MS4xODMuMDM5LjE4Ny4wMzkuMTkuMDM3LjE5NC4wMzUuMTk3LjAzNS4yMDIuMDMzLjIwNC4wMzEuMjA5LjAzLjIxMi4wMjkuMjE2LjAyNy4yMTkuMDI1LjIyMi4wMjQuMjI2LjAyMS4yMy4wMi4yMzMuMDE4LjIzNi4wMTYuMjQuMDE1LjI0My4wMTIuMjQ2LjAxLjI0OS4wMDguMjUzLjAwNS4yNTYuMDA0LjI1OS4wMDEuMjYtLjAwMS4yNTctLjAwNC4yNTQtLjAwNS4yNS0uMDA4LjI0Ny0uMDExLjI0NC0uMDEyLjI0MS0uMDE0LjIzNy0uMDE2LjIzMy0uMDE4LjIzMS0uMDIxLjIyNi0uMDIxLjIyNC0uMDI0LjIyLS4wMjYuMjE2LS4wMjcuMjEyLS4wMjguMjEtLjAzMS4yMDUtLjAzMS4yMDItLjAzNC4xOTgtLjAzNC4xOTQtLjAzNi4xOTEtLjAzNy4xODctLjAzOS4xODMtLjA0LjE3OS0uMDQuMTc1LS4wNDIuMTcyLS4wNDMuMTY4LS4wNDQuMTYzLS4wNDUuMTYtLjA0Ni4xNTUtLjA0Ni4xNTItLjA0Ny4xNDgtLjA0OC4xNDMtLjA0OS4xMzktLjA0OS4xMzYtLjA1LjEzMS0uMDUuMTI2LS4wNS4xMjMtLjA1MS4xMTgtLjA1Mi4xMTQtLjA1MS4xMS0uMDUyLjEwNi0uMDUyLjEwMS0uMDUyLjA5Ni0uMDUyLjA5Mi0uMDUyLjA4OC0uMDUzLjA4My0uMDUxLjA3OS0uMDUyLjA3NC0uMDUyLjA3LS4wNTEuMDY1LS4wNTEuMDYtLjA1MS4wNTYtLjA1LjA1MS0uMDUuMDIzLS4wMjQuMDIzLS4wMjUuMDIxLS4wMjQuMDItLjAyNC4wMTktLjAyNC4wMTgtLjAyNC4wMTctLjAyNC4wMTUtLjAyMy4wMTQtLjAyNC4wMTMtLjAyMy4wMTItLjAyMy4wMS0uMDIzLjAxLS4wMjIuMDA4LS4wMjIuMDA2LS4wMjIuMDA2LS4wMjIuMDA0LS4wMjIuMDA0LS4wMjEuMDAxLS4wMjEuMDAxLS4wMjF2LTQuMTI3bC0uMDc3LjA1NS0uMDguMDUzLS4wODMuMDU0LS4wODUuMDUzLS4wODcuMDUyLS4wOS4wNTItLjA5My4wNTEtLjA5NS4wNS0uMDk3LjA1LS4xLjA0OS0uMTAyLjA0OS0uMTA1LjA0OC0uMTA2LjA0Ny0uMTA5LjA0Ny0uMTExLjA0Ni0uMTE0LjA0NS0uMTE1LjA0NS0uMTE4LjA0NC0uMTIuMDQzLS4xMjIuMDQyLS4xMjQuMDQyLS4xMjYuMDQxLS4xMjguMDQtLjEzLjA0LS4xMzIuMDM4LS4xMzQuMDM4LS4xMzUuMDM3LS4xMzguMDM3LS4xMzkuMDM1LS4xNDIuMDM1LS4xNDMuMDM0LS4xNDQuMDMzLS4xNDcuMDMyLS4xNDguMDMxLS4xNS4wMy0uMTUxLjAzLS4xNTMuMDI5LS4xNTQuMDI3LS4xNTYuMDI3LS4xNTguMDI2LS4xNTkuMDI1LS4xNjEuMDI0LS4xNjIuMDIzLS4xNjMuMDIyLS4xNjUuMDIxLS4xNjYuMDItLjE2Ny4wMTktLjE2OS4wMTgtLjE2OS4wMTctLjE3MS4wMTYtLjE3My4wMTUtLjE3My4wMTQtLjE3NS4wMTMtLjE3NS4wMTItLjE3Ny4wMTEtLjE3OC4wMS0uMTc5LjAwOC0uMTc5LjAwOC0uMTgxLjAwNi0uMTgyLjAwNS0uMTgyLjAwNC0uMTg0LjAwMy0uMTg0LjAwMmgtLjM3bC0uMTg0LS4wMDItLjE4NC0uMDAzLS4xODItLjAwNC0uMTgyLS4wMDUtLjE4MS0uMDA2LS4xNzktLjAwOC0uMTc5LS4wMDgtLjE3OC0uMDEtLjE3Ni0uMDExLS4xNzYtLjAxMi0uMTc1LS4wMTMtLjE3My0uMDE0LS4xNzItLjAxNS0uMTcxLS4wMTYtLjE3LS4wMTctLjE2OS0uMDE4LS4xNjctLjAxOS0uMTY2LS4wMi0uMTY1LS4wMjEtLjE2My0uMDIyLS4xNjItLjAyMy0uMTYxLS4wMjQtLjE1OS0uMDI1LS4xNTctLjAyNi0uMTU2LS4wMjctLjE1NS0uMDI3LS4xNTMtLjAyOS0uMTUxLS4wMy0uMTUtLjAzLS4xNDgtLjAzMS0uMTQ2LS4wMzItLjE0NS0uMDMzLS4xNDMtLjAzNC0uMTQxLS4wMzUtLjE0LS4wMzUtLjEzNy0uMDM3LS4xMzYtLjAzNy0uMTM0LS4wMzgtLjEzMi0uMDM4LS4xMy0uMDQtLjEyOC0uMDQtLjEyNi0uMDQxLS4xMjQtLjA0Mi0uMTIyLS4wNDItLjEyLS4wNDQtLjExNy0uMDQzLS4xMTYtLjA0NS0uMTEzLS4wNDUtLjExMi0uMDQ2LS4xMDktLjA0Ny0uMTA2LS4wNDctLjEwNS0uMDQ4LS4xMDItLjA0OS0uMS0uMDQ5LS4wOTctLjA1LS4wOTUtLjA1LS4wOTMtLjA1Mi0uMDktLjA1MS0uMDg3LS4wNTItLjA4NS0uMDUzLS4wODMtLjA1NC0uMDgtLjA1NC0uMDc3LS4wNTR2NC4xMjd6bTAtNS42NTR2LjAxMWwuMDAxLjAyMS4wMDMuMDIxLjAwNC4wMjEuMDA1LjAyMi4wMDYuMDIyLjAwNy4wMjIuMDA5LjAyMi4wMS4wMjIuMDExLjAyMy4wMTIuMDIzLjAxMy4wMjMuMDE1LjAyNC4wMTYuMDIzLjAxNy4wMjQuMDE4LjAyNC4wMTkuMDI0LjAyMS4wMjQuMDIyLjAyNC4wMjMuMDI1LjAyNC4wMjQuMDUyLjA1LjA1Ni4wNS4wNjEuMDUuMDY2LjA1MS4wNy4wNTEuMDc1LjA1Mi4wNzkuMDUxLjA4NC4wNTIuMDg4LjA1Mi4wOTIuMDUyLjA5Ny4wNTIuMTAyLjA1Mi4xMDUuMDUyLjExLjA1MS4xMTQuMDUxLjExOS4wNTIuMTIzLjA1LjEyNy4wNTEuMTMxLjA1LjEzNS4wNDkuMTM5LjA0OS4xNDQuMDQ4LjE0Ny4wNDguMTUyLjA0Ny4xNTUuMDQ2LjE2LjA0NS4xNjMuMDQ1LjE2Ny4wNDQuMTcxLjA0Mi4xNzYuMDQyLjE3OC4wNC4xODMuMDQuMTg3LjAzOC4xOS4wMzcuMTk0LjAzNi4xOTcuMDM0LjIwMi4wMzMuMjA0LjAzMi4yMDkuMDMuMjEyLjAyOC4yMTYuMDI3LjIxOS4wMjUuMjIyLjAyNC4yMjYuMDIyLjIzLjAyLjIzMy4wMTguMjM2LjAxNi4yNC4wMTQuMjQzLjAxMi4yNDYuMDEuMjQ5LjAwOC4yNTMuMDA2LjI1Ni4wMDMuMjU5LjAwMS4yNi0uMDAxLjI1Ny0uMDAzLjI1NC0uMDA2LjI1LS4wMDguMjQ3LS4wMS4yNDQtLjAxMi4yNDEtLjAxNS4yMzctLjAxNi4yMzMtLjAxOC4yMzEtLjAyLjIyNi0uMDIyLjIyNC0uMDI0LjIyLS4wMjUuMjE2LS4wMjcuMjEyLS4wMjkuMjEtLjAzLjIwNS0uMDMyLjIwMi0uMDMzLjE5OC0uMDM1LjE5NC0uMDM2LjE5MS0uMDM3LjE4Ny0uMDM5LjE4My0uMDM5LjE3OS0uMDQxLjE3NS0uMDQyLjE3Mi0uMDQzLjE2OC0uMDQ0LjE2My0uMDQ1LjE2LS4wNDUuMTU1LS4wNDcuMTUyLS4wNDcuMTQ4LS4wNDguMTQzLS4wNDguMTM5LS4wNS4xMzYtLjA0OS4xMzEtLjA1LjEyNi0uMDUxLjEyMy0uMDUxLjExOC0uMDUxLjExNC0uMDUyLjExLS4wNTIuMTA2LS4wNTIuMTAxLS4wNTIuMDk2LS4wNTIuMDkyLS4wNTIuMDg4LS4wNTIuMDgzLS4wNTIuMDc5LS4wNTIuMDc0LS4wNTEuMDctLjA1Mi4wNjUtLjA1MS4wNi0uMDUuMDU2LS4wNTEuMDUxLS4wNDkuMDIzLS4wMjUuMDIzLS4wMjQuMDIxLS4wMjUuMDItLjAyNC4wMTktLjAyNC4wMTgtLjAyNC4wMTctLjAyNC4wMTUtLjAyMy4wMTQtLjAyMy4wMTMtLjAyNC4wMTItLjAyMi4wMS0uMDIzLjAxLS4wMjMuMDA4LS4wMjIuMDA2LS4wMjIuMDA2LS4wMjIuMDA0LS4wMjEuMDA0LS4wMjIuMDAxLS4wMjEuMDAxLS4wMjF2LTQuMTM5bC0uMDc3LjA1NC0uMDguMDU0LS4wODMuMDU0LS4wODUuMDUyLS4wODcuMDUzLS4wOS4wNTEtLjA5My4wNTEtLjA5NS4wNTEtLjA5Ny4wNS0uMS4wNDktLjEwMi4wNDktLjEwNS4wNDgtLjEwNi4wNDctLjEwOS4wNDctLjExMS4wNDYtLjExNC4wNDUtLjExNS4wNDQtLjExOC4wNDQtLjEyLjA0NC0uMTIyLjA0Mi0uMTI0LjA0Mi0uMTI2LjA0MS0uMTI4LjA0LS4xMy4wMzktLjEzMi4wMzktLjEzNC4wMzgtLjEzNS4wMzctLjEzOC4wMzYtLjEzOS4wMzYtLjE0Mi4wMzUtLjE0My4wMzMtLjE0NC4wMzMtLjE0Ny4wMzMtLjE0OC4wMzEtLjE1LjAzLS4xNTEuMDMtLjE1My4wMjgtLjE1NC4wMjgtLjE1Ni4wMjctLjE1OC4wMjYtLjE1OS4wMjUtLjE2MS4wMjQtLjE2Mi4wMjMtLjE2My4wMjItLjE2NS4wMjEtLjE2Ni4wMi0uMTY3LjAxOS0uMTY5LjAxOC0uMTY5LjAxNy0uMTcxLjAxNi0uMTczLjAxNS0uMTczLjAxNC0uMTc1LjAxMy0uMTc1LjAxMi0uMTc3LjAxMS0uMTc4LjAwOS0uMTc5LjAwOS0uMTc5LjAwNy0uMTgxLjAwNy0uMTgyLjAwNS0uMTgyLjAwNC0uMTg0LjAwMy0uMTg0LjAwMmgtLjM3bC0uMTg0LS4wMDItLjE4NC0uMDAzLS4xODItLjAwNC0uMTgyLS4wMDUtLjE4MS0uMDA3LS4xNzktLjAwNy0uMTc5LS4wMDktLjE3OC0uMDA5LS4xNzYtLjAxMS0uMTc2LS4wMTItLjE3NS0uMDEzLS4xNzMtLjAxNC0uMTcyLS4wMTUtLjE3MS0uMDE2LS4xNy0uMDE3LS4xNjktLjAxOC0uMTY3LS4wMTktLjE2Ni0uMDItLjE2NS0uMDIxLS4xNjMtLjAyMi0uMTYyLS4wMjMtLjE2MS0uMDI0LS4xNTktLjAyNS0uMTU3LS4wMjYtLjE1Ni0uMDI3LS4xNTUtLjAyOC0uMTUzLS4wMjgtLjE1MS0uMDMtLjE1LS4wMy0uMTQ4LS4wMzEtLjE0Ni0uMDMzLS4xNDUtLjAzMy0uMTQzLS4wMzMtLjE0MS0uMDM1LS4xNC0uMDM2LS4xMzctLjAzNi0uMTM2LS4wMzctLjEzNC0uMDM4LS4xMzItLjAzOS0uMTMtLjAzOS0uMTI4LS4wNC0uMTI2LS4wNDEtLjEyNC0uMDQyLS4xMjItLjA0My0uMTItLjA0My0uMTE3LS4wNDQtLjExNi0uMDQ0LS4xMTMtLjA0Ni0uMTEyLS4wNDYtLjEwOS0uMDQ2LS4xMDYtLjA0Ny0uMTA1LS4wNDgtLjEwMi0uMDQ5LS4xLS4wNDktLjA5Ny0uMDUtLjA5NS0uMDUxLS4wOTMtLjA1MS0uMDktLjA1MS0uMDg3LS4wNTMtLjA4NS0uMDUyLS4wODMtLjA1NC0uMDgtLjA1NC0uMDc3LS4wNTR2NC4xMzl6bTAtNS42NjZ2LjAxMWwuMDAxLjAyLjAwMy4wMjIuMDA0LjAyMS4wMDUuMDIyLjAwNi4wMjEuMDA3LjAyMi4wMDkuMDIzLjAxLjAyMi4wMTEuMDIzLjAxMi4wMjMuMDEzLjAyMy4wMTUuMDIzLjAxNi4wMjQuMDE3LjAyNC4wMTguMDIzLjAxOS4wMjQuMDIxLjAyNS4wMjIuMDI0LjAyMy4wMjQuMDI0LjAyNS4wNTIuMDUuMDU2LjA1LjA2MS4wNS4wNjYuMDUxLjA3LjA1MS4wNzUuMDUyLjA3OS4wNTEuMDg0LjA1Mi4wODguMDUyLjA5Mi4wNTIuMDk3LjA1Mi4xMDIuMDUyLjEwNS4wNTEuMTEuMDUyLjExNC4wNTEuMTE5LjA1MS4xMjMuMDUxLjEyNy4wNS4xMzEuMDUuMTM1LjA1LjEzOS4wNDkuMTQ0LjA0OC4xNDcuMDQ4LjE1Mi4wNDcuMTU1LjA0Ni4xNi4wNDUuMTYzLjA0NS4xNjcuMDQzLjE3MS4wNDMuMTc2LjA0Mi4xNzguMDQuMTgzLjA0LjE4Ny4wMzguMTkuMDM3LjE5NC4wMzYuMTk3LjAzNC4yMDIuMDMzLjIwNC4wMzIuMjA5LjAzLjIxMi4wMjguMjE2LjAyNy4yMTkuMDI1LjIyMi4wMjQuMjI2LjAyMS4yMy4wMi4yMzMuMDE4LjIzNi4wMTcuMjQuMDE0LjI0My4wMTIuMjQ2LjAxLjI0OS4wMDguMjUzLjAwNi4yNTYuMDAzLjI1OS4wMDEuMjYtLjAwMS4yNTctLjAwMy4yNTQtLjAwNi4yNS0uMDA4LjI0Ny0uMDEuMjQ0LS4wMTMuMjQxLS4wMTQuMjM3LS4wMTYuMjMzLS4wMTguMjMxLS4wMi4yMjYtLjAyMi4yMjQtLjAyNC4yMi0uMDI1LjIxNi0uMDI3LjIxMi0uMDI5LjIxLS4wMy4yMDUtLjAzMi4yMDItLjAzMy4xOTgtLjAzNS4xOTQtLjAzNi4xOTEtLjAzNy4xODctLjAzOS4xODMtLjAzOS4xNzktLjA0MS4xNzUtLjA0Mi4xNzItLjA0My4xNjgtLjA0NC4xNjMtLjA0NS4xNi0uMDQ1LjE1NS0uMDQ3LjE1Mi0uMDQ3LjE0OC0uMDQ4LjE0My0uMDQ5LjEzOS0uMDQ5LjEzNi0uMDQ5LjEzMS0uMDUxLjEyNi0uMDUuMTIzLS4wNTEuMTE4LS4wNTIuMTE0LS4wNTEuMTEtLjA1Mi4xMDYtLjA1Mi4xMDEtLjA1Mi4wOTYtLjA1Mi4wOTItLjA1Mi4wODgtLjA1Mi4wODMtLjA1Mi4wNzktLjA1Mi4wNzQtLjA1Mi4wNy0uMDUxLjA2NS0uMDUxLjA2LS4wNTEuMDU2LS4wNS4wNTEtLjA0OS4wMjMtLjAyNS4wMjMtLjAyNS4wMjEtLjAyNC4wMi0uMDI0LjAxOS0uMDI0LjAxOC0uMDI0LjAxNy0uMDI0LjAxNS0uMDIzLjAxNC0uMDI0LjAxMy0uMDIzLjAxMi0uMDIzLjAxLS4wMjIuMDEtLjAyMy4wMDgtLjAyMi4wMDYtLjAyMi4wMDYtLjAyMi4wMDQtLjAyMi4wMDQtLjAyMS4wMDEtLjAyMS4wMDEtLjAyMXYtNC4xNTNsLS4wNzcuMDU0LS4wOC4wNTQtLjA4My4wNTMtLjA4NS4wNTMtLjA4Ny4wNTMtLjA5LjA1MS0uMDkzLjA1MS0uMDk1LjA1MS0uMDk3LjA1LS4xLjA0OS0uMTAyLjA0OC0uMTA1LjA0OC0uMTA2LjA0OC0uMTA5LjA0Ni0uMTExLjA0Ni0uMTE0LjA0Ni0uMTE1LjA0NC0uMTE4LjA0NC0uMTIuMDQzLS4xMjIuMDQzLS4xMjQuMDQyLS4xMjYuMDQxLS4xMjguMDQtLjEzLjAzOS0uMTMyLjAzOS0uMTM0LjAzOC0uMTM1LjAzNy0uMTM4LjAzNi0uMTM5LjAzNi0uMTQyLjAzNC0uMTQzLjAzNC0uMTQ0LjAzMy0uMTQ3LjAzMi0uMTQ4LjAzMi0uMTUuMDMtLjE1MS4wMy0uMTUzLjAyOC0uMTU0LjAyOC0uMTU2LjAyNy0uMTU4LjAyNi0uMTU5LjAyNC0uMTYxLjAyNC0uMTYyLjAyMy0uMTYzLjAyMy0uMTY1LjAyMS0uMTY2LjAyLS4xNjcuMDE5LS4xNjkuMDE4LS4xNjkuMDE3LS4xNzEuMDE2LS4xNzMuMDE1LS4xNzMuMDE0LS4xNzUuMDEzLS4xNzUuMDEyLS4xNzcuMDEtLjE3OC4wMS0uMTc5LjAwOS0uMTc5LjAwNy0uMTgxLjAwNi0uMTgyLjAwNi0uMTgyLjAwNC0uMTg0LjAwMy0uMTg0LjAwMS0uMTg1LjAwMS0uMTg1LS4wMDEtLjE4NC0uMDAxLS4xODQtLjAwMy0uMTgyLS4wMDQtLjE4Mi0uMDA2LS4xODEtLjAwNi0uMTc5LS4wMDctLjE3OS0uMDA5LS4xNzgtLjAxLS4xNzYtLjAxLS4xNzYtLjAxMi0uMTc1LS4wMTMtLjE3My0uMDE0LS4xNzItLjAxNS0uMTcxLS4wMTYtLjE3LS4wMTctLjE2OS0uMDE4LS4xNjctLjAxOS0uMTY2LS4wMi0uMTY1LS4wMjEtLjE2My0uMDIzLS4xNjItLjAyMy0uMTYxLS4wMjQtLjE1OS0uMDI0LS4xNTctLjAyNi0uMTU2LS4wMjctLjE1NS0uMDI4LS4xNTMtLjAyOC0uMTUxLS4wMy0uMTUtLjAzLS4xNDgtLjAzMi0uMTQ2LS4wMzItLjE0NS0uMDMzLS4xNDMtLjAzNC0uMTQxLS4wMzQtLjE0LS4wMzYtLjEzNy0uMDM2LS4xMzYtLjAzNy0uMTM0LS4wMzgtLjEzMi0uMDM5LS4xMy0uMDM5LS4xMjgtLjA0MS0uMTI2LS4wNDEtLjEyNC0uMDQxLS4xMjItLjA0My0uMTItLjA0My0uMTE3LS4wNDQtLjExNi0uMDQ0LS4xMTMtLjA0Ni0uMTEyLS4wNDYtLjEwOS0uMDQ2LS4xMDYtLjA0OC0uMTA1LS4wNDgtLjEwMi0uMDQ4LS4xLS4wNS0uMDk3LS4wNDktLjA5NS0uMDUxLS4wOTMtLjA1MS0uMDktLjA1Mi0uMDg3LS4wNTItLjA4NS0uMDUzLS4wODMtLjA1My0uMDgtLjA1NC0uMDc3LS4wNTR2NC4xNTN6bTguNzQtOC4xNzlsLS4yNTcuMDA0LS4yNTQuMDA1LS4yNS4wMDgtLjI0Ny4wMTEtLjI0NC4wMTItLjI0MS4wMTQtLjIzNy4wMTYtLjIzMy4wMTgtLjIzMS4wMjEtLjIyNi4wMjItLjIyNC4wMjMtLjIyLjAyNi0uMjE2LjAyNy0uMjEyLjAyOC0uMjEuMDMxLS4yMDUuMDMyLS4yMDIuMDMzLS4xOTguMDM0LS4xOTQuMDM2LS4xOTEuMDM4LS4xODcuMDM4LS4xODMuMDQtLjE3OS4wNDEtLjE3NS4wNDItLjE3Mi4wNDMtLjE2OC4wNDMtLjE2My4wNDUtLjE2LjA0Ni0uMTU1LjA0Ni0uMTUyLjA0OC0uMTQ4LjA0OC0uMTQzLjA0OC0uMTM5LjA0OS0uMTM2LjA1LS4xMzEuMDUtLjEyNi4wNTEtLjEyMy4wNTEtLjExOC4wNTEtLjExNC4wNTItLjExLjA1Mi0uMTA2LjA1Mi0uMTAxLjA1Mi0uMDk2LjA1Mi0uMDkyLjA1Mi0uMDg4LjA1Mi0uMDgzLjA1Mi0uMDc5LjA1Mi0uMDc0LjA1MS0uMDcuMDUyLS4wNjUuMDUxLS4wNi4wNS0uMDU2LjA1LS4wNTEuMDUtLjAyMy4wMjUtLjAyMy4wMjQtLjAyMS4wMjQtLjAyLjAyNS0uMDE5LjAyNC0uMDE4LjAyNC0uMDE3LjAyMy0uMDE1LjAyNC0uMDE0LjAyMy0uMDEzLjAyMy0uMDEyLjAyMy0uMDEuMDIzLS4wMS4wMjItLjAwOC4wMjItLjAwNi4wMjMtLjAwNi4wMjEtLjAwNC4wMjItLjAwNC4wMjEtLjAwMS4wMjEtLjAwMS4wMjEuMDAxLjAyMS4wMDEuMDIxLjAwNC4wMjEuMDA0LjAyMi4wMDYuMDIxLjAwNi4wMjMuMDA4LjAyMi4wMS4wMjIuMDEuMDIzLjAxMi4wMjMuMDEzLjAyMy4wMTQuMDIzLjAxNS4wMjQuMDE3LjAyMy4wMTguMDI0LjAxOS4wMjQuMDIuMDI1LjAyMS4wMjQuMDIzLjAyNC4wMjMuMDI1LjA1MS4wNS4wNTYuMDUuMDYuMDUuMDY1LjA1MS4wNy4wNTIuMDc0LjA1MS4wNzkuMDUyLjA4My4wNTIuMDg4LjA1Mi4wOTIuMDUyLjA5Ni4wNTIuMTAxLjA1Mi4xMDYuMDUyLjExLjA1Mi4xMTQuMDUyLjExOC4wNTEuMTIzLjA1MS4xMjYuMDUxLjEzMS4wNS4xMzYuMDUuMTM5LjA0OS4xNDMuMDQ4LjE0OC4wNDguMTUyLjA0OC4xNTUuMDQ2LjE2LjA0Ni4xNjMuMDQ1LjE2OC4wNDMuMTcyLjA0My4xNzUuMDQyLjE3OS4wNDEuMTgzLjA0LjE4Ny4wMzguMTkxLjAzOC4xOTQuMDM2LjE5OC4wMzQuMjAyLjAzMy4yMDUuMDMyLjIxLjAzMS4yMTIuMDI4LjIxNi4wMjcuMjIuMDI2LjIyNC4wMjMuMjI2LjAyMi4yMzEuMDIxLjIzMy4wMTguMjM3LjAxNi4yNDEuMDE0LjI0NC4wMTIuMjQ3LjAxMS4yNS4wMDguMjU0LjAwNS4yNTcuMDA0LjI2LjAwMS4yNi0uMDAxLjI1Ny0uMDA0LjI1NC0uMDA1LjI1LS4wMDguMjQ3LS4wMTEuMjQ0LS4wMTIuMjQxLS4wMTQuMjM3LS4wMTYuMjMzLS4wMTguMjMxLS4wMjEuMjI2LS4wMjIuMjI0LS4wMjMuMjItLjAyNi4yMTYtLjAyNy4yMTItLjAyOC4yMS0uMDMxLjIwNS0uMDMyLjIwMi0uMDMzLjE5OC0uMDM0LjE5NC0uMDM2LjE5MS0uMDM4LjE4Ny0uMDM4LjE4My0uMDQuMTc5LS4wNDEuMTc1LS4wNDIuMTcyLS4wNDMuMTY4LS4wNDMuMTYzLS4wNDUuMTYtLjA0Ni4xNTUtLjA0Ni4xNTItLjA0OC4xNDgtLjA0OC4xNDMtLjA0OC4xMzktLjA0OS4xMzYtLjA1LjEzMS0uMDUuMTI2LS4wNTEuMTIzLS4wNTEuMTE4LS4wNTEuMTE0LS4wNTIuMTEtLjA1Mi4xMDYtLjA1Mi4xMDEtLjA1Mi4wOTYtLjA1Mi4wOTItLjA1Mi4wODgtLjA1Mi4wODMtLjA1Mi4wNzktLjA1Mi4wNzQtLjA1MS4wNy0uMDUyLjA2NS0uMDUxLjA2LS4wNS4wNTYtLjA1LjA1MS0uMDUuMDIzLS4wMjUuMDIzLS4wMjQuMDIxLS4wMjQuMDItLjAyNS4wMTktLjAyNC4wMTgtLjAyNC4wMTctLjAyMy4wMTUtLjAyNC4wMTQtLjAyMy4wMTMtLjAyMy4wMTItLjAyMy4wMS0uMDIzLjAxLS4wMjIuMDA4LS4wMjIuMDA2LS4wMjMuMDA2LS4wMjEuMDA0LS4wMjIuMDA0LS4wMjEuMDAxLS4wMjEuMDAxLS4wMjEtLjAwMS0uMDIxLS4wMDEtLjAyMS0uMDA0LS4wMjEtLjAwNC0uMDIyLS4wMDYtLjAyMS0uMDA2LS4wMjMtLjAwOC0uMDIyLS4wMS0uMDIyLS4wMS0uMDIzLS4wMTItLjAyMy0uMDEzLS4wMjMtLjAxNC0uMDIzLS4wMTUtLjAyNC0uMDE3LS4wMjMtLjAxOC0uMDI0LS4wMTktLjAyNC0uMDItLjAyNS0uMDIxLS4wMjQtLjAyMy0uMDI0LS4wMjMtLjAyNS0uMDUxLS4wNS0uMDU2LS4wNS0uMDYtLjA1LS4wNjUtLjA1MS0uMDctLjA1Mi0uMDc0LS4wNTEtLjA3OS0uMDUyLS4wODMtLjA1Mi0uMDg4LS4wNTItLjA5Mi0uMDUyLS4wOTYtLjA1Mi0uMTAxLS4wNTItLjEwNi0uMDUyLS4xMS0uMDUyLS4xMTQtLjA1Mi0uMTE4LS4wNTEtLjEyMy0uMDUxLS4xMjYtLjA1MS0uMTMxLS4wNS0uMTM2LS4wNS0uMTM5LS4wNDktLjE0My0uMDQ4LS4xNDgtLjA0OC0uMTUyLS4wNDgtLjE1NS0uMDQ2LS4xNi0uMDQ2LS4xNjMtLjA0NS0uMTY4LS4wNDMtLjE3Mi0uMDQzLS4xNzUtLjA0Mi0uMTc5LS4wNDEtLjE4My0uMDQtLjE4Ny0uMDM4LS4xOTEtLjAzOC0uMTk0LS4wMzYtLjE5OC0uMDM0LS4yMDItLjAzMy0uMjA1LS4wMzItLjIxLS4wMzEtLjIxMi0uMDI4LS4yMTYtLjAyNy0uMjItLjAyNi0uMjI0LS4wMjMtLjIyNi0uMDIyLS4yMzEtLjAyMS0uMjMzLS4wMTgtLjIzNy0uMDE2LS4yNDEtLjAxNC0uMjQ0LS4wMTItLjI0Ny0uMDExLS4yNS0uMDA4LS4yNTQtLjAwNS0uMjU3LS4wMDQtLjI2LS4wMDEtLjI2LjAwMXoiPjwvcGF0aD48L3N5bWJvbD48L2RlZnM+PGRlZnM+PHN5bWJvbCBpZD0iY2xvY2siIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCI+PHBhdGggdHJhbnNmb3JtPSJzY2FsZSguNSkiIGQ9Ik0xMiAyYzUuNTE0IDAgMTAgNC40ODYgMTAgMTBzLTQuNDg2IDEwLTEwIDEwLTEwLTQuNDg2LTEwLTEwIDQuNDg2LTEwIDEwLTEwem0wLTJjLTYuNjI3IDAtMTIgNS4zNzMtMTIgMTJzNS4zNzMgMTIgMTIgMTIgMTItNS4zNzMgMTItMTItNS4zNzMtMTItMTItMTJ6bTUuODQ4IDEyLjQ1OWMuMjAyLjAzOC4yMDIuMzMzLjAwMS4zNzItMS45MDcuMzYxLTYuMDQ1IDEuMTExLTYuNTQ3IDEuMTExLS43MTkgMC0xLjMwMS0uNTgyLTEuMzAxLTEuMzAxIDAtLjUxMi43Ny01LjQ0NyAxLjEyNS03LjQ0NS4wMzQtLjE5Mi4zMTItLjE4MS4zNDMuMDE0bC45ODUgNi4yMzggNS4zOTQgMS4wMTF6Ij48L3BhdGg+PC9zeW1ib2w+PC9kZWZzPjxkZWZzPjxtYXJrZXIgaWQ9ImFycm93aGVhZCIgcmVmWD0iNy45IiByZWZZPSI1IiBtYXJrZXJVbml0cz0idXNlclNwYWNlT25Vc2UiIG1hcmtlcldpZHRoPSIxMiIgbWFya2VySGVpZ2h0PSIxMiIgb3JpZW50PSJhdXRvLXN0YXJ0LXJldmVyc2UiPjxwYXRoIGQ9Ik0gLTEgMCBMIDEwIDUgTCAwIDEwIHoiPjwvcGF0aD48L21hcmtlcj48L2RlZnM+PGRlZnM+PG1hcmtlciBpZD0iY3Jvc3NoZWFkIiBtYXJrZXJXaWR0aD0iMTUiIG1hcmtlckhlaWdodD0iOCIgb3JpZW50PSJhdXRvIiByZWZYPSI0IiByZWZZPSI0LjUiPjxwYXRoIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzAwMDAwMCIgc3Ryb2tlLXdpZHRoPSIxcHQiIGQ9Ik0gMSwyIEwgNiw3IE0gNiwyIEwgMSw3IiBzdHlsZT0ic3Ryb2tlLWRhc2hhcnJheTogMCwgMDsiPjwvcGF0aD48L21hcmtlcj48L2RlZnM+PGRlZnM+PG1hcmtlciBpZD0iZmlsbGVkLWhlYWQiIHJlZlg9IjE1LjUiIHJlZlk9IjciIG1hcmtlcldpZHRoPSIyMCIgbWFya2VySGVpZ2h0PSIyOCIgb3JpZW50PSJhdXRvIj48cGF0aCBkPSJNIDE4LDcgTDksMTMgTDE0LDcgTDksMSBaIj48L3BhdGg+PC9tYXJrZXI+PC9kZWZzPjxkZWZzPjxtYXJrZXIgaWQ9InNlcXVlbmNlbnVtYmVyIiByZWZYPSIxNSIgcmVmWT0iMTUiIG1hcmtlcldpZHRoPSI2MCIgbWFya2VySGVpZ2h0PSI0MCIgb3JpZW50PSJhdXRvIj48Y2lyY2xlIGN4PSIxNSIgY3k9IjE1IiByPSI2Ij48L2NpcmNsZT48L21hcmtlcj48L2RlZnM+PHRleHQgeD0iMjI4IiB5PSI4MCIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZG9taW5hbnQtYmFzZWxpbmU9Im1pZGRsZSIgYWxpZ25tZW50LWJhc2VsaW5lPSJtaWRkbGUiIGNsYXNzPSJtZXNzYWdlVGV4dCIgZHk9IjFlbSIgc3R5bGU9ImZvbnQtc2l6ZTogMTZweDsgZm9udC13ZWlnaHQ6IDQwMDsiPuyjvOusuCDsoJXrs7Qg7KGw7ZqMIOyalOyyrTwvdGV4dD48bGluZSB4MT0iNzYiIHkxPSIxMTMiIHgyPSIzNzkiIHkyPSIxMTMiIGNsYXNzPSJtZXNzYWdlTGluZTAiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlPSJub25lIiBtYXJrZXItZW5kPSJ1cmwoI2Fycm93aGVhZCkiIHN0eWxlPSJmaWxsOiBub25lOyI+PC9saW5lPjx0ZXh0IHg9IjIzMSIgeT0iMTI4IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBkb21pbmFudC1iYXNlbGluZT0ibWlkZGxlIiBhbGlnbm1lbnQtYmFzZWxpbmU9Im1pZGRsZSIgY2xhc3M9Im1lc3NhZ2VUZXh0IiBkeT0iMWVtIiBzdHlsZT0iZm9udC1zaXplOiAxNnB4OyBmb250LXdlaWdodDogNDAwOyI+7KO866y4IOygleuztCDsobDtmowg7J2R64u1ICgyLDUwMG1zIOyGjOyalCk8L3RleHQ+PGxpbmUgeDE9IjM4MiIgeTE9IjE2MSIgeDI9Ijc5IiB5Mj0iMTYxIiBjbGFzcz0ibWVzc2FnZUxpbmUxIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZT0ibm9uZSIgbWFya2VyLWVuZD0idXJsKCNhcnJvd2hlYWQpIiBzdHlsZT0ic3Ryb2tlLWRhc2hhcnJheTogMywgMzsgZmlsbDogbm9uZTsiPjwvbGluZT48L3N2Zz4="><p>전통적인 Hikari Connection Pool에서는 애플리케이션이 주문 정보를 조회하기 위해 커넥션 풀에서 하나의 커넥션을 가져옵니다. 아래 그림에서 볼 수 있듯이, <strong>idleConnections</strong>가 10개라면, 그 중 하나의 커넥션을 가져와 <strong>activeConnections</strong>로 전환하게 됩니다. 이 경우 <strong>idleConnections</strong>는 9개로 줄고, <strong>activeConnections</strong>는 1개가 됩니다. 전체 <strong>totalConnections</strong>는 변하지 않고 유지됩니다.</p><p><img src="https://raw.githubusercontent.com/cheese10yun/blog-sample/refs/heads/master/redis/docs/connection-pool-001.png" alt="Hikari Connection Pool - Idle to Active"></p><p>만약 요청이 많이 들어와 응답이 지연되고 있는 경우를 생각해 봅시다. 아래 그림처럼 <strong>maximum-pool-size</strong>가 10개인 상황에서, 모든 10개의 커넥션이 <strong>activeConnections</strong>로 전환되어 사용 중이라면, 추가적인 요청은 <strong>threadsAwaitingConnection</strong>으로 들어가 대기하게 됩니다. 즉, 사용 가능한 커넥션이 없기 때문에 요청 스레드는 커넥션이 반환될 때까지 기다려야 합니다.</p><p><img src="https://raw.githubusercontent.com/cheese10yun/blog-sample/refs/heads/master/redis/docs/connection-pool-002.png" alt="Hikari Connection Pool - Threads Awaiting Connection"></p><p>이와 같이 전통적인 커넥션 풀의 개념에서는, <strong>요청 스레드마다 활성화된 커넥션을 사용</strong>하며, 해당 스레드가 작업을 끝내기 전까지는 <strong>커넥션을 점유</strong>하게 됩니다. 이는 데이터베이스의 동시 연결 수와 처리 능력을 효과적으로 관리할 수 있는 방법이지만, 커넥션이 사용 중일 때 대기하는 요청들이 발생할 수 있다는 단점이 있습니다.</p><p>이제 이러한 전통적인 커넥션 풀과 비교하여, <strong>Redis Lettuce Connection Pool</strong>이 어떻게 다른 방식으로 동작하는지에 대해 알아보겠습니다.</p><h2><span id="redis-lettuce-connection-poolyi-dongjag-bangsig">Redis Lettuce Connection Pool의 동작 방식</span><a href="#redis-lettuce-connection-poolyi-dongjag-bangsig" class="header-anchor">#</a></h2><p>Redis Lettuce Connection Pool의 동작 방식을 이해하기 위해, <strong>Redis 조회와 MySQL 조회가 함께 사용되는 시나리오</strong>를 살펴보겠습니다.</p><img class="kroki" src="data:image/svg+xml;base64,PHN2ZyBpZD0iY29udGFpbmVyIiB3aWR0aD0iNjY4IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMzYzIiB2aWV3Qm94PSItNTAgLTEwIDY2OCAzNjMiIHJvbGU9ImdyYXBoaWNzLWRvY3VtZW50IGRvY3VtZW50IiBhcmlhLXJvbGVkZXNjcmlwdGlvbj0ic2VxdWVuY2UiPjxnPjxyZWN0IHg9IjQxOCIgeT0iMjc3IiBmaWxsPSIjZWFlYWVhIiBzdHJva2U9IiM2NjYiIHdpZHRoPSIxNTAiIGhlaWdodD0iNjUiIG5hbWU9Ik15U1FMIiByeD0iMyIgcnk9IjMiIGNsYXNzPSJhY3RvciBhY3Rvci1ib3R0b20iPjwvcmVjdD48dGV4dCB4PSI0OTMiIHk9IjMwOS41IiBkb21pbmFudC1iYXNlbGluZT0iY2VudHJhbCIgYWxpZ25tZW50LWJhc2VsaW5lPSJjZW50cmFsIiBjbGFzcz0iYWN0b3IgYWN0b3ItYm94IiBzdHlsZT0idGV4dC1hbmNob3I6IG1pZGRsZTsgZm9udC1zaXplOiAxNnB4OyBmb250LXdlaWdodDogNDAwOyI+PHRzcGFuIHg9IjQ5MyIgZHk9IjAiPk15U1FMPC90c3Bhbj48L3RleHQ+PC9nPjxnPjxyZWN0IHg9IjIxOCIgeT0iMjc3IiBmaWxsPSIjZWFlYWVhIiBzdHJva2U9IiM2NjYiIHdpZHRoPSIxNTAiIGhlaWdodD0iNjUiIG5hbWU9IlJlZGlzIiByeD0iMyIgcnk9IjMiIGNsYXNzPSJhY3RvciBhY3Rvci1ib3R0b20iPjwvcmVjdD48dGV4dCB4PSIyOTMiIHk9IjMwOS41IiBkb21pbmFudC1iYXNlbGluZT0iY2VudHJhbCIgYWxpZ25tZW50LWJhc2VsaW5lPSJjZW50cmFsIiBjbGFzcz0iYWN0b3IgYWN0b3ItYm94IiBzdHlsZT0idGV4dC1hbmNob3I6IG1pZGRsZTsgZm9udC1zaXplOiAxNnB4OyBmb250LXdlaWdodDogNDAwOyI+PHRzcGFuIHg9IjI5MyIgZHk9IjAiPlJlZGlzPC90c3Bhbj48L3RleHQ+PC9nPjxnPjxyZWN0IHg9IjAiIHk9IjI3NyIgZmlsbD0iI2VhZWFlYSIgc3Ryb2tlPSIjNjY2IiB3aWR0aD0iMTUwIiBoZWlnaHQ9IjY1IiBuYW1lPSJTZXJ2ZXIiIHJ4PSIzIiByeT0iMyIgY2xhc3M9ImFjdG9yIGFjdG9yLWJvdHRvbSI+PC9yZWN0Pjx0ZXh0IHg9Ijc1IiB5PSIzMDkuNSIgZG9taW5hbnQtYmFzZWxpbmU9ImNlbnRyYWwiIGFsaWdubWVudC1iYXNlbGluZT0iY2VudHJhbCIgY2xhc3M9ImFjdG9yIGFjdG9yLWJveCIgc3R5bGU9InRleHQtYW5jaG9yOiBtaWRkbGU7IGZvbnQtc2l6ZTogMTZweDsgZm9udC13ZWlnaHQ6IDQwMDsiPjx0c3BhbiB4PSI3NSIgZHk9IjAiPlNlcnZlcjwvdHNwYW4+PC90ZXh0PjwvZz48Zz48bGluZSBpZD0iYWN0b3IyIiB4MT0iNDkzIiB5MT0iNjUiIHgyPSI0OTMiIHkyPSIyNzciIGNsYXNzPSJhY3Rvci1saW5lIDIwMCIgc3Ryb2tlLXdpZHRoPSIwLjVweCIgc3Ryb2tlPSIjOTk5IiBuYW1lPSJNeVNRTCI+PC9saW5lPjxnIGlkPSJyb290LTIiPjxyZWN0IHg9IjQxOCIgeT0iMCIgZmlsbD0iI2VhZWFlYSIgc3Ryb2tlPSIjNjY2IiB3aWR0aD0iMTUwIiBoZWlnaHQ9IjY1IiBuYW1lPSJNeVNRTCIgcng9IjMiIHJ5PSIzIiBjbGFzcz0iYWN0b3IgYWN0b3ItdG9wIj48L3JlY3Q+PHRleHQgeD0iNDkzIiB5PSIzMi41IiBkb21pbmFudC1iYXNlbGluZT0iY2VudHJhbCIgYWxpZ25tZW50LWJhc2VsaW5lPSJjZW50cmFsIiBjbGFzcz0iYWN0b3IgYWN0b3ItYm94IiBzdHlsZT0idGV4dC1hbmNob3I6IG1pZGRsZTsgZm9udC1zaXplOiAxNnB4OyBmb250LXdlaWdodDogNDAwOyI+PHRzcGFuIHg9IjQ5MyIgZHk9IjAiPk15U1FMPC90c3Bhbj48L3RleHQ+PC9nPjwvZz48Zz48bGluZSBpZD0iYWN0b3IxIiB4MT0iMjkzIiB5MT0iNjUiIHgyPSIyOTMiIHkyPSIyNzciIGNsYXNzPSJhY3Rvci1saW5lIDIwMCIgc3Ryb2tlLXdpZHRoPSIwLjVweCIgc3Ryb2tlPSIjOTk5IiBuYW1lPSJSZWRpcyI+PC9saW5lPjxnIGlkPSJyb290LTEiPjxyZWN0IHg9IjIxOCIgeT0iMCIgZmlsbD0iI2VhZWFlYSIgc3Ryb2tlPSIjNjY2IiB3aWR0aD0iMTUwIiBoZWlnaHQ9IjY1IiBuYW1lPSJSZWRpcyIgcng9IjMiIHJ5PSIzIiBjbGFzcz0iYWN0b3IgYWN0b3ItdG9wIj48L3JlY3Q+PHRleHQgeD0iMjkzIiB5PSIzMi41IiBkb21pbmFudC1iYXNlbGluZT0iY2VudHJhbCIgYWxpZ25tZW50LWJhc2VsaW5lPSJjZW50cmFsIiBjbGFzcz0iYWN0b3IgYWN0b3ItYm94IiBzdHlsZT0idGV4dC1hbmNob3I6IG1pZGRsZTsgZm9udC1zaXplOiAxNnB4OyBmb250LXdlaWdodDogNDAwOyI+PHRzcGFuIHg9IjI5MyIgZHk9IjAiPlJlZGlzPC90c3Bhbj48L3RleHQ+PC9nPjwvZz48Zz48bGluZSBpZD0iYWN0b3IwIiB4MT0iNzUiIHkxPSI2NSIgeDI9Ijc1IiB5Mj0iMjc3IiBjbGFzcz0iYWN0b3ItbGluZSAyMDAiIHN0cm9rZS13aWR0aD0iMC41cHgiIHN0cm9rZT0iIzk5OSIgbmFtZT0iU2VydmVyIj48L2xpbmU+PGcgaWQ9InJvb3QtMCI+PHJlY3QgeD0iMCIgeT0iMCIgZmlsbD0iI2VhZWFlYSIgc3Ryb2tlPSIjNjY2IiB3aWR0aD0iMTUwIiBoZWlnaHQ9IjY1IiBuYW1lPSJTZXJ2ZXIiIHJ4PSIzIiByeT0iMyIgY2xhc3M9ImFjdG9yIGFjdG9yLXRvcCI+PC9yZWN0Pjx0ZXh0IHg9Ijc1IiB5PSIzMi41IiBkb21pbmFudC1iYXNlbGluZT0iY2VudHJhbCIgYWxpZ25tZW50LWJhc2VsaW5lPSJjZW50cmFsIiBjbGFzcz0iYWN0b3IgYWN0b3ItYm94IiBzdHlsZT0idGV4dC1hbmNob3I6IG1pZGRsZTsgZm9udC1zaXplOiAxNnB4OyBmb250LXdlaWdodDogNDAwOyI+PHRzcGFuIHg9Ijc1IiBkeT0iMCI+U2VydmVyPC90c3Bhbj48L3RleHQ+PC9nPjwvZz48c3R5bGU+I2NvbnRhaW5lcntmb250LWZhbWlseToidHJlYnVjaGV0IG1zIix2ZXJkYW5hLGFyaWFsLHNhbnMtc2VyaWY7Zm9udC1zaXplOjE2cHg7ZmlsbDojMzMzO31Aa2V5ZnJhbWVzIGVkZ2UtYW5pbWF0aW9uLWZyYW1le2Zyb217c3Ryb2tlLWRhc2hvZmZzZXQ6MDt9fUBrZXlmcmFtZXMgZGFzaHt0b3tzdHJva2UtZGFzaG9mZnNldDowO319I2NvbnRhaW5lciAuZWRnZS1hbmltYXRpb24tc2xvd3tzdHJva2UtZGFzaGFycmF5OjksNSFpbXBvcnRhbnQ7c3Ryb2tlLWRhc2hvZmZzZXQ6OTAwO2FuaW1hdGlvbjpkYXNoIDUwcyBsaW5lYXIgaW5maW5pdGU7c3Ryb2tlLWxpbmVjYXA6cm91bmQ7fSNjb250YWluZXIgLmVkZ2UtYW5pbWF0aW9uLWZhc3R7c3Ryb2tlLWRhc2hhcnJheTo5LDUhaW1wb3J0YW50O3N0cm9rZS1kYXNob2Zmc2V0OjkwMDthbmltYXRpb246ZGFzaCAyMHMgbGluZWFyIGluZmluaXRlO3N0cm9rZS1saW5lY2FwOnJvdW5kO30jY29udGFpbmVyIC5lcnJvci1pY29ue2ZpbGw6IzU1MjIyMjt9I2NvbnRhaW5lciAuZXJyb3ItdGV4dHtmaWxsOiM1NTIyMjI7c3Ryb2tlOiM1NTIyMjI7fSNjb250YWluZXIgLmVkZ2UtdGhpY2tuZXNzLW5vcm1hbHtzdHJva2Utd2lkdGg6MXB4O30jY29udGFpbmVyIC5lZGdlLXRoaWNrbmVzcy10aGlja3tzdHJva2Utd2lkdGg6My41cHg7fSNjb250YWluZXIgLmVkZ2UtcGF0dGVybi1zb2xpZHtzdHJva2UtZGFzaGFycmF5OjA7fSNjb250YWluZXIgLmVkZ2UtdGhpY2tuZXNzLWludmlzaWJsZXtzdHJva2Utd2lkdGg6MDtmaWxsOm5vbmU7fSNjb250YWluZXIgLmVkZ2UtcGF0dGVybi1kYXNoZWR7c3Ryb2tlLWRhc2hhcnJheTozO30jY29udGFpbmVyIC5lZGdlLXBhdHRlcm4tZG90dGVke3N0cm9rZS1kYXNoYXJyYXk6Mjt9I2NvbnRhaW5lciAubWFya2Vye2ZpbGw6IzMzMzMzMztzdHJva2U6IzMzMzMzMzt9I2NvbnRhaW5lciAubWFya2VyLmNyb3Nze3N0cm9rZTojMzMzMzMzO30jY29udGFpbmVyIHN2Z3tmb250LWZhbWlseToidHJlYnVjaGV0IG1zIix2ZXJkYW5hLGFyaWFsLHNhbnMtc2VyaWY7Zm9udC1zaXplOjE2cHg7fSNjb250YWluZXIgcHttYXJnaW46MDt9I2NvbnRhaW5lciAuYWN0b3J7c3Ryb2tlOmhzbCgyNTkuNjI2MTY4MjI0MywgNTkuNzc2NTM2MzEyOCUsIDg3LjkwMTk2MDc4NDMlKTtmaWxsOiNFQ0VDRkY7fSNjb250YWluZXIgdGV4dC5hY3RvciZndDt0c3BhbntmaWxsOmJsYWNrO3N0cm9rZTpub25lO30jY29udGFpbmVyIC5hY3Rvci1saW5le3N0cm9rZTpoc2woMjU5LjYyNjE2ODIyNDMsIDU5Ljc3NjUzNjMxMjglLCA4Ny45MDE5NjA3ODQzJSk7fSNjb250YWluZXIgLmlubmVyQXJje3N0cm9rZS13aWR0aDoxLjU7c3Ryb2tlLWRhc2hhcnJheTpub25lO30jY29udGFpbmVyIC5tZXNzYWdlTGluZTB7c3Ryb2tlLXdpZHRoOjEuNTtzdHJva2UtZGFzaGFycmF5Om5vbmU7c3Ryb2tlOiMzMzM7fSNjb250YWluZXIgLm1lc3NhZ2VMaW5lMXtzdHJva2Utd2lkdGg6MS41O3N0cm9rZS1kYXNoYXJyYXk6MiwyO3N0cm9rZTojMzMzO30jY29udGFpbmVyICNhcnJvd2hlYWQgcGF0aHtmaWxsOiMzMzM7c3Ryb2tlOiMzMzM7fSNjb250YWluZXIgLnNlcXVlbmNlTnVtYmVye2ZpbGw6d2hpdGU7fSNjb250YWluZXIgI3NlcXVlbmNlbnVtYmVye2ZpbGw6IzMzMzt9I2NvbnRhaW5lciAjY3Jvc3NoZWFkIHBhdGh7ZmlsbDojMzMzO3N0cm9rZTojMzMzO30jY29udGFpbmVyIC5tZXNzYWdlVGV4dHtmaWxsOiMzMzM7c3Ryb2tlOm5vbmU7fSNjb250YWluZXIgLmxhYmVsQm94e3N0cm9rZTpoc2woMjU5LjYyNjE2ODIyNDMsIDU5Ljc3NjUzNjMxMjglLCA4Ny45MDE5NjA3ODQzJSk7ZmlsbDojRUNFQ0ZGO30jY29udGFpbmVyIC5sYWJlbFRleHQsI2NvbnRhaW5lciAubGFiZWxUZXh0Jmd0O3RzcGFue2ZpbGw6YmxhY2s7c3Ryb2tlOm5vbmU7fSNjb250YWluZXIgLmxvb3BUZXh0LCNjb250YWluZXIgLmxvb3BUZXh0Jmd0O3RzcGFue2ZpbGw6YmxhY2s7c3Ryb2tlOm5vbmU7fSNjb250YWluZXIgLmxvb3BMaW5le3N0cm9rZS13aWR0aDoycHg7c3Ryb2tlLWRhc2hhcnJheToyLDI7c3Ryb2tlOmhzbCgyNTkuNjI2MTY4MjI0MywgNTkuNzc2NTM2MzEyOCUsIDg3LjkwMTk2MDc4NDMlKTtmaWxsOmhzbCgyNTkuNjI2MTY4MjI0MywgNTkuNzc2NTM2MzEyOCUsIDg3LjkwMTk2MDc4NDMlKTt9I2NvbnRhaW5lciAubm90ZXtzdHJva2U6I2FhYWEzMztmaWxsOiNmZmY1YWQ7fSNjb250YWluZXIgLm5vdGVUZXh0LCNjb250YWluZXIgLm5vdGVUZXh0Jmd0O3RzcGFue2ZpbGw6YmxhY2s7c3Ryb2tlOm5vbmU7fSNjb250YWluZXIgLmFjdGl2YXRpb24we2ZpbGw6I2Y0ZjRmNDtzdHJva2U6IzY2Njt9I2NvbnRhaW5lciAuYWN0aXZhdGlvbjF7ZmlsbDojZjRmNGY0O3N0cm9rZTojNjY2O30jY29udGFpbmVyIC5hY3RpdmF0aW9uMntmaWxsOiNmNGY0ZjQ7c3Ryb2tlOiM2NjY7fSNjb250YWluZXIgLmFjdG9yUG9wdXBNZW51e3Bvc2l0aW9uOmFic29sdXRlO30jY29udGFpbmVyIC5hY3RvclBvcHVwTWVudVBhbmVse3Bvc2l0aW9uOmFic29sdXRlO2ZpbGw6I0VDRUNGRjtib3gtc2hhZG93OjBweCA4cHggMTZweCAwcHggcmdiYSgwLDAsMCwwLjIpO2ZpbHRlcjpkcm9wLXNoYWRvdygzcHggNXB4IDJweCByZ2IoMCAwIDAgLyAwLjQpKTt9I2NvbnRhaW5lciAuYWN0b3ItbWFuIGxpbmV7c3Ryb2tlOmhzbCgyNTkuNjI2MTY4MjI0MywgNTkuNzc2NTM2MzEyOCUsIDg3LjkwMTk2MDc4NDMlKTtmaWxsOiNFQ0VDRkY7fSNjb250YWluZXIgLmFjdG9yLW1hbiBjaXJjbGUsI2NvbnRhaW5lciBsaW5le3N0cm9rZTpoc2woMjU5LjYyNjE2ODIyNDMsIDU5Ljc3NjUzNjMxMjglLCA4Ny45MDE5NjA3ODQzJSk7ZmlsbDojRUNFQ0ZGO3N0cm9rZS13aWR0aDoycHg7fSNjb250YWluZXIgOnJvb3R7LS1tZXJtYWlkLWZvbnQtZmFtaWx5OiJ0cmVidWNoZXQgbXMiLHZlcmRhbmEsYXJpYWwsc2Fucy1zZXJpZjt9PC9zdHlsZT48Zz48L2c+PGRlZnM+PHN5bWJvbCBpZD0iY29tcHV0ZXIiIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCI+PHBhdGggdHJhbnNmb3JtPSJzY2FsZSguNSkiIGQ9Ik0yIDJ2MTNoMjB2LTEzaC0yMHptMTggMTFoLTE2di05aDE2djl6bS0xMC4yMjggNmwuNDY2LTFoMy41MjRsLjQ2NyAxaC00LjQ1N3ptMTQuMjI4IDNoLTI0bDItNmgyLjEwNGwtMS4zMyA0aDE4LjQ1bC0xLjI5Ny00aDIuMDczbDIgNnptLTUtMTBoLTE0di03aDE0djd6Ij48L3BhdGg+PC9zeW1ib2w+PC9kZWZzPjxkZWZzPjxzeW1ib2wgaWQ9ImRhdGFiYXNlIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCI+PHBhdGggdHJhbnNmb3JtPSJzY2FsZSguNSkiIGQ9Ik0xMi4yNTguMDAxbC4yNTYuMDA0LjI1NS4wMDUuMjUzLjAwOC4yNTEuMDEuMjQ5LjAxMi4yNDcuMDE1LjI0Ni4wMTYuMjQyLjAxOS4yNDEuMDIuMjM5LjAyMy4yMzYuMDI0LjIzMy4wMjcuMjMxLjAyOC4yMjkuMDMxLjIyNS4wMzIuMjIzLjAzNC4yMi4wMzYuMjE3LjAzOC4yMTQuMDQuMjExLjA0MS4yMDguMDQzLjIwNS4wNDUuMjAxLjA0Ni4xOTguMDQ4LjE5NC4wNS4xOTEuMDUxLjE4Ny4wNTMuMTgzLjA1NC4xOC4wNTYuMTc1LjA1Ny4xNzIuMDU5LjE2OC4wNi4xNjMuMDYxLjE2LjA2My4xNTUuMDY0LjE1LjA2Ni4wNzQuMDMzLjA3My4wMzMuMDcxLjAzNC4wNy4wMzQuMDY5LjAzNS4wNjguMDM1LjA2Ny4wMzUuMDY2LjAzNS4wNjQuMDM2LjA2NC4wMzYuMDYyLjAzNi4wNi4wMzYuMDYuMDM3LjA1OC4wMzcuMDU4LjAzNy4wNTUuMDM4LjA1NS4wMzguMDUzLjAzOC4wNTIuMDM4LjA1MS4wMzkuMDUuMDM5LjA0OC4wMzkuMDQ3LjAzOS4wNDUuMDQuMDQ0LjA0LjA0My4wNC4wNDEuMDQuMDQuMDQxLjAzOS4wNDEuMDM3LjA0MS4wMzYuMDQxLjAzNC4wNDEuMDMzLjA0Mi4wMzIuMDQyLjAzLjA0Mi4wMjkuMDQyLjAyNy4wNDIuMDI2LjA0My4wMjQuMDQzLjAyMy4wNDMuMDIxLjA0My4wMi4wNDMuMDE4LjA0NC4wMTcuMDQzLjAxNS4wNDQuMDEzLjA0NC4wMTIuMDQ0LjAxMS4wNDUuMDA5LjA0NC4wMDcuMDQ1LjAwNi4wNDUuMDA0LjA0NS4wMDIuMDQ1LjAwMS4wNDV2MTdsLS4wMDEuMDQ1LS4wMDIuMDQ1LS4wMDQuMDQ1LS4wMDYuMDQ1LS4wMDcuMDQ1LS4wMDkuMDQ0LS4wMTEuMDQ1LS4wMTIuMDQ0LS4wMTMuMDQ0LS4wMTUuMDQ0LS4wMTcuMDQzLS4wMTguMDQ0LS4wMi4wNDMtLjAyMS4wNDMtLjAyMy4wNDMtLjAyNC4wNDMtLjAyNi4wNDMtLjAyNy4wNDItLjAyOS4wNDItLjAzLjA0Mi0uMDMyLjA0Mi0uMDMzLjA0Mi0uMDM0LjA0MS0uMDM2LjA0MS0uMDM3LjA0MS0uMDM5LjA0MS0uMDQuMDQxLS4wNDEuMDQtLjA0My4wNC0uMDQ0LjA0LS4wNDUuMDQtLjA0Ny4wMzktLjA0OC4wMzktLjA1LjAzOS0uMDUxLjAzOS0uMDUyLjAzOC0uMDUzLjAzOC0uMDU1LjAzOC0uMDU1LjAzOC0uMDU4LjAzNy0uMDU4LjAzNy0uMDYuMDM3LS4wNi4wMzYtLjA2Mi4wMzYtLjA2NC4wMzYtLjA2NC4wMzYtLjA2Ni4wMzUtLjA2Ny4wMzUtLjA2OC4wMzUtLjA2OS4wMzUtLjA3LjAzNC0uMDcxLjAzNC0uMDczLjAzMy0uMDc0LjAzMy0uMTUuMDY2LS4xNTUuMDY0LS4xNi4wNjMtLjE2My4wNjEtLjE2OC4wNi0uMTcyLjA1OS0uMTc1LjA1Ny0uMTguMDU2LS4xODMuMDU0LS4xODcuMDUzLS4xOTEuMDUxLS4xOTQuMDUtLjE5OC4wNDgtLjIwMS4wNDYtLjIwNS4wNDUtLjIwOC4wNDMtLjIxMS4wNDEtLjIxNC4wNC0uMjE3LjAzOC0uMjIuMDM2LS4yMjMuMDM0LS4yMjUuMDMyLS4yMjkuMDMxLS4yMzEuMDI4LS4yMzMuMDI3LS4yMzYuMDI0LS4yMzkuMDIzLS4yNDEuMDItLjI0Mi4wMTktLjI0Ni4wMTYtLjI0Ny4wMTUtLjI0OS4wMTItLjI1MS4wMS0uMjUzLjAwOC0uMjU1LjAwNS0uMjU2LjAwNC0uMjU4LjAwMS0uMjU4LS4wMDEtLjI1Ni0uMDA0LS4yNTUtLjAwNS0uMjUzLS4wMDgtLjI1MS0uMDEtLjI0OS0uMDEyLS4yNDctLjAxNS0uMjQ1LS4wMTYtLjI0My0uMDE5LS4yNDEtLjAyLS4yMzgtLjAyMy0uMjM2LS4wMjQtLjIzNC0uMDI3LS4yMzEtLjAyOC0uMjI4LS4wMzEtLjIyNi0uMDMyLS4yMjMtLjAzNC0uMjItLjAzNi0uMjE3LS4wMzgtLjIxNC0uMDQtLjIxMS0uMDQxLS4yMDgtLjA0My0uMjA0LS4wNDUtLjIwMS0uMDQ2LS4xOTgtLjA0OC0uMTk1LS4wNS0uMTktLjA1MS0uMTg3LS4wNTMtLjE4NC0uMDU0LS4xNzktLjA1Ni0uMTc2LS4wNTctLjE3Mi0uMDU5LS4xNjctLjA2LS4xNjQtLjA2MS0uMTU5LS4wNjMtLjE1NS0uMDY0LS4xNTEtLjA2Ni0uMDc0LS4wMzMtLjA3Mi0uMDMzLS4wNzItLjAzNC0uMDctLjAzNC0uMDY5LS4wMzUtLjA2OC0uMDM1LS4wNjctLjAzNS0uMDY2LS4wMzUtLjA2NC0uMDM2LS4wNjMtLjAzNi0uMDYyLS4wMzYtLjA2MS0uMDM2LS4wNi0uMDM3LS4wNTgtLjAzNy0uMDU3LS4wMzctLjA1Ni0uMDM4LS4wNTUtLjAzOC0uMDUzLS4wMzgtLjA1Mi0uMDM4LS4wNTEtLjAzOS0uMDQ5LS4wMzktLjA0OS0uMDM5LS4wNDYtLjAzOS0uMDQ2LS4wNC0uMDQ0LS4wNC0uMDQzLS4wNC0uMDQxLS4wNC0uMDQtLjA0MS0uMDM5LS4wNDEtLjAzNy0uMDQxLS4wMzYtLjA0MS0uMDM0LS4wNDEtLjAzMy0uMDQyLS4wMzItLjA0Mi0uMDMtLjA0Mi0uMDI5LS4wNDItLjAyNy0uMDQyLS4wMjYtLjA0My0uMDI0LS4wNDMtLjAyMy0uMDQzLS4wMjEtLjA0My0uMDItLjA0My0uMDE4LS4wNDQtLjAxNy0uMDQzLS4wMTUtLjA0NC0uMDEzLS4wNDQtLjAxMi0uMDQ0LS4wMTEtLjA0NS0uMDA5LS4wNDQtLjAwNy0uMDQ1LS4wMDYtLjA0NS0uMDA0LS4wNDUtLjAwMi0uMDQ1LS4wMDEtLjA0NXYtMTdsLjAwMS0uMDQ1LjAwMi0uMDQ1LjAwNC0uMDQ1LjAwNi0uMDQ1LjAwNy0uMDQ1LjAwOS0uMDQ0LjAxMS0uMDQ1LjAxMi0uMDQ0LjAxMy0uMDQ0LjAxNS0uMDQ0LjAxNy0uMDQzLjAxOC0uMDQ0LjAyLS4wNDMuMDIxLS4wNDMuMDIzLS4wNDMuMDI0LS4wNDMuMDI2LS4wNDMuMDI3LS4wNDIuMDI5LS4wNDIuMDMtLjA0Mi4wMzItLjA0Mi4wMzMtLjA0Mi4wMzQtLjA0MS4wMzYtLjA0MS4wMzctLjA0MS4wMzktLjA0MS4wNC0uMDQxLjA0MS0uMDQuMDQzLS4wNC4wNDQtLjA0LjA0Ni0uMDQuMDQ2LS4wMzkuMDQ5LS4wMzkuMDQ5LS4wMzkuMDUxLS4wMzkuMDUyLS4wMzguMDUzLS4wMzguMDU1LS4wMzguMDU2LS4wMzguMDU3LS4wMzcuMDU4LS4wMzcuMDYtLjAzNy4wNjEtLjAzNi4wNjItLjAzNi4wNjMtLjAzNi4wNjQtLjAzNi4wNjYtLjAzNS4wNjctLjAzNS4wNjgtLjAzNS4wNjktLjAzNS4wNy0uMDM0LjA3Mi0uMDM0LjA3Mi0uMDMzLjA3NC0uMDMzLjE1MS0uMDY2LjE1NS0uMDY0LjE1OS0uMDYzLjE2NC0uMDYxLjE2Ny0uMDYuMTcyLS4wNTkuMTc2LS4wNTcuMTc5LS4wNTYuMTg0LS4wNTQuMTg3LS4wNTMuMTktLjA1MS4xOTUtLjA1LjE5OC0uMDQ4LjIwMS0uMDQ2LjIwNC0uMDQ1LjIwOC0uMDQzLjIxMS0uMDQxLjIxNC0uMDQuMjE3LS4wMzguMjItLjAzNi4yMjMtLjAzNC4yMjYtLjAzMi4yMjgtLjAzMS4yMzEtLjAyOC4yMzQtLjAyNy4yMzYtLjAyNC4yMzgtLjAyMy4yNDEtLjAyLjI0My0uMDE5LjI0NS0uMDE2LjI0Ny0uMDE1LjI0OS0uMDEyLjI1MS0uMDEuMjUzLS4wMDguMjU1LS4wMDUuMjU2LS4wMDQuMjU4LS4wMDEuMjU4LjAwMXptLTkuMjU4IDIwLjQ5OXYuMDFsLjAwMS4wMjEuMDAzLjAyMS4wMDQuMDIyLjAwNS4wMjEuMDA2LjAyMi4wMDcuMDIyLjAwOS4wMjMuMDEuMDIyLjAxMS4wMjMuMDEyLjAyMy4wMTMuMDIzLjAxNS4wMjMuMDE2LjAyNC4wMTcuMDIzLjAxOC4wMjQuMDE5LjAyNC4wMjEuMDI0LjAyMi4wMjUuMDIzLjAyNC4wMjQuMDI1LjA1Mi4wNDkuMDU2LjA1LjA2MS4wNTEuMDY2LjA1MS4wNy4wNTEuMDc1LjA1MS4wNzkuMDUyLjA4NC4wNTIuMDg4LjA1Mi4wOTIuMDUyLjA5Ny4wNTIuMTAyLjA1MS4xMDUuMDUyLjExLjA1Mi4xMTQuMDUxLjExOS4wNTEuMTIzLjA1MS4xMjcuMDUuMTMxLjA1LjEzNS4wNS4xMzkuMDQ4LjE0NC4wNDkuMTQ3LjA0Ny4xNTIuMDQ3LjE1NS4wNDcuMTYuMDQ1LjE2My4wNDUuMTY3LjA0My4xNzEuMDQzLjE3Ni4wNDEuMTc4LjA0MS4xODMuMDM5LjE4Ny4wMzkuMTkuMDM3LjE5NC4wMzUuMTk3LjAzNS4yMDIuMDMzLjIwNC4wMzEuMjA5LjAzLjIxMi4wMjkuMjE2LjAyNy4yMTkuMDI1LjIyMi4wMjQuMjI2LjAyMS4yMy4wMi4yMzMuMDE4LjIzNi4wMTYuMjQuMDE1LjI0My4wMTIuMjQ2LjAxLjI0OS4wMDguMjUzLjAwNS4yNTYuMDA0LjI1OS4wMDEuMjYtLjAwMS4yNTctLjAwNC4yNTQtLjAwNS4yNS0uMDA4LjI0Ny0uMDExLjI0NC0uMDEyLjI0MS0uMDE0LjIzNy0uMDE2LjIzMy0uMDE4LjIzMS0uMDIxLjIyNi0uMDIxLjIyNC0uMDI0LjIyLS4wMjYuMjE2LS4wMjcuMjEyLS4wMjguMjEtLjAzMS4yMDUtLjAzMS4yMDItLjAzNC4xOTgtLjAzNC4xOTQtLjAzNi4xOTEtLjAzNy4xODctLjAzOS4xODMtLjA0LjE3OS0uMDQuMTc1LS4wNDIuMTcyLS4wNDMuMTY4LS4wNDQuMTYzLS4wNDUuMTYtLjA0Ni4xNTUtLjA0Ni4xNTItLjA0Ny4xNDgtLjA0OC4xNDMtLjA0OS4xMzktLjA0OS4xMzYtLjA1LjEzMS0uMDUuMTI2LS4wNS4xMjMtLjA1MS4xMTgtLjA1Mi4xMTQtLjA1MS4xMS0uMDUyLjEwNi0uMDUyLjEwMS0uMDUyLjA5Ni0uMDUyLjA5Mi0uMDUyLjA4OC0uMDUzLjA4My0uMDUxLjA3OS0uMDUyLjA3NC0uMDUyLjA3LS4wNTEuMDY1LS4wNTEuMDYtLjA1MS4wNTYtLjA1LjA1MS0uMDUuMDIzLS4wMjQuMDIzLS4wMjUuMDIxLS4wMjQuMDItLjAyNC4wMTktLjAyNC4wMTgtLjAyNC4wMTctLjAyNC4wMTUtLjAyMy4wMTQtLjAyNC4wMTMtLjAyMy4wMTItLjAyMy4wMS0uMDIzLjAxLS4wMjIuMDA4LS4wMjIuMDA2LS4wMjIuMDA2LS4wMjIuMDA0LS4wMjIuMDA0LS4wMjEuMDAxLS4wMjEuMDAxLS4wMjF2LTQuMTI3bC0uMDc3LjA1NS0uMDguMDUzLS4wODMuMDU0LS4wODUuMDUzLS4wODcuMDUyLS4wOS4wNTItLjA5My4wNTEtLjA5NS4wNS0uMDk3LjA1LS4xLjA0OS0uMTAyLjA0OS0uMTA1LjA0OC0uMTA2LjA0Ny0uMTA5LjA0Ny0uMTExLjA0Ni0uMTE0LjA0NS0uMTE1LjA0NS0uMTE4LjA0NC0uMTIuMDQzLS4xMjIuMDQyLS4xMjQuMDQyLS4xMjYuMDQxLS4xMjguMDQtLjEzLjA0LS4xMzIuMDM4LS4xMzQuMDM4LS4xMzUuMDM3LS4xMzguMDM3LS4xMzkuMDM1LS4xNDIuMDM1LS4xNDMuMDM0LS4xNDQuMDMzLS4xNDcuMDMyLS4xNDguMDMxLS4xNS4wMy0uMTUxLjAzLS4xNTMuMDI5LS4xNTQuMDI3LS4xNTYuMDI3LS4xNTguMDI2LS4xNTkuMDI1LS4xNjEuMDI0LS4xNjIuMDIzLS4xNjMuMDIyLS4xNjUuMDIxLS4xNjYuMDItLjE2Ny4wMTktLjE2OS4wMTgtLjE2OS4wMTctLjE3MS4wMTYtLjE3My4wMTUtLjE3My4wMTQtLjE3NS4wMTMtLjE3NS4wMTItLjE3Ny4wMTEtLjE3OC4wMS0uMTc5LjAwOC0uMTc5LjAwOC0uMTgxLjAwNi0uMTgyLjAwNS0uMTgyLjAwNC0uMTg0LjAwMy0uMTg0LjAwMmgtLjM3bC0uMTg0LS4wMDItLjE4NC0uMDAzLS4xODItLjAwNC0uMTgyLS4wMDUtLjE4MS0uMDA2LS4xNzktLjAwOC0uMTc5LS4wMDgtLjE3OC0uMDEtLjE3Ni0uMDExLS4xNzYtLjAxMi0uMTc1LS4wMTMtLjE3My0uMDE0LS4xNzItLjAxNS0uMTcxLS4wMTYtLjE3LS4wMTctLjE2OS0uMDE4LS4xNjctLjAxOS0uMTY2LS4wMi0uMTY1LS4wMjEtLjE2My0uMDIyLS4xNjItLjAyMy0uMTYxLS4wMjQtLjE1OS0uMDI1LS4xNTctLjAyNi0uMTU2LS4wMjctLjE1NS0uMDI3LS4xNTMtLjAyOS0uMTUxLS4wMy0uMTUtLjAzLS4xNDgtLjAzMS0uMTQ2LS4wMzItLjE0NS0uMDMzLS4xNDMtLjAzNC0uMTQxLS4wMzUtLjE0LS4wMzUtLjEzNy0uMDM3LS4xMzYtLjAzNy0uMTM0LS4wMzgtLjEzMi0uMDM4LS4xMy0uMDQtLjEyOC0uMDQtLjEyNi0uMDQxLS4xMjQtLjA0Mi0uMTIyLS4wNDItLjEyLS4wNDQtLjExNy0uMDQzLS4xMTYtLjA0NS0uMTEzLS4wNDUtLjExMi0uMDQ2LS4xMDktLjA0Ny0uMTA2LS4wNDctLjEwNS0uMDQ4LS4xMDItLjA0OS0uMS0uMDQ5LS4wOTctLjA1LS4wOTUtLjA1LS4wOTMtLjA1Mi0uMDktLjA1MS0uMDg3LS4wNTItLjA4NS0uMDUzLS4wODMtLjA1NC0uMDgtLjA1NC0uMDc3LS4wNTR2NC4xMjd6bTAtNS42NTR2LjAxMWwuMDAxLjAyMS4wMDMuMDIxLjAwNC4wMjEuMDA1LjAyMi4wMDYuMDIyLjAwNy4wMjIuMDA5LjAyMi4wMS4wMjIuMDExLjAyMy4wMTIuMDIzLjAxMy4wMjMuMDE1LjAyNC4wMTYuMDIzLjAxNy4wMjQuMDE4LjAyNC4wMTkuMDI0LjAyMS4wMjQuMDIyLjAyNC4wMjMuMDI1LjAyNC4wMjQuMDUyLjA1LjA1Ni4wNS4wNjEuMDUuMDY2LjA1MS4wNy4wNTEuMDc1LjA1Mi4wNzkuMDUxLjA4NC4wNTIuMDg4LjA1Mi4wOTIuMDUyLjA5Ny4wNTIuMTAyLjA1Mi4xMDUuMDUyLjExLjA1MS4xMTQuMDUxLjExOS4wNTIuMTIzLjA1LjEyNy4wNTEuMTMxLjA1LjEzNS4wNDkuMTM5LjA0OS4xNDQuMDQ4LjE0Ny4wNDguMTUyLjA0Ny4xNTUuMDQ2LjE2LjA0NS4xNjMuMDQ1LjE2Ny4wNDQuMTcxLjA0Mi4xNzYuMDQyLjE3OC4wNC4xODMuMDQuMTg3LjAzOC4xOS4wMzcuMTk0LjAzNi4xOTcuMDM0LjIwMi4wMzMuMjA0LjAzMi4yMDkuMDMuMjEyLjAyOC4yMTYuMDI3LjIxOS4wMjUuMjIyLjAyNC4yMjYuMDIyLjIzLjAyLjIzMy4wMTguMjM2LjAxNi4yNC4wMTQuMjQzLjAxMi4yNDYuMDEuMjQ5LjAwOC4yNTMuMDA2LjI1Ni4wMDMuMjU5LjAwMS4yNi0uMDAxLjI1Ny0uMDAzLjI1NC0uMDA2LjI1LS4wMDguMjQ3LS4wMS4yNDQtLjAxMi4yNDEtLjAxNS4yMzctLjAxNi4yMzMtLjAxOC4yMzEtLjAyLjIyNi0uMDIyLjIyNC0uMDI0LjIyLS4wMjUuMjE2LS4wMjcuMjEyLS4wMjkuMjEtLjAzLjIwNS0uMDMyLjIwMi0uMDMzLjE5OC0uMDM1LjE5NC0uMDM2LjE5MS0uMDM3LjE4Ny0uMDM5LjE4My0uMDM5LjE3OS0uMDQxLjE3NS0uMDQyLjE3Mi0uMDQzLjE2OC0uMDQ0LjE2My0uMDQ1LjE2LS4wNDUuMTU1LS4wNDcuMTUyLS4wNDcuMTQ4LS4wNDguMTQzLS4wNDguMTM5LS4wNS4xMzYtLjA0OS4xMzEtLjA1LjEyNi0uMDUxLjEyMy0uMDUxLjExOC0uMDUxLjExNC0uMDUyLjExLS4wNTIuMTA2LS4wNTIuMTAxLS4wNTIuMDk2LS4wNTIuMDkyLS4wNTIuMDg4LS4wNTIuMDgzLS4wNTIuMDc5LS4wNTIuMDc0LS4wNTEuMDctLjA1Mi4wNjUtLjA1MS4wNi0uMDUuMDU2LS4wNTEuMDUxLS4wNDkuMDIzLS4wMjUuMDIzLS4wMjQuMDIxLS4wMjUuMDItLjAyNC4wMTktLjAyNC4wMTgtLjAyNC4wMTctLjAyNC4wMTUtLjAyMy4wMTQtLjAyMy4wMTMtLjAyNC4wMTItLjAyMi4wMS0uMDIzLjAxLS4wMjMuMDA4LS4wMjIuMDA2LS4wMjIuMDA2LS4wMjIuMDA0LS4wMjEuMDA0LS4wMjIuMDAxLS4wMjEuMDAxLS4wMjF2LTQuMTM5bC0uMDc3LjA1NC0uMDguMDU0LS4wODMuMDU0LS4wODUuMDUyLS4wODcuMDUzLS4wOS4wNTEtLjA5My4wNTEtLjA5NS4wNTEtLjA5Ny4wNS0uMS4wNDktLjEwMi4wNDktLjEwNS4wNDgtLjEwNi4wNDctLjEwOS4wNDctLjExMS4wNDYtLjExNC4wNDUtLjExNS4wNDQtLjExOC4wNDQtLjEyLjA0NC0uMTIyLjA0Mi0uMTI0LjA0Mi0uMTI2LjA0MS0uMTI4LjA0LS4xMy4wMzktLjEzMi4wMzktLjEzNC4wMzgtLjEzNS4wMzctLjEzOC4wMzYtLjEzOS4wMzYtLjE0Mi4wMzUtLjE0My4wMzMtLjE0NC4wMzMtLjE0Ny4wMzMtLjE0OC4wMzEtLjE1LjAzLS4xNTEuMDMtLjE1My4wMjgtLjE1NC4wMjgtLjE1Ni4wMjctLjE1OC4wMjYtLjE1OS4wMjUtLjE2MS4wMjQtLjE2Mi4wMjMtLjE2My4wMjItLjE2NS4wMjEtLjE2Ni4wMi0uMTY3LjAxOS0uMTY5LjAxOC0uMTY5LjAxNy0uMTcxLjAxNi0uMTczLjAxNS0uMTczLjAxNC0uMTc1LjAxMy0uMTc1LjAxMi0uMTc3LjAxMS0uMTc4LjAwOS0uMTc5LjAwOS0uMTc5LjAwNy0uMTgxLjAwNy0uMTgyLjAwNS0uMTgyLjAwNC0uMTg0LjAwMy0uMTg0LjAwMmgtLjM3bC0uMTg0LS4wMDItLjE4NC0uMDAzLS4xODItLjAwNC0uMTgyLS4wMDUtLjE4MS0uMDA3LS4xNzktLjAwNy0uMTc5LS4wMDktLjE3OC0uMDA5LS4xNzYtLjAxMS0uMTc2LS4wMTItLjE3NS0uMDEzLS4xNzMtLjAxNC0uMTcyLS4wMTUtLjE3MS0uMDE2LS4xNy0uMDE3LS4xNjktLjAxOC0uMTY3LS4wMTktLjE2Ni0uMDItLjE2NS0uMDIxLS4xNjMtLjAyMi0uMTYyLS4wMjMtLjE2MS0uMDI0LS4xNTktLjAyNS0uMTU3LS4wMjYtLjE1Ni0uMDI3LS4xNTUtLjAyOC0uMTUzLS4wMjgtLjE1MS0uMDMtLjE1LS4wMy0uMTQ4LS4wMzEtLjE0Ni0uMDMzLS4xNDUtLjAzMy0uMTQzLS4wMzMtLjE0MS0uMDM1LS4xNC0uMDM2LS4xMzctLjAzNi0uMTM2LS4wMzctLjEzNC0uMDM4LS4xMzItLjAzOS0uMTMtLjAzOS0uMTI4LS4wNC0uMTI2LS4wNDEtLjEyNC0uMDQyLS4xMjItLjA0My0uMTItLjA0My0uMTE3LS4wNDQtLjExNi0uMDQ0LS4xMTMtLjA0Ni0uMTEyLS4wNDYtLjEwOS0uMDQ2LS4xMDYtLjA0Ny0uMTA1LS4wNDgtLjEwMi0uMDQ5LS4xLS4wNDktLjA5Ny0uMDUtLjA5NS0uMDUxLS4wOTMtLjA1MS0uMDktLjA1MS0uMDg3LS4wNTMtLjA4NS0uMDUyLS4wODMtLjA1NC0uMDgtLjA1NC0uMDc3LS4wNTR2NC4xMzl6bTAtNS42NjZ2LjAxMWwuMDAxLjAyLjAwMy4wMjIuMDA0LjAyMS4wMDUuMDIyLjAwNi4wMjEuMDA3LjAyMi4wMDkuMDIzLjAxLjAyMi4wMTEuMDIzLjAxMi4wMjMuMDEzLjAyMy4wMTUuMDIzLjAxNi4wMjQuMDE3LjAyNC4wMTguMDIzLjAxOS4wMjQuMDIxLjAyNS4wMjIuMDI0LjAyMy4wMjQuMDI0LjAyNS4wNTIuMDUuMDU2LjA1LjA2MS4wNS4wNjYuMDUxLjA3LjA1MS4wNzUuMDUyLjA3OS4wNTEuMDg0LjA1Mi4wODguMDUyLjA5Mi4wNTIuMDk3LjA1Mi4xMDIuMDUyLjEwNS4wNTEuMTEuMDUyLjExNC4wNTEuMTE5LjA1MS4xMjMuMDUxLjEyNy4wNS4xMzEuMDUuMTM1LjA1LjEzOS4wNDkuMTQ0LjA0OC4xNDcuMDQ4LjE1Mi4wNDcuMTU1LjA0Ni4xNi4wNDUuMTYzLjA0NS4xNjcuMDQzLjE3MS4wNDMuMTc2LjA0Mi4xNzguMDQuMTgzLjA0LjE4Ny4wMzguMTkuMDM3LjE5NC4wMzYuMTk3LjAzNC4yMDIuMDMzLjIwNC4wMzIuMjA5LjAzLjIxMi4wMjguMjE2LjAyNy4yMTkuMDI1LjIyMi4wMjQuMjI2LjAyMS4yMy4wMi4yMzMuMDE4LjIzNi4wMTcuMjQuMDE0LjI0My4wMTIuMjQ2LjAxLjI0OS4wMDguMjUzLjAwNi4yNTYuMDAzLjI1OS4wMDEuMjYtLjAwMS4yNTctLjAwMy4yNTQtLjAwNi4yNS0uMDA4LjI0Ny0uMDEuMjQ0LS4wMTMuMjQxLS4wMTQuMjM3LS4wMTYuMjMzLS4wMTguMjMxLS4wMi4yMjYtLjAyMi4yMjQtLjAyNC4yMi0uMDI1LjIxNi0uMDI3LjIxMi0uMDI5LjIxLS4wMy4yMDUtLjAzMi4yMDItLjAzMy4xOTgtLjAzNS4xOTQtLjAzNi4xOTEtLjAzNy4xODctLjAzOS4xODMtLjAzOS4xNzktLjA0MS4xNzUtLjA0Mi4xNzItLjA0My4xNjgtLjA0NC4xNjMtLjA0NS4xNi0uMDQ1LjE1NS0uMDQ3LjE1Mi0uMDQ3LjE0OC0uMDQ4LjE0My0uMDQ5LjEzOS0uMDQ5LjEzNi0uMDQ5LjEzMS0uMDUxLjEyNi0uMDUuMTIzLS4wNTEuMTE4LS4wNTIuMTE0LS4wNTEuMTEtLjA1Mi4xMDYtLjA1Mi4xMDEtLjA1Mi4wOTYtLjA1Mi4wOTItLjA1Mi4wODgtLjA1Mi4wODMtLjA1Mi4wNzktLjA1Mi4wNzQtLjA1Mi4wNy0uMDUxLjA2NS0uMDUxLjA2LS4wNTEuMDU2LS4wNS4wNTEtLjA0OS4wMjMtLjAyNS4wMjMtLjAyNS4wMjEtLjAyNC4wMi0uMDI0LjAxOS0uMDI0LjAxOC0uMDI0LjAxNy0uMDI0LjAxNS0uMDIzLjAxNC0uMDI0LjAxMy0uMDIzLjAxMi0uMDIzLjAxLS4wMjIuMDEtLjAyMy4wMDgtLjAyMi4wMDYtLjAyMi4wMDYtLjAyMi4wMDQtLjAyMi4wMDQtLjAyMS4wMDEtLjAyMS4wMDEtLjAyMXYtNC4xNTNsLS4wNzcuMDU0LS4wOC4wNTQtLjA4My4wNTMtLjA4NS4wNTMtLjA4Ny4wNTMtLjA5LjA1MS0uMDkzLjA1MS0uMDk1LjA1MS0uMDk3LjA1LS4xLjA0OS0uMTAyLjA0OC0uMTA1LjA0OC0uMTA2LjA0OC0uMTA5LjA0Ni0uMTExLjA0Ni0uMTE0LjA0Ni0uMTE1LjA0NC0uMTE4LjA0NC0uMTIuMDQzLS4xMjIuMDQzLS4xMjQuMDQyLS4xMjYuMDQxLS4xMjguMDQtLjEzLjAzOS0uMTMyLjAzOS0uMTM0LjAzOC0uMTM1LjAzNy0uMTM4LjAzNi0uMTM5LjAzNi0uMTQyLjAzNC0uMTQzLjAzNC0uMTQ0LjAzMy0uMTQ3LjAzMi0uMTQ4LjAzMi0uMTUuMDMtLjE1MS4wMy0uMTUzLjAyOC0uMTU0LjAyOC0uMTU2LjAyNy0uMTU4LjAyNi0uMTU5LjAyNC0uMTYxLjAyNC0uMTYyLjAyMy0uMTYzLjAyMy0uMTY1LjAyMS0uMTY2LjAyLS4xNjcuMDE5LS4xNjkuMDE4LS4xNjkuMDE3LS4xNzEuMDE2LS4xNzMuMDE1LS4xNzMuMDE0LS4xNzUuMDEzLS4xNzUuMDEyLS4xNzcuMDEtLjE3OC4wMS0uMTc5LjAwOS0uMTc5LjAwNy0uMTgxLjAwNi0uMTgyLjAwNi0uMTgyLjAwNC0uMTg0LjAwMy0uMTg0LjAwMS0uMTg1LjAwMS0uMTg1LS4wMDEtLjE4NC0uMDAxLS4xODQtLjAwMy0uMTgyLS4wMDQtLjE4Mi0uMDA2LS4xODEtLjAwNi0uMTc5LS4wMDctLjE3OS0uMDA5LS4xNzgtLjAxLS4xNzYtLjAxLS4xNzYtLjAxMi0uMTc1LS4wMTMtLjE3My0uMDE0LS4xNzItLjAxNS0uMTcxLS4wMTYtLjE3LS4wMTctLjE2OS0uMDE4LS4xNjctLjAxOS0uMTY2LS4wMi0uMTY1LS4wMjEtLjE2My0uMDIzLS4xNjItLjAyMy0uMTYxLS4wMjQtLjE1OS0uMDI0LS4xNTctLjAyNi0uMTU2LS4wMjctLjE1NS0uMDI4LS4xNTMtLjAyOC0uMTUxLS4wMy0uMTUtLjAzLS4xNDgtLjAzMi0uMTQ2LS4wMzItLjE0NS0uMDMzLS4xNDMtLjAzNC0uMTQxLS4wMzQtLjE0LS4wMzYtLjEzNy0uMDM2LS4xMzYtLjAzNy0uMTM0LS4wMzgtLjEzMi0uMDM5LS4xMy0uMDM5LS4xMjgtLjA0MS0uMTI2LS4wNDEtLjEyNC0uMDQxLS4xMjItLjA0My0uMTItLjA0My0uMTE3LS4wNDQtLjExNi0uMDQ0LS4xMTMtLjA0Ni0uMTEyLS4wNDYtLjEwOS0uMDQ2LS4xMDYtLjA0OC0uMTA1LS4wNDgtLjEwMi0uMDQ4LS4xLS4wNS0uMDk3LS4wNDktLjA5NS0uMDUxLS4wOTMtLjA1MS0uMDktLjA1Mi0uMDg3LS4wNTItLjA4NS0uMDUzLS4wODMtLjA1My0uMDgtLjA1NC0uMDc3LS4wNTR2NC4xNTN6bTguNzQtOC4xNzlsLS4yNTcuMDA0LS4yNTQuMDA1LS4yNS4wMDgtLjI0Ny4wMTEtLjI0NC4wMTItLjI0MS4wMTQtLjIzNy4wMTYtLjIzMy4wMTgtLjIzMS4wMjEtLjIyNi4wMjItLjIyNC4wMjMtLjIyLjAyNi0uMjE2LjAyNy0uMjEyLjAyOC0uMjEuMDMxLS4yMDUuMDMyLS4yMDIuMDMzLS4xOTguMDM0LS4xOTQuMDM2LS4xOTEuMDM4LS4xODcuMDM4LS4xODMuMDQtLjE3OS4wNDEtLjE3NS4wNDItLjE3Mi4wNDMtLjE2OC4wNDMtLjE2My4wNDUtLjE2LjA0Ni0uMTU1LjA0Ni0uMTUyLjA0OC0uMTQ4LjA0OC0uMTQzLjA0OC0uMTM5LjA0OS0uMTM2LjA1LS4xMzEuMDUtLjEyNi4wNTEtLjEyMy4wNTEtLjExOC4wNTEtLjExNC4wNTItLjExLjA1Mi0uMTA2LjA1Mi0uMTAxLjA1Mi0uMDk2LjA1Mi0uMDkyLjA1Mi0uMDg4LjA1Mi0uMDgzLjA1Mi0uMDc5LjA1Mi0uMDc0LjA1MS0uMDcuMDUyLS4wNjUuMDUxLS4wNi4wNS0uMDU2LjA1LS4wNTEuMDUtLjAyMy4wMjUtLjAyMy4wMjQtLjAyMS4wMjQtLjAyLjAyNS0uMDE5LjAyNC0uMDE4LjAyNC0uMDE3LjAyMy0uMDE1LjAyNC0uMDE0LjAyMy0uMDEzLjAyMy0uMDEyLjAyMy0uMDEuMDIzLS4wMS4wMjItLjAwOC4wMjItLjAwNi4wMjMtLjAwNi4wMjEtLjAwNC4wMjItLjAwNC4wMjEtLjAwMS4wMjEtLjAwMS4wMjEuMDAxLjAyMS4wMDEuMDIxLjAwNC4wMjEuMDA0LjAyMi4wMDYuMDIxLjAwNi4wMjMuMDA4LjAyMi4wMS4wMjIuMDEuMDIzLjAxMi4wMjMuMDEzLjAyMy4wMTQuMDIzLjAxNS4wMjQuMDE3LjAyMy4wMTguMDI0LjAxOS4wMjQuMDIuMDI1LjAyMS4wMjQuMDIzLjAyNC4wMjMuMDI1LjA1MS4wNS4wNTYuMDUuMDYuMDUuMDY1LjA1MS4wNy4wNTIuMDc0LjA1MS4wNzkuMDUyLjA4My4wNTIuMDg4LjA1Mi4wOTIuMDUyLjA5Ni4wNTIuMTAxLjA1Mi4xMDYuMDUyLjExLjA1Mi4xMTQuMDUyLjExOC4wNTEuMTIzLjA1MS4xMjYuMDUxLjEzMS4wNS4xMzYuMDUuMTM5LjA0OS4xNDMuMDQ4LjE0OC4wNDguMTUyLjA0OC4xNTUuMDQ2LjE2LjA0Ni4xNjMuMDQ1LjE2OC4wNDMuMTcyLjA0My4xNzUuMDQyLjE3OS4wNDEuMTgzLjA0LjE4Ny4wMzguMTkxLjAzOC4xOTQuMDM2LjE5OC4wMzQuMjAyLjAzMy4yMDUuMDMyLjIxLjAzMS4yMTIuMDI4LjIxNi4wMjcuMjIuMDI2LjIyNC4wMjMuMjI2LjAyMi4yMzEuMDIxLjIzMy4wMTguMjM3LjAxNi4yNDEuMDE0LjI0NC4wMTIuMjQ3LjAxMS4yNS4wMDguMjU0LjAwNS4yNTcuMDA0LjI2LjAwMS4yNi0uMDAxLjI1Ny0uMDA0LjI1NC0uMDA1LjI1LS4wMDguMjQ3LS4wMTEuMjQ0LS4wMTIuMjQxLS4wMTQuMjM3LS4wMTYuMjMzLS4wMTguMjMxLS4wMjEuMjI2LS4wMjIuMjI0LS4wMjMuMjItLjAyNi4yMTYtLjAyNy4yMTItLjAyOC4yMS0uMDMxLjIwNS0uMDMyLjIwMi0uMDMzLjE5OC0uMDM0LjE5NC0uMDM2LjE5MS0uMDM4LjE4Ny0uMDM4LjE4My0uMDQuMTc5LS4wNDEuMTc1LS4wNDIuMTcyLS4wNDMuMTY4LS4wNDMuMTYzLS4wNDUuMTYtLjA0Ni4xNTUtLjA0Ni4xNTItLjA0OC4xNDgtLjA0OC4xNDMtLjA0OC4xMzktLjA0OS4xMzYtLjA1LjEzMS0uMDUuMTI2LS4wNTEuMTIzLS4wNTEuMTE4LS4wNTEuMTE0LS4wNTIuMTEtLjA1Mi4xMDYtLjA1Mi4xMDEtLjA1Mi4wOTYtLjA1Mi4wOTItLjA1Mi4wODgtLjA1Mi4wODMtLjA1Mi4wNzktLjA1Mi4wNzQtLjA1MS4wNy0uMDUyLjA2NS0uMDUxLjA2LS4wNS4wNTYtLjA1LjA1MS0uMDUuMDIzLS4wMjUuMDIzLS4wMjQuMDIxLS4wMjQuMDItLjAyNS4wMTktLjAyNC4wMTgtLjAyNC4wMTctLjAyMy4wMTUtLjAyNC4wMTQtLjAyMy4wMTMtLjAyMy4wMTItLjAyMy4wMS0uMDIzLjAxLS4wMjIuMDA4LS4wMjIuMDA2LS4wMjMuMDA2LS4wMjEuMDA0LS4wMjIuMDA0LS4wMjEuMDAxLS4wMjEuMDAxLS4wMjEtLjAwMS0uMDIxLS4wMDEtLjAyMS0uMDA0LS4wMjEtLjAwNC0uMDIyLS4wMDYtLjAyMS0uMDA2LS4wMjMtLjAwOC0uMDIyLS4wMS0uMDIyLS4wMS0uMDIzLS4wMTItLjAyMy0uMDEzLS4wMjMtLjAxNC0uMDIzLS4wMTUtLjAyNC0uMDE3LS4wMjMtLjAxOC0uMDI0LS4wMTktLjAyNC0uMDItLjAyNS0uMDIxLS4wMjQtLjAyMy0uMDI0LS4wMjMtLjAyNS0uMDUxLS4wNS0uMDU2LS4wNS0uMDYtLjA1LS4wNjUtLjA1MS0uMDctLjA1Mi0uMDc0LS4wNTEtLjA3OS0uMDUyLS4wODMtLjA1Mi0uMDg4LS4wNTItLjA5Mi0uMDUyLS4wOTYtLjA1Mi0uMTAxLS4wNTItLjEwNi0uMDUyLS4xMS0uMDUyLS4xMTQtLjA1Mi0uMTE4LS4wNTEtLjEyMy0uMDUxLS4xMjYtLjA1MS0uMTMxLS4wNS0uMTM2LS4wNS0uMTM5LS4wNDktLjE0My0uMDQ4LS4xNDgtLjA0OC0uMTUyLS4wNDgtLjE1NS0uMDQ2LS4xNi0uMDQ2LS4xNjMtLjA0NS0uMTY4LS4wNDMtLjE3Mi0uMDQzLS4xNzUtLjA0Mi0uMTc5LS4wNDEtLjE4My0uMDQtLjE4Ny0uMDM4LS4xOTEtLjAzOC0uMTk0LS4wMzYtLjE5OC0uMDM0LS4yMDItLjAzMy0uMjA1LS4wMzItLjIxLS4wMzEtLjIxMi0uMDI4LS4yMTYtLjAyNy0uMjItLjAyNi0uMjI0LS4wMjMtLjIyNi0uMDIyLS4yMzEtLjAyMS0uMjMzLS4wMTgtLjIzNy0uMDE2LS4yNDEtLjAxNC0uMjQ0LS4wMTItLjI0Ny0uMDExLS4yNS0uMDA4LS4yNTQtLjAwNS0uMjU3LS4wMDQtLjI2LS4wMDEtLjI2LjAwMXoiPjwvcGF0aD48L3N5bWJvbD48L2RlZnM+PGRlZnM+PHN5bWJvbCBpZD0iY2xvY2siIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCI+PHBhdGggdHJhbnNmb3JtPSJzY2FsZSguNSkiIGQ9Ik0xMiAyYzUuNTE0IDAgMTAgNC40ODYgMTAgMTBzLTQuNDg2IDEwLTEwIDEwLTEwLTQuNDg2LTEwLTEwIDQuNDg2LTEwIDEwLTEwem0wLTJjLTYuNjI3IDAtMTIgNS4zNzMtMTIgMTJzNS4zNzMgMTIgMTIgMTIgMTItNS4zNzMgMTItMTItNS4zNzMtMTItMTItMTJ6bTUuODQ4IDEyLjQ1OWMuMjAyLjAzOC4yMDIuMzMzLjAwMS4zNzItMS45MDcuMzYxLTYuMDQ1IDEuMTExLTYuNTQ3IDEuMTExLS43MTkgMC0xLjMwMS0uNTgyLTEuMzAxLTEuMzAxIDAtLjUxMi43Ny01LjQ0NyAxLjEyNS03LjQ0NS4wMzQtLjE5Mi4zMTItLjE4MS4zNDMuMDE0bC45ODUgNi4yMzggNS4zOTQgMS4wMTF6Ij48L3BhdGg+PC9zeW1ib2w+PC9kZWZzPjxkZWZzPjxtYXJrZXIgaWQ9ImFycm93aGVhZCIgcmVmWD0iNy45IiByZWZZPSI1IiBtYXJrZXJVbml0cz0idXNlclNwYWNlT25Vc2UiIG1hcmtlcldpZHRoPSIxMiIgbWFya2VySGVpZ2h0PSIxMiIgb3JpZW50PSJhdXRvLXN0YXJ0LXJldmVyc2UiPjxwYXRoIGQ9Ik0gLTEgMCBMIDEwIDUgTCAwIDEwIHoiPjwvcGF0aD48L21hcmtlcj48L2RlZnM+PGRlZnM+PG1hcmtlciBpZD0iY3Jvc3NoZWFkIiBtYXJrZXJXaWR0aD0iMTUiIG1hcmtlckhlaWdodD0iOCIgb3JpZW50PSJhdXRvIiByZWZYPSI0IiByZWZZPSI0LjUiPjxwYXRoIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzAwMDAwMCIgc3Ryb2tlLXdpZHRoPSIxcHQiIGQ9Ik0gMSwyIEwgNiw3IE0gNiwyIEwgMSw3IiBzdHlsZT0ic3Ryb2tlLWRhc2hhcnJheTogMCwgMDsiPjwvcGF0aD48L21hcmtlcj48L2RlZnM+PGRlZnM+PG1hcmtlciBpZD0iZmlsbGVkLWhlYWQiIHJlZlg9IjE1LjUiIHJlZlk9IjciIG1hcmtlcldpZHRoPSIyMCIgbWFya2VySGVpZ2h0PSIyOCIgb3JpZW50PSJhdXRvIj48cGF0aCBkPSJNIDE4LDcgTDksMTMgTDE0LDcgTDksMSBaIj48L3BhdGg+PC9tYXJrZXI+PC9kZWZzPjxkZWZzPjxtYXJrZXIgaWQ9InNlcXVlbmNlbnVtYmVyIiByZWZYPSIxNSIgcmVmWT0iMTUiIG1hcmtlcldpZHRoPSI2MCIgbWFya2VySGVpZ2h0PSI0MCIgb3JpZW50PSJhdXRvIj48Y2lyY2xlIGN4PSIxNSIgY3k9IjE1IiByPSI2Ij48L2NpcmNsZT48L21hcmtlcj48L2RlZnM+PHRleHQgeD0iMTgzIiB5PSI4MCIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZG9taW5hbnQtYmFzZWxpbmU9Im1pZGRsZSIgYWxpZ25tZW50LWJhc2VsaW5lPSJtaWRkbGUiIGNsYXNzPSJtZXNzYWdlVGV4dCIgZHk9IjFlbSIgc3R5bGU9ImZvbnQtc2l6ZTogMTZweDsgZm9udC13ZWlnaHQ6IDQwMDsiPuy/oO2PsCDsobDtmowg7JqU7LKtPC90ZXh0PjxsaW5lIHgxPSI3NiIgeTE9IjExMyIgeDI9IjI4OSIgeTI9IjExMyIgY2xhc3M9Im1lc3NhZ2VMaW5lMCIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2U9Im5vbmUiIG1hcmtlci1lbmQ9InVybCgjYXJyb3doZWFkKSIgc3R5bGU9ImZpbGw6IG5vbmU7Ij48L2xpbmU+PHRleHQgeD0iMTg2IiB5PSIxMjgiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGRvbWluYW50LWJhc2VsaW5lPSJtaWRkbGUiIGFsaWdubWVudC1iYXNlbGluZT0ibWlkZGxlIiBjbGFzcz0ibWVzc2FnZVRleHQiIGR5PSIxZW0iIHN0eWxlPSJmb250LXNpemU6IDE2cHg7IGZvbnQtd2VpZ2h0OiA0MDA7Ij7sv6Dtj7Ag7KGw7ZqMIOydkeuLtSAoMTBtcyk8L3RleHQ+PGxpbmUgeDE9IjI5MiIgeTE9IjE2MSIgeDI9Ijc5IiB5Mj0iMTYxIiBjbGFzcz0ibWVzc2FnZUxpbmUxIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZT0ibm9uZSIgbWFya2VyLWVuZD0idXJsKCNhcnJvd2hlYWQpIiBzdHlsZT0ic3Ryb2tlLWRhc2hhcnJheTogMywgMzsgZmlsbDogbm9uZTsiPjwvbGluZT48dGV4dCB4PSIyODMiIHk9IjE3NiIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZG9taW5hbnQtYmFzZWxpbmU9Im1pZGRsZSIgYWxpZ25tZW50LWJhc2VsaW5lPSJtaWRkbGUiIGNsYXNzPSJtZXNzYWdlVGV4dCIgZHk9IjFlbSIgc3R5bGU9ImZvbnQtc2l6ZTogMTZweDsgZm9udC13ZWlnaHQ6IDQwMDsiPuyjvOusuCDsoJXrs7Qg7KGw7ZqMIOyalOyyrSAoMiw1MDBtcyk8L3RleHQ+PGxpbmUgeDE9Ijc2IiB5MT0iMjA5IiB4Mj0iNDg5IiB5Mj0iMjA5IiBjbGFzcz0ibWVzc2FnZUxpbmUwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZT0ibm9uZSIgbWFya2VyLWVuZD0idXJsKCNhcnJvd2hlYWQpIiBzdHlsZT0iZmlsbDogbm9uZTsiPjwvbGluZT48dGV4dCB4PSIyODYiIHk9IjIyNCIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZG9taW5hbnQtYmFzZWxpbmU9Im1pZGRsZSIgYWxpZ25tZW50LWJhc2VsaW5lPSJtaWRkbGUiIGNsYXNzPSJtZXNzYWdlVGV4dCIgZHk9IjFlbSIgc3R5bGU9ImZvbnQtc2l6ZTogMTZweDsgZm9udC13ZWlnaHQ6IDQwMDsiPuyjvOusuCDsoJXrs7Qg7KGw7ZqMIOydkeuLtTwvdGV4dD48bGluZSB4MT0iNDkyIiB5MT0iMjU3IiB4Mj0iNzkiIHkyPSIyNTciIGNsYXNzPSJtZXNzYWdlTGluZTEiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlPSJub25lIiBtYXJrZXItZW5kPSJ1cmwoI2Fycm93aGVhZCkiIHN0eWxlPSJzdHJva2UtZGFzaGFycmF5OiAzLCAzOyBmaWxsOiBub25lOyI+PC9saW5lPjwvc3ZnPg=="><p>위의 시나리오에서 애플리케이션은 먼저 Redis에서 쿠폰 정보를 조회한 후, MySQL에서 주문 정보를 조회합니다. Redis 쿠폰 조회는 10ms 만에 응답이 오지만, 이후 이어지는 MySQL 조회는 2,500ms가 걸립니다. 이 상황에서 Redis Lettuce 커넥션 풀이 어떻게 동작하는지를 이해하는 것이 중요합니다.</p><p>Redis에 쿠폰 조회 요청을 보내면, 10ms 내에 쿠폰 정보가 응답됩니다. 여기서 Lettuce 커넥션 풀이 1개만 있다고 가정해 보겠습니다. 만약 이 상황이 전통적인 커넥션 풀 구조였다면, MySQL 데이터 조회(2,500ms)가 완료되기 전까지 하나뿐인 Redis 커넥션이 블록되어 Redis에 대한 추가적인 요청을 처리할 수 없었을 것입니다. 이는 Redis 서버가 이미 응답을 완료했음에도 불구하고, 애플리케이션 측에서 더 이상 Redis에 대한 요청을 처리할 수 없게 된다는 문제를 야기합니다.</p><p>그러나 Redis Lettuce의 경우 비동기적으로 동작할 수 있습니다. Redis 서버에서 응답을 내린 후 해당 커넥션이 즉시 반환된다면, MySQL 조회가 진행 중이더라도 Redis에 대한 새로운 요청을 처리할 수 있게 됩니다. 이는 Redis 서버가 싱글 스레드로 동작하더라도 Lettuce 클라이언트 측에서는 추가적인 요청을 계속해서 보낼 수 있는 가능성을 열어줍니다. 그렇다면 실제로 Redis Lettuce가 이러한 방식으로 동작하는지, 아니면 다른 방식으로 동작하는지 <strong>코드를 통해 더 자세히 살펴보겠습니다</strong>.</p><h2><span id="redis-lettucewa-hikari-dongjag-bigyoreul-wihan-kodeu">Redis Lettuce와 Hikari 동작 비교를 위한 코드</span><a href="#redis-lettucewa-hikari-dongjag-bigyoreul-wihan-kodeu" class="header-anchor">#</a></h2><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br><span class="line">47</span><br><span class="line">48</span><br><span class="line">49</span><br><span class="line">50</span><br><span class="line">51</span><br><span class="line">52</span><br><span class="line">53</span><br><span class="line">54</span><br><span class="line">55</span><br><span class="line">56</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@RestController</span></span><br><span class="line"><span class="meta">@RequestMapping</span></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">MemberController</span>(</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">val</span> redisConnectionPoolSample: RedisConnectionPoolSample,</span><br><span class="line">) &#123;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@GetMapping(<span class="string">&quot;/api/redis&quot;</span>)</span></span><br><span class="line">    <span class="function"><span class="keyword">fun</span> <span class="title">getRedis</span><span class="params">(<span class="meta">@RequestParam(<span class="string">&quot;id&quot;</span>)</span> id: <span class="type">String</span>)</span></span> = redisConnectionPoolSample.getRedis(id)</span><br><span class="line"></span><br><span class="line">    <span class="meta">@GetMapping(<span class="string">&quot;/api/mysql&quot;</span>)</span></span><br><span class="line">    <span class="function"><span class="keyword">fun</span> <span class="title">getMySql</span><span class="params">(<span class="meta">@RequestParam(<span class="string">&quot;id&quot;</span>)</span> id: <span class="type">Long</span>)</span></span> = redisConnectionPoolSample.getMySql(id)</span><br><span class="line"></span><br><span class="line">    <span class="meta">@GetMapping(<span class="string">&quot;/api/composite&quot;</span>)</span></span><br><span class="line">    <span class="function"><span class="keyword">fun</span> <span class="title">getRedis2</span><span class="params">(<span class="meta">@RequestParam(<span class="string">&quot;id&quot;</span>)</span> id: <span class="type">String</span>)</span></span> = redisConnectionPoolSample.getComposite(id)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="meta">@Service</span></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">RedisConnectionPoolSample</span>(</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">val</span> couponRepository: CouponRepository,</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">val</span> orderRepository: OrderRepository</span><br><span class="line"></span><br><span class="line">) &#123;</span><br><span class="line"></span><br><span class="line">    <span class="function"><span class="keyword">fun</span> <span class="title">getRedis</span><span class="params">(id: <span class="type">String</span>)</span></span>: Coupon? &#123;</span><br><span class="line">        <span class="keyword">return</span> couponRepository.findByIdOrNull(id)</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line"></span><br><span class="line">    <span class="function"><span class="keyword">fun</span> <span class="title">getMySql</span><span class="params">(id: <span class="type">Long</span>)</span></span>: Order? &#123;</span><br><span class="line">        printHikariConnection()</span><br><span class="line">        <span class="keyword">return</span> orderRepository.findByIdOrNull(id)</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="function"><span class="keyword">fun</span> <span class="title">getComposite</span><span class="params">(id: <span class="type">String</span>)</span></span>: Pair&lt;Coupon?, Order?&gt; &#123;</span><br><span class="line">        <span class="keyword">val</span> coupon = couponRepository.findByIdOrNull(id)</span><br><span class="line">        <span class="keyword">val</span> order = orderRepository.findByIdOrNull(id.toLong())</span><br><span class="line">        Thread.sleep(<span class="number">2500</span>) <span class="comment">// 2.5초 대기</span></span><br><span class="line">        printHikariConnection()</span><br><span class="line">        <span class="keyword">return</span> Pair(coupon, order)</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">private</span> <span class="function"><span class="keyword">fun</span> <span class="title">printHikariConnection</span><span class="params">()</span></span> &#123;</span><br><span class="line">        <span class="keyword">val</span> targetDataSource = dataSource.unwrap(HikariDataSource::<span class="keyword">class</span>.java)</span><br><span class="line">        <span class="keyword">val</span> hikariDataSource = targetDataSource <span class="keyword">as</span> HikariDataSource</span><br><span class="line">        <span class="keyword">val</span> hikariPoolMXBean = hikariDataSource.hikariPoolMXBean</span><br><span class="line">        <span class="keyword">val</span> hikariConfigMXBean = hikariDataSource.hikariConfigMXBean</span><br><span class="line"></span><br><span class="line">        <span class="keyword">val</span> log = buildString &#123;</span><br><span class="line">            append(<span class="string">&quot;totalConnections: <span class="subst">$&#123;hikariPoolMXBean.totalConnections&#125;</span>, &quot;</span>)</span><br><span class="line">            append(<span class="string">&quot;activeConnections: <span class="subst">$&#123;hikariPoolMXBean.activeConnections&#125;</span>, &quot;</span>)</span><br><span class="line">            append(<span class="string">&quot;idleConnections: <span class="subst">$&#123;hikariPoolMXBean.idleConnections&#125;</span>, &quot;</span>)</span><br><span class="line">            append(<span class="string">&quot;threadsAwaitingConnection: <span class="subst">$&#123;hikariPoolMXBean.threadsAwaitingConnection&#125;</span>&quot;</span>)</span><br><span class="line">        &#125;</span><br><span class="line">        println(log)</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>위의 코드에서 <code>/api/redis</code>는 단순히 Redis에서 쿠폰 정보를 조회하는 API입니다. <code>/api/mysql</code>은 MySQL에서 주문 정보를 조회하는 API이며, <code>/api/composite</code>는 Redis 조회 후 MySQL 조회를 수행하고 2.5초 동안 대기한 후 응답을 반환하는 API입니다.</p><p>특히 <code>/api/composite</code>는 Redis에서 쿠폰을 조회한 후 MySQL 조회를 수행하며, 이때 <code>Thread.sleep(2500)</code>으로 인해 2.5초 동안 대기하게 됩니다. 이 상황에서 Lettuce의 커넥션 풀이 어떻게 동작하는지 살펴볼 수 있습니다.</p><h2><span id="sinariobyeol-hikariwa-lettuceyi-caijeom">시나리오별 Hikari와 Lettuce의 차이점</span><a href="#sinariobyeol-hikariwa-lettuceyi-caijeom" class="header-anchor">#</a></h2><p>이 섹션에서는 Hikari와 Lettuce의 동작 방식을 서로 비교하면서 각 시나리오에서 어떤 차이점이 발생하는지에 대해 구체적으로 살펴보겠습니다. 이를 통해 각 커넥션 풀이 어떤 차이점을 갖고 있는지 명확히 이해할 수 있습니다.</p><h3><span id="sinario-getcomposite-hocul-ihu-getmysql-hocul">시나리오: getComposite 호출 이후 getMySql 호출</span><a href="#sinario-getcomposite-hocul-ihu-getmysql-hocul" class="header-anchor">#</a></h3><p>Hikari 설정은 <code>maximum-pool-size=1</code>, <code>minimum-idle=1</code>로 구성되어 있으며, <code>getComposite</code> 호출 시 사용 가능한 단 하나의 커넥션을 사용하여 작업이 진행됩니다. 이번 테스트의 목적은 <strong><code>/api/composite</code> 호출 이후 <code>getMySql</code> 호출의 응답 속도를 확인</strong>하는 것입니다. 만약 Hikari Connection Pool이 블로킹 방식으로 동작한다면 <code>getComposite</code> 호출 중 MySQL 조회 요청으로 점유된 커넥션이 반환되지 않아 <code>getMySql</code> 요청은 대기 상태에 놓이고 응답 시간이 지연될 것입니다. 반대로 MySQL 조회 요청이 빠르게 완료되거나, 추가적인 idle 커넥션이 있다면 <code>getMySql</code> 요청은 지연 없이 처리될 수 있습니다.</p><p>이 상태에서 <code>getMySql</code> 호출을 시도하면, 사용 가능한 <strong>idle 커넥션</strong>이 없기 때문에 <code>getComposite</code> 호출이 끝난 후 반환된 커넥션을 사용해야 합니다. 이로 인해 <strong>threadsAwaitingConnection</strong> 상태에서 대기하게 되고, 지연이 발생합니다. 이후 <strong>threadsAwaitingConnection</strong>에서 대기하던 요청이 <strong>activeConnections</strong>로 전환되면, <code>getMySql</code> 호출에서 해당 커넥션을 사용할 수 있게 됩니다.</p><h4><span id="teseuteu-gyeolgwa">테스트 결과</span><a href="#teseuteu-gyeolgwa" class="header-anchor">#</a></h4><ol><li><p><code>/api/mysql</code> 단독 호출 시:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"># Connection totalConnections: 1, activeConnections: 1, idleConnections: 0, threadsAwaitingConnection: 0</span><br><span class="line"># HTTP 응답 Response code: 200; Time: 24ms (24 ms); Content length: 64 bytes (64 B)</span><br></pre></td></tr></table></figure></li><li><p><code>/api/composite</code> 호출 이후 바로 <code>/api/mysql</code>를 호출한 경우:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"># Connection totalConnections: 1, activeConnections: 1, idleConnections: 0, threadsAwaitingConnection: 1</span><br><span class="line"># HTTP 응답 Response code: 200; Time: 1112ms (1 s 112 ms); Content length: 64 bytes (64 B)</span><br></pre></td></tr></table></figure></li></ol><p>테스트 결과, <code>/api/composite</code> 호출 이후 <code>getMySql</code> 요청은 대기 상태에 놓이며, MySQL 조회 작업이 완료되어 커넥션이 반환된 이후에 처리되는 것을 확인할 수 있었습니다. 이는 Hikari Connection Pool이 동기적이며, 사용 중인 커넥션이 반환될 때까지 대기 상태에 놓이는 블로킹 방식으로 동작하기 때문입니다.</p><h4><span id="dongjag-heureum">동작 흐름</span><a href="#dongjag-heureum" class="header-anchor">#</a></h4><img class="kroki" src="data:image/svg+xml;base64,PHN2ZyBpZD0iY29udGFpbmVyIiB3aWR0aD0iODU1IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iNTU1IiB2aWV3Qm94PSItNTAgLTEwIDg1NSA1NTUiIHJvbGU9ImdyYXBoaWNzLWRvY3VtZW50IGRvY3VtZW50IiBhcmlhLXJvbGVkZXNjcmlwdGlvbj0ic2VxdWVuY2UiPjxnPjxyZWN0IHg9IjYwNSIgeT0iNDY5IiBmaWxsPSIjZWFlYWVhIiBzdHJva2U9IiM2NjYiIHdpZHRoPSIxNTAiIGhlaWdodD0iNjUiIG5hbWU9Ik15U1FMIiByeD0iMyIgcnk9IjMiIGNsYXNzPSJhY3RvciBhY3Rvci1ib3R0b20iPjwvcmVjdD48dGV4dCB4PSI2ODAiIHk9IjUwMS41IiBkb21pbmFudC1iYXNlbGluZT0iY2VudHJhbCIgYWxpZ25tZW50LWJhc2VsaW5lPSJjZW50cmFsIiBjbGFzcz0iYWN0b3IgYWN0b3ItYm94IiBzdHlsZT0idGV4dC1hbmNob3I6IG1pZGRsZTsgZm9udC1zaXplOiAxNnB4OyBmb250LXdlaWdodDogNDAwOyI+PHRzcGFuIHg9IjY4MCIgZHk9IjAiPk15U1FMPC90c3Bhbj48L3RleHQ+PC9nPjxnPjxyZWN0IHg9IjIzNyIgeT0iNDY5IiBmaWxsPSIjZWFlYWVhIiBzdHJva2U9IiM2NjYiIHdpZHRoPSIxNTAiIGhlaWdodD0iNjUiIG5hbWU9IlNlcnZpY2UiIHJ4PSIzIiByeT0iMyIgY2xhc3M9ImFjdG9yIGFjdG9yLWJvdHRvbSI+PC9yZWN0Pjx0ZXh0IHg9IjMxMiIgeT0iNTAxLjUiIGRvbWluYW50LWJhc2VsaW5lPSJjZW50cmFsIiBhbGlnbm1lbnQtYmFzZWxpbmU9ImNlbnRyYWwiIGNsYXNzPSJhY3RvciBhY3Rvci1ib3giIHN0eWxlPSJ0ZXh0LWFuY2hvcjogbWlkZGxlOyBmb250LXNpemU6IDE2cHg7IGZvbnQtd2VpZ2h0OiA0MDA7Ij48dHNwYW4geD0iMzEyIiBkeT0iMCI+U2VydmljZTwvdHNwYW4+PC90ZXh0PjwvZz48Zz48cmVjdCB4PSIwIiB5PSI0NjkiIGZpbGw9IiNlYWVhZWEiIHN0cm9rZT0iIzY2NiIgd2lkdGg9IjE1MCIgaGVpZ2h0PSI2NSIgbmFtZT0iQ29udHJvbGxlciIgcng9IjMiIHJ5PSIzIiBjbGFzcz0iYWN0b3IgYWN0b3ItYm90dG9tIj48L3JlY3Q+PHRleHQgeD0iNzUiIHk9IjUwMS41IiBkb21pbmFudC1iYXNlbGluZT0iY2VudHJhbCIgYWxpZ25tZW50LWJhc2VsaW5lPSJjZW50cmFsIiBjbGFzcz0iYWN0b3IgYWN0b3ItYm94IiBzdHlsZT0idGV4dC1hbmNob3I6IG1pZGRsZTsgZm9udC1zaXplOiAxNnB4OyBmb250LXdlaWdodDogNDAwOyI+PHRzcGFuIHg9Ijc1IiBkeT0iMCI+Q29udHJvbGxlcjwvdHNwYW4+PC90ZXh0PjwvZz48Zz48bGluZSBpZD0iYWN0b3IyIiB4MT0iNjgwIiB5MT0iNjUiIHgyPSI2ODAiIHkyPSI0NjkiIGNsYXNzPSJhY3Rvci1saW5lIDIwMCIgc3Ryb2tlLXdpZHRoPSIwLjVweCIgc3Ryb2tlPSIjOTk5IiBuYW1lPSJNeVNRTCI+PC9saW5lPjxnIGlkPSJyb290LTIiPjxyZWN0IHg9IjYwNSIgeT0iMCIgZmlsbD0iI2VhZWFlYSIgc3Ryb2tlPSIjNjY2IiB3aWR0aD0iMTUwIiBoZWlnaHQ9IjY1IiBuYW1lPSJNeVNRTCIgcng9IjMiIHJ5PSIzIiBjbGFzcz0iYWN0b3IgYWN0b3ItdG9wIj48L3JlY3Q+PHRleHQgeD0iNjgwIiB5PSIzMi41IiBkb21pbmFudC1iYXNlbGluZT0iY2VudHJhbCIgYWxpZ25tZW50LWJhc2VsaW5lPSJjZW50cmFsIiBjbGFzcz0iYWN0b3IgYWN0b3ItYm94IiBzdHlsZT0idGV4dC1hbmNob3I6IG1pZGRsZTsgZm9udC1zaXplOiAxNnB4OyBmb250LXdlaWdodDogNDAwOyI+PHRzcGFuIHg9IjY4MCIgZHk9IjAiPk15U1FMPC90c3Bhbj48L3RleHQ+PC9nPjwvZz48Zz48bGluZSBpZD0iYWN0b3IxIiB4MT0iMzEyIiB5MT0iNjUiIHgyPSIzMTIiIHkyPSI0NjkiIGNsYXNzPSJhY3Rvci1saW5lIDIwMCIgc3Ryb2tlLXdpZHRoPSIwLjVweCIgc3Ryb2tlPSIjOTk5IiBuYW1lPSJTZXJ2aWNlIj48L2xpbmU+PGcgaWQ9InJvb3QtMSI+PHJlY3QgeD0iMjM3IiB5PSIwIiBmaWxsPSIjZWFlYWVhIiBzdHJva2U9IiM2NjYiIHdpZHRoPSIxNTAiIGhlaWdodD0iNjUiIG5hbWU9IlNlcnZpY2UiIHJ4PSIzIiByeT0iMyIgY2xhc3M9ImFjdG9yIGFjdG9yLXRvcCI+PC9yZWN0Pjx0ZXh0IHg9IjMxMiIgeT0iMzIuNSIgZG9taW5hbnQtYmFzZWxpbmU9ImNlbnRyYWwiIGFsaWdubWVudC1iYXNlbGluZT0iY2VudHJhbCIgY2xhc3M9ImFjdG9yIGFjdG9yLWJveCIgc3R5bGU9InRleHQtYW5jaG9yOiBtaWRkbGU7IGZvbnQtc2l6ZTogMTZweDsgZm9udC13ZWlnaHQ6IDQwMDsiPjx0c3BhbiB4PSIzMTIiIGR5PSIwIj5TZXJ2aWNlPC90c3Bhbj48L3RleHQ+PC9nPjwvZz48Zz48bGluZSBpZD0iYWN0b3IwIiB4MT0iNzUiIHkxPSI2NSIgeDI9Ijc1IiB5Mj0iNDY5IiBjbGFzcz0iYWN0b3ItbGluZSAyMDAiIHN0cm9rZS13aWR0aD0iMC41cHgiIHN0cm9rZT0iIzk5OSIgbmFtZT0iQ29udHJvbGxlciI+PC9saW5lPjxnIGlkPSJyb290LTAiPjxyZWN0IHg9IjAiIHk9IjAiIGZpbGw9IiNlYWVhZWEiIHN0cm9rZT0iIzY2NiIgd2lkdGg9IjE1MCIgaGVpZ2h0PSI2NSIgbmFtZT0iQ29udHJvbGxlciIgcng9IjMiIHJ5PSIzIiBjbGFzcz0iYWN0b3IgYWN0b3ItdG9wIj48L3JlY3Q+PHRleHQgeD0iNzUiIHk9IjMyLjUiIGRvbWluYW50LWJhc2VsaW5lPSJjZW50cmFsIiBhbGlnbm1lbnQtYmFzZWxpbmU9ImNlbnRyYWwiIGNsYXNzPSJhY3RvciBhY3Rvci1ib3giIHN0eWxlPSJ0ZXh0LWFuY2hvcjogbWlkZGxlOyBmb250LXNpemU6IDE2cHg7IGZvbnQtd2VpZ2h0OiA0MDA7Ij48dHNwYW4geD0iNzUiIGR5PSIwIj5Db250cm9sbGVyPC90c3Bhbj48L3RleHQ+PC9nPjwvZz48c3R5bGU+I2NvbnRhaW5lcntmb250LWZhbWlseToidHJlYnVjaGV0IG1zIix2ZXJkYW5hLGFyaWFsLHNhbnMtc2VyaWY7Zm9udC1zaXplOjE2cHg7ZmlsbDojMzMzO31Aa2V5ZnJhbWVzIGVkZ2UtYW5pbWF0aW9uLWZyYW1le2Zyb217c3Ryb2tlLWRhc2hvZmZzZXQ6MDt9fUBrZXlmcmFtZXMgZGFzaHt0b3tzdHJva2UtZGFzaG9mZnNldDowO319I2NvbnRhaW5lciAuZWRnZS1hbmltYXRpb24tc2xvd3tzdHJva2UtZGFzaGFycmF5OjksNSFpbXBvcnRhbnQ7c3Ryb2tlLWRhc2hvZmZzZXQ6OTAwO2FuaW1hdGlvbjpkYXNoIDUwcyBsaW5lYXIgaW5maW5pdGU7c3Ryb2tlLWxpbmVjYXA6cm91bmQ7fSNjb250YWluZXIgLmVkZ2UtYW5pbWF0aW9uLWZhc3R7c3Ryb2tlLWRhc2hhcnJheTo5LDUhaW1wb3J0YW50O3N0cm9rZS1kYXNob2Zmc2V0OjkwMDthbmltYXRpb246ZGFzaCAyMHMgbGluZWFyIGluZmluaXRlO3N0cm9rZS1saW5lY2FwOnJvdW5kO30jY29udGFpbmVyIC5lcnJvci1pY29ue2ZpbGw6IzU1MjIyMjt9I2NvbnRhaW5lciAuZXJyb3ItdGV4dHtmaWxsOiM1NTIyMjI7c3Ryb2tlOiM1NTIyMjI7fSNjb250YWluZXIgLmVkZ2UtdGhpY2tuZXNzLW5vcm1hbHtzdHJva2Utd2lkdGg6MXB4O30jY29udGFpbmVyIC5lZGdlLXRoaWNrbmVzcy10aGlja3tzdHJva2Utd2lkdGg6My41cHg7fSNjb250YWluZXIgLmVkZ2UtcGF0dGVybi1zb2xpZHtzdHJva2UtZGFzaGFycmF5OjA7fSNjb250YWluZXIgLmVkZ2UtdGhpY2tuZXNzLWludmlzaWJsZXtzdHJva2Utd2lkdGg6MDtmaWxsOm5vbmU7fSNjb250YWluZXIgLmVkZ2UtcGF0dGVybi1kYXNoZWR7c3Ryb2tlLWRhc2hhcnJheTozO30jY29udGFpbmVyIC5lZGdlLXBhdHRlcm4tZG90dGVke3N0cm9rZS1kYXNoYXJyYXk6Mjt9I2NvbnRhaW5lciAubWFya2Vye2ZpbGw6IzMzMzMzMztzdHJva2U6IzMzMzMzMzt9I2NvbnRhaW5lciAubWFya2VyLmNyb3Nze3N0cm9rZTojMzMzMzMzO30jY29udGFpbmVyIHN2Z3tmb250LWZhbWlseToidHJlYnVjaGV0IG1zIix2ZXJkYW5hLGFyaWFsLHNhbnMtc2VyaWY7Zm9udC1zaXplOjE2cHg7fSNjb250YWluZXIgcHttYXJnaW46MDt9I2NvbnRhaW5lciAuYWN0b3J7c3Ryb2tlOmhzbCgyNTkuNjI2MTY4MjI0MywgNTkuNzc2NTM2MzEyOCUsIDg3LjkwMTk2MDc4NDMlKTtmaWxsOiNFQ0VDRkY7fSNjb250YWluZXIgdGV4dC5hY3RvciZndDt0c3BhbntmaWxsOmJsYWNrO3N0cm9rZTpub25lO30jY29udGFpbmVyIC5hY3Rvci1saW5le3N0cm9rZTpoc2woMjU5LjYyNjE2ODIyNDMsIDU5Ljc3NjUzNjMxMjglLCA4Ny45MDE5NjA3ODQzJSk7fSNjb250YWluZXIgLmlubmVyQXJje3N0cm9rZS13aWR0aDoxLjU7c3Ryb2tlLWRhc2hhcnJheTpub25lO30jY29udGFpbmVyIC5tZXNzYWdlTGluZTB7c3Ryb2tlLXdpZHRoOjEuNTtzdHJva2UtZGFzaGFycmF5Om5vbmU7c3Ryb2tlOiMzMzM7fSNjb250YWluZXIgLm1lc3NhZ2VMaW5lMXtzdHJva2Utd2lkdGg6MS41O3N0cm9rZS1kYXNoYXJyYXk6MiwyO3N0cm9rZTojMzMzO30jY29udGFpbmVyICNhcnJvd2hlYWQgcGF0aHtmaWxsOiMzMzM7c3Ryb2tlOiMzMzM7fSNjb250YWluZXIgLnNlcXVlbmNlTnVtYmVye2ZpbGw6d2hpdGU7fSNjb250YWluZXIgI3NlcXVlbmNlbnVtYmVye2ZpbGw6IzMzMzt9I2NvbnRhaW5lciAjY3Jvc3NoZWFkIHBhdGh7ZmlsbDojMzMzO3N0cm9rZTojMzMzO30jY29udGFpbmVyIC5tZXNzYWdlVGV4dHtmaWxsOiMzMzM7c3Ryb2tlOm5vbmU7fSNjb250YWluZXIgLmxhYmVsQm94e3N0cm9rZTpoc2woMjU5LjYyNjE2ODIyNDMsIDU5Ljc3NjUzNjMxMjglLCA4Ny45MDE5NjA3ODQzJSk7ZmlsbDojRUNFQ0ZGO30jY29udGFpbmVyIC5sYWJlbFRleHQsI2NvbnRhaW5lciAubGFiZWxUZXh0Jmd0O3RzcGFue2ZpbGw6YmxhY2s7c3Ryb2tlOm5vbmU7fSNjb250YWluZXIgLmxvb3BUZXh0LCNjb250YWluZXIgLmxvb3BUZXh0Jmd0O3RzcGFue2ZpbGw6YmxhY2s7c3Ryb2tlOm5vbmU7fSNjb250YWluZXIgLmxvb3BMaW5le3N0cm9rZS13aWR0aDoycHg7c3Ryb2tlLWRhc2hhcnJheToyLDI7c3Ryb2tlOmhzbCgyNTkuNjI2MTY4MjI0MywgNTkuNzc2NTM2MzEyOCUsIDg3LjkwMTk2MDc4NDMlKTtmaWxsOmhzbCgyNTkuNjI2MTY4MjI0MywgNTkuNzc2NTM2MzEyOCUsIDg3LjkwMTk2MDc4NDMlKTt9I2NvbnRhaW5lciAubm90ZXtzdHJva2U6I2FhYWEzMztmaWxsOiNmZmY1YWQ7fSNjb250YWluZXIgLm5vdGVUZXh0LCNjb250YWluZXIgLm5vdGVUZXh0Jmd0O3RzcGFue2ZpbGw6YmxhY2s7c3Ryb2tlOm5vbmU7fSNjb250YWluZXIgLmFjdGl2YXRpb24we2ZpbGw6I2Y0ZjRmNDtzdHJva2U6IzY2Njt9I2NvbnRhaW5lciAuYWN0aXZhdGlvbjF7ZmlsbDojZjRmNGY0O3N0cm9rZTojNjY2O30jY29udGFpbmVyIC5hY3RpdmF0aW9uMntmaWxsOiNmNGY0ZjQ7c3Ryb2tlOiM2NjY7fSNjb250YWluZXIgLmFjdG9yUG9wdXBNZW51e3Bvc2l0aW9uOmFic29sdXRlO30jY29udGFpbmVyIC5hY3RvclBvcHVwTWVudVBhbmVse3Bvc2l0aW9uOmFic29sdXRlO2ZpbGw6I0VDRUNGRjtib3gtc2hhZG93OjBweCA4cHggMTZweCAwcHggcmdiYSgwLDAsMCwwLjIpO2ZpbHRlcjpkcm9wLXNoYWRvdygzcHggNXB4IDJweCByZ2IoMCAwIDAgLyAwLjQpKTt9I2NvbnRhaW5lciAuYWN0b3ItbWFuIGxpbmV7c3Ryb2tlOmhzbCgyNTkuNjI2MTY4MjI0MywgNTkuNzc2NTM2MzEyOCUsIDg3LjkwMTk2MDc4NDMlKTtmaWxsOiNFQ0VDRkY7fSNjb250YWluZXIgLmFjdG9yLW1hbiBjaXJjbGUsI2NvbnRhaW5lciBsaW5le3N0cm9rZTpoc2woMjU5LjYyNjE2ODIyNDMsIDU5Ljc3NjUzNjMxMjglLCA4Ny45MDE5NjA3ODQzJSk7ZmlsbDojRUNFQ0ZGO3N0cm9rZS13aWR0aDoycHg7fSNjb250YWluZXIgOnJvb3R7LS1tZXJtYWlkLWZvbnQtZmFtaWx5OiJ0cmVidWNoZXQgbXMiLHZlcmRhbmEsYXJpYWwsc2Fucy1zZXJpZjt9PC9zdHlsZT48Zz48L2c+PGRlZnM+PHN5bWJvbCBpZD0iY29tcHV0ZXIiIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCI+PHBhdGggdHJhbnNmb3JtPSJzY2FsZSguNSkiIGQ9Ik0yIDJ2MTNoMjB2LTEzaC0yMHptMTggMTFoLTE2di05aDE2djl6bS0xMC4yMjggNmwuNDY2LTFoMy41MjRsLjQ2NyAxaC00LjQ1N3ptMTQuMjI4IDNoLTI0bDItNmgyLjEwNGwtMS4zMyA0aDE4LjQ1bC0xLjI5Ny00aDIuMDczbDIgNnptLTUtMTBoLTE0di03aDE0djd6Ij48L3BhdGg+PC9zeW1ib2w+PC9kZWZzPjxkZWZzPjxzeW1ib2wgaWQ9ImRhdGFiYXNlIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCI+PHBhdGggdHJhbnNmb3JtPSJzY2FsZSguNSkiIGQ9Ik0xMi4yNTguMDAxbC4yNTYuMDA0LjI1NS4wMDUuMjUzLjAwOC4yNTEuMDEuMjQ5LjAxMi4yNDcuMDE1LjI0Ni4wMTYuMjQyLjAxOS4yNDEuMDIuMjM5LjAyMy4yMzYuMDI0LjIzMy4wMjcuMjMxLjAyOC4yMjkuMDMxLjIyNS4wMzIuMjIzLjAzNC4yMi4wMzYuMjE3LjAzOC4yMTQuMDQuMjExLjA0MS4yMDguMDQzLjIwNS4wNDUuMjAxLjA0Ni4xOTguMDQ4LjE5NC4wNS4xOTEuMDUxLjE4Ny4wNTMuMTgzLjA1NC4xOC4wNTYuMTc1LjA1Ny4xNzIuMDU5LjE2OC4wNi4xNjMuMDYxLjE2LjA2My4xNTUuMDY0LjE1LjA2Ni4wNzQuMDMzLjA3My4wMzMuMDcxLjAzNC4wNy4wMzQuMDY5LjAzNS4wNjguMDM1LjA2Ny4wMzUuMDY2LjAzNS4wNjQuMDM2LjA2NC4wMzYuMDYyLjAzNi4wNi4wMzYuMDYuMDM3LjA1OC4wMzcuMDU4LjAzNy4wNTUuMDM4LjA1NS4wMzguMDUzLjAzOC4wNTIuMDM4LjA1MS4wMzkuMDUuMDM5LjA0OC4wMzkuMDQ3LjAzOS4wNDUuMDQuMDQ0LjA0LjA0My4wNC4wNDEuMDQuMDQuMDQxLjAzOS4wNDEuMDM3LjA0MS4wMzYuMDQxLjAzNC4wNDEuMDMzLjA0Mi4wMzIuMDQyLjAzLjA0Mi4wMjkuMDQyLjAyNy4wNDIuMDI2LjA0My4wMjQuMDQzLjAyMy4wNDMuMDIxLjA0My4wMi4wNDMuMDE4LjA0NC4wMTcuMDQzLjAxNS4wNDQuMDEzLjA0NC4wMTIuMDQ0LjAxMS4wNDUuMDA5LjA0NC4wMDcuMDQ1LjAwNi4wNDUuMDA0LjA0NS4wMDIuMDQ1LjAwMS4wNDV2MTdsLS4wMDEuMDQ1LS4wMDIuMDQ1LS4wMDQuMDQ1LS4wMDYuMDQ1LS4wMDcuMDQ1LS4wMDkuMDQ0LS4wMTEuMDQ1LS4wMTIuMDQ0LS4wMTMuMDQ0LS4wMTUuMDQ0LS4wMTcuMDQzLS4wMTguMDQ0LS4wMi4wNDMtLjAyMS4wNDMtLjAyMy4wNDMtLjAyNC4wNDMtLjAyNi4wNDMtLjAyNy4wNDItLjAyOS4wNDItLjAzLjA0Mi0uMDMyLjA0Mi0uMDMzLjA0Mi0uMDM0LjA0MS0uMDM2LjA0MS0uMDM3LjA0MS0uMDM5LjA0MS0uMDQuMDQxLS4wNDEuMDQtLjA0My4wNC0uMDQ0LjA0LS4wNDUuMDQtLjA0Ny4wMzktLjA0OC4wMzktLjA1LjAzOS0uMDUxLjAzOS0uMDUyLjAzOC0uMDUzLjAzOC0uMDU1LjAzOC0uMDU1LjAzOC0uMDU4LjAzNy0uMDU4LjAzNy0uMDYuMDM3LS4wNi4wMzYtLjA2Mi4wMzYtLjA2NC4wMzYtLjA2NC4wMzYtLjA2Ni4wMzUtLjA2Ny4wMzUtLjA2OC4wMzUtLjA2OS4wMzUtLjA3LjAzNC0uMDcxLjAzNC0uMDczLjAzMy0uMDc0LjAzMy0uMTUuMDY2LS4xNTUuMDY0LS4xNi4wNjMtLjE2My4wNjEtLjE2OC4wNi0uMTcyLjA1OS0uMTc1LjA1Ny0uMTguMDU2LS4xODMuMDU0LS4xODcuMDUzLS4xOTEuMDUxLS4xOTQuMDUtLjE5OC4wNDgtLjIwMS4wNDYtLjIwNS4wNDUtLjIwOC4wNDMtLjIxMS4wNDEtLjIxNC4wNC0uMjE3LjAzOC0uMjIuMDM2LS4yMjMuMDM0LS4yMjUuMDMyLS4yMjkuMDMxLS4yMzEuMDI4LS4yMzMuMDI3LS4yMzYuMDI0LS4yMzkuMDIzLS4yNDEuMDItLjI0Mi4wMTktLjI0Ni4wMTYtLjI0Ny4wMTUtLjI0OS4wMTItLjI1MS4wMS0uMjUzLjAwOC0uMjU1LjAwNS0uMjU2LjAwNC0uMjU4LjAwMS0uMjU4LS4wMDEtLjI1Ni0uMDA0LS4yNTUtLjAwNS0uMjUzLS4wMDgtLjI1MS0uMDEtLjI0OS0uMDEyLS4yNDctLjAxNS0uMjQ1LS4wMTYtLjI0My0uMDE5LS4yNDEtLjAyLS4yMzgtLjAyMy0uMjM2LS4wMjQtLjIzNC0uMDI3LS4yMzEtLjAyOC0uMjI4LS4wMzEtLjIyNi0uMDMyLS4yMjMtLjAzNC0uMjItLjAzNi0uMjE3LS4wMzgtLjIxNC0uMDQtLjIxMS0uMDQxLS4yMDgtLjA0My0uMjA0LS4wNDUtLjIwMS0uMDQ2LS4xOTgtLjA0OC0uMTk1LS4wNS0uMTktLjA1MS0uMTg3LS4wNTMtLjE4NC0uMDU0LS4xNzktLjA1Ni0uMTc2LS4wNTctLjE3Mi0uMDU5LS4xNjctLjA2LS4xNjQtLjA2MS0uMTU5LS4wNjMtLjE1NS0uMDY0LS4xNTEtLjA2Ni0uMDc0LS4wMzMtLjA3Mi0uMDMzLS4wNzItLjAzNC0uMDctLjAzNC0uMDY5LS4wMzUtLjA2OC0uMDM1LS4wNjctLjAzNS0uMDY2LS4wMzUtLjA2NC0uMDM2LS4wNjMtLjAzNi0uMDYyLS4wMzYtLjA2MS0uMDM2LS4wNi0uMDM3LS4wNTgtLjAzNy0uMDU3LS4wMzctLjA1Ni0uMDM4LS4wNTUtLjAzOC0uMDUzLS4wMzgtLjA1Mi0uMDM4LS4wNTEtLjAzOS0uMDQ5LS4wMzktLjA0OS0uMDM5LS4wNDYtLjAzOS0uMDQ2LS4wNC0uMDQ0LS4wNC0uMDQzLS4wNC0uMDQxLS4wNC0uMDQtLjA0MS0uMDM5LS4wNDEtLjAzNy0uMDQxLS4wMzYtLjA0MS0uMDM0LS4wNDEtLjAzMy0uMDQyLS4wMzItLjA0Mi0uMDMtLjA0Mi0uMDI5LS4wNDItLjAyNy0uMDQyLS4wMjYtLjA0My0uMDI0LS4wNDMtLjAyMy0uMDQzLS4wMjEtLjA0My0uMDItLjA0My0uMDE4LS4wNDQtLjAxNy0uMDQzLS4wMTUtLjA0NC0uMDEzLS4wNDQtLjAxMi0uMDQ0LS4wMTEtLjA0NS0uMDA5LS4wNDQtLjAwNy0uMDQ1LS4wMDYtLjA0NS0uMDA0LS4wNDUtLjAwMi0uMDQ1LS4wMDEtLjA0NXYtMTdsLjAwMS0uMDQ1LjAwMi0uMDQ1LjAwNC0uMDQ1LjAwNi0uMDQ1LjAwNy0uMDQ1LjAwOS0uMDQ0LjAxMS0uMDQ1LjAxMi0uMDQ0LjAxMy0uMDQ0LjAxNS0uMDQ0LjAxNy0uMDQzLjAxOC0uMDQ0LjAyLS4wNDMuMDIxLS4wNDMuMDIzLS4wNDMuMDI0LS4wNDMuMDI2LS4wNDMuMDI3LS4wNDIuMDI5LS4wNDIuMDMtLjA0Mi4wMzItLjA0Mi4wMzMtLjA0Mi4wMzQtLjA0MS4wMzYtLjA0MS4wMzctLjA0MS4wMzktLjA0MS4wNC0uMDQxLjA0MS0uMDQuMDQzLS4wNC4wNDQtLjA0LjA0Ni0uMDQuMDQ2LS4wMzkuMDQ5LS4wMzkuMDQ5LS4wMzkuMDUxLS4wMzkuMDUyLS4wMzguMDUzLS4wMzguMDU1LS4wMzguMDU2LS4wMzguMDU3LS4wMzcuMDU4LS4wMzcuMDYtLjAzNy4wNjEtLjAzNi4wNjItLjAzNi4wNjMtLjAzNi4wNjQtLjAzNi4wNjYtLjAzNS4wNjctLjAzNS4wNjgtLjAzNS4wNjktLjAzNS4wNy0uMDM0LjA3Mi0uMDM0LjA3Mi0uMDMzLjA3NC0uMDMzLjE1MS0uMDY2LjE1NS0uMDY0LjE1OS0uMDYzLjE2NC0uMDYxLjE2Ny0uMDYuMTcyLS4wNTkuMTc2LS4wNTcuMTc5LS4wNTYuMTg0LS4wNTQuMTg3LS4wNTMuMTktLjA1MS4xOTUtLjA1LjE5OC0uMDQ4LjIwMS0uMDQ2LjIwNC0uMDQ1LjIwOC0uMDQzLjIxMS0uMDQxLjIxNC0uMDQuMjE3LS4wMzguMjItLjAzNi4yMjMtLjAzNC4yMjYtLjAzMi4yMjgtLjAzMS4yMzEtLjAyOC4yMzQtLjAyNy4yMzYtLjAyNC4yMzgtLjAyMy4yNDEtLjAyLjI0My0uMDE5LjI0NS0uMDE2LjI0Ny0uMDE1LjI0OS0uMDEyLjI1MS0uMDEuMjUzLS4wMDguMjU1LS4wMDUuMjU2LS4wMDQuMjU4LS4wMDEuMjU4LjAwMXptLTkuMjU4IDIwLjQ5OXYuMDFsLjAwMS4wMjEuMDAzLjAyMS4wMDQuMDIyLjAwNS4wMjEuMDA2LjAyMi4wMDcuMDIyLjAwOS4wMjMuMDEuMDIyLjAxMS4wMjMuMDEyLjAyMy4wMTMuMDIzLjAxNS4wMjMuMDE2LjAyNC4wMTcuMDIzLjAxOC4wMjQuMDE5LjAyNC4wMjEuMDI0LjAyMi4wMjUuMDIzLjAyNC4wMjQuMDI1LjA1Mi4wNDkuMDU2LjA1LjA2MS4wNTEuMDY2LjA1MS4wNy4wNTEuMDc1LjA1MS4wNzkuMDUyLjA4NC4wNTIuMDg4LjA1Mi4wOTIuMDUyLjA5Ny4wNTIuMTAyLjA1MS4xMDUuMDUyLjExLjA1Mi4xMTQuMDUxLjExOS4wNTEuMTIzLjA1MS4xMjcuMDUuMTMxLjA1LjEzNS4wNS4xMzkuMDQ4LjE0NC4wNDkuMTQ3LjA0Ny4xNTIuMDQ3LjE1NS4wNDcuMTYuMDQ1LjE2My4wNDUuMTY3LjA0My4xNzEuMDQzLjE3Ni4wNDEuMTc4LjA0MS4xODMuMDM5LjE4Ny4wMzkuMTkuMDM3LjE5NC4wMzUuMTk3LjAzNS4yMDIuMDMzLjIwNC4wMzEuMjA5LjAzLjIxMi4wMjkuMjE2LjAyNy4yMTkuMDI1LjIyMi4wMjQuMjI2LjAyMS4yMy4wMi4yMzMuMDE4LjIzNi4wMTYuMjQuMDE1LjI0My4wMTIuMjQ2LjAxLjI0OS4wMDguMjUzLjAwNS4yNTYuMDA0LjI1OS4wMDEuMjYtLjAwMS4yNTctLjAwNC4yNTQtLjAwNS4yNS0uMDA4LjI0Ny0uMDExLjI0NC0uMDEyLjI0MS0uMDE0LjIzNy0uMDE2LjIzMy0uMDE4LjIzMS0uMDIxLjIyNi0uMDIxLjIyNC0uMDI0LjIyLS4wMjYuMjE2LS4wMjcuMjEyLS4wMjguMjEtLjAzMS4yMDUtLjAzMS4yMDItLjAzNC4xOTgtLjAzNC4xOTQtLjAzNi4xOTEtLjAzNy4xODctLjAzOS4xODMtLjA0LjE3OS0uMDQuMTc1LS4wNDIuMTcyLS4wNDMuMTY4LS4wNDQuMTYzLS4wNDUuMTYtLjA0Ni4xNTUtLjA0Ni4xNTItLjA0Ny4xNDgtLjA0OC4xNDMtLjA0OS4xMzktLjA0OS4xMzYtLjA1LjEzMS0uMDUuMTI2LS4wNS4xMjMtLjA1MS4xMTgtLjA1Mi4xMTQtLjA1MS4xMS0uMDUyLjEwNi0uMDUyLjEwMS0uMDUyLjA5Ni0uMDUyLjA5Mi0uMDUyLjA4OC0uMDUzLjA4My0uMDUxLjA3OS0uMDUyLjA3NC0uMDUyLjA3LS4wNTEuMDY1LS4wNTEuMDYtLjA1MS4wNTYtLjA1LjA1MS0uMDUuMDIzLS4wMjQuMDIzLS4wMjUuMDIxLS4wMjQuMDItLjAyNC4wMTktLjAyNC4wMTgtLjAyNC4wMTctLjAyNC4wMTUtLjAyMy4wMTQtLjAyNC4wMTMtLjAyMy4wMTItLjAyMy4wMS0uMDIzLjAxLS4wMjIuMDA4LS4wMjIuMDA2LS4wMjIuMDA2LS4wMjIuMDA0LS4wMjIuMDA0LS4wMjEuMDAxLS4wMjEuMDAxLS4wMjF2LTQuMTI3bC0uMDc3LjA1NS0uMDguMDUzLS4wODMuMDU0LS4wODUuMDUzLS4wODcuMDUyLS4wOS4wNTItLjA5My4wNTEtLjA5NS4wNS0uMDk3LjA1LS4xLjA0OS0uMTAyLjA0OS0uMTA1LjA0OC0uMTA2LjA0Ny0uMTA5LjA0Ny0uMTExLjA0Ni0uMTE0LjA0NS0uMTE1LjA0NS0uMTE4LjA0NC0uMTIuMDQzLS4xMjIuMDQyLS4xMjQuMDQyLS4xMjYuMDQxLS4xMjguMDQtLjEzLjA0LS4xMzIuMDM4LS4xMzQuMDM4LS4xMzUuMDM3LS4xMzguMDM3LS4xMzkuMDM1LS4xNDIuMDM1LS4xNDMuMDM0LS4xNDQuMDMzLS4xNDcuMDMyLS4xNDguMDMxLS4xNS4wMy0uMTUxLjAzLS4xNTMuMDI5LS4xNTQuMDI3LS4xNTYuMDI3LS4xNTguMDI2LS4xNTkuMDI1LS4xNjEuMDI0LS4xNjIuMDIzLS4xNjMuMDIyLS4xNjUuMDIxLS4xNjYuMDItLjE2Ny4wMTktLjE2OS4wMTgtLjE2OS4wMTctLjE3MS4wMTYtLjE3My4wMTUtLjE3My4wMTQtLjE3NS4wMTMtLjE3NS4wMTItLjE3Ny4wMTEtLjE3OC4wMS0uMTc5LjAwOC0uMTc5LjAwOC0uMTgxLjAwNi0uMTgyLjAwNS0uMTgyLjAwNC0uMTg0LjAwMy0uMTg0LjAwMmgtLjM3bC0uMTg0LS4wMDItLjE4NC0uMDAzLS4xODItLjAwNC0uMTgyLS4wMDUtLjE4MS0uMDA2LS4xNzktLjAwOC0uMTc5LS4wMDgtLjE3OC0uMDEtLjE3Ni0uMDExLS4xNzYtLjAxMi0uMTc1LS4wMTMtLjE3My0uMDE0LS4xNzItLjAxNS0uMTcxLS4wMTYtLjE3LS4wMTctLjE2OS0uMDE4LS4xNjctLjAxOS0uMTY2LS4wMi0uMTY1LS4wMjEtLjE2My0uMDIyLS4xNjItLjAyMy0uMTYxLS4wMjQtLjE1OS0uMDI1LS4xNTctLjAyNi0uMTU2LS4wMjctLjE1NS0uMDI3LS4xNTMtLjAyOS0uMTUxLS4wMy0uMTUtLjAzLS4xNDgtLjAzMS0uMTQ2LS4wMzItLjE0NS0uMDMzLS4xNDMtLjAzNC0uMTQxLS4wMzUtLjE0LS4wMzUtLjEzNy0uMDM3LS4xMzYtLjAzNy0uMTM0LS4wMzgtLjEzMi0uMDM4LS4xMy0uMDQtLjEyOC0uMDQtLjEyNi0uMDQxLS4xMjQtLjA0Mi0uMTIyLS4wNDItLjEyLS4wNDQtLjExNy0uMDQzLS4xMTYtLjA0NS0uMTEzLS4wNDUtLjExMi0uMDQ2LS4xMDktLjA0Ny0uMTA2LS4wNDctLjEwNS0uMDQ4LS4xMDItLjA0OS0uMS0uMDQ5LS4wOTctLjA1LS4wOTUtLjA1LS4wOTMtLjA1Mi0uMDktLjA1MS0uMDg3LS4wNTItLjA4NS0uMDUzLS4wODMtLjA1NC0uMDgtLjA1NC0uMDc3LS4wNTR2NC4xMjd6bTAtNS42NTR2LjAxMWwuMDAxLjAyMS4wMDMuMDIxLjAwNC4wMjEuMDA1LjAyMi4wMDYuMDIyLjAwNy4wMjIuMDA5LjAyMi4wMS4wMjIuMDExLjAyMy4wMTIuMDIzLjAxMy4wMjMuMDE1LjAyNC4wMTYuMDIzLjAxNy4wMjQuMDE4LjAyNC4wMTkuMDI0LjAyMS4wMjQuMDIyLjAyNC4wMjMuMDI1LjAyNC4wMjQuMDUyLjA1LjA1Ni4wNS4wNjEuMDUuMDY2LjA1MS4wNy4wNTEuMDc1LjA1Mi4wNzkuMDUxLjA4NC4wNTIuMDg4LjA1Mi4wOTIuMDUyLjA5Ny4wNTIuMTAyLjA1Mi4xMDUuMDUyLjExLjA1MS4xMTQuMDUxLjExOS4wNTIuMTIzLjA1LjEyNy4wNTEuMTMxLjA1LjEzNS4wNDkuMTM5LjA0OS4xNDQuMDQ4LjE0Ny4wNDguMTUyLjA0Ny4xNTUuMDQ2LjE2LjA0NS4xNjMuMDQ1LjE2Ny4wNDQuMTcxLjA0Mi4xNzYuMDQyLjE3OC4wNC4xODMuMDQuMTg3LjAzOC4xOS4wMzcuMTk0LjAzNi4xOTcuMDM0LjIwMi4wMzMuMjA0LjAzMi4yMDkuMDMuMjEyLjAyOC4yMTYuMDI3LjIxOS4wMjUuMjIyLjAyNC4yMjYuMDIyLjIzLjAyLjIzMy4wMTguMjM2LjAxNi4yNC4wMTQuMjQzLjAxMi4yNDYuMDEuMjQ5LjAwOC4yNTMuMDA2LjI1Ni4wMDMuMjU5LjAwMS4yNi0uMDAxLjI1Ny0uMDAzLjI1NC0uMDA2LjI1LS4wMDguMjQ3LS4wMS4yNDQtLjAxMi4yNDEtLjAxNS4yMzctLjAxNi4yMzMtLjAxOC4yMzEtLjAyLjIyNi0uMDIyLjIyNC0uMDI0LjIyLS4wMjUuMjE2LS4wMjcuMjEyLS4wMjkuMjEtLjAzLjIwNS0uMDMyLjIwMi0uMDMzLjE5OC0uMDM1LjE5NC0uMDM2LjE5MS0uMDM3LjE4Ny0uMDM5LjE4My0uMDM5LjE3OS0uMDQxLjE3NS0uMDQyLjE3Mi0uMDQzLjE2OC0uMDQ0LjE2My0uMDQ1LjE2LS4wNDUuMTU1LS4wNDcuMTUyLS4wNDcuMTQ4LS4wNDguMTQzLS4wNDguMTM5LS4wNS4xMzYtLjA0OS4xMzEtLjA1LjEyNi0uMDUxLjEyMy0uMDUxLjExOC0uMDUxLjExNC0uMDUyLjExLS4wNTIuMTA2LS4wNTIuMTAxLS4wNTIuMDk2LS4wNTIuMDkyLS4wNTIuMDg4LS4wNTIuMDgzLS4wNTIuMDc5LS4wNTIuMDc0LS4wNTEuMDctLjA1Mi4wNjUtLjA1MS4wNi0uMDUuMDU2LS4wNTEuMDUxLS4wNDkuMDIzLS4wMjUuMDIzLS4wMjQuMDIxLS4wMjUuMDItLjAyNC4wMTktLjAyNC4wMTgtLjAyNC4wMTctLjAyNC4wMTUtLjAyMy4wMTQtLjAyMy4wMTMtLjAyNC4wMTItLjAyMi4wMS0uMDIzLjAxLS4wMjMuMDA4LS4wMjIuMDA2LS4wMjIuMDA2LS4wMjIuMDA0LS4wMjEuMDA0LS4wMjIuMDAxLS4wMjEuMDAxLS4wMjF2LTQuMTM5bC0uMDc3LjA1NC0uMDguMDU0LS4wODMuMDU0LS4wODUuMDUyLS4wODcuMDUzLS4wOS4wNTEtLjA5My4wNTEtLjA5NS4wNTEtLjA5Ny4wNS0uMS4wNDktLjEwMi4wNDktLjEwNS4wNDgtLjEwNi4wNDctLjEwOS4wNDctLjExMS4wNDYtLjExNC4wNDUtLjExNS4wNDQtLjExOC4wNDQtLjEyLjA0NC0uMTIyLjA0Mi0uMTI0LjA0Mi0uMTI2LjA0MS0uMTI4LjA0LS4xMy4wMzktLjEzMi4wMzktLjEzNC4wMzgtLjEzNS4wMzctLjEzOC4wMzYtLjEzOS4wMzYtLjE0Mi4wMzUtLjE0My4wMzMtLjE0NC4wMzMtLjE0Ny4wMzMtLjE0OC4wMzEtLjE1LjAzLS4xNTEuMDMtLjE1My4wMjgtLjE1NC4wMjgtLjE1Ni4wMjctLjE1OC4wMjYtLjE1OS4wMjUtLjE2MS4wMjQtLjE2Mi4wMjMtLjE2My4wMjItLjE2NS4wMjEtLjE2Ni4wMi0uMTY3LjAxOS0uMTY5LjAxOC0uMTY5LjAxNy0uMTcxLjAxNi0uMTczLjAxNS0uMTczLjAxNC0uMTc1LjAxMy0uMTc1LjAxMi0uMTc3LjAxMS0uMTc4LjAwOS0uMTc5LjAwOS0uMTc5LjAwNy0uMTgxLjAwNy0uMTgyLjAwNS0uMTgyLjAwNC0uMTg0LjAwMy0uMTg0LjAwMmgtLjM3bC0uMTg0LS4wMDItLjE4NC0uMDAzLS4xODItLjAwNC0uMTgyLS4wMDUtLjE4MS0uMDA3LS4xNzktLjAwNy0uMTc5LS4wMDktLjE3OC0uMDA5LS4xNzYtLjAxMS0uMTc2LS4wMTItLjE3NS0uMDEzLS4xNzMtLjAxNC0uMTcyLS4wMTUtLjE3MS0uMDE2LS4xNy0uMDE3LS4xNjktLjAxOC0uMTY3LS4wMTktLjE2Ni0uMDItLjE2NS0uMDIxLS4xNjMtLjAyMi0uMTYyLS4wMjMtLjE2MS0uMDI0LS4xNTktLjAyNS0uMTU3LS4wMjYtLjE1Ni0uMDI3LS4xNTUtLjAyOC0uMTUzLS4wMjgtLjE1MS0uMDMtLjE1LS4wMy0uMTQ4LS4wMzEtLjE0Ni0uMDMzLS4xNDUtLjAzMy0uMTQzLS4wMzMtLjE0MS0uMDM1LS4xNC0uMDM2LS4xMzctLjAzNi0uMTM2LS4wMzctLjEzNC0uMDM4LS4xMzItLjAzOS0uMTMtLjAzOS0uMTI4LS4wNC0uMTI2LS4wNDEtLjEyNC0uMDQyLS4xMjItLjA0My0uMTItLjA0My0uMTE3LS4wNDQtLjExNi0uMDQ0LS4xMTMtLjA0Ni0uMTEyLS4wNDYtLjEwOS0uMDQ2LS4xMDYtLjA0Ny0uMTA1LS4wNDgtLjEwMi0uMDQ5LS4xLS4wNDktLjA5Ny0uMDUtLjA5NS0uMDUxLS4wOTMtLjA1MS0uMDktLjA1MS0uMDg3LS4wNTMtLjA4NS0uMDUyLS4wODMtLjA1NC0uMDgtLjA1NC0uMDc3LS4wNTR2NC4xMzl6bTAtNS42NjZ2LjAxMWwuMDAxLjAyLjAwMy4wMjIuMDA0LjAyMS4wMDUuMDIyLjAwNi4wMjEuMDA3LjAyMi4wMDkuMDIzLjAxLjAyMi4wMTEuMDIzLjAxMi4wMjMuMDEzLjAyMy4wMTUuMDIzLjAxNi4wMjQuMDE3LjAyNC4wMTguMDIzLjAxOS4wMjQuMDIxLjAyNS4wMjIuMDI0LjAyMy4wMjQuMDI0LjAyNS4wNTIuMDUuMDU2LjA1LjA2MS4wNS4wNjYuMDUxLjA3LjA1MS4wNzUuMDUyLjA3OS4wNTEuMDg0LjA1Mi4wODguMDUyLjA5Mi4wNTIuMDk3LjA1Mi4xMDIuMDUyLjEwNS4wNTEuMTEuMDUyLjExNC4wNTEuMTE5LjA1MS4xMjMuMDUxLjEyNy4wNS4xMzEuMDUuMTM1LjA1LjEzOS4wNDkuMTQ0LjA0OC4xNDcuMDQ4LjE1Mi4wNDcuMTU1LjA0Ni4xNi4wNDUuMTYzLjA0NS4xNjcuMDQzLjE3MS4wNDMuMTc2LjA0Mi4xNzguMDQuMTgzLjA0LjE4Ny4wMzguMTkuMDM3LjE5NC4wMzYuMTk3LjAzNC4yMDIuMDMzLjIwNC4wMzIuMjA5LjAzLjIxMi4wMjguMjE2LjAyNy4yMTkuMDI1LjIyMi4wMjQuMjI2LjAyMS4yMy4wMi4yMzMuMDE4LjIzNi4wMTcuMjQuMDE0LjI0My4wMTIuMjQ2LjAxLjI0OS4wMDguMjUzLjAwNi4yNTYuMDAzLjI1OS4wMDEuMjYtLjAwMS4yNTctLjAwMy4yNTQtLjAwNi4yNS0uMDA4LjI0Ny0uMDEuMjQ0LS4wMTMuMjQxLS4wMTQuMjM3LS4wMTYuMjMzLS4wMTguMjMxLS4wMi4yMjYtLjAyMi4yMjQtLjAyNC4yMi0uMDI1LjIxNi0uMDI3LjIxMi0uMDI5LjIxLS4wMy4yMDUtLjAzMi4yMDItLjAzMy4xOTgtLjAzNS4xOTQtLjAzNi4xOTEtLjAzNy4xODctLjAzOS4xODMtLjAzOS4xNzktLjA0MS4xNzUtLjA0Mi4xNzItLjA0My4xNjgtLjA0NC4xNjMtLjA0NS4xNi0uMDQ1LjE1NS0uMDQ3LjE1Mi0uMDQ3LjE0OC0uMDQ4LjE0My0uMDQ5LjEzOS0uMDQ5LjEzNi0uMDQ5LjEzMS0uMDUxLjEyNi0uMDUuMTIzLS4wNTEuMTE4LS4wNTIuMTE0LS4wNTEuMTEtLjA1Mi4xMDYtLjA1Mi4xMDEtLjA1Mi4wOTYtLjA1Mi4wOTItLjA1Mi4wODgtLjA1Mi4wODMtLjA1Mi4wNzktLjA1Mi4wNzQtLjA1Mi4wNy0uMDUxLjA2NS0uMDUxLjA2LS4wNTEuMDU2LS4wNS4wNTEtLjA0OS4wMjMtLjAyNS4wMjMtLjAyNS4wMjEtLjAyNC4wMi0uMDI0LjAxOS0uMDI0LjAxOC0uMDI0LjAxNy0uMDI0LjAxNS0uMDIzLjAxNC0uMDI0LjAxMy0uMDIzLjAxMi0uMDIzLjAxLS4wMjIuMDEtLjAyMy4wMDgtLjAyMi4wMDYtLjAyMi4wMDYtLjAyMi4wMDQtLjAyMi4wMDQtLjAyMS4wMDEtLjAyMS4wMDEtLjAyMXYtNC4xNTNsLS4wNzcuMDU0LS4wOC4wNTQtLjA4My4wNTMtLjA4NS4wNTMtLjA4Ny4wNTMtLjA5LjA1MS0uMDkzLjA1MS0uMDk1LjA1MS0uMDk3LjA1LS4xLjA0OS0uMTAyLjA0OC0uMTA1LjA0OC0uMTA2LjA0OC0uMTA5LjA0Ni0uMTExLjA0Ni0uMTE0LjA0Ni0uMTE1LjA0NC0uMTE4LjA0NC0uMTIuMDQzLS4xMjIuMDQzLS4xMjQuMDQyLS4xMjYuMDQxLS4xMjguMDQtLjEzLjAzOS0uMTMyLjAzOS0uMTM0LjAzOC0uMTM1LjAzNy0uMTM4LjAzNi0uMTM5LjAzNi0uMTQyLjAzNC0uMTQzLjAzNC0uMTQ0LjAzMy0uMTQ3LjAzMi0uMTQ4LjAzMi0uMTUuMDMtLjE1MS4wMy0uMTUzLjAyOC0uMTU0LjAyOC0uMTU2LjAyNy0uMTU4LjAyNi0uMTU5LjAyNC0uMTYxLjAyNC0uMTYyLjAyMy0uMTYzLjAyMy0uMTY1LjAyMS0uMTY2LjAyLS4xNjcuMDE5LS4xNjkuMDE4LS4xNjkuMDE3LS4xNzEuMDE2LS4xNzMuMDE1LS4xNzMuMDE0LS4xNzUuMDEzLS4xNzUuMDEyLS4xNzcuMDEtLjE3OC4wMS0uMTc5LjAwOS0uMTc5LjAwNy0uMTgxLjAwNi0uMTgyLjAwNi0uMTgyLjAwNC0uMTg0LjAwMy0uMTg0LjAwMS0uMTg1LjAwMS0uMTg1LS4wMDEtLjE4NC0uMDAxLS4xODQtLjAwMy0uMTgyLS4wMDQtLjE4Mi0uMDA2LS4xODEtLjAwNi0uMTc5LS4wMDctLjE3OS0uMDA5LS4xNzgtLjAxLS4xNzYtLjAxLS4xNzYtLjAxMi0uMTc1LS4wMTMtLjE3My0uMDE0LS4xNzItLjAxNS0uMTcxLS4wMTYtLjE3LS4wMTctLjE2OS0uMDE4LS4xNjctLjAxOS0uMTY2LS4wMi0uMTY1LS4wMjEtLjE2My0uMDIzLS4xNjItLjAyMy0uMTYxLS4wMjQtLjE1OS0uMDI0LS4xNTctLjAyNi0uMTU2LS4wMjctLjE1NS0uMDI4LS4xNTMtLjAyOC0uMTUxLS4wMy0uMTUtLjAzLS4xNDgtLjAzMi0uMTQ2LS4wMzItLjE0NS0uMDMzLS4xNDMtLjAzNC0uMTQxLS4wMzQtLjE0LS4wMzYtLjEzNy0uMDM2LS4xMzYtLjAzNy0uMTM0LS4wMzgtLjEzMi0uMDM5LS4xMy0uMDM5LS4xMjgtLjA0MS0uMTI2LS4wNDEtLjEyNC0uMDQxLS4xMjItLjA0My0uMTItLjA0My0uMTE3LS4wNDQtLjExNi0uMDQ0LS4xMTMtLjA0Ni0uMTEyLS4wNDYtLjEwOS0uMDQ2LS4xMDYtLjA0OC0uMTA1LS4wNDgtLjEwMi0uMDQ4LS4xLS4wNS0uMDk3LS4wNDktLjA5NS0uMDUxLS4wOTMtLjA1MS0uMDktLjA1Mi0uMDg3LS4wNTItLjA4NS0uMDUzLS4wODMtLjA1My0uMDgtLjA1NC0uMDc3LS4wNTR2NC4xNTN6bTguNzQtOC4xNzlsLS4yNTcuMDA0LS4yNTQuMDA1LS4yNS4wMDgtLjI0Ny4wMTEtLjI0NC4wMTItLjI0MS4wMTQtLjIzNy4wMTYtLjIzMy4wMTgtLjIzMS4wMjEtLjIyNi4wMjItLjIyNC4wMjMtLjIyLjAyNi0uMjE2LjAyNy0uMjEyLjAyOC0uMjEuMDMxLS4yMDUuMDMyLS4yMDIuMDMzLS4xOTguMDM0LS4xOTQuMDM2LS4xOTEuMDM4LS4xODcuMDM4LS4xODMuMDQtLjE3OS4wNDEtLjE3NS4wNDItLjE3Mi4wNDMtLjE2OC4wNDMtLjE2My4wNDUtLjE2LjA0Ni0uMTU1LjA0Ni0uMTUyLjA0OC0uMTQ4LjA0OC0uMTQzLjA0OC0uMTM5LjA0OS0uMTM2LjA1LS4xMzEuMDUtLjEyNi4wNTEtLjEyMy4wNTEtLjExOC4wNTEtLjExNC4wNTItLjExLjA1Mi0uMTA2LjA1Mi0uMTAxLjA1Mi0uMDk2LjA1Mi0uMDkyLjA1Mi0uMDg4LjA1Mi0uMDgzLjA1Mi0uMDc5LjA1Mi0uMDc0LjA1MS0uMDcuMDUyLS4wNjUuMDUxLS4wNi4wNS0uMDU2LjA1LS4wNTEuMDUtLjAyMy4wMjUtLjAyMy4wMjQtLjAyMS4wMjQtLjAyLjAyNS0uMDE5LjAyNC0uMDE4LjAyNC0uMDE3LjAyMy0uMDE1LjAyNC0uMDE0LjAyMy0uMDEzLjAyMy0uMDEyLjAyMy0uMDEuMDIzLS4wMS4wMjItLjAwOC4wMjItLjAwNi4wMjMtLjAwNi4wMjEtLjAwNC4wMjItLjAwNC4wMjEtLjAwMS4wMjEtLjAwMS4wMjEuMDAxLjAyMS4wMDEuMDIxLjAwNC4wMjEuMDA0LjAyMi4wMDYuMDIxLjAwNi4wMjMuMDA4LjAyMi4wMS4wMjIuMDEuMDIzLjAxMi4wMjMuMDEzLjAyMy4wMTQuMDIzLjAxNS4wMjQuMDE3LjAyMy4wMTguMDI0LjAxOS4wMjQuMDIuMDI1LjAyMS4wMjQuMDIzLjAyNC4wMjMuMDI1LjA1MS4wNS4wNTYuMDUuMDYuMDUuMDY1LjA1MS4wNy4wNTIuMDc0LjA1MS4wNzkuMDUyLjA4My4wNTIuMDg4LjA1Mi4wOTIuMDUyLjA5Ni4wNTIuMTAxLjA1Mi4xMDYuMDUyLjExLjA1Mi4xMTQuMDUyLjExOC4wNTEuMTIzLjA1MS4xMjYuMDUxLjEzMS4wNS4xMzYuMDUuMTM5LjA0OS4xNDMuMDQ4LjE0OC4wNDguMTUyLjA0OC4xNTUuMDQ2LjE2LjA0Ni4xNjMuMDQ1LjE2OC4wNDMuMTcyLjA0My4xNzUuMDQyLjE3OS4wNDEuMTgzLjA0LjE4Ny4wMzguMTkxLjAzOC4xOTQuMDM2LjE5OC4wMzQuMjAyLjAzMy4yMDUuMDMyLjIxLjAzMS4yMTIuMDI4LjIxNi4wMjcuMjIuMDI2LjIyNC4wMjMuMjI2LjAyMi4yMzEuMDIxLjIzMy4wMTguMjM3LjAxNi4yNDEuMDE0LjI0NC4wMTIuMjQ3LjAxMS4yNS4wMDguMjU0LjAwNS4yNTcuMDA0LjI2LjAwMS4yNi0uMDAxLjI1Ny0uMDA0LjI1NC0uMDA1LjI1LS4wMDguMjQ3LS4wMTEuMjQ0LS4wMTIuMjQxLS4wMTQuMjM3LS4wMTYuMjMzLS4wMTguMjMxLS4wMjEuMjI2LS4wMjIuMjI0LS4wMjMuMjItLjAyNi4yMTYtLjAyNy4yMTItLjAyOC4yMS0uMDMxLjIwNS0uMDMyLjIwMi0uMDMzLjE5OC0uMDM0LjE5NC0uMDM2LjE5MS0uMDM4LjE4Ny0uMDM4LjE4My0uMDQuMTc5LS4wNDEuMTc1LS4wNDIuMTcyLS4wNDMuMTY4LS4wNDMuMTYzLS4wNDUuMTYtLjA0Ni4xNTUtLjA0Ni4xNTItLjA0OC4xNDgtLjA0OC4xNDMtLjA0OC4xMzktLjA0OS4xMzYtLjA1LjEzMS0uMDUuMTI2LS4wNTEuMTIzLS4wNTEuMTE4LS4wNTEuMTE0LS4wNTIuMTEtLjA1Mi4xMDYtLjA1Mi4xMDEtLjA1Mi4wOTYtLjA1Mi4wOTItLjA1Mi4wODgtLjA1Mi4wODMtLjA1Mi4wNzktLjA1Mi4wNzQtLjA1MS4wNy0uMDUyLjA2NS0uMDUxLjA2LS4wNS4wNTYtLjA1LjA1MS0uMDUuMDIzLS4wMjUuMDIzLS4wMjQuMDIxLS4wMjQuMDItLjAyNS4wMTktLjAyNC4wMTgtLjAyNC4wMTctLjAyMy4wMTUtLjAyNC4wMTQtLjAyMy4wMTMtLjAyMy4wMTItLjAyMy4wMS0uMDIzLjAxLS4wMjIuMDA4LS4wMjIuMDA2LS4wMjMuMDA2LS4wMjEuMDA0LS4wMjIuMDA0LS4wMjEuMDAxLS4wMjEuMDAxLS4wMjEtLjAwMS0uMDIxLS4wMDEtLjAyMS0uMDA0LS4wMjEtLjAwNC0uMDIyLS4wMDYtLjAyMS0uMDA2LS4wMjMtLjAwOC0uMDIyLS4wMS0uMDIyLS4wMS0uMDIzLS4wMTItLjAyMy0uMDEzLS4wMjMtLjAxNC0uMDIzLS4wMTUtLjAyNC0uMDE3LS4wMjMtLjAxOC0uMDI0LS4wMTktLjAyNC0uMDItLjAyNS0uMDIxLS4wMjQtLjAyMy0uMDI0LS4wMjMtLjAyNS0uMDUxLS4wNS0uMDU2LS4wNS0uMDYtLjA1LS4wNjUtLjA1MS0uMDctLjA1Mi0uMDc0LS4wNTEtLjA3OS0uMDUyLS4wODMtLjA1Mi0uMDg4LS4wNTItLjA5Mi0uMDUyLS4wOTYtLjA1Mi0uMTAxLS4wNTItLjEwNi0uMDUyLS4xMS0uMDUyLS4xMTQtLjA1Mi0uMTE4LS4wNTEtLjEyMy0uMDUxLS4xMjYtLjA1MS0uMTMxLS4wNS0uMTM2LS4wNS0uMTM5LS4wNDktLjE0My0uMDQ4LS4xNDgtLjA0OC0uMTUyLS4wNDgtLjE1NS0uMDQ2LS4xNi0uMDQ2LS4xNjMtLjA0NS0uMTY4LS4wNDMtLjE3Mi0uMDQzLS4xNzUtLjA0Mi0uMTc5LS4wNDEtLjE4My0uMDQtLjE4Ny0uMDM4LS4xOTEtLjAzOC0uMTk0LS4wMzYtLjE5OC0uMDM0LS4yMDItLjAzMy0uMjA1LS4wMzItLjIxLS4wMzEtLjIxMi0uMDI4LS4yMTYtLjAyNy0uMjItLjAyNi0uMjI0LS4wMjMtLjIyNi0uMDIyLS4yMzEtLjAyMS0uMjMzLS4wMTgtLjIzNy0uMDE2LS4yNDEtLjAxNC0uMjQ0LS4wMTItLjI0Ny0uMDExLS4yNS0uMDA4LS4yNTQtLjAwNS0uMjU3LS4wMDQtLjI2LS4wMDEtLjI2LjAwMXoiPjwvcGF0aD48L3N5bWJvbD48L2RlZnM+PGRlZnM+PHN5bWJvbCBpZD0iY2xvY2siIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCI+PHBhdGggdHJhbnNmb3JtPSJzY2FsZSguNSkiIGQ9Ik0xMiAyYzUuNTE0IDAgMTAgNC40ODYgMTAgMTBzLTQuNDg2IDEwLTEwIDEwLTEwLTQuNDg2LTEwLTEwIDQuNDg2LTEwIDEwLTEwem0wLTJjLTYuNjI3IDAtMTIgNS4zNzMtMTIgMTJzNS4zNzMgMTIgMTIgMTIgMTItNS4zNzMgMTItMTItNS4zNzMtMTItMTItMTJ6bTUuODQ4IDEyLjQ1OWMuMjAyLjAzOC4yMDIuMzMzLjAwMS4zNzItMS45MDcuMzYxLTYuMDQ1IDEuMTExLTYuNTQ3IDEuMTExLS43MTkgMC0xLjMwMS0uNTgyLTEuMzAxLTEuMzAxIDAtLjUxMi43Ny01LjQ0NyAxLjEyNS03LjQ0NS4wMzQtLjE5Mi4zMTItLjE4MS4zNDMuMDE0bC45ODUgNi4yMzggNS4zOTQgMS4wMTF6Ij48L3BhdGg+PC9zeW1ib2w+PC9kZWZzPjxkZWZzPjxtYXJrZXIgaWQ9ImFycm93aGVhZCIgcmVmWD0iNy45IiByZWZZPSI1IiBtYXJrZXJVbml0cz0idXNlclNwYWNlT25Vc2UiIG1hcmtlcldpZHRoPSIxMiIgbWFya2VySGVpZ2h0PSIxMiIgb3JpZW50PSJhdXRvLXN0YXJ0LXJldmVyc2UiPjxwYXRoIGQ9Ik0gLTEgMCBMIDEwIDUgTCAwIDEwIHoiPjwvcGF0aD48L21hcmtlcj48L2RlZnM+PGRlZnM+PG1hcmtlciBpZD0iY3Jvc3NoZWFkIiBtYXJrZXJXaWR0aD0iMTUiIG1hcmtlckhlaWdodD0iOCIgb3JpZW50PSJhdXRvIiByZWZYPSI0IiByZWZZPSI0LjUiPjxwYXRoIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzAwMDAwMCIgc3Ryb2tlLXdpZHRoPSIxcHQiIGQ9Ik0gMSwyIEwgNiw3IE0gNiwyIEwgMSw3IiBzdHlsZT0ic3Ryb2tlLWRhc2hhcnJheTogMCwgMDsiPjwvcGF0aD48L21hcmtlcj48L2RlZnM+PGRlZnM+PG1hcmtlciBpZD0iZmlsbGVkLWhlYWQiIHJlZlg9IjE1LjUiIHJlZlk9IjciIG1hcmtlcldpZHRoPSIyMCIgbWFya2VySGVpZ2h0PSIyOCIgb3JpZW50PSJhdXRvIj48cGF0aCBkPSJNIDE4LDcgTDksMTMgTDE0LDcgTDksMSBaIj48L3BhdGg+PC9tYXJrZXI+PC9kZWZzPjxkZWZzPjxtYXJrZXIgaWQ9InNlcXVlbmNlbnVtYmVyIiByZWZYPSIxNSIgcmVmWT0iMTUiIG1hcmtlcldpZHRoPSI2MCIgbWFya2VySGVpZ2h0PSI0MCIgb3JpZW50PSJhdXRvIj48Y2lyY2xlIGN4PSIxNSIgY3k9IjE1IiByPSI2Ij48L2NpcmNsZT48L21hcmtlcj48L2RlZnM+PHRleHQgeD0iMTkyIiB5PSI4MCIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZG9taW5hbnQtYmFzZWxpbmU9Im1pZGRsZSIgYWxpZ25tZW50LWJhc2VsaW5lPSJtaWRkbGUiIGNsYXNzPSJtZXNzYWdlVGV4dCIgZHk9IjFlbSIgc3R5bGU9ImZvbnQtc2l6ZTogMTZweDsgZm9udC13ZWlnaHQ6IDQwMDsiPmdldENvbXBvc2l0ZSDtmLjstpw8L3RleHQ+PGxpbmUgeDE9Ijc2IiB5MT0iMTEzIiB4Mj0iMzA4IiB5Mj0iMTEzIiBjbGFzcz0ibWVzc2FnZUxpbmUwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZT0ibm9uZSIgbWFya2VyLWVuZD0idXJsKCNhcnJvd2hlYWQpIiBzdHlsZT0iZmlsbDogbm9uZTsiPjwvbGluZT48dGV4dCB4PSI0OTUiIHk9IjEyOCIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZG9taW5hbnQtYmFzZWxpbmU9Im1pZGRsZSIgYWxpZ25tZW50LWJhc2VsaW5lPSJtaWRkbGUiIGNsYXNzPSJtZXNzYWdlVGV4dCIgZHk9IjFlbSIgc3R5bGU9ImZvbnQtc2l6ZTogMTZweDsgZm9udC13ZWlnaHQ6IDQwMDsiPk15U1FMIEEg7KGw7ZqMIOyalOyyrTwvdGV4dD48bGluZSB4MT0iMzEzIiB5MT0iMTYxIiB4Mj0iNjc2IiB5Mj0iMTYxIiBjbGFzcz0ibWVzc2FnZUxpbmUwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZT0ibm9uZSIgbWFya2VyLWVuZD0idXJsKCNhcnJvd2hlYWQpIiBzdHlsZT0iZmlsbDogbm9uZTsiPjwvbGluZT48dGV4dCB4PSIxOTIiIHk9IjE3NiIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZG9taW5hbnQtYmFzZWxpbmU9Im1pZGRsZSIgYWxpZ25tZW50LWJhc2VsaW5lPSJtaWRkbGUiIGNsYXNzPSJtZXNzYWdlVGV4dCIgZHk9IjFlbSIgc3R5bGU9ImZvbnQtc2l6ZTogMTZweDsgZm9udC13ZWlnaHQ6IDQwMDsiPmdldE15U3FsIO2YuOy2nDwvdGV4dD48bGluZSB4MT0iNzYiIHkxPSIyMDkiIHgyPSIzMDgiIHkyPSIyMDkiIGNsYXNzPSJtZXNzYWdlTGluZTAiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlPSJub25lIiBtYXJrZXItZW5kPSJ1cmwoI2Fycm93aGVhZCkiIHN0eWxlPSJmaWxsOiBub25lOyI+PC9saW5lPjx0ZXh0IHg9IjQ5OCIgeT0iMjI0IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBkb21pbmFudC1iYXNlbGluZT0ibWlkZGxlIiBhbGlnbm1lbnQtYmFzZWxpbmU9Im1pZGRsZSIgY2xhc3M9Im1lc3NhZ2VUZXh0IiBkeT0iMWVtIiBzdHlsZT0iZm9udC1zaXplOiAxNnB4OyBmb250LXdlaWdodDogNDAwOyI+TXlTUUwgQSDsnZHri7UgKDIsNTAwbXMg7J207ZuEIOy7pOuEpeyFmCDrsJjtmZgpPC90ZXh0PjxsaW5lIHgxPSI2NzkiIHkxPSIyNTciIHgyPSIzMTYiIHkyPSIyNTciIGNsYXNzPSJtZXNzYWdlTGluZTEiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlPSJub25lIiBtYXJrZXItZW5kPSJ1cmwoI2Fycm93aGVhZCkiIHN0eWxlPSJzdHJva2UtZGFzaGFycmF5OiAzLCAzOyBmaWxsOiBub25lOyI+PC9saW5lPjx0ZXh0IHg9IjE5NSIgeT0iMjcyIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBkb21pbmFudC1iYXNlbGluZT0ibWlkZGxlIiBhbGlnbm1lbnQtYmFzZWxpbmU9Im1pZGRsZSIgY2xhc3M9Im1lc3NhZ2VUZXh0IiBkeT0iMWVtIiBzdHlsZT0iZm9udC1zaXplOiAxNnB4OyBmb250LXdlaWdodDogNDAwOyI+Z2V0Q29tcG9zaXRlIOydkeuLtSDrsJjtmZg8L3RleHQ+PGxpbmUgeDE9IjMxMSIgeTE9IjMwNSIgeDI9Ijc5IiB5Mj0iMzA1IiBjbGFzcz0ibWVzc2FnZUxpbmUxIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZT0ibm9uZSIgbWFya2VyLWVuZD0idXJsKCNhcnJvd2hlYWQpIiBzdHlsZT0ic3Ryb2tlLWRhc2hhcnJheTogMywgMzsgZmlsbDogbm9uZTsiPjwvbGluZT48dGV4dCB4PSI0OTUiIHk9IjMyMCIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZG9taW5hbnQtYmFzZWxpbmU9Im1pZGRsZSIgYWxpZ25tZW50LWJhc2VsaW5lPSJtaWRkbGUiIGNsYXNzPSJtZXNzYWdlVGV4dCIgZHk9IjFlbSIgc3R5bGU9ImZvbnQtc2l6ZTogMTZweDsgZm9udC13ZWlnaHQ6IDQwMDsiPk15U1FMIOyhsO2ajCBCIOyalOyyrSAoQSDsu6TrhKXshZgg67CY64Kp6rmM7KeAIOuMgOq4sCk8L3RleHQ+PGxpbmUgeDE9IjMxMyIgeTE9IjM1MyIgeDI9IjY3NiIgeTI9IjM1MyIgY2xhc3M9Im1lc3NhZ2VMaW5lMCIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2U9Im5vbmUiIG1hcmtlci1lbmQ9InVybCgjYXJyb3doZWFkKSIgc3R5bGU9ImZpbGw6IG5vbmU7Ij48L2xpbmU+PHRleHQgeD0iNDk4IiB5PSIzNjgiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGRvbWluYW50LWJhc2VsaW5lPSJtaWRkbGUiIGFsaWdubWVudC1iYXNlbGluZT0ibWlkZGxlIiBjbGFzcz0ibWVzc2FnZVRleHQiIGR5PSIxZW0iIHN0eWxlPSJmb250LXNpemU6IDE2cHg7IGZvbnQtd2VpZ2h0OiA0MDA7Ij5NeVNRTCBCIOydkeuLtTwvdGV4dD48bGluZSB4MT0iNjc5IiB5MT0iNDAxIiB4Mj0iMzE2IiB5Mj0iNDAxIiBjbGFzcz0ibWVzc2FnZUxpbmUxIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZT0ibm9uZSIgbWFya2VyLWVuZD0idXJsKCNhcnJvd2hlYWQpIiBzdHlsZT0ic3Ryb2tlLWRhc2hhcnJheTogMywgMzsgZmlsbDogbm9uZTsiPjwvbGluZT48dGV4dCB4PSIxOTUiIHk9IjQxNiIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZG9taW5hbnQtYmFzZWxpbmU9Im1pZGRsZSIgYWxpZ25tZW50LWJhc2VsaW5lPSJtaWRkbGUiIGNsYXNzPSJtZXNzYWdlVGV4dCIgZHk9IjFlbSIgc3R5bGU9ImZvbnQtc2l6ZTogMTZweDsgZm9udC13ZWlnaHQ6IDQwMDsiPmdldE15U3FsIOydkeuLtSDrsJjtmZg8L3RleHQ+PGxpbmUgeDE9IjMxMSIgeTE9IjQ0OSIgeDI9Ijc5IiB5Mj0iNDQ5IiBjbGFzcz0ibWVzc2FnZUxpbmUxIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZT0ibm9uZSIgbWFya2VyLWVuZD0idXJsKCNhcnJvd2hlYWQpIiBzdHlsZT0ic3Ryb2tlLWRhc2hhcnJheTogMywgMzsgZmlsbDogbm9uZTsiPjwvbGluZT48L3N2Zz4="><p>Hikari Connection Pool의 동작 방식은 아래와 같습니다.</p><ol><li><strong><code>getComposite</code> 호출</strong>: <code>Service</code>는 MySQL A 조회를 요청하고, 사용 가능한 idle 커넥션을 가져옵니다. 이 요청은 2,500ms가 소요되며, 해당 커넥션은 점유된 상태로 유지됩니다.</li><li><strong><code>getMySql</code> 호출</strong>: <code>Controller</code>는 <code>Service</code>로 <code>getMySql</code> 호출을 보냅니다. 그러나 MySQL A 조회 요청이 아직 진행 중이므로 사용 가능한 커넥션이 없습니다. 이에 따라 요청은 <strong>threadsAwaitingConnection</strong> 상태로 대기하게 됩니다.</li><li><strong>MySQL A 응답 반환</strong>: MySQL A 조회 요청이 완료되면서 커넥션이 반환됩니다. 반환된 커넥션은 대기 중이던 <code>getMySql</code>의 MySQL B 조회 요청에서 다시 사용됩니다.</li><li><strong>MySQL B 조회 요청 및 응답</strong>: 반환된 커넥션을 사용해 MySQL B 조회 요청이 처리됩니다. B 조회는 지연 없이 완료되며, 응답이 반환됩니다.</li><li><strong>최종 응답 반환</strong>: <code>getComposite</code>와 <code>getMySql</code> 요청이 순차적으로 완료되며, 최종적으로 각각의 결과가 <code>Controller</code>로 반환됩니다.</li></ol><h4><span id="beulroking-bangsigyi-hangye">블로킹 방식의 한계</span><a href="#beulroking-bangsigyi-hangye" class="header-anchor">#</a></h4><p>이 시나리오에서 Hikari Connection Pool의 블로킹 특성으로 인해 다음과 같은 한계가 발생합니다. MySQL A 조회 요청이 완료되기 전까지 커넥션이 반환되지 않아 <code>getMySql</code> 호출이 대기 상태에 놓이고, 동시 요청 수가 증가하면 <strong>threadsAwaitingConnection</strong> 상태가 늘어나면서 대기 시간이 길어질 가능성이 있습니다. 반면, Redis Lettuce 커넥션 풀은 이러한 상황에서 다르게 동작할 수 있습니다. Lettuce가 스레드를 블록시키는 방식으로 작동한다면 비슷한 지연 문제가 발생하지만, 논블로킹 방식이라면 MySQL의 지연과 관계없이 추가적인 Redis 요청에 빠르게 응답할 수 있습니다.</p><h3><span id="sinario-getcomposite-hocul-ihu-getredis-hocul">시나리오: getComposite 호출 이후 getRedis 호출</span><a href="#sinario-getcomposite-hocul-ihu-getredis-hocul" class="header-anchor">#</a></h3><p>Lettuce 설정은 <code>max-active=1</code>, <code>max-idle=1</code>, <code>min-idle=1</code>로 구성되어 있으며, <code>getComposite</code> 호출 시 사용 가능한 단 하나의 커넥션을 사용하여 작업이 진행됩니다. 이번 테스트의 목적은 <strong><code>/api/composite</code> 호출 이후 <code>getRedis</code> 호출의 응답 속도를 확인</strong>하는 것입니다. 만약 Lettuce가 블록되는 방식으로 동작한다면 <code>getComposite</code> 호출 중 Redis 조회 요청으로 점유된 커넥션이 MySQL 작업이 끝날 때까지 반환되지 않아 <code>getRedis</code> 요청은 대기 상태에 놓이고 응답 시간이 지연될 것입니다. 반대로 Lettuce가 비동기적이며 블로킹되지 않는 방식으로 동작한다면 <code>getComposite</code> 호출 중에도 커넥션이 Redis 요청 응답 후 즉시 반환되므로, <code>getRedis</code> 요청이 지연 없이 처리될 것입니다.</p><h4><span id="teseuteu-gyeolgwa">테스트 결과</span><a href="#teseuteu-gyeolgwa" class="header-anchor">#</a></h4><ol><li><code>/api/composite</code> 호출 이후 바로 <code>/api/redis</code>를 호출한 경우:<figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">Response code: 200; Time: 8ms (8 ms); Content length: 56 bytes (56 B)</span><br></pre></td></tr></table></figure></li><li><code>/api/redis</code> 단독 호출 시:<figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">Response code: 200; Time: 6ms (6 ms); Content length: 56 bytes (56 B)</span><br></pre></td></tr></table></figure></li></ol><p>테스트 결과, <code>/api/composite</code> 호출 중에도 <code>/api/redis</code> 요청은 지연 없이 빠르게 응답하는 것을 확인할 수 있었습니다. 이는 Lettuce가 비동기적으로 동작하며, 스레드를 블록하지 않음을 시사합니다.</p><h4><span id="dongjag-heureum">동작 흐름</span><a href="#dongjag-heureum" class="header-anchor">#</a></h4><img class="kroki" src="data:image/svg+xml;base64,PHN2ZyBpZD0iY29udGFpbmVyIiB3aWR0aD0iOTc1IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iNjUxIiB2aWV3Qm94PSItNTAgLTEwIDk3NSA2NTEiIHJvbGU9ImdyYXBoaWNzLWRvY3VtZW50IGRvY3VtZW50IiBhcmlhLXJvbGVkZXNjcmlwdGlvbj0ic2VxdWVuY2UiPjxnPjxyZWN0IHg9IjcyNSIgeT0iNTY1IiBmaWxsPSIjZWFlYWVhIiBzdHJva2U9IiM2NjYiIHdpZHRoPSIxNTAiIGhlaWdodD0iNjUiIG5hbWU9Ik15U1FMIiByeD0iMyIgcnk9IjMiIGNsYXNzPSJhY3RvciBhY3Rvci1ib3R0b20iPjwvcmVjdD48dGV4dCB4PSI4MDAiIHk9IjU5Ny41IiBkb21pbmFudC1iYXNlbGluZT0iY2VudHJhbCIgYWxpZ25tZW50LWJhc2VsaW5lPSJjZW50cmFsIiBjbGFzcz0iYWN0b3IgYWN0b3ItYm94IiBzdHlsZT0idGV4dC1hbmNob3I6IG1pZGRsZTsgZm9udC1zaXplOiAxNnB4OyBmb250LXdlaWdodDogNDAwOyI+PHRzcGFuIHg9IjgwMCIgZHk9IjAiPk15U1FMPC90c3Bhbj48L3RleHQ+PC9nPjxnPjxyZWN0IHg9IjUyNSIgeT0iNTY1IiBmaWxsPSIjZWFlYWVhIiBzdHJva2U9IiM2NjYiIHdpZHRoPSIxNTAiIGhlaWdodD0iNjUiIG5hbWU9IlJlZGlzIiByeD0iMyIgcnk9IjMiIGNsYXNzPSJhY3RvciBhY3Rvci1ib3R0b20iPjwvcmVjdD48dGV4dCB4PSI2MDAiIHk9IjU5Ny41IiBkb21pbmFudC1iYXNlbGluZT0iY2VudHJhbCIgYWxpZ25tZW50LWJhc2VsaW5lPSJjZW50cmFsIiBjbGFzcz0iYWN0b3IgYWN0b3ItYm94IiBzdHlsZT0idGV4dC1hbmNob3I6IG1pZGRsZTsgZm9udC1zaXplOiAxNnB4OyBmb250LXdlaWdodDogNDAwOyI+PHRzcGFuIHg9IjYwMCIgZHk9IjAiPlJlZGlzPC90c3Bhbj48L3RleHQ+PC9nPjxnPjxyZWN0IHg9IjIzNyIgeT0iNTY1IiBmaWxsPSIjZWFlYWVhIiBzdHJva2U9IiM2NjYiIHdpZHRoPSIxNTAiIGhlaWdodD0iNjUiIG5hbWU9IlNlcnZpY2UiIHJ4PSIzIiByeT0iMyIgY2xhc3M9ImFjdG9yIGFjdG9yLWJvdHRvbSI+PC9yZWN0Pjx0ZXh0IHg9IjMxMiIgeT0iNTk3LjUiIGRvbWluYW50LWJhc2VsaW5lPSJjZW50cmFsIiBhbGlnbm1lbnQtYmFzZWxpbmU9ImNlbnRyYWwiIGNsYXNzPSJhY3RvciBhY3Rvci1ib3giIHN0eWxlPSJ0ZXh0LWFuY2hvcjogbWlkZGxlOyBmb250LXNpemU6IDE2cHg7IGZvbnQtd2VpZ2h0OiA0MDA7Ij48dHNwYW4geD0iMzEyIiBkeT0iMCI+U2VydmljZTwvdHNwYW4+PC90ZXh0PjwvZz48Zz48cmVjdCB4PSIwIiB5PSI1NjUiIGZpbGw9IiNlYWVhZWEiIHN0cm9rZT0iIzY2NiIgd2lkdGg9IjE1MCIgaGVpZ2h0PSI2NSIgbmFtZT0iQ29udHJvbGxlciIgcng9IjMiIHJ5PSIzIiBjbGFzcz0iYWN0b3IgYWN0b3ItYm90dG9tIj48L3JlY3Q+PHRleHQgeD0iNzUiIHk9IjU5Ny41IiBkb21pbmFudC1iYXNlbGluZT0iY2VudHJhbCIgYWxpZ25tZW50LWJhc2VsaW5lPSJjZW50cmFsIiBjbGFzcz0iYWN0b3IgYWN0b3ItYm94IiBzdHlsZT0idGV4dC1hbmNob3I6IG1pZGRsZTsgZm9udC1zaXplOiAxNnB4OyBmb250LXdlaWdodDogNDAwOyI+PHRzcGFuIHg9Ijc1IiBkeT0iMCI+Q29udHJvbGxlcjwvdHNwYW4+PC90ZXh0PjwvZz48Zz48bGluZSBpZD0iYWN0b3IzIiB4MT0iODAwIiB5MT0iNjUiIHgyPSI4MDAiIHkyPSI1NjUiIGNsYXNzPSJhY3Rvci1saW5lIDIwMCIgc3Ryb2tlLXdpZHRoPSIwLjVweCIgc3Ryb2tlPSIjOTk5IiBuYW1lPSJNeVNRTCI+PC9saW5lPjxnIGlkPSJyb290LTMiPjxyZWN0IHg9IjcyNSIgeT0iMCIgZmlsbD0iI2VhZWFlYSIgc3Ryb2tlPSIjNjY2IiB3aWR0aD0iMTUwIiBoZWlnaHQ9IjY1IiBuYW1lPSJNeVNRTCIgcng9IjMiIHJ5PSIzIiBjbGFzcz0iYWN0b3IgYWN0b3ItdG9wIj48L3JlY3Q+PHRleHQgeD0iODAwIiB5PSIzMi41IiBkb21pbmFudC1iYXNlbGluZT0iY2VudHJhbCIgYWxpZ25tZW50LWJhc2VsaW5lPSJjZW50cmFsIiBjbGFzcz0iYWN0b3IgYWN0b3ItYm94IiBzdHlsZT0idGV4dC1hbmNob3I6IG1pZGRsZTsgZm9udC1zaXplOiAxNnB4OyBmb250LXdlaWdodDogNDAwOyI+PHRzcGFuIHg9IjgwMCIgZHk9IjAiPk15U1FMPC90c3Bhbj48L3RleHQ+PC9nPjwvZz48Zz48bGluZSBpZD0iYWN0b3IyIiB4MT0iNjAwIiB5MT0iNjUiIHgyPSI2MDAiIHkyPSI1NjUiIGNsYXNzPSJhY3Rvci1saW5lIDIwMCIgc3Ryb2tlLXdpZHRoPSIwLjVweCIgc3Ryb2tlPSIjOTk5IiBuYW1lPSJSZWRpcyI+PC9saW5lPjxnIGlkPSJyb290LTIiPjxyZWN0IHg9IjUyNSIgeT0iMCIgZmlsbD0iI2VhZWFlYSIgc3Ryb2tlPSIjNjY2IiB3aWR0aD0iMTUwIiBoZWlnaHQ9IjY1IiBuYW1lPSJSZWRpcyIgcng9IjMiIHJ5PSIzIiBjbGFzcz0iYWN0b3IgYWN0b3ItdG9wIj48L3JlY3Q+PHRleHQgeD0iNjAwIiB5PSIzMi41IiBkb21pbmFudC1iYXNlbGluZT0iY2VudHJhbCIgYWxpZ25tZW50LWJhc2VsaW5lPSJjZW50cmFsIiBjbGFzcz0iYWN0b3IgYWN0b3ItYm94IiBzdHlsZT0idGV4dC1hbmNob3I6IG1pZGRsZTsgZm9udC1zaXplOiAxNnB4OyBmb250LXdlaWdodDogNDAwOyI+PHRzcGFuIHg9IjYwMCIgZHk9IjAiPlJlZGlzPC90c3Bhbj48L3RleHQ+PC9nPjwvZz48Zz48bGluZSBpZD0iYWN0b3IxIiB4MT0iMzEyIiB5MT0iNjUiIHgyPSIzMTIiIHkyPSI1NjUiIGNsYXNzPSJhY3Rvci1saW5lIDIwMCIgc3Ryb2tlLXdpZHRoPSIwLjVweCIgc3Ryb2tlPSIjOTk5IiBuYW1lPSJTZXJ2aWNlIj48L2xpbmU+PGcgaWQ9InJvb3QtMSI+PHJlY3QgeD0iMjM3IiB5PSIwIiBmaWxsPSIjZWFlYWVhIiBzdHJva2U9IiM2NjYiIHdpZHRoPSIxNTAiIGhlaWdodD0iNjUiIG5hbWU9IlNlcnZpY2UiIHJ4PSIzIiByeT0iMyIgY2xhc3M9ImFjdG9yIGFjdG9yLXRvcCI+PC9yZWN0Pjx0ZXh0IHg9IjMxMiIgeT0iMzIuNSIgZG9taW5hbnQtYmFzZWxpbmU9ImNlbnRyYWwiIGFsaWdubWVudC1iYXNlbGluZT0iY2VudHJhbCIgY2xhc3M9ImFjdG9yIGFjdG9yLWJveCIgc3R5bGU9InRleHQtYW5jaG9yOiBtaWRkbGU7IGZvbnQtc2l6ZTogMTZweDsgZm9udC13ZWlnaHQ6IDQwMDsiPjx0c3BhbiB4PSIzMTIiIGR5PSIwIj5TZXJ2aWNlPC90c3Bhbj48L3RleHQ+PC9nPjwvZz48Zz48bGluZSBpZD0iYWN0b3IwIiB4MT0iNzUiIHkxPSI2NSIgeDI9Ijc1IiB5Mj0iNTY1IiBjbGFzcz0iYWN0b3ItbGluZSAyMDAiIHN0cm9rZS13aWR0aD0iMC41cHgiIHN0cm9rZT0iIzk5OSIgbmFtZT0iQ29udHJvbGxlciI+PC9saW5lPjxnIGlkPSJyb290LTAiPjxyZWN0IHg9IjAiIHk9IjAiIGZpbGw9IiNlYWVhZWEiIHN0cm9rZT0iIzY2NiIgd2lkdGg9IjE1MCIgaGVpZ2h0PSI2NSIgbmFtZT0iQ29udHJvbGxlciIgcng9IjMiIHJ5PSIzIiBjbGFzcz0iYWN0b3IgYWN0b3ItdG9wIj48L3JlY3Q+PHRleHQgeD0iNzUiIHk9IjMyLjUiIGRvbWluYW50LWJhc2VsaW5lPSJjZW50cmFsIiBhbGlnbm1lbnQtYmFzZWxpbmU9ImNlbnRyYWwiIGNsYXNzPSJhY3RvciBhY3Rvci1ib3giIHN0eWxlPSJ0ZXh0LWFuY2hvcjogbWlkZGxlOyBmb250LXNpemU6IDE2cHg7IGZvbnQtd2VpZ2h0OiA0MDA7Ij48dHNwYW4geD0iNzUiIGR5PSIwIj5Db250cm9sbGVyPC90c3Bhbj48L3RleHQ+PC9nPjwvZz48c3R5bGU+I2NvbnRhaW5lcntmb250LWZhbWlseToidHJlYnVjaGV0IG1zIix2ZXJkYW5hLGFyaWFsLHNhbnMtc2VyaWY7Zm9udC1zaXplOjE2cHg7ZmlsbDojMzMzO31Aa2V5ZnJhbWVzIGVkZ2UtYW5pbWF0aW9uLWZyYW1le2Zyb217c3Ryb2tlLWRhc2hvZmZzZXQ6MDt9fUBrZXlmcmFtZXMgZGFzaHt0b3tzdHJva2UtZGFzaG9mZnNldDowO319I2NvbnRhaW5lciAuZWRnZS1hbmltYXRpb24tc2xvd3tzdHJva2UtZGFzaGFycmF5OjksNSFpbXBvcnRhbnQ7c3Ryb2tlLWRhc2hvZmZzZXQ6OTAwO2FuaW1hdGlvbjpkYXNoIDUwcyBsaW5lYXIgaW5maW5pdGU7c3Ryb2tlLWxpbmVjYXA6cm91bmQ7fSNjb250YWluZXIgLmVkZ2UtYW5pbWF0aW9uLWZhc3R7c3Ryb2tlLWRhc2hhcnJheTo5LDUhaW1wb3J0YW50O3N0cm9rZS1kYXNob2Zmc2V0OjkwMDthbmltYXRpb246ZGFzaCAyMHMgbGluZWFyIGluZmluaXRlO3N0cm9rZS1saW5lY2FwOnJvdW5kO30jY29udGFpbmVyIC5lcnJvci1pY29ue2ZpbGw6IzU1MjIyMjt9I2NvbnRhaW5lciAuZXJyb3ItdGV4dHtmaWxsOiM1NTIyMjI7c3Ryb2tlOiM1NTIyMjI7fSNjb250YWluZXIgLmVkZ2UtdGhpY2tuZXNzLW5vcm1hbHtzdHJva2Utd2lkdGg6MXB4O30jY29udGFpbmVyIC5lZGdlLXRoaWNrbmVzcy10aGlja3tzdHJva2Utd2lkdGg6My41cHg7fSNjb250YWluZXIgLmVkZ2UtcGF0dGVybi1zb2xpZHtzdHJva2UtZGFzaGFycmF5OjA7fSNjb250YWluZXIgLmVkZ2UtdGhpY2tuZXNzLWludmlzaWJsZXtzdHJva2Utd2lkdGg6MDtmaWxsOm5vbmU7fSNjb250YWluZXIgLmVkZ2UtcGF0dGVybi1kYXNoZWR7c3Ryb2tlLWRhc2hhcnJheTozO30jY29udGFpbmVyIC5lZGdlLXBhdHRlcm4tZG90dGVke3N0cm9rZS1kYXNoYXJyYXk6Mjt9I2NvbnRhaW5lciAubWFya2Vye2ZpbGw6IzMzMzMzMztzdHJva2U6IzMzMzMzMzt9I2NvbnRhaW5lciAubWFya2VyLmNyb3Nze3N0cm9rZTojMzMzMzMzO30jY29udGFpbmVyIHN2Z3tmb250LWZhbWlseToidHJlYnVjaGV0IG1zIix2ZXJkYW5hLGFyaWFsLHNhbnMtc2VyaWY7Zm9udC1zaXplOjE2cHg7fSNjb250YWluZXIgcHttYXJnaW46MDt9I2NvbnRhaW5lciAuYWN0b3J7c3Ryb2tlOmhzbCgyNTkuNjI2MTY4MjI0MywgNTkuNzc2NTM2MzEyOCUsIDg3LjkwMTk2MDc4NDMlKTtmaWxsOiNFQ0VDRkY7fSNjb250YWluZXIgdGV4dC5hY3RvciZndDt0c3BhbntmaWxsOmJsYWNrO3N0cm9rZTpub25lO30jY29udGFpbmVyIC5hY3Rvci1saW5le3N0cm9rZTpoc2woMjU5LjYyNjE2ODIyNDMsIDU5Ljc3NjUzNjMxMjglLCA4Ny45MDE5NjA3ODQzJSk7fSNjb250YWluZXIgLmlubmVyQXJje3N0cm9rZS13aWR0aDoxLjU7c3Ryb2tlLWRhc2hhcnJheTpub25lO30jY29udGFpbmVyIC5tZXNzYWdlTGluZTB7c3Ryb2tlLXdpZHRoOjEuNTtzdHJva2UtZGFzaGFycmF5Om5vbmU7c3Ryb2tlOiMzMzM7fSNjb250YWluZXIgLm1lc3NhZ2VMaW5lMXtzdHJva2Utd2lkdGg6MS41O3N0cm9rZS1kYXNoYXJyYXk6MiwyO3N0cm9rZTojMzMzO30jY29udGFpbmVyICNhcnJvd2hlYWQgcGF0aHtmaWxsOiMzMzM7c3Ryb2tlOiMzMzM7fSNjb250YWluZXIgLnNlcXVlbmNlTnVtYmVye2ZpbGw6d2hpdGU7fSNjb250YWluZXIgI3NlcXVlbmNlbnVtYmVye2ZpbGw6IzMzMzt9I2NvbnRhaW5lciAjY3Jvc3NoZWFkIHBhdGh7ZmlsbDojMzMzO3N0cm9rZTojMzMzO30jY29udGFpbmVyIC5tZXNzYWdlVGV4dHtmaWxsOiMzMzM7c3Ryb2tlOm5vbmU7fSNjb250YWluZXIgLmxhYmVsQm94e3N0cm9rZTpoc2woMjU5LjYyNjE2ODIyNDMsIDU5Ljc3NjUzNjMxMjglLCA4Ny45MDE5NjA3ODQzJSk7ZmlsbDojRUNFQ0ZGO30jY29udGFpbmVyIC5sYWJlbFRleHQsI2NvbnRhaW5lciAubGFiZWxUZXh0Jmd0O3RzcGFue2ZpbGw6YmxhY2s7c3Ryb2tlOm5vbmU7fSNjb250YWluZXIgLmxvb3BUZXh0LCNjb250YWluZXIgLmxvb3BUZXh0Jmd0O3RzcGFue2ZpbGw6YmxhY2s7c3Ryb2tlOm5vbmU7fSNjb250YWluZXIgLmxvb3BMaW5le3N0cm9rZS13aWR0aDoycHg7c3Ryb2tlLWRhc2hhcnJheToyLDI7c3Ryb2tlOmhzbCgyNTkuNjI2MTY4MjI0MywgNTkuNzc2NTM2MzEyOCUsIDg3LjkwMTk2MDc4NDMlKTtmaWxsOmhzbCgyNTkuNjI2MTY4MjI0MywgNTkuNzc2NTM2MzEyOCUsIDg3LjkwMTk2MDc4NDMlKTt9I2NvbnRhaW5lciAubm90ZXtzdHJva2U6I2FhYWEzMztmaWxsOiNmZmY1YWQ7fSNjb250YWluZXIgLm5vdGVUZXh0LCNjb250YWluZXIgLm5vdGVUZXh0Jmd0O3RzcGFue2ZpbGw6YmxhY2s7c3Ryb2tlOm5vbmU7fSNjb250YWluZXIgLmFjdGl2YXRpb24we2ZpbGw6I2Y0ZjRmNDtzdHJva2U6IzY2Njt9I2NvbnRhaW5lciAuYWN0aXZhdGlvbjF7ZmlsbDojZjRmNGY0O3N0cm9rZTojNjY2O30jY29udGFpbmVyIC5hY3RpdmF0aW9uMntmaWxsOiNmNGY0ZjQ7c3Ryb2tlOiM2NjY7fSNjb250YWluZXIgLmFjdG9yUG9wdXBNZW51e3Bvc2l0aW9uOmFic29sdXRlO30jY29udGFpbmVyIC5hY3RvclBvcHVwTWVudVBhbmVse3Bvc2l0aW9uOmFic29sdXRlO2ZpbGw6I0VDRUNGRjtib3gtc2hhZG93OjBweCA4cHggMTZweCAwcHggcmdiYSgwLDAsMCwwLjIpO2ZpbHRlcjpkcm9wLXNoYWRvdygzcHggNXB4IDJweCByZ2IoMCAwIDAgLyAwLjQpKTt9I2NvbnRhaW5lciAuYWN0b3ItbWFuIGxpbmV7c3Ryb2tlOmhzbCgyNTkuNjI2MTY4MjI0MywgNTkuNzc2NTM2MzEyOCUsIDg3LjkwMTk2MDc4NDMlKTtmaWxsOiNFQ0VDRkY7fSNjb250YWluZXIgLmFjdG9yLW1hbiBjaXJjbGUsI2NvbnRhaW5lciBsaW5le3N0cm9rZTpoc2woMjU5LjYyNjE2ODIyNDMsIDU5Ljc3NjUzNjMxMjglLCA4Ny45MDE5NjA3ODQzJSk7ZmlsbDojRUNFQ0ZGO3N0cm9rZS13aWR0aDoycHg7fSNjb250YWluZXIgOnJvb3R7LS1tZXJtYWlkLWZvbnQtZmFtaWx5OiJ0cmVidWNoZXQgbXMiLHZlcmRhbmEsYXJpYWwsc2Fucy1zZXJpZjt9PC9zdHlsZT48Zz48L2c+PGRlZnM+PHN5bWJvbCBpZD0iY29tcHV0ZXIiIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCI+PHBhdGggdHJhbnNmb3JtPSJzY2FsZSguNSkiIGQ9Ik0yIDJ2MTNoMjB2LTEzaC0yMHptMTggMTFoLTE2di05aDE2djl6bS0xMC4yMjggNmwuNDY2LTFoMy41MjRsLjQ2NyAxaC00LjQ1N3ptMTQuMjI4IDNoLTI0bDItNmgyLjEwNGwtMS4zMyA0aDE4LjQ1bC0xLjI5Ny00aDIuMDczbDIgNnptLTUtMTBoLTE0di03aDE0djd6Ij48L3BhdGg+PC9zeW1ib2w+PC9kZWZzPjxkZWZzPjxzeW1ib2wgaWQ9ImRhdGFiYXNlIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCI+PHBhdGggdHJhbnNmb3JtPSJzY2FsZSguNSkiIGQ9Ik0xMi4yNTguMDAxbC4yNTYuMDA0LjI1NS4wMDUuMjUzLjAwOC4yNTEuMDEuMjQ5LjAxMi4yNDcuMDE1LjI0Ni4wMTYuMjQyLjAxOS4yNDEuMDIuMjM5LjAyMy4yMzYuMDI0LjIzMy4wMjcuMjMxLjAyOC4yMjkuMDMxLjIyNS4wMzIuMjIzLjAzNC4yMi4wMzYuMjE3LjAzOC4yMTQuMDQuMjExLjA0MS4yMDguMDQzLjIwNS4wNDUuMjAxLjA0Ni4xOTguMDQ4LjE5NC4wNS4xOTEuMDUxLjE4Ny4wNTMuMTgzLjA1NC4xOC4wNTYuMTc1LjA1Ny4xNzIuMDU5LjE2OC4wNi4xNjMuMDYxLjE2LjA2My4xNTUuMDY0LjE1LjA2Ni4wNzQuMDMzLjA3My4wMzMuMDcxLjAzNC4wNy4wMzQuMDY5LjAzNS4wNjguMDM1LjA2Ny4wMzUuMDY2LjAzNS4wNjQuMDM2LjA2NC4wMzYuMDYyLjAzNi4wNi4wMzYuMDYuMDM3LjA1OC4wMzcuMDU4LjAzNy4wNTUuMDM4LjA1NS4wMzguMDUzLjAzOC4wNTIuMDM4LjA1MS4wMzkuMDUuMDM5LjA0OC4wMzkuMDQ3LjAzOS4wNDUuMDQuMDQ0LjA0LjA0My4wNC4wNDEuMDQuMDQuMDQxLjAzOS4wNDEuMDM3LjA0MS4wMzYuMDQxLjAzNC4wNDEuMDMzLjA0Mi4wMzIuMDQyLjAzLjA0Mi4wMjkuMDQyLjAyNy4wNDIuMDI2LjA0My4wMjQuMDQzLjAyMy4wNDMuMDIxLjA0My4wMi4wNDMuMDE4LjA0NC4wMTcuMDQzLjAxNS4wNDQuMDEzLjA0NC4wMTIuMDQ0LjAxMS4wNDUuMDA5LjA0NC4wMDcuMDQ1LjAwNi4wNDUuMDA0LjA0NS4wMDIuMDQ1LjAwMS4wNDV2MTdsLS4wMDEuMDQ1LS4wMDIuMDQ1LS4wMDQuMDQ1LS4wMDYuMDQ1LS4wMDcuMDQ1LS4wMDkuMDQ0LS4wMTEuMDQ1LS4wMTIuMDQ0LS4wMTMuMDQ0LS4wMTUuMDQ0LS4wMTcuMDQzLS4wMTguMDQ0LS4wMi4wNDMtLjAyMS4wNDMtLjAyMy4wNDMtLjAyNC4wNDMtLjAyNi4wNDMtLjAyNy4wNDItLjAyOS4wNDItLjAzLjA0Mi0uMDMyLjA0Mi0uMDMzLjA0Mi0uMDM0LjA0MS0uMDM2LjA0MS0uMDM3LjA0MS0uMDM5LjA0MS0uMDQuMDQxLS4wNDEuMDQtLjA0My4wNC0uMDQ0LjA0LS4wNDUuMDQtLjA0Ny4wMzktLjA0OC4wMzktLjA1LjAzOS0uMDUxLjAzOS0uMDUyLjAzOC0uMDUzLjAzOC0uMDU1LjAzOC0uMDU1LjAzOC0uMDU4LjAzNy0uMDU4LjAzNy0uMDYuMDM3LS4wNi4wMzYtLjA2Mi4wMzYtLjA2NC4wMzYtLjA2NC4wMzYtLjA2Ni4wMzUtLjA2Ny4wMzUtLjA2OC4wMzUtLjA2OS4wMzUtLjA3LjAzNC0uMDcxLjAzNC0uMDczLjAzMy0uMDc0LjAzMy0uMTUuMDY2LS4xNTUuMDY0LS4xNi4wNjMtLjE2My4wNjEtLjE2OC4wNi0uMTcyLjA1OS0uMTc1LjA1Ny0uMTguMDU2LS4xODMuMDU0LS4xODcuMDUzLS4xOTEuMDUxLS4xOTQuMDUtLjE5OC4wNDgtLjIwMS4wNDYtLjIwNS4wNDUtLjIwOC4wNDMtLjIxMS4wNDEtLjIxNC4wNC0uMjE3LjAzOC0uMjIuMDM2LS4yMjMuMDM0LS4yMjUuMDMyLS4yMjkuMDMxLS4yMzEuMDI4LS4yMzMuMDI3LS4yMzYuMDI0LS4yMzkuMDIzLS4yNDEuMDItLjI0Mi4wMTktLjI0Ni4wMTYtLjI0Ny4wMTUtLjI0OS4wMTItLjI1MS4wMS0uMjUzLjAwOC0uMjU1LjAwNS0uMjU2LjAwNC0uMjU4LjAwMS0uMjU4LS4wMDEtLjI1Ni0uMDA0LS4yNTUtLjAwNS0uMjUzLS4wMDgtLjI1MS0uMDEtLjI0OS0uMDEyLS4yNDctLjAxNS0uMjQ1LS4wMTYtLjI0My0uMDE5LS4yNDEtLjAyLS4yMzgtLjAyMy0uMjM2LS4wMjQtLjIzNC0uMDI3LS4yMzEtLjAyOC0uMjI4LS4wMzEtLjIyNi0uMDMyLS4yMjMtLjAzNC0uMjItLjAzNi0uMjE3LS4wMzgtLjIxNC0uMDQtLjIxMS0uMDQxLS4yMDgtLjA0My0uMjA0LS4wNDUtLjIwMS0uMDQ2LS4xOTgtLjA0OC0uMTk1LS4wNS0uMTktLjA1MS0uMTg3LS4wNTMtLjE4NC0uMDU0LS4xNzktLjA1Ni0uMTc2LS4wNTctLjE3Mi0uMDU5LS4xNjctLjA2LS4xNjQtLjA2MS0uMTU5LS4wNjMtLjE1NS0uMDY0LS4xNTEtLjA2Ni0uMDc0LS4wMzMtLjA3Mi0uMDMzLS4wNzItLjAzNC0uMDctLjAzNC0uMDY5LS4wMzUtLjA2OC0uMDM1LS4wNjctLjAzNS0uMDY2LS4wMzUtLjA2NC0uMDM2LS4wNjMtLjAzNi0uMDYyLS4wMzYtLjA2MS0uMDM2LS4wNi0uMDM3LS4wNTgtLjAzNy0uMDU3LS4wMzctLjA1Ni0uMDM4LS4wNTUtLjAzOC0uMDUzLS4wMzgtLjA1Mi0uMDM4LS4wNTEtLjAzOS0uMDQ5LS4wMzktLjA0OS0uMDM5LS4wNDYtLjAzOS0uMDQ2LS4wNC0uMDQ0LS4wNC0uMDQzLS4wNC0uMDQxLS4wNC0uMDQtLjA0MS0uMDM5LS4wNDEtLjAzNy0uMDQxLS4wMzYtLjA0MS0uMDM0LS4wNDEtLjAzMy0uMDQyLS4wMzItLjA0Mi0uMDMtLjA0Mi0uMDI5LS4wNDItLjAyNy0uMDQyLS4wMjYtLjA0My0uMDI0LS4wNDMtLjAyMy0uMDQzLS4wMjEtLjA0My0uMDItLjA0My0uMDE4LS4wNDQtLjAxNy0uMDQzLS4wMTUtLjA0NC0uMDEzLS4wNDQtLjAxMi0uMDQ0LS4wMTEtLjA0NS0uMDA5LS4wNDQtLjAwNy0uMDQ1LS4wMDYtLjA0NS0uMDA0LS4wNDUtLjAwMi0uMDQ1LS4wMDEtLjA0NXYtMTdsLjAwMS0uMDQ1LjAwMi0uMDQ1LjAwNC0uMDQ1LjAwNi0uMDQ1LjAwNy0uMDQ1LjAwOS0uMDQ0LjAxMS0uMDQ1LjAxMi0uMDQ0LjAxMy0uMDQ0LjAxNS0uMDQ0LjAxNy0uMDQzLjAxOC0uMDQ0LjAyLS4wNDMuMDIxLS4wNDMuMDIzLS4wNDMuMDI0LS4wNDMuMDI2LS4wNDMuMDI3LS4wNDIuMDI5LS4wNDIuMDMtLjA0Mi4wMzItLjA0Mi4wMzMtLjA0Mi4wMzQtLjA0MS4wMzYtLjA0MS4wMzctLjA0MS4wMzktLjA0MS4wNC0uMDQxLjA0MS0uMDQuMDQzLS4wNC4wNDQtLjA0LjA0Ni0uMDQuMDQ2LS4wMzkuMDQ5LS4wMzkuMDQ5LS4wMzkuMDUxLS4wMzkuMDUyLS4wMzguMDUzLS4wMzguMDU1LS4wMzguMDU2LS4wMzguMDU3LS4wMzcuMDU4LS4wMzcuMDYtLjAzNy4wNjEtLjAzNi4wNjItLjAzNi4wNjMtLjAzNi4wNjQtLjAzNi4wNjYtLjAzNS4wNjctLjAzNS4wNjgtLjAzNS4wNjktLjAzNS4wNy0uMDM0LjA3Mi0uMDM0LjA3Mi0uMDMzLjA3NC0uMDMzLjE1MS0uMDY2LjE1NS0uMDY0LjE1OS0uMDYzLjE2NC0uMDYxLjE2Ny0uMDYuMTcyLS4wNTkuMTc2LS4wNTcuMTc5LS4wNTYuMTg0LS4wNTQuMTg3LS4wNTMuMTktLjA1MS4xOTUtLjA1LjE5OC0uMDQ4LjIwMS0uMDQ2LjIwNC0uMDQ1LjIwOC0uMDQzLjIxMS0uMDQxLjIxNC0uMDQuMjE3LS4wMzguMjItLjAzNi4yMjMtLjAzNC4yMjYtLjAzMi4yMjgtLjAzMS4yMzEtLjAyOC4yMzQtLjAyNy4yMzYtLjAyNC4yMzgtLjAyMy4yNDEtLjAyLjI0My0uMDE5LjI0NS0uMDE2LjI0Ny0uMDE1LjI0OS0uMDEyLjI1MS0uMDEuMjUzLS4wMDguMjU1LS4wMDUuMjU2LS4wMDQuMjU4LS4wMDEuMjU4LjAwMXptLTkuMjU4IDIwLjQ5OXYuMDFsLjAwMS4wMjEuMDAzLjAyMS4wMDQuMDIyLjAwNS4wMjEuMDA2LjAyMi4wMDcuMDIyLjAwOS4wMjMuMDEuMDIyLjAxMS4wMjMuMDEyLjAyMy4wMTMuMDIzLjAxNS4wMjMuMDE2LjAyNC4wMTcuMDIzLjAxOC4wMjQuMDE5LjAyNC4wMjEuMDI0LjAyMi4wMjUuMDIzLjAyNC4wMjQuMDI1LjA1Mi4wNDkuMDU2LjA1LjA2MS4wNTEuMDY2LjA1MS4wNy4wNTEuMDc1LjA1MS4wNzkuMDUyLjA4NC4wNTIuMDg4LjA1Mi4wOTIuMDUyLjA5Ny4wNTIuMTAyLjA1MS4xMDUuMDUyLjExLjA1Mi4xMTQuMDUxLjExOS4wNTEuMTIzLjA1MS4xMjcuMDUuMTMxLjA1LjEzNS4wNS4xMzkuMDQ4LjE0NC4wNDkuMTQ3LjA0Ny4xNTIuMDQ3LjE1NS4wNDcuMTYuMDQ1LjE2My4wNDUuMTY3LjA0My4xNzEuMDQzLjE3Ni4wNDEuMTc4LjA0MS4xODMuMDM5LjE4Ny4wMzkuMTkuMDM3LjE5NC4wMzUuMTk3LjAzNS4yMDIuMDMzLjIwNC4wMzEuMjA5LjAzLjIxMi4wMjkuMjE2LjAyNy4yMTkuMDI1LjIyMi4wMjQuMjI2LjAyMS4yMy4wMi4yMzMuMDE4LjIzNi4wMTYuMjQuMDE1LjI0My4wMTIuMjQ2LjAxLjI0OS4wMDguMjUzLjAwNS4yNTYuMDA0LjI1OS4wMDEuMjYtLjAwMS4yNTctLjAwNC4yNTQtLjAwNS4yNS0uMDA4LjI0Ny0uMDExLjI0NC0uMDEyLjI0MS0uMDE0LjIzNy0uMDE2LjIzMy0uMDE4LjIzMS0uMDIxLjIyNi0uMDIxLjIyNC0uMDI0LjIyLS4wMjYuMjE2LS4wMjcuMjEyLS4wMjguMjEtLjAzMS4yMDUtLjAzMS4yMDItLjAzNC4xOTgtLjAzNC4xOTQtLjAzNi4xOTEtLjAzNy4xODctLjAzOS4xODMtLjA0LjE3OS0uMDQuMTc1LS4wNDIuMTcyLS4wNDMuMTY4LS4wNDQuMTYzLS4wNDUuMTYtLjA0Ni4xNTUtLjA0Ni4xNTItLjA0Ny4xNDgtLjA0OC4xNDMtLjA0OS4xMzktLjA0OS4xMzYtLjA1LjEzMS0uMDUuMTI2LS4wNS4xMjMtLjA1MS4xMTgtLjA1Mi4xMTQtLjA1MS4xMS0uMDUyLjEwNi0uMDUyLjEwMS0uMDUyLjA5Ni0uMDUyLjA5Mi0uMDUyLjA4OC0uMDUzLjA4My0uMDUxLjA3OS0uMDUyLjA3NC0uMDUyLjA3LS4wNTEuMDY1LS4wNTEuMDYtLjA1MS4wNTYtLjA1LjA1MS0uMDUuMDIzLS4wMjQuMDIzLS4wMjUuMDIxLS4wMjQuMDItLjAyNC4wMTktLjAyNC4wMTgtLjAyNC4wMTctLjAyNC4wMTUtLjAyMy4wMTQtLjAyNC4wMTMtLjAyMy4wMTItLjAyMy4wMS0uMDIzLjAxLS4wMjIuMDA4LS4wMjIuMDA2LS4wMjIuMDA2LS4wMjIuMDA0LS4wMjIuMDA0LS4wMjEuMDAxLS4wMjEuMDAxLS4wMjF2LTQuMTI3bC0uMDc3LjA1NS0uMDguMDUzLS4wODMuMDU0LS4wODUuMDUzLS4wODcuMDUyLS4wOS4wNTItLjA5My4wNTEtLjA5NS4wNS0uMDk3LjA1LS4xLjA0OS0uMTAyLjA0OS0uMTA1LjA0OC0uMTA2LjA0Ny0uMTA5LjA0Ny0uMTExLjA0Ni0uMTE0LjA0NS0uMTE1LjA0NS0uMTE4LjA0NC0uMTIuMDQzLS4xMjIuMDQyLS4xMjQuMDQyLS4xMjYuMDQxLS4xMjguMDQtLjEzLjA0LS4xMzIuMDM4LS4xMzQuMDM4LS4xMzUuMDM3LS4xMzguMDM3LS4xMzkuMDM1LS4xNDIuMDM1LS4xNDMuMDM0LS4xNDQuMDMzLS4xNDcuMDMyLS4xNDguMDMxLS4xNS4wMy0uMTUxLjAzLS4xNTMuMDI5LS4xNTQuMDI3LS4xNTYuMDI3LS4xNTguMDI2LS4xNTkuMDI1LS4xNjEuMDI0LS4xNjIuMDIzLS4xNjMuMDIyLS4xNjUuMDIxLS4xNjYuMDItLjE2Ny4wMTktLjE2OS4wMTgtLjE2OS4wMTctLjE3MS4wMTYtLjE3My4wMTUtLjE3My4wMTQtLjE3NS4wMTMtLjE3NS4wMTItLjE3Ny4wMTEtLjE3OC4wMS0uMTc5LjAwOC0uMTc5LjAwOC0uMTgxLjAwNi0uMTgyLjAwNS0uMTgyLjAwNC0uMTg0LjAwMy0uMTg0LjAwMmgtLjM3bC0uMTg0LS4wMDItLjE4NC0uMDAzLS4xODItLjAwNC0uMTgyLS4wMDUtLjE4MS0uMDA2LS4xNzktLjAwOC0uMTc5LS4wMDgtLjE3OC0uMDEtLjE3Ni0uMDExLS4xNzYtLjAxMi0uMTc1LS4wMTMtLjE3My0uMDE0LS4xNzItLjAxNS0uMTcxLS4wMTYtLjE3LS4wMTctLjE2OS0uMDE4LS4xNjctLjAxOS0uMTY2LS4wMi0uMTY1LS4wMjEtLjE2My0uMDIyLS4xNjItLjAyMy0uMTYxLS4wMjQtLjE1OS0uMDI1LS4xNTctLjAyNi0uMTU2LS4wMjctLjE1NS0uMDI3LS4xNTMtLjAyOS0uMTUxLS4wMy0uMTUtLjAzLS4xNDgtLjAzMS0uMTQ2LS4wMzItLjE0NS0uMDMzLS4xNDMtLjAzNC0uMTQxLS4wMzUtLjE0LS4wMzUtLjEzNy0uMDM3LS4xMzYtLjAzNy0uMTM0LS4wMzgtLjEzMi0uMDM4LS4xMy0uMDQtLjEyOC0uMDQtLjEyNi0uMDQxLS4xMjQtLjA0Mi0uMTIyLS4wNDItLjEyLS4wNDQtLjExNy0uMDQzLS4xMTYtLjA0NS0uMTEzLS4wNDUtLjExMi0uMDQ2LS4xMDktLjA0Ny0uMTA2LS4wNDctLjEwNS0uMDQ4LS4xMDItLjA0OS0uMS0uMDQ5LS4wOTctLjA1LS4wOTUtLjA1LS4wOTMtLjA1Mi0uMDktLjA1MS0uMDg3LS4wNTItLjA4NS0uMDUzLS4wODMtLjA1NC0uMDgtLjA1NC0uMDc3LS4wNTR2NC4xMjd6bTAtNS42NTR2LjAxMWwuMDAxLjAyMS4wMDMuMDIxLjAwNC4wMjEuMDA1LjAyMi4wMDYuMDIyLjAwNy4wMjIuMDA5LjAyMi4wMS4wMjIuMDExLjAyMy4wMTIuMDIzLjAxMy4wMjMuMDE1LjAyNC4wMTYuMDIzLjAxNy4wMjQuMDE4LjAyNC4wMTkuMDI0LjAyMS4wMjQuMDIyLjAyNC4wMjMuMDI1LjAyNC4wMjQuMDUyLjA1LjA1Ni4wNS4wNjEuMDUuMDY2LjA1MS4wNy4wNTEuMDc1LjA1Mi4wNzkuMDUxLjA4NC4wNTIuMDg4LjA1Mi4wOTIuMDUyLjA5Ny4wNTIuMTAyLjA1Mi4xMDUuMDUyLjExLjA1MS4xMTQuMDUxLjExOS4wNTIuMTIzLjA1LjEyNy4wNTEuMTMxLjA1LjEzNS4wNDkuMTM5LjA0OS4xNDQuMDQ4LjE0Ny4wNDguMTUyLjA0Ny4xNTUuMDQ2LjE2LjA0NS4xNjMuMDQ1LjE2Ny4wNDQuMTcxLjA0Mi4xNzYuMDQyLjE3OC4wNC4xODMuMDQuMTg3LjAzOC4xOS4wMzcuMTk0LjAzNi4xOTcuMDM0LjIwMi4wMzMuMjA0LjAzMi4yMDkuMDMuMjEyLjAyOC4yMTYuMDI3LjIxOS4wMjUuMjIyLjAyNC4yMjYuMDIyLjIzLjAyLjIzMy4wMTguMjM2LjAxNi4yNC4wMTQuMjQzLjAxMi4yNDYuMDEuMjQ5LjAwOC4yNTMuMDA2LjI1Ni4wMDMuMjU5LjAwMS4yNi0uMDAxLjI1Ny0uMDAzLjI1NC0uMDA2LjI1LS4wMDguMjQ3LS4wMS4yNDQtLjAxMi4yNDEtLjAxNS4yMzctLjAxNi4yMzMtLjAxOC4yMzEtLjAyLjIyNi0uMDIyLjIyNC0uMDI0LjIyLS4wMjUuMjE2LS4wMjcuMjEyLS4wMjkuMjEtLjAzLjIwNS0uMDMyLjIwMi0uMDMzLjE5OC0uMDM1LjE5NC0uMDM2LjE5MS0uMDM3LjE4Ny0uMDM5LjE4My0uMDM5LjE3OS0uMDQxLjE3NS0uMDQyLjE3Mi0uMDQzLjE2OC0uMDQ0LjE2My0uMDQ1LjE2LS4wNDUuMTU1LS4wNDcuMTUyLS4wNDcuMTQ4LS4wNDguMTQzLS4wNDguMTM5LS4wNS4xMzYtLjA0OS4xMzEtLjA1LjEyNi0uMDUxLjEyMy0uMDUxLjExOC0uMDUxLjExNC0uMDUyLjExLS4wNTIuMTA2LS4wNTIuMTAxLS4wNTIuMDk2LS4wNTIuMDkyLS4wNTIuMDg4LS4wNTIuMDgzLS4wNTIuMDc5LS4wNTIuMDc0LS4wNTEuMDctLjA1Mi4wNjUtLjA1MS4wNi0uMDUuMDU2LS4wNTEuMDUxLS4wNDkuMDIzLS4wMjUuMDIzLS4wMjQuMDIxLS4wMjUuMDItLjAyNC4wMTktLjAyNC4wMTgtLjAyNC4wMTctLjAyNC4wMTUtLjAyMy4wMTQtLjAyMy4wMTMtLjAyNC4wMTItLjAyMi4wMS0uMDIzLjAxLS4wMjMuMDA4LS4wMjIuMDA2LS4wMjIuMDA2LS4wMjIuMDA0LS4wMjEuMDA0LS4wMjIuMDAxLS4wMjEuMDAxLS4wMjF2LTQuMTM5bC0uMDc3LjA1NC0uMDguMDU0LS4wODMuMDU0LS4wODUuMDUyLS4wODcuMDUzLS4wOS4wNTEtLjA5My4wNTEtLjA5NS4wNTEtLjA5Ny4wNS0uMS4wNDktLjEwMi4wNDktLjEwNS4wNDgtLjEwNi4wNDctLjEwOS4wNDctLjExMS4wNDYtLjExNC4wNDUtLjExNS4wNDQtLjExOC4wNDQtLjEyLjA0NC0uMTIyLjA0Mi0uMTI0LjA0Mi0uMTI2LjA0MS0uMTI4LjA0LS4xMy4wMzktLjEzMi4wMzktLjEzNC4wMzgtLjEzNS4wMzctLjEzOC4wMzYtLjEzOS4wMzYtLjE0Mi4wMzUtLjE0My4wMzMtLjE0NC4wMzMtLjE0Ny4wMzMtLjE0OC4wMzEtLjE1LjAzLS4xNTEuMDMtLjE1My4wMjgtLjE1NC4wMjgtLjE1Ni4wMjctLjE1OC4wMjYtLjE1OS4wMjUtLjE2MS4wMjQtLjE2Mi4wMjMtLjE2My4wMjItLjE2NS4wMjEtLjE2Ni4wMi0uMTY3LjAxOS0uMTY5LjAxOC0uMTY5LjAxNy0uMTcxLjAxNi0uMTczLjAxNS0uMTczLjAxNC0uMTc1LjAxMy0uMTc1LjAxMi0uMTc3LjAxMS0uMTc4LjAwOS0uMTc5LjAwOS0uMTc5LjAwNy0uMTgxLjAwNy0uMTgyLjAwNS0uMTgyLjAwNC0uMTg0LjAwMy0uMTg0LjAwMmgtLjM3bC0uMTg0LS4wMDItLjE4NC0uMDAzLS4xODItLjAwNC0uMTgyLS4wMDUtLjE4MS0uMDA3LS4xNzktLjAwNy0uMTc5LS4wMDktLjE3OC0uMDA5LS4xNzYtLjAxMS0uMTc2LS4wMTItLjE3NS0uMDEzLS4xNzMtLjAxNC0uMTcyLS4wMTUtLjE3MS0uMDE2LS4xNy0uMDE3LS4xNjktLjAxOC0uMTY3LS4wMTktLjE2Ni0uMDItLjE2NS0uMDIxLS4xNjMtLjAyMi0uMTYyLS4wMjMtLjE2MS0uMDI0LS4xNTktLjAyNS0uMTU3LS4wMjYtLjE1Ni0uMDI3LS4xNTUtLjAyOC0uMTUzLS4wMjgtLjE1MS0uMDMtLjE1LS4wMy0uMTQ4LS4wMzEtLjE0Ni0uMDMzLS4xNDUtLjAzMy0uMTQzLS4wMzMtLjE0MS0uMDM1LS4xNC0uMDM2LS4xMzctLjAzNi0uMTM2LS4wMzctLjEzNC0uMDM4LS4xMzItLjAzOS0uMTMtLjAzOS0uMTI4LS4wNC0uMTI2LS4wNDEtLjEyNC0uMDQyLS4xMjItLjA0My0uMTItLjA0My0uMTE3LS4wNDQtLjExNi0uMDQ0LS4xMTMtLjA0Ni0uMTEyLS4wNDYtLjEwOS0uMDQ2LS4xMDYtLjA0Ny0uMTA1LS4wNDgtLjEwMi0uMDQ5LS4xLS4wNDktLjA5Ny0uMDUtLjA5NS0uMDUxLS4wOTMtLjA1MS0uMDktLjA1MS0uMDg3LS4wNTMtLjA4NS0uMDUyLS4wODMtLjA1NC0uMDgtLjA1NC0uMDc3LS4wNTR2NC4xMzl6bTAtNS42NjZ2LjAxMWwuMDAxLjAyLjAwMy4wMjIuMDA0LjAyMS4wMDUuMDIyLjAwNi4wMjEuMDA3LjAyMi4wMDkuMDIzLjAxLjAyMi4wMTEuMDIzLjAxMi4wMjMuMDEzLjAyMy4wMTUuMDIzLjAxNi4wMjQuMDE3LjAyNC4wMTguMDIzLjAxOS4wMjQuMDIxLjAyNS4wMjIuMDI0LjAyMy4wMjQuMDI0LjAyNS4wNTIuMDUuMDU2LjA1LjA2MS4wNS4wNjYuMDUxLjA3LjA1MS4wNzUuMDUyLjA3OS4wNTEuMDg0LjA1Mi4wODguMDUyLjA5Mi4wNTIuMDk3LjA1Mi4xMDIuMDUyLjEwNS4wNTEuMTEuMDUyLjExNC4wNTEuMTE5LjA1MS4xMjMuMDUxLjEyNy4wNS4xMzEuMDUuMTM1LjA1LjEzOS4wNDkuMTQ0LjA0OC4xNDcuMDQ4LjE1Mi4wNDcuMTU1LjA0Ni4xNi4wNDUuMTYzLjA0NS4xNjcuMDQzLjE3MS4wNDMuMTc2LjA0Mi4xNzguMDQuMTgzLjA0LjE4Ny4wMzguMTkuMDM3LjE5NC4wMzYuMTk3LjAzNC4yMDIuMDMzLjIwNC4wMzIuMjA5LjAzLjIxMi4wMjguMjE2LjAyNy4yMTkuMDI1LjIyMi4wMjQuMjI2LjAyMS4yMy4wMi4yMzMuMDE4LjIzNi4wMTcuMjQuMDE0LjI0My4wMTIuMjQ2LjAxLjI0OS4wMDguMjUzLjAwNi4yNTYuMDAzLjI1OS4wMDEuMjYtLjAwMS4yNTctLjAwMy4yNTQtLjAwNi4yNS0uMDA4LjI0Ny0uMDEuMjQ0LS4wMTMuMjQxLS4wMTQuMjM3LS4wMTYuMjMzLS4wMTguMjMxLS4wMi4yMjYtLjAyMi4yMjQtLjAyNC4yMi0uMDI1LjIxNi0uMDI3LjIxMi0uMDI5LjIxLS4wMy4yMDUtLjAzMi4yMDItLjAzMy4xOTgtLjAzNS4xOTQtLjAzNi4xOTEtLjAzNy4xODctLjAzOS4xODMtLjAzOS4xNzktLjA0MS4xNzUtLjA0Mi4xNzItLjA0My4xNjgtLjA0NC4xNjMtLjA0NS4xNi0uMDQ1LjE1NS0uMDQ3LjE1Mi0uMDQ3LjE0OC0uMDQ4LjE0My0uMDQ5LjEzOS0uMDQ5LjEzNi0uMDQ5LjEzMS0uMDUxLjEyNi0uMDUuMTIzLS4wNTEuMTE4LS4wNTIuMTE0LS4wNTEuMTEtLjA1Mi4xMDYtLjA1Mi4xMDEtLjA1Mi4wOTYtLjA1Mi4wOTItLjA1Mi4wODgtLjA1Mi4wODMtLjA1Mi4wNzktLjA1Mi4wNzQtLjA1Mi4wNy0uMDUxLjA2NS0uMDUxLjA2LS4wNTEuMDU2LS4wNS4wNTEtLjA0OS4wMjMtLjAyNS4wMjMtLjAyNS4wMjEtLjAyNC4wMi0uMDI0LjAxOS0uMDI0LjAxOC0uMDI0LjAxNy0uMDI0LjAxNS0uMDIzLjAxNC0uMDI0LjAxMy0uMDIzLjAxMi0uMDIzLjAxLS4wMjIuMDEtLjAyMy4wMDgtLjAyMi4wMDYtLjAyMi4wMDYtLjAyMi4wMDQtLjAyMi4wMDQtLjAyMS4wMDEtLjAyMS4wMDEtLjAyMXYtNC4xNTNsLS4wNzcuMDU0LS4wOC4wNTQtLjA4My4wNTMtLjA4NS4wNTMtLjA4Ny4wNTMtLjA5LjA1MS0uMDkzLjA1MS0uMDk1LjA1MS0uMDk3LjA1LS4xLjA0OS0uMTAyLjA0OC0uMTA1LjA0OC0uMTA2LjA0OC0uMTA5LjA0Ni0uMTExLjA0Ni0uMTE0LjA0Ni0uMTE1LjA0NC0uMTE4LjA0NC0uMTIuMDQzLS4xMjIuMDQzLS4xMjQuMDQyLS4xMjYuMDQxLS4xMjguMDQtLjEzLjAzOS0uMTMyLjAzOS0uMTM0LjAzOC0uMTM1LjAzNy0uMTM4LjAzNi0uMTM5LjAzNi0uMTQyLjAzNC0uMTQzLjAzNC0uMTQ0LjAzMy0uMTQ3LjAzMi0uMTQ4LjAzMi0uMTUuMDMtLjE1MS4wMy0uMTUzLjAyOC0uMTU0LjAyOC0uMTU2LjAyNy0uMTU4LjAyNi0uMTU5LjAyNC0uMTYxLjAyNC0uMTYyLjAyMy0uMTYzLjAyMy0uMTY1LjAyMS0uMTY2LjAyLS4xNjcuMDE5LS4xNjkuMDE4LS4xNjkuMDE3LS4xNzEuMDE2LS4xNzMuMDE1LS4xNzMuMDE0LS4xNzUuMDEzLS4xNzUuMDEyLS4xNzcuMDEtLjE3OC4wMS0uMTc5LjAwOS0uMTc5LjAwNy0uMTgxLjAwNi0uMTgyLjAwNi0uMTgyLjAwNC0uMTg0LjAwMy0uMTg0LjAwMS0uMTg1LjAwMS0uMTg1LS4wMDEtLjE4NC0uMDAxLS4xODQtLjAwMy0uMTgyLS4wMDQtLjE4Mi0uMDA2LS4xODEtLjAwNi0uMTc5LS4wMDctLjE3OS0uMDA5LS4xNzgtLjAxLS4xNzYtLjAxLS4xNzYtLjAxMi0uMTc1LS4wMTMtLjE3My0uMDE0LS4xNzItLjAxNS0uMTcxLS4wMTYtLjE3LS4wMTctLjE2OS0uMDE4LS4xNjctLjAxOS0uMTY2LS4wMi0uMTY1LS4wMjEtLjE2My0uMDIzLS4xNjItLjAyMy0uMTYxLS4wMjQtLjE1OS0uMDI0LS4xNTctLjAyNi0uMTU2LS4wMjctLjE1NS0uMDI4LS4xNTMtLjAyOC0uMTUxLS4wMy0uMTUtLjAzLS4xNDgtLjAzMi0uMTQ2LS4wMzItLjE0NS0uMDMzLS4xNDMtLjAzNC0uMTQxLS4wMzQtLjE0LS4wMzYtLjEzNy0uMDM2LS4xMzYtLjAzNy0uMTM0LS4wMzgtLjEzMi0uMDM5LS4xMy0uMDM5LS4xMjgtLjA0MS0uMTI2LS4wNDEtLjEyNC0uMDQxLS4xMjItLjA0My0uMTItLjA0My0uMTE3LS4wNDQtLjExNi0uMDQ0LS4xMTMtLjA0Ni0uMTEyLS4wNDYtLjEwOS0uMDQ2LS4xMDYtLjA0OC0uMTA1LS4wNDgtLjEwMi0uMDQ4LS4xLS4wNS0uMDk3LS4wNDktLjA5NS0uMDUxLS4wOTMtLjA1MS0uMDktLjA1Mi0uMDg3LS4wNTItLjA4NS0uMDUzLS4wODMtLjA1My0uMDgtLjA1NC0uMDc3LS4wNTR2NC4xNTN6bTguNzQtOC4xNzlsLS4yNTcuMDA0LS4yNTQuMDA1LS4yNS4wMDgtLjI0Ny4wMTEtLjI0NC4wMTItLjI0MS4wMTQtLjIzNy4wMTYtLjIzMy4wMTgtLjIzMS4wMjEtLjIyNi4wMjItLjIyNC4wMjMtLjIyLjAyNi0uMjE2LjAyNy0uMjEyLjAyOC0uMjEuMDMxLS4yMDUuMDMyLS4yMDIuMDMzLS4xOTguMDM0LS4xOTQuMDM2LS4xOTEuMDM4LS4xODcuMDM4LS4xODMuMDQtLjE3OS4wNDEtLjE3NS4wNDItLjE3Mi4wNDMtLjE2OC4wNDMtLjE2My4wNDUtLjE2LjA0Ni0uMTU1LjA0Ni0uMTUyLjA0OC0uMTQ4LjA0OC0uMTQzLjA0OC0uMTM5LjA0OS0uMTM2LjA1LS4xMzEuMDUtLjEyNi4wNTEtLjEyMy4wNTEtLjExOC4wNTEtLjExNC4wNTItLjExLjA1Mi0uMTA2LjA1Mi0uMTAxLjA1Mi0uMDk2LjA1Mi0uMDkyLjA1Mi0uMDg4LjA1Mi0uMDgzLjA1Mi0uMDc5LjA1Mi0uMDc0LjA1MS0uMDcuMDUyLS4wNjUuMDUxLS4wNi4wNS0uMDU2LjA1LS4wNTEuMDUtLjAyMy4wMjUtLjAyMy4wMjQtLjAyMS4wMjQtLjAyLjAyNS0uMDE5LjAyNC0uMDE4LjAyNC0uMDE3LjAyMy0uMDE1LjAyNC0uMDE0LjAyMy0uMDEzLjAyMy0uMDEyLjAyMy0uMDEuMDIzLS4wMS4wMjItLjAwOC4wMjItLjAwNi4wMjMtLjAwNi4wMjEtLjAwNC4wMjItLjAwNC4wMjEtLjAwMS4wMjEtLjAwMS4wMjEuMDAxLjAyMS4wMDEuMDIxLjAwNC4wMjEuMDA0LjAyMi4wMDYuMDIxLjAwNi4wMjMuMDA4LjAyMi4wMS4wMjIuMDEuMDIzLjAxMi4wMjMuMDEzLjAyMy4wMTQuMDIzLjAxNS4wMjQuMDE3LjAyMy4wMTguMDI0LjAxOS4wMjQuMDIuMDI1LjAyMS4wMjQuMDIzLjAyNC4wMjMuMDI1LjA1MS4wNS4wNTYuMDUuMDYuMDUuMDY1LjA1MS4wNy4wNTIuMDc0LjA1MS4wNzkuMDUyLjA4My4wNTIuMDg4LjA1Mi4wOTIuMDUyLjA5Ni4wNTIuMTAxLjA1Mi4xMDYuMDUyLjExLjA1Mi4xMTQuMDUyLjExOC4wNTEuMTIzLjA1MS4xMjYuMDUxLjEzMS4wNS4xMzYuMDUuMTM5LjA0OS4xNDMuMDQ4LjE0OC4wNDguMTUyLjA0OC4xNTUuMDQ2LjE2LjA0Ni4xNjMuMDQ1LjE2OC4wNDMuMTcyLjA0My4xNzUuMDQyLjE3OS4wNDEuMTgzLjA0LjE4Ny4wMzguMTkxLjAzOC4xOTQuMDM2LjE5OC4wMzQuMjAyLjAzMy4yMDUuMDMyLjIxLjAzMS4yMTIuMDI4LjIxNi4wMjcuMjIuMDI2LjIyNC4wMjMuMjI2LjAyMi4yMzEuMDIxLjIzMy4wMTguMjM3LjAxNi4yNDEuMDE0LjI0NC4wMTIuMjQ3LjAxMS4yNS4wMDguMjU0LjAwNS4yNTcuMDA0LjI2LjAwMS4yNi0uMDAxLjI1Ny0uMDA0LjI1NC0uMDA1LjI1LS4wMDguMjQ3LS4wMTEuMjQ0LS4wMTIuMjQxLS4wMTQuMjM3LS4wMTYuMjMzLS4wMTguMjMxLS4wMjEuMjI2LS4wMjIuMjI0LS4wMjMuMjItLjAyNi4yMTYtLjAyNy4yMTItLjAyOC4yMS0uMDMxLjIwNS0uMDMyLjIwMi0uMDMzLjE5OC0uMDM0LjE5NC0uMDM2LjE5MS0uMDM4LjE4Ny0uMDM4LjE4My0uMDQuMTc5LS4wNDEuMTc1LS4wNDIuMTcyLS4wNDMuMTY4LS4wNDMuMTYzLS4wNDUuMTYtLjA0Ni4xNTUtLjA0Ni4xNTItLjA0OC4xNDgtLjA0OC4xNDMtLjA0OC4xMzktLjA0OS4xMzYtLjA1LjEzMS0uMDUuMTI2LS4wNTEuMTIzLS4wNTEuMTE4LS4wNTEuMTE0LS4wNTIuMTEtLjA1Mi4xMDYtLjA1Mi4xMDEtLjA1Mi4wOTYtLjA1Mi4wOTItLjA1Mi4wODgtLjA1Mi4wODMtLjA1Mi4wNzktLjA1Mi4wNzQtLjA1MS4wNy0uMDUyLjA2NS0uMDUxLjA2LS4wNS4wNTYtLjA1LjA1MS0uMDUuMDIzLS4wMjUuMDIzLS4wMjQuMDIxLS4wMjQuMDItLjAyNS4wMTktLjAyNC4wMTgtLjAyNC4wMTctLjAyMy4wMTUtLjAyNC4wMTQtLjAyMy4wMTMtLjAyMy4wMTItLjAyMy4wMS0uMDIzLjAxLS4wMjIuMDA4LS4wMjIuMDA2LS4wMjMuMDA2LS4wMjEuMDA0LS4wMjIuMDA0LS4wMjEuMDAxLS4wMjEuMDAxLS4wMjEtLjAwMS0uMDIxLS4wMDEtLjAyMS0uMDA0LS4wMjEtLjAwNC0uMDIyLS4wMDYtLjAyMS0uMDA2LS4wMjMtLjAwOC0uMDIyLS4wMS0uMDIyLS4wMS0uMDIzLS4wMTItLjAyMy0uMDEzLS4wMjMtLjAxNC0uMDIzLS4wMTUtLjAyNC0uMDE3LS4wMjMtLjAxOC0uMDI0LS4wMTktLjAyNC0uMDItLjAyNS0uMDIxLS4wMjQtLjAyMy0uMDI0LS4wMjMtLjAyNS0uMDUxLS4wNS0uMDU2LS4wNS0uMDYtLjA1LS4wNjUtLjA1MS0uMDctLjA1Mi0uMDc0LS4wNTEtLjA3OS0uMDUyLS4wODMtLjA1Mi0uMDg4LS4wNTItLjA5Mi0uMDUyLS4wOTYtLjA1Mi0uMTAxLS4wNTItLjEwNi0uMDUyLS4xMS0uMDUyLS4xMTQtLjA1Mi0uMTE4LS4wNTEtLjEyMy0uMDUxLS4xMjYtLjA1MS0uMTMxLS4wNS0uMTM2LS4wNS0uMTM5LS4wNDktLjE0My0uMDQ4LS4xNDgtLjA0OC0uMTUyLS4wNDgtLjE1NS0uMDQ2LS4xNi0uMDQ2LS4xNjMtLjA0NS0uMTY4LS4wNDMtLjE3Mi0uMDQzLS4xNzUtLjA0Mi0uMTc5LS4wNDEtLjE4My0uMDQtLjE4Ny0uMDM4LS4xOTEtLjAzOC0uMTk0LS4wMzYtLjE5OC0uMDM0LS4yMDItLjAzMy0uMjA1LS4wMzItLjIxLS4wMzEtLjIxMi0uMDI4LS4yMTYtLjAyNy0uMjItLjAyNi0uMjI0LS4wMjMtLjIyNi0uMDIyLS4yMzEtLjAyMS0uMjMzLS4wMTgtLjIzNy0uMDE2LS4yNDEtLjAxNC0uMjQ0LS4wMTItLjI0Ny0uMDExLS4yNS0uMDA4LS4yNTQtLjAwNS0uMjU3LS4wMDQtLjI2LS4wMDEtLjI2LjAwMXoiPjwvcGF0aD48L3N5bWJvbD48L2RlZnM+PGRlZnM+PHN5bWJvbCBpZD0iY2xvY2siIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCI+PHBhdGggdHJhbnNmb3JtPSJzY2FsZSguNSkiIGQ9Ik0xMiAyYzUuNTE0IDAgMTAgNC40ODYgMTAgMTBzLTQuNDg2IDEwLTEwIDEwLTEwLTQuNDg2LTEwLTEwIDQuNDg2LTEwIDEwLTEwem0wLTJjLTYuNjI3IDAtMTIgNS4zNzMtMTIgMTJzNS4zNzMgMTIgMTIgMTIgMTItNS4zNzMgMTItMTItNS4zNzMtMTItMTItMTJ6bTUuODQ4IDEyLjQ1OWMuMjAyLjAzOC4yMDIuMzMzLjAwMS4zNzItMS45MDcuMzYxLTYuMDQ1IDEuMTExLTYuNTQ3IDEuMTExLS43MTkgMC0xLjMwMS0uNTgyLTEuMzAxLTEuMzAxIDAtLjUxMi43Ny01LjQ0NyAxLjEyNS03LjQ0NS4wMzQtLjE5Mi4zMTItLjE4MS4zNDMuMDE0bC45ODUgNi4yMzggNS4zOTQgMS4wMTF6Ij48L3BhdGg+PC9zeW1ib2w+PC9kZWZzPjxkZWZzPjxtYXJrZXIgaWQ9ImFycm93aGVhZCIgcmVmWD0iNy45IiByZWZZPSI1IiBtYXJrZXJVbml0cz0idXNlclNwYWNlT25Vc2UiIG1hcmtlcldpZHRoPSIxMiIgbWFya2VySGVpZ2h0PSIxMiIgb3JpZW50PSJhdXRvLXN0YXJ0LXJldmVyc2UiPjxwYXRoIGQ9Ik0gLTEgMCBMIDEwIDUgTCAwIDEwIHoiPjwvcGF0aD48L21hcmtlcj48L2RlZnM+PGRlZnM+PG1hcmtlciBpZD0iY3Jvc3NoZWFkIiBtYXJrZXJXaWR0aD0iMTUiIG1hcmtlckhlaWdodD0iOCIgb3JpZW50PSJhdXRvIiByZWZYPSI0IiByZWZZPSI0LjUiPjxwYXRoIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzAwMDAwMCIgc3Ryb2tlLXdpZHRoPSIxcHQiIGQ9Ik0gMSwyIEwgNiw3IE0gNiwyIEwgMSw3IiBzdHlsZT0ic3Ryb2tlLWRhc2hhcnJheTogMCwgMDsiPjwvcGF0aD48L21hcmtlcj48L2RlZnM+PGRlZnM+PG1hcmtlciBpZD0iZmlsbGVkLWhlYWQiIHJlZlg9IjE1LjUiIHJlZlk9IjciIG1hcmtlcldpZHRoPSIyMCIgbWFya2VySGVpZ2h0PSIyOCIgb3JpZW50PSJhdXRvIj48cGF0aCBkPSJNIDE4LDcgTDksMTMgTDE0LDcgTDksMSBaIj48L3BhdGg+PC9tYXJrZXI+PC9kZWZzPjxkZWZzPjxtYXJrZXIgaWQ9InNlcXVlbmNlbnVtYmVyIiByZWZYPSIxNSIgcmVmWT0iMTUiIG1hcmtlcldpZHRoPSI2MCIgbWFya2VySGVpZ2h0PSI0MCIgb3JpZW50PSJhdXRvIj48Y2lyY2xlIGN4PSIxNSIgY3k9IjE1IiByPSI2Ij48L2NpcmNsZT48L21hcmtlcj48L2RlZnM+PHRleHQgeD0iMTkyIiB5PSI4MCIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZG9taW5hbnQtYmFzZWxpbmU9Im1pZGRsZSIgYWxpZ25tZW50LWJhc2VsaW5lPSJtaWRkbGUiIGNsYXNzPSJtZXNzYWdlVGV4dCIgZHk9IjFlbSIgc3R5bGU9ImZvbnQtc2l6ZTogMTZweDsgZm9udC13ZWlnaHQ6IDQwMDsiPmdldENvbXBvc2l0ZSDtmLjstpw8L3RleHQ+PGxpbmUgeDE9Ijc2IiB5MT0iMTEzIiB4Mj0iMzA4IiB5Mj0iMTEzIiBjbGFzcz0ibWVzc2FnZUxpbmUwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZT0ibm9uZSIgbWFya2VyLWVuZD0idXJsKCNhcnJvd2hlYWQpIiBzdHlsZT0iZmlsbDogbm9uZTsiPjwvbGluZT48dGV4dCB4PSI0NTUiIHk9IjEyOCIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZG9taW5hbnQtYmFzZWxpbmU9Im1pZGRsZSIgYWxpZ25tZW50LWJhc2VsaW5lPSJtaWRkbGUiIGNsYXNzPSJtZXNzYWdlVGV4dCIgZHk9IjFlbSIgc3R5bGU9ImZvbnQtc2l6ZTogMTZweDsgZm9udC13ZWlnaHQ6IDQwMDsiPlJlZGlzIOyhsO2ajCDsmpTssq08L3RleHQ+PGxpbmUgeDE9IjMxMyIgeTE9IjE2MSIgeDI9IjU5NiIgeTI9IjE2MSIgY2xhc3M9Im1lc3NhZ2VMaW5lMCIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2U9Im5vbmUiIG1hcmtlci1lbmQ9InVybCgjYXJyb3doZWFkKSIgc3R5bGU9ImZpbGw6IG5vbmU7Ij48L2xpbmU+PHRleHQgeD0iNDU4IiB5PSIxNzYiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGRvbWluYW50LWJhc2VsaW5lPSJtaWRkbGUiIGFsaWdubWVudC1iYXNlbGluZT0ibWlkZGxlIiBjbGFzcz0ibWVzc2FnZVRleHQiIGR5PSIxZW0iIHN0eWxlPSJmb250LXNpemU6IDE2cHg7IGZvbnQtd2VpZ2h0OiA0MDA7Ij5SZWRpcyDsnZHri7UgKDEwbXMpPC90ZXh0PjxsaW5lIHgxPSI1OTkiIHkxPSIyMDkiIHgyPSIzMTYiIHkyPSIyMDkiIGNsYXNzPSJtZXNzYWdlTGluZTEiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlPSJub25lIiBtYXJrZXItZW5kPSJ1cmwoI2Fycm93aGVhZCkiIHN0eWxlPSJzdHJva2UtZGFzaGFycmF5OiAzLCAzOyBmaWxsOiBub25lOyI+PC9saW5lPjx0ZXh0IHg9IjU1NSIgeT0iMjI0IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBkb21pbmFudC1iYXNlbGluZT0ibWlkZGxlIiBhbGlnbm1lbnQtYmFzZWxpbmU9Im1pZGRsZSIgY2xhc3M9Im1lc3NhZ2VUZXh0IiBkeT0iMWVtIiBzdHlsZT0iZm9udC1zaXplOiAxNnB4OyBmb250LXdlaWdodDogNDAwOyI+TXlTUUwg7KGw7ZqMIOyalOyyrTwvdGV4dD48bGluZSB4MT0iMzEzIiB5MT0iMjU3IiB4Mj0iNzk2IiB5Mj0iMjU3IiBjbGFzcz0ibWVzc2FnZUxpbmUwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZT0ibm9uZSIgbWFya2VyLWVuZD0idXJsKCNhcnJvd2hlYWQpIiBzdHlsZT0iZmlsbDogbm9uZTsiPjwvbGluZT48dGV4dCB4PSI1NTgiIHk9IjI3MiIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZG9taW5hbnQtYmFzZWxpbmU9Im1pZGRsZSIgYWxpZ25tZW50LWJhc2VsaW5lPSJtaWRkbGUiIGNsYXNzPSJtZXNzYWdlVGV4dCIgZHk9IjFlbSIgc3R5bGU9ImZvbnQtc2l6ZTogMTZweDsgZm9udC13ZWlnaHQ6IDQwMDsiPk15U1FMIOydkeuLtSAoMiw1MDBtcyk8L3RleHQ+PGxpbmUgeDE9Ijc5OSIgeTE9IjMwNSIgeDI9IjMxNiIgeTI9IjMwNSIgY2xhc3M9Im1lc3NhZ2VMaW5lMSIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2U9Im5vbmUiIG1hcmtlci1lbmQ9InVybCgjYXJyb3doZWFkKSIgc3R5bGU9InN0cm9rZS1kYXNoYXJyYXk6IDMsIDM7IGZpbGw6IG5vbmU7Ij48L2xpbmU+PHRleHQgeD0iMTkyIiB5PSIzMjAiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGRvbWluYW50LWJhc2VsaW5lPSJtaWRkbGUiIGFsaWdubWVudC1iYXNlbGluZT0ibWlkZGxlIiBjbGFzcz0ibWVzc2FnZVRleHQiIGR5PSIxZW0iIHN0eWxlPSJmb250LXNpemU6IDE2cHg7IGZvbnQtd2VpZ2h0OiA0MDA7Ij5nZXRSZWRpcyDtmLjstpw8L3RleHQ+PGxpbmUgeDE9Ijc2IiB5MT0iMzUzIiB4Mj0iMzA4IiB5Mj0iMzUzIiBjbGFzcz0ibWVzc2FnZUxpbmUwIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZT0ibm9uZSIgbWFya2VyLWVuZD0idXJsKCNhcnJvd2hlYWQpIiBzdHlsZT0iZmlsbDogbm9uZTsiPjwvbGluZT48dGV4dCB4PSIxOTUiIHk9IjM2OCIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZG9taW5hbnQtYmFzZWxpbmU9Im1pZGRsZSIgYWxpZ25tZW50LWJhc2VsaW5lPSJtaWRkbGUiIGNsYXNzPSJtZXNzYWdlVGV4dCIgZHk9IjFlbSIgc3R5bGU9ImZvbnQtc2l6ZTogMTZweDsgZm9udC13ZWlnaHQ6IDQwMDsiPmdldENvbXBvc2l0ZSDsnZHri7Ug67CY7ZmYPC90ZXh0PjxsaW5lIHgxPSIzMTEiIHkxPSI0MDEiIHgyPSI3OSIgeTI9IjQwMSIgY2xhc3M9Im1lc3NhZ2VMaW5lMSIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2U9Im5vbmUiIG1hcmtlci1lbmQ9InVybCgjYXJyb3doZWFkKSIgc3R5bGU9InN0cm9rZS1kYXNoYXJyYXk6IDMsIDM7IGZpbGw6IG5vbmU7Ij48L2xpbmU+PHRleHQgeD0iNDU1IiB5PSI0MTYiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGRvbWluYW50LWJhc2VsaW5lPSJtaWRkbGUiIGFsaWdubWVudC1iYXNlbGluZT0ibWlkZGxlIiBjbGFzcz0ibWVzc2FnZVRleHQiIGR5PSIxZW0iIHN0eWxlPSJmb250LXNpemU6IDE2cHg7IGZvbnQtd2VpZ2h0OiA0MDA7Ij5SZWRpcyDsobDtmowg7JqU7LKtICjsp4Dsl7Ag7JeG7J20IOydkeuLtSk8L3RleHQ+PGxpbmUgeDE9IjMxMyIgeTE9IjQ0OSIgeDI9IjU5NiIgeTI9IjQ0OSIgY2xhc3M9Im1lc3NhZ2VMaW5lMCIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2U9Im5vbmUiIG1hcmtlci1lbmQ9InVybCgjYXJyb3doZWFkKSIgc3R5bGU9ImZpbGw6IG5vbmU7Ij48L2xpbmU+PHRleHQgeD0iNDU4IiB5PSI0NjQiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGRvbWluYW50LWJhc2VsaW5lPSJtaWRkbGUiIGFsaWdubWVudC1iYXNlbGluZT0ibWlkZGxlIiBjbGFzcz0ibWVzc2FnZVRleHQiIGR5PSIxZW0iIHN0eWxlPSJmb250LXNpemU6IDE2cHg7IGZvbnQtd2VpZ2h0OiA0MDA7Ij5SZWRpcyDsnZHri7U8L3RleHQ+PGxpbmUgeDE9IjU5OSIgeTE9IjQ5NyIgeDI9IjMxNiIgeTI9IjQ5NyIgY2xhc3M9Im1lc3NhZ2VMaW5lMSIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2U9Im5vbmUiIG1hcmtlci1lbmQ9InVybCgjYXJyb3doZWFkKSIgc3R5bGU9InN0cm9rZS1kYXNoYXJyYXk6IDMsIDM7IGZpbGw6IG5vbmU7Ij48L2xpbmU+PHRleHQgeD0iMTk1IiB5PSI1MTIiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGRvbWluYW50LWJhc2VsaW5lPSJtaWRkbGUiIGFsaWdubWVudC1iYXNlbGluZT0ibWlkZGxlIiBjbGFzcz0ibWVzc2FnZVRleHQiIGR5PSIxZW0iIHN0eWxlPSJmb250LXNpemU6IDE2cHg7IGZvbnQtd2VpZ2h0OiA0MDA7Ij5nZXRSZWRpcyDsnZHri7Ug67CY7ZmYPC90ZXh0PjxsaW5lIHgxPSIzMTEiIHkxPSI1NDUiIHgyPSI3OSIgeTI9IjU0NSIgY2xhc3M9Im1lc3NhZ2VMaW5lMSIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2U9Im5vbmUiIG1hcmtlci1lbmQ9InVybCgjYXJyb3doZWFkKSIgc3R5bGU9InN0cm9rZS1kYXNoYXJyYXk6IDMsIDM7IGZpbGw6IG5vbmU7Ij48L2xpbmU+PC9zdmc+"><ol><li><strong><code>getComposite</code> 호출</strong>: <code>Controller</code>가 <code>getComposite</code> 요청을 보냅니다. <code>Service</code>는 먼저 Redis 조회를 수행하며, 이 작업은 10ms 만에 완료되고 커넥션은 즉시 반환됩니다. 이후 MySQL 조회를 시작하며, MySQL 조회 작업은 2,500ms가 소요됩니다.</li><li><strong><code>getRedis</code> 호출</strong>: MySQL 조회가 진행 중인 상태에서 <code>Controller</code>가 <code>getRedis</code> 요청을 보냅니다. Redis는 MySQL 작업과는 독립적으로 동작하므로, Redis 조회 요청은 지연 없이 처리됩니다. 반환된 Redis 커넥션이 즉시 재사용되어 <code>getRedis</code> 요청이 빠르게 완료됩니다.</li><li><strong><code>getComposite</code> 응답 반환</strong>: MySQL 작업이 완료되면 <code>getComposite</code> 응답이 반환됩니다.</li><li><strong><code>getRedis</code> 응답 반환</strong>: hRedis 조회 요청이 완료된 후 응답이 반환됩니다. Redis 작업이 MySQL 작업의 지연과 상관없이 즉시 처리되었기 때문에 빠른 응답 시간을 유지합니다.</li></ol><h4><span id="nonbeulrokingyi-jangjeom">논블로킹의 장점</span><a href="#nonbeulrokingyi-jangjeom" class="header-anchor">#</a></h4><p>테스트 결과를 통해 Lettuce가 비동기적이고 논블로킹 방식으로 동작한다는 것을 확인할 수 있었습니다. Redis 조회는 10ms 만에 응답을 완료하고 커넥션을 즉시 반환하므로, MySQL 작업이 진행 중이라도 Redis 커넥션이 점유된 상태로 남아있지 않습니다. 결과적으로, 추가적인 Redis 요청(<code>getRedis</code>)은 MySQL의 작업 지연과 관계없이 빠르게 처리됩니다.</p><p>이는 MySQL의 Hikari Connection Pool이 스레드를 블록하여 대기 시간을 유발하는 방식과 대비됩니다. Hikari에서는 커넥션이 반환될 때까지 다른 요청이 대기 상태에 놓이지만, Lettuce는 커넥션 반환이 즉시 이루어져 이러한 지연 없이 요청을 처리할 수 있습니다.</p><p>Redis의 비동기 I/O와 Lettuce의 설계가 결합되어 높은 동시성 환경에서도 안정적인 성능을 유지할 수 있음을 보여주는 사례라 할 수 있습니다. 이러한 특성은 Redis와 같이 빠른 응답성을 요구하는 환경에서 Lettuce가 얼마나 효율적인지를 잘 보여줍니다.</p><h3><span id="hikariwa-lettuceyi-caijeom-jeongri">Hikari와 Lettuce의 차이점 정리</span><a href="#hikariwa-lettuceyi-caijeom-jeongri" class="header-anchor">#</a></h3><ol><li><strong>동작 방식의 차이</strong>:<ul><li>Hikari Connection Pool은 <strong>전통적인 동기식 동작</strong>을 기반으로 하며, 스레드 블로킹이 발생합니다. MySQL 같은 관계형 데이터베이스와 함께 사용하는 경우, 데이터 조회 시 커넥션이 점유된 상태로 유지되므로 다른 요청들은 사용 가능한 커넥션이 없어 대기하게 됩니다.</li><li>Redis Lettuce Connection Pool은 <strong>비동기 및 넌블로킹 I/O</strong>를 지원하여, Redis 서버에서 응답을 받은 후에도 바로 커넥션을 반환할 수 있습니다. 이를 통해 <strong>커넥션 점유 시간이 짧아져</strong> Redis에 대한 다른 요청들도 빠르게 처리될 수 있습니다.</li></ul></li><li><strong>커넥션 풀의 활용도</strong>:<ul><li>Hikari Connection Pool은 <strong>threadsAwaitingConnection</strong>을 관리하며, 커넥션 풀이 모두 사용 중일 경우 새 요청들은 대기하게 됩니다. 따라서 <strong>커넥션 풀 크기 설정</strong>이 성능에 큰 영향을 미칩니다.</li><li>Redis Lettuce Connection Pool은 커넥션을 빠르게 반환하기 때문에, <strong>커넥션 풀 크기를 크게 설정하지 않아도 효율적으로 동작</strong>할 수 있습니다. Redis의 비동기 처리 덕분에 서버의 응답이 빠르다면, 적은 수의 커넥션으로도 많은 요청을 처리할 수 있습니다.</li></ul></li><li><strong>특정 상황에서의 블로킹 차이</strong>:<ul><li>Hikari Connection Pool은 요청이 지연되면, 다른 커넥션이 점유되지 않는 한 <strong>대기 스레드</strong>가 계속 늘어날 수 있습니다. 이는 높은 동시성에서 성능 저하를 야기할 수 있습니다.</li><li>Redis Lettuce는 비동기적이며, Redis 서버로부터 응답을 받은 후에는 <strong>커넥션을 빠르게 반환</strong>하므로, <strong>서버 응답 시간</strong>이 Lettuce의 성능에 직접적인 영향을 미칩니다. 즉, Redis 명령어가 복잡하고 시간이 오래 걸리면(예: <code>keys *</code> 사용), 그 시간 동안 다른 요청들이 블록될 수 있습니다.</li></ul></li><li><strong>블로킹/넌블로킹의 영향</strong>:<ul><li>Hikari의 경우 <strong>스레드 블로킹</strong>이 빈번히 발생하는 반면, Lettuce는 넌블로킹으로 <strong>추가적인 스레드 리소스를 사용하지 않고도</strong> 더 많은 요청을 처리할 수 있는 장점이 있습니다.</li><li>이는 특히 <strong>IO 작업이 많은 환경</strong>에서 Lettuce가 더 효율적으로 동작하도록 만듭니다. 반면, Hikari는 <strong>동기적 처리</strong>로 인해 CPU 자원을 더 많이 사용하게 되며, 이는 고비용의 대기 시간이 발생할 가능성을 증가시킵니다.</li></ul></li></ol><h2><span id="lettuce-connection-pool-seoljeonggwa-yimi">Lettuce Connection Pool 설정과 의미</span><a href="#lettuce-connection-pool-seoljeonggwa-yimi" class="header-anchor">#</a></h2><h3><span id="lettuce-connection-pool-properties">Lettuce Connection Pool Properties</span><a href="#lettuce-connection-pool-properties" class="header-anchor">#</a></h3><p>Spring Data Redis에서는 <code>application.yml</code> 파일을 통해 Lettuce Connection Pool의 설정을 손쉽게 구성할 수 있습니다. 아래는 주요 프로퍼티와 그 설명입니다:</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">spring:</span></span><br><span class="line">    <span class="attr">redis:</span></span><br><span class="line">        <span class="attr">lettuce:</span></span><br><span class="line">            <span class="attr">pool:</span></span><br><span class="line">                <span class="attr">max-active:</span> <span class="number">1</span></span><br><span class="line">                <span class="attr">max-idle:</span> <span class="number">1</span></span><br><span class="line">                <span class="attr">min-idle:</span> <span class="number">1</span></span><br><span class="line">                <span class="attr">enabled:</span> <span class="literal">true</span></span><br><span class="line">                <span class="attr">max-wait:</span> <span class="string">1000ms</span></span><br><span class="line">                <span class="attr">time-between-eviction-runs:</span> <span class="string">1000ms</span></span><br></pre></td></tr></table></figure><table><thead><tr><th>설정 항목</th><th>설명</th><th>기본 값</th></tr></thead><tbody><tr><td><code>max-active</code></td><td>Connection Pool에서 사용할 수 있는 최대 커넥션 수로, 동시 연결 수를 제한합니다.</td><td>8</td></tr><tr><td><code>max-idle</code></td><td>Connection Pool에서 유지할 수 있는 최대 유휴 커넥션 수입니다. 이 값을 초과하는 유휴 커넥션은 폐기됩니다.</td><td>8</td></tr><tr><td><code>min-idle</code></td><td>Connection Pool에서 유지할 최소 유휴 커넥션 수입니다. 유휴 커넥션이 부족하면 새 커넥션을 생성합니다.</td><td>0</td></tr><tr><td><code>enabled</code></td><td>Lettuce Connection Pool 사용 여부를 설정합니다. <code>true</code>로 설정 시 Pool이 활성화됩니다.</td><td><code>false</code></td></tr><tr><td><code>max-wait</code></td><td>커넥션이 사용 중일 때 새로운 요청이 대기할 수 있는 최대 시간입니다. 설정된 시간이 초과되면 예외가 발생합니다.</td><td>-1 (무제한 대기)</td></tr><tr><td><code>time-between-eviction-runs</code></td><td>유휴 커넥션 검사 및 제거 작업의 주기를 설정합니다. (단위: 밀리초)</td><td>-1 (검사하지 않음)</td></tr></tbody></table><p>이러한 설정은 Lettuce Connection Pool의 동작을 세부적으로 제어하며, 애플리케이션의 요구 사항에 맞게 성능과 리소스 사용량을 조정할 수 있습니다. 특히, <code>max-active</code>, <code>min-idle</code>, <code>max-wait</code>와 같은 설정은 TPS가 높은 환경이나 트래픽 변동이 큰 상황에서 애플리케이션의 안정성을 보장하는 데 중요한 역할을 합니다.</p><h3><span id="lettuce-connection-poolyi-gaesuga-gajneun-yimi">Lettuce Connection Pool의 개수가 갖는 의미</span><a href="#lettuce-connection-poolyi-gaesuga-gajneun-yimi" class="header-anchor">#</a></h3><p>Redis Lettuce Connection은 비동기 방식으로 동작하며, 요청을 처리한 후 커넥션을 즉시 반환하는 구조를 가지고 있습니다. 이는 적은 수의 커넥션으로도 높은 효율을 발휘할 수 있다는 점에서 큰 장점입니다. 하지만, Hikari와 같은 전통적인 동기 커넥션 풀처럼 미리 커넥션을 확보하여 동시성을 처리하는 방식은 아니기 때문에, &quot;Lettuce Connection Pool의 개수가 많다고 해서 이점이 있을까?&quot;라는 의문을 가질 수 있습니다.</p><p>사실, Lettuce Connection Pool의 개수는 TPS가 높아지고 Redis 서버의 응답이 느려질 때 중요한 의미를 가집니다. 다음은 Lettuce Connection Pool의 개수가 많을 때 가지는 이점을 설명합니다.</p><ol><li><p><strong>Redis 응답 지연 시 동시 요청 처리 가능</strong>:<br>Redis 서버가 응답을 지연하는 경우, 요청이 커넥션을 점유하는 시간이 길어집니다. 이때 Connection Pool의 크기가 작다면 모든 커넥션이 점유된 상태에서 새로운 요청이 대기 상태로 전환될 가능성이 높습니다. 하지만 Connection Pool 크기가 충분히 크다면, 지연된 요청과 별개로 다른 요청을 처리할 여유를 확보할 수 있습니다. 이는 TPS가 몰리는 상황에서도 전체 시스템의 성능 저하를 방지합니다.</p></li><li><p><strong>Burst 트래픽에서의 안정성 확보</strong>:<br>트래픽이 순간적으로 폭증하는 경우, Pool 크기가 충분히 크면 새로운 요청을 대기시키지 않고 처리할 수 있습니다. 이는 특히 Redis 응답 시간이 일관되지 않은 상황에서 더욱 중요한데, Pool 크기가 충분하지 않으면 요청 처리 시간이 예측 불가능하게 증가할 수 있습니다.</p></li><li><p><strong>멀티 인스턴스 활용 가능성</strong>:<br>Redis는 싱글 스레드로 동작하지만, 애플리케이션 인스턴스가 여러 개일 경우 각 인스턴스에서 동시에 Redis에 접근합니다. Pool 크기가 충분히 크다면, 여러 인스턴스가 Redis와 병렬로 통신하면서도 효율적으로 커넥션을 재사용할 수 있습니다.</p></li><li><p><strong>비동기 요청 처리 속도 향상</strong>:<br>Lettuce는 비동기적으로 여러 요청을 처리할 수 있지만, Pool 크기가 제한적이라면 커넥션 재사용의 병목이 발생할 수 있습니다. Pool 크기를 늘리면 이러한 병목을 완화하고, Redis 서버의 응답 속도에 따라 더 많은 요청을 처리할 수 있습니다.</p></li><li><p><strong>장시간 실행되는 명령어의 영향 완화</strong>:<br>일부 Redis 명령어(예: <code>zrange</code>, <code>keys</code>)는 실행 시간이 길어질 수 있습니다. Pool 크기가 작다면 이런 명령이 다른 요청 처리에 직접적인 영향을 미치지만, 충분한 Pool 크기는 이런 상황에서도 다른 요청을 지연 없이 처리할 수 있도록 합니다.</p></li></ol><p>Lettuce Connection Pool의 개수는 TPS와 Redis 서버의 응답 지연이 증가하는 상황에서 중요한 역할을 합니다. 비록 Lettuce가 비동기적이고 효율적인 구조를 가졌더라도, Pool 크기를 적절히 설정하지 않으면 요청 대기가 발생할 수 있습니다.</p><p>따라서 Lettuce Connection Pool의 개수는 단순히 많은 요청을 처리하기 위한 것이 아니라, Redis 서버 응답 시간의 변동성, Burst 트래픽, 그리고 장시간 실행되는 명령어 처리와 같은 다양한 상황에 대비하기 위한 안정성을 제공한다고 볼 수 있습니다. Connection Pool 크기를 TPS와 트래픽 패턴에 맞게 조정하는 것이 Lettuce의 효율성을 극대화하는 핵심 전략입니다.</p><h2><span id="gyeolron">결론</span><a href="#gyeolron" class="header-anchor">#</a></h2><p>논블로킹 방식이 항상 절대적으로 좋은 것은 아닙니다. Redis Lettuce와 Hikari Connection Pool은 서로 다른 특성과 장점을 가지고 있으며, 각 환경에 따라 적절히 선택해야 합니다.</p><p>Lettuce의 경우, Redis 서버로부터 응답을 받으면 커넥션을 즉시 반환하고 다음 요청을 처리할 준비를 합니다. 이는 커넥션 점유 시간이 짧아져 더 작은 풀 크기로 효율적인 요청 처리가 가능하다는 큰 장점이 있습니다. 하지만 Redis 서버 자체가 응답을 지연한다면, 다른 요청이 대기 상태에 놓이게 되어 성능 저하로 이어질 수 있습니다. 이러한 상황에서는 Lettuce의 비동기적 특성이 한계를 보일 수 있습니다.</p><p>반면, Hikari Connection Pool은 유휴 커넥션이 있는 경우 한 요청이 오래 걸리더라도 다른 커넥션을 사용하여 추가적인 요청을 처리할 수 있습니다. 이는 Redis와 같은 싱글 스레드 구조가 아닌, 다중 커넥션을 사용하는 데이터베이스 환경에 잘 맞는 전략입니다. 그러나 Hikari는 요청 중 커넥션이 블록되는 방식으로 동작하기 때문에, 풀 크기가 제한적이거나 동시 요청이 많아지면 대기 시간이 길어질 가능성이 있습니다.</p><p>이러한 차이는 Redis가 싱글 스레드로 모든 요청을 처리하도록 설계된 서버라는 점에서 기인합니다. Lettuce는 Redis 서버의 메커니즘과 잘 맞는 전략을 선택하여 구현되었으며, 비동기와 논블로킹을 통해 Redis의 성능 특성을 최대한 활용합니다. 이는 Hikari Connection Pool과는 다른 접근 방식으로, Redis 서버와의 높은 호환성을 유지하며 효율적인 자원 관리를 가능하게 합니다.</p><p>소프트웨어 개발에서는 항상 트레이드오프가 존재합니다. 특정 기술이 모든 상황에서 우월하다고 말할 수는 없습니다. Lettuce와 Hikari의 차이는 각기 다른 환경에 적합한 도구를 제공하며, 사용자는 자신의 애플리케이션 요구사항에 따라 적합한 전략을 선택해야 합니다. 결국, 어떤 도구가 &quot;좋다&quot;라고 단정짓기보다는, 각 기술의 장단점을 이해하고 상황에 맞게 활용하는 것이 중요합니다.</p>]]></content:encoded>
      
      
      
      <category domain="https://cheese10yun.github.io/tags/Connection-Pool/">Connection Pool</category>
      
      <category domain="https://cheese10yun.github.io/tags/Redis/">Redis</category>
      
      
      <comments>https://cheese10yun.github.io/redis-lettuce-connection/#disqus_thread</comments>
      
    </item>
    
    <item>
      <title>Mermaid 코드로 그리는 다이어그램</title>
      <link>https://cheese10yun.github.io/mermaid/</link>
      <guid>https://cheese10yun.github.io/mermaid/</guid>
      <pubDate>Sat, 16 Nov 2024 06:17:10 GMT</pubDate>
      
      <description>Mermaid는 간단한 텍스트 형식의 코드를 통해 다양한 다이어그램을 생성할 수 있는 강력한 도구입니다. 특히, 개발자들에게 큰 이점을 제공하며, 프로젝트 문서화 작업에서 필수적인 역할을 할 수 있습니다.</description>
      
      
      
      <content:encoded><![CDATA[<p>Mermaid는 간단한 텍스트 형식의 코드를 통해 다양한 다이어그램을 생성할 수 있는 강력한 도구입니다. 특히, 개발자들에게 큰 이점을 제공하며, 프로젝트 문서화 작업에서 필수적인 역할을 할 수 있습니다. 이번 글에서는 Mermaid가 어떤 이점을 가지고 있는지, 그리고 이를 활용하여 프로젝트 문서화를 어떻게 개선할 수 있는지에 대해 설명드리겠습니다.</p><h2><span id="kodeu-beiseu-gwanriyi-keun-ijeom">코드 베이스 관리의 큰 이점</span><a href="#kodeu-beiseu-gwanriyi-keun-ijeom" class="header-anchor">#</a></h2><p>Mermaid의 가장 큰 이점 중 하나는 <strong>코드 베이스 관리의 용이성</strong>입니다. 다이어그램을 코드로 관리하면 다음과 같은 장점이 있습니다:</p><ul><li><strong>유지보수성</strong>: 다이어그램이 코드로 작성되어 있기 때문에, 코드와 함께 버전 관리 시스템에서 추적할 수 있어 변경 이력을 쉽게 관리할 수 있습니다. 새로운 기능 추가나 수정이 있을 때 다이어그램을 업데이트하는 것도 매우 간단합니다.</li><li><strong>수정의 용이성</strong>: GUI로 그려진 다이어그램은 수정이 어렵고 시간이 많이 걸리지만, Mermaid는 텍스트 기반이므로 코드를 수정하는 것만으로 빠르게 다이어그램을 변경할 수 있습니다.</li><li><strong>검색의 용이성</strong>: 코드로 작성된 다이어그램은 텍스트 파일이기 때문에 프로젝트 내에서 검색이 가능하여, 유지보수 작업을 더 쉽게 할 수 있습니다.</li></ul><h2><span id="markdowneseo-jigjeob-sayong-ganeung">Markdown에서 직접 사용 가능</span><a href="#markdowneseo-jigjeob-sayong-ganeung" class="header-anchor">#</a></h2><p>Mermaid는 <strong>Markdown 프리뷰 플러그인</strong>을 제공하기 때문에, Markdown 문서 내에서 다이어그램을 바로 작성하고 확인할 수 있습니다. 이는 프로젝트의 <strong>README 파일</strong>에 다이어그램을 포함시켜 팀원들이 쉽게 접근할 수 있도록 만들어줍니다. 문서화를 프로젝트의 중심에서 관리할 수 있어 <strong>프로젝트의 가독성과 접근성을 크게 향상</strong>시킬 수 있습니다.</p><h2><span id="dayanghan-daieogeuraem-hyeongsig-jegong">다양한 다이어그램 형식 제공</span><a href="#dayanghan-daieogeuraem-hyeongsig-jegong" class="header-anchor">#</a></h2><p>Mermaid는 다양한 다이어그램 형식을 제공합니다. 몇 가지 주요 다이어그램 유형은 다음과 같습니다:</p><h3><span id="flowchart">Flowchart</span><a href="#flowchart" class="header-anchor">#</a></h3><p>프로세스 흐름을 나타내는 데 적합합니다.</p><img class="kroki" src="data:image/svg+xml;base64,PHN2ZyBpZD0iY29udGFpbmVyIiB3aWR0aD0iNDIwLjQwNjI1IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGNsYXNzPSJmbG93Y2hhcnQiIGhlaWdodD0iNTIwLjIxODc1IiB2aWV3Qm94PSIwIDAgNDIwLjQwNjI1IDUyMC4yMTg3NSIgcm9sZT0iZ3JhcGhpY3MtZG9jdW1lbnQgZG9jdW1lbnQiIGFyaWEtcm9sZWRlc2NyaXB0aW9uPSJmbG93Y2hhcnQtdjIiPjxzdHlsZT4jY29udGFpbmVye2ZvbnQtZmFtaWx5OiJ0cmVidWNoZXQgbXMiLHZlcmRhbmEsYXJpYWwsc2Fucy1zZXJpZjtmb250LXNpemU6MTZweDtmaWxsOiMzMzM7fUBrZXlmcmFtZXMgZWRnZS1hbmltYXRpb24tZnJhbWV7ZnJvbXtzdHJva2UtZGFzaG9mZnNldDowO319QGtleWZyYW1lcyBkYXNoe3Rve3N0cm9rZS1kYXNob2Zmc2V0OjA7fX0jY29udGFpbmVyIC5lZGdlLWFuaW1hdGlvbi1zbG93e3N0cm9rZS1kYXNoYXJyYXk6OSw1IWltcG9ydGFudDtzdHJva2UtZGFzaG9mZnNldDo5MDA7YW5pbWF0aW9uOmRhc2ggNTBzIGxpbmVhciBpbmZpbml0ZTtzdHJva2UtbGluZWNhcDpyb3VuZDt9I2NvbnRhaW5lciAuZWRnZS1hbmltYXRpb24tZmFzdHtzdHJva2UtZGFzaGFycmF5OjksNSFpbXBvcnRhbnQ7c3Ryb2tlLWRhc2hvZmZzZXQ6OTAwO2FuaW1hdGlvbjpkYXNoIDIwcyBsaW5lYXIgaW5maW5pdGU7c3Ryb2tlLWxpbmVjYXA6cm91bmQ7fSNjb250YWluZXIgLmVycm9yLWljb257ZmlsbDojNTUyMjIyO30jY29udGFpbmVyIC5lcnJvci10ZXh0e2ZpbGw6IzU1MjIyMjtzdHJva2U6IzU1MjIyMjt9I2NvbnRhaW5lciAuZWRnZS10aGlja25lc3Mtbm9ybWFse3N0cm9rZS13aWR0aDoxcHg7fSNjb250YWluZXIgLmVkZ2UtdGhpY2tuZXNzLXRoaWNre3N0cm9rZS13aWR0aDozLjVweDt9I2NvbnRhaW5lciAuZWRnZS1wYXR0ZXJuLXNvbGlke3N0cm9rZS1kYXNoYXJyYXk6MDt9I2NvbnRhaW5lciAuZWRnZS10aGlja25lc3MtaW52aXNpYmxle3N0cm9rZS13aWR0aDowO2ZpbGw6bm9uZTt9I2NvbnRhaW5lciAuZWRnZS1wYXR0ZXJuLWRhc2hlZHtzdHJva2UtZGFzaGFycmF5OjM7fSNjb250YWluZXIgLmVkZ2UtcGF0dGVybi1kb3R0ZWR7c3Ryb2tlLWRhc2hhcnJheToyO30jY29udGFpbmVyIC5tYXJrZXJ7ZmlsbDojMzMzMzMzO3N0cm9rZTojMzMzMzMzO30jY29udGFpbmVyIC5tYXJrZXIuY3Jvc3N7c3Ryb2tlOiMzMzMzMzM7fSNjb250YWluZXIgc3Zne2ZvbnQtZmFtaWx5OiJ0cmVidWNoZXQgbXMiLHZlcmRhbmEsYXJpYWwsc2Fucy1zZXJpZjtmb250LXNpemU6MTZweDt9I2NvbnRhaW5lciBwe21hcmdpbjowO30jY29udGFpbmVyIC5sYWJlbHtmb250LWZhbWlseToidHJlYnVjaGV0IG1zIix2ZXJkYW5hLGFyaWFsLHNhbnMtc2VyaWY7Y29sb3I6IzMzMzt9I2NvbnRhaW5lciAuY2x1c3Rlci1sYWJlbCB0ZXh0e2ZpbGw6IzMzMzt9I2NvbnRhaW5lciAuY2x1c3Rlci1sYWJlbCBzcGFue2NvbG9yOiMzMzM7fSNjb250YWluZXIgLmNsdXN0ZXItbGFiZWwgc3BhbiBwe2JhY2tncm91bmQtY29sb3I6dHJhbnNwYXJlbnQ7fSNjb250YWluZXIgLmxhYmVsIHRleHQsI2NvbnRhaW5lciBzcGFue2ZpbGw6IzMzMztjb2xvcjojMzMzO30jY29udGFpbmVyIC5ub2RlIHJlY3QsI2NvbnRhaW5lciAubm9kZSBjaXJjbGUsI2NvbnRhaW5lciAubm9kZSBlbGxpcHNlLCNjb250YWluZXIgLm5vZGUgcG9seWdvbiwjY29udGFpbmVyIC5ub2RlIHBhdGh7ZmlsbDojRUNFQ0ZGO3N0cm9rZTojOTM3MERCO3N0cm9rZS13aWR0aDoxcHg7fSNjb250YWluZXIgLnJvdWdoLW5vZGUgLmxhYmVsIHRleHQsI2NvbnRhaW5lciAubm9kZSAubGFiZWwgdGV4dCwjY29udGFpbmVyIC5pbWFnZS1zaGFwZSAubGFiZWwsI2NvbnRhaW5lciAuaWNvbi1zaGFwZSAubGFiZWx7dGV4dC1hbmNob3I6bWlkZGxlO30jY29udGFpbmVyIC5ub2RlIC5rYXRleCBwYXRoe2ZpbGw6IzAwMDtzdHJva2U6IzAwMDtzdHJva2Utd2lkdGg6MXB4O30jY29udGFpbmVyIC5yb3VnaC1ub2RlIC5sYWJlbCwjY29udGFpbmVyIC5ub2RlIC5sYWJlbCwjY29udGFpbmVyIC5pbWFnZS1zaGFwZSAubGFiZWwsI2NvbnRhaW5lciAuaWNvbi1zaGFwZSAubGFiZWx7dGV4dC1hbGlnbjpjZW50ZXI7fSNjb250YWluZXIgLm5vZGUuY2xpY2thYmxle2N1cnNvcjpwb2ludGVyO30jY29udGFpbmVyIC5yb290IC5hbmNob3IgcGF0aHtmaWxsOiMzMzMzMzMhaW1wb3J0YW50O3N0cm9rZS13aWR0aDowO3N0cm9rZTojMzMzMzMzO30jY29udGFpbmVyIC5hcnJvd2hlYWRQYXRoe2ZpbGw6IzMzMzMzMzt9I2NvbnRhaW5lciAuZWRnZVBhdGggLnBhdGh7c3Ryb2tlOiMzMzMzMzM7c3Ryb2tlLXdpZHRoOjIuMHB4O30jY29udGFpbmVyIC5mbG93Y2hhcnQtbGlua3tzdHJva2U6IzMzMzMzMztmaWxsOm5vbmU7fSNjb250YWluZXIgLmVkZ2VMYWJlbHtiYWNrZ3JvdW5kLWNvbG9yOnJnYmEoMjMyLDIzMiwyMzIsIDAuOCk7dGV4dC1hbGlnbjpjZW50ZXI7fSNjb250YWluZXIgLmVkZ2VMYWJlbCBwe2JhY2tncm91bmQtY29sb3I6cmdiYSgyMzIsMjMyLDIzMiwgMC44KTt9I2NvbnRhaW5lciAuZWRnZUxhYmVsIHJlY3R7b3BhY2l0eTowLjU7YmFja2dyb3VuZC1jb2xvcjpyZ2JhKDIzMiwyMzIsMjMyLCAwLjgpO2ZpbGw6cmdiYSgyMzIsMjMyLDIzMiwgMC44KTt9I2NvbnRhaW5lciAubGFiZWxCa2d7YmFja2dyb3VuZC1jb2xvcjpyZ2JhKDIzMiwgMjMyLCAyMzIsIDAuNSk7fSNjb250YWluZXIgLmNsdXN0ZXIgcmVjdHtmaWxsOiNmZmZmZGU7c3Ryb2tlOiNhYWFhMzM7c3Ryb2tlLXdpZHRoOjFweDt9I2NvbnRhaW5lciAuY2x1c3RlciB0ZXh0e2ZpbGw6IzMzMzt9I2NvbnRhaW5lciAuY2x1c3RlciBzcGFue2NvbG9yOiMzMzM7fSNjb250YWluZXIgZGl2Lm1lcm1haWRUb29sdGlwe3Bvc2l0aW9uOmFic29sdXRlO3RleHQtYWxpZ246Y2VudGVyO21heC13aWR0aDoyMDBweDtwYWRkaW5nOjJweDtmb250LWZhbWlseToidHJlYnVjaGV0IG1zIix2ZXJkYW5hLGFyaWFsLHNhbnMtc2VyaWY7Zm9udC1zaXplOjEycHg7YmFja2dyb3VuZDpoc2woODAsIDEwMCUsIDk2LjI3NDUwOTgwMzklKTtib3JkZXI6MXB4IHNvbGlkICNhYWFhMzM7Ym9yZGVyLXJhZGl1czoycHg7cG9pbnRlci1ldmVudHM6bm9uZTt6LWluZGV4OjEwMDt9I2NvbnRhaW5lciAuZmxvd2NoYXJ0VGl0bGVUZXh0e3RleHQtYW5jaG9yOm1pZGRsZTtmb250LXNpemU6MThweDtmaWxsOiMzMzM7fSNjb250YWluZXIgcmVjdC50ZXh0e2ZpbGw6bm9uZTtzdHJva2Utd2lkdGg6MDt9I2NvbnRhaW5lciAuaWNvbi1zaGFwZSwjY29udGFpbmVyIC5pbWFnZS1zaGFwZXtiYWNrZ3JvdW5kLWNvbG9yOnJnYmEoMjMyLDIzMiwyMzIsIDAuOCk7dGV4dC1hbGlnbjpjZW50ZXI7fSNjb250YWluZXIgLmljb24tc2hhcGUgcCwjY29udGFpbmVyIC5pbWFnZS1zaGFwZSBwe2JhY2tncm91bmQtY29sb3I6cmdiYSgyMzIsMjMyLDIzMiwgMC44KTtwYWRkaW5nOjJweDt9I2NvbnRhaW5lciAuaWNvbi1zaGFwZSByZWN0LCNjb250YWluZXIgLmltYWdlLXNoYXBlIHJlY3R7b3BhY2l0eTowLjU7YmFja2dyb3VuZC1jb2xvcjpyZ2JhKDIzMiwyMzIsMjMyLCAwLjgpO2ZpbGw6cmdiYSgyMzIsMjMyLDIzMiwgMC44KTt9I2NvbnRhaW5lciAubGFiZWwtaWNvbntkaXNwbGF5OmlubGluZS1ibG9jaztoZWlnaHQ6MWVtO292ZXJmbG93OnZpc2libGU7dmVydGljYWwtYWxpZ246LTAuMTI1ZW07fSNjb250YWluZXIgLm5vZGUgLmxhYmVsLWljb24gcGF0aHtmaWxsOmN1cnJlbnRDb2xvcjtzdHJva2U6cmV2ZXJ0O3N0cm9rZS13aWR0aDpyZXZlcnQ7fSNjb250YWluZXIgOnJvb3R7LS1tZXJtYWlkLWZvbnQtZmFtaWx5OiJ0cmVidWNoZXQgbXMiLHZlcmRhbmEsYXJpYWwsc2Fucy1zZXJpZjt9PC9zdHlsZT48Zz48bWFya2VyIGlkPSJjb250YWluZXJfZmxvd2NoYXJ0LXYyLXBvaW50RW5kIiBjbGFzcz0ibWFya2VyIGZsb3djaGFydC12MiIgdmlld0JveD0iMCAwIDEwIDEwIiByZWZYPSI1IiByZWZZPSI1IiBtYXJrZXJVbml0cz0idXNlclNwYWNlT25Vc2UiIG1hcmtlcldpZHRoPSI4IiBtYXJrZXJIZWlnaHQ9IjgiIG9yaWVudD0iYXV0byI+PHBhdGggZD0iTSAwIDAgTCAxMCA1IEwgMCAxMCB6IiBjbGFzcz0iYXJyb3dNYXJrZXJQYXRoIiBzdHlsZT0ic3Ryb2tlLXdpZHRoOiAxOyBzdHJva2UtZGFzaGFycmF5OiAxLCAwOyI+PC9wYXRoPjwvbWFya2VyPjxtYXJrZXIgaWQ9ImNvbnRhaW5lcl9mbG93Y2hhcnQtdjItcG9pbnRTdGFydCIgY2xhc3M9Im1hcmtlciBmbG93Y2hhcnQtdjIiIHZpZXdCb3g9IjAgMCAxMCAxMCIgcmVmWD0iNC41IiByZWZZPSI1IiBtYXJrZXJVbml0cz0idXNlclNwYWNlT25Vc2UiIG1hcmtlcldpZHRoPSI4IiBtYXJrZXJIZWlnaHQ9IjgiIG9yaWVudD0iYXV0byI+PHBhdGggZD0iTSAwIDUgTCAxMCAxMCBMIDEwIDAgeiIgY2xhc3M9ImFycm93TWFya2VyUGF0aCIgc3R5bGU9InN0cm9rZS13aWR0aDogMTsgc3Ryb2tlLWRhc2hhcnJheTogMSwgMDsiPjwvcGF0aD48L21hcmtlcj48bWFya2VyIGlkPSJjb250YWluZXJfZmxvd2NoYXJ0LXYyLWNpcmNsZUVuZCIgY2xhc3M9Im1hcmtlciBmbG93Y2hhcnQtdjIiIHZpZXdCb3g9IjAgMCAxMCAxMCIgcmVmWD0iMTEiIHJlZlk9IjUiIG1hcmtlclVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgbWFya2VyV2lkdGg9IjExIiBtYXJrZXJIZWlnaHQ9IjExIiBvcmllbnQ9ImF1dG8iPjxjaXJjbGUgY3g9IjUiIGN5PSI1IiByPSI1IiBjbGFzcz0iYXJyb3dNYXJrZXJQYXRoIiBzdHlsZT0ic3Ryb2tlLXdpZHRoOiAxOyBzdHJva2UtZGFzaGFycmF5OiAxLCAwOyI+PC9jaXJjbGU+PC9tYXJrZXI+PG1hcmtlciBpZD0iY29udGFpbmVyX2Zsb3djaGFydC12Mi1jaXJjbGVTdGFydCIgY2xhc3M9Im1hcmtlciBmbG93Y2hhcnQtdjIiIHZpZXdCb3g9IjAgMCAxMCAxMCIgcmVmWD0iLTEiIHJlZlk9IjUiIG1hcmtlclVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgbWFya2VyV2lkdGg9IjExIiBtYXJrZXJIZWlnaHQ9IjExIiBvcmllbnQ9ImF1dG8iPjxjaXJjbGUgY3g9IjUiIGN5PSI1IiByPSI1IiBjbGFzcz0iYXJyb3dNYXJrZXJQYXRoIiBzdHlsZT0ic3Ryb2tlLXdpZHRoOiAxOyBzdHJva2UtZGFzaGFycmF5OiAxLCAwOyI+PC9jaXJjbGU+PC9tYXJrZXI+PG1hcmtlciBpZD0iY29udGFpbmVyX2Zsb3djaGFydC12Mi1jcm9zc0VuZCIgY2xhc3M9Im1hcmtlciBjcm9zcyBmbG93Y2hhcnQtdjIiIHZpZXdCb3g9IjAgMCAxMSAxMSIgcmVmWD0iMTIiIHJlZlk9IjUuMiIgbWFya2VyVW5pdHM9InVzZXJTcGFjZU9uVXNlIiBtYXJrZXJXaWR0aD0iMTEiIG1hcmtlckhlaWdodD0iMTEiIG9yaWVudD0iYXV0byI+PHBhdGggZD0iTSAxLDEgbCA5LDkgTSAxMCwxIGwgLTksOSIgY2xhc3M9ImFycm93TWFya2VyUGF0aCIgc3R5bGU9InN0cm9rZS13aWR0aDogMjsgc3Ryb2tlLWRhc2hhcnJheTogMSwgMDsiPjwvcGF0aD48L21hcmtlcj48bWFya2VyIGlkPSJjb250YWluZXJfZmxvd2NoYXJ0LXYyLWNyb3NzU3RhcnQiIGNsYXNzPSJtYXJrZXIgY3Jvc3MgZmxvd2NoYXJ0LXYyIiB2aWV3Qm94PSIwIDAgMTEgMTEiIHJlZlg9Ii0xIiByZWZZPSI1LjIiIG1hcmtlclVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgbWFya2VyV2lkdGg9IjExIiBtYXJrZXJIZWlnaHQ9IjExIiBvcmllbnQ9ImF1dG8iPjxwYXRoIGQ9Ik0gMSwxIGwgOSw5IE0gMTAsMSBsIC05LDkiIGNsYXNzPSJhcnJvd01hcmtlclBhdGgiIHN0eWxlPSJzdHJva2Utd2lkdGg6IDI7IHN0cm9rZS1kYXNoYXJyYXk6IDEsIDA7Ij48L3BhdGg+PC9tYXJrZXI+PGcgY2xhc3M9InJvb3QiPjxnIGNsYXNzPSJjbHVzdGVycyI+PC9nPjxnIGNsYXNzPSJlZGdlUGF0aHMiPjxwYXRoIGQ9Ik0yMjMuNTYzLDYyTDIyMy41NjMsNjguMTY3QzIyMy41NjMsNzQuMzMzLDIyMy41NjMsODYuNjY3LDIyMy42MzcsOTguNDE3QzIyMy43MTEsMTEwLjE2NywyMjMuODYsMTIxLjMzNCwyMjMuOTM1LDEyNi45MTdMMjI0LjAwOSwxMzIuNSIgaWQ9IkxfQV9CXzAiIGNsYXNzPSJlZGdlLXRoaWNrbmVzcy1ub3JtYWwgZWRnZS1wYXR0ZXJuLXNvbGlkIGVkZ2UtdGhpY2tuZXNzLW5vcm1hbCBlZGdlLXBhdHRlcm4tc29saWQgZmxvd2NoYXJ0LWxpbmsiIHN0eWxlPSI7IiBkYXRhLWVkZ2U9InRydWUiIGRhdGEtZXQ9ImVkZ2UiIGRhdGEtaWQ9IkxfQV9CXzAiIGRhdGEtcG9pbnRzPSJXM3NpZUNJNk1qSXpMalUyTWpVc0lua2lPall5ZlN4N0luZ2lPakl5TXk0MU5qSTFMQ0o1SWpvNU9YMHNleUo0SWpveU1qUXVNRFl5TlN3aWVTSTZNVE0yTGpWOVhRPT0iIG1hcmtlci1lbmQ9InVybCgjY29udGFpbmVyX2Zsb3djaGFydC12Mi1wb2ludEVuZCkiPjwvcGF0aD48cGF0aCBkPSJNMjI0LjA2MywxOTAuNUwyMjMuOTc5LDE5NC41ODNDMjIzLjg5NiwxOTguNjY3LDIyMy43MjksMjA2LjgzMywyMjMuNjQ2LDIxNC40MTdDMjIzLjU2MywyMjIsMjIzLjU2MywyMjksMjIzLjU2MywyMzIuNUwyMjMuNTYzLDIzNiIgaWQ9IkxfQl9DXzAiIGNsYXNzPSJlZGdlLXRoaWNrbmVzcy1ub3JtYWwgZWRnZS1wYXR0ZXJuLXNvbGlkIGVkZ2UtdGhpY2tuZXNzLW5vcm1hbCBlZGdlLXBhdHRlcm4tc29saWQgZmxvd2NoYXJ0LWxpbmsiIHN0eWxlPSI7IiBkYXRhLWVkZ2U9InRydWUiIGRhdGEtZXQ9ImVkZ2UiIGRhdGEtaWQ9IkxfQl9DXzAiIGRhdGEtcG9pbnRzPSJXM3NpZUNJNk1qSTBMakEyTWpVc0lua2lPakU1TUM0MWZTeDdJbmdpT2pJeU15NDFOakkxTENKNUlqb3lNVFY5TEhzaWVDSTZNakl6TGpVMk1qVXNJbmtpT2pJME1IMWQiIG1hcmtlci1lbmQ9InVybCgjY29udGFpbmVyX2Zsb3djaGFydC12Mi1wb2ludEVuZCkiPjwvcGF0aD48cGF0aCBkPSJNMTgwLjY1MSwzNDEuMzA3TDE2MS4wNzYsMzU0LjYyNkMxNDEuNTAxLDM2Ny45NDQsMTAyLjM1MiwzOTQuNTgxLDgyLjc3OCw0MTMuNEM2My4yMDMsNDMyLjIxOSw2My4yMDMsNDQzLjIxOSw2My4yMDMsNDQ4LjcxOUw2My4yMDMsNDU0LjIxOSIgaWQ9IkxfQ19EXzAiIGNsYXNzPSJlZGdlLXRoaWNrbmVzcy1ub3JtYWwgZWRnZS1wYXR0ZXJuLXNvbGlkIGVkZ2UtdGhpY2tuZXNzLW5vcm1hbCBlZGdlLXBhdHRlcm4tc29saWQgZmxvd2NoYXJ0LWxpbmsiIHN0eWxlPSI7IiBkYXRhLWVkZ2U9InRydWUiIGRhdGEtZXQ9ImVkZ2UiIGRhdGEtaWQ9IkxfQ19EXzAiIGRhdGEtcG9pbnRzPSJXM3NpZUNJNk1UZ3dMalkxTURZd05qUTRNVEl4TXpBekxDSjVJam96TkRFdU16QTJPRFUyTkRneE1qRXpNRFo5TEhzaWVDSTZOak11TWpBek1USTFMQ0o1SWpvME1qRXVNakU0TnpWOUxIc2llQ0k2TmpNdU1qQXpNVEkxTENKNUlqbzBOVGd1TWpFNE56VjlYUT09IiBtYXJrZXItZW5kPSJ1cmwoI2NvbnRhaW5lcl9mbG93Y2hhcnQtdjItcG9pbnRFbmQpIj48L3BhdGg+PHBhdGggZD0iTTIyMy41NjMsMzg0LjIxOUwyMjMuNTYzLDM5MC4zODVDMjIzLjU2MywzOTYuNTUyLDIyMy41NjMsNDA4Ljg4NSwyMjMuNTYzLDQyMC41NTJDMjIzLjU2Myw0MzIuMjE5LDIyMy41NjMsNDQzLjIxOSwyMjMuNTYzLDQ0OC43MTlMMjIzLjU2Myw0NTQuMjE5IiBpZD0iTF9DX0VfMCIgY2xhc3M9ImVkZ2UtdGhpY2tuZXNzLW5vcm1hbCBlZGdlLXBhdHRlcm4tc29saWQgZWRnZS10aGlja25lc3Mtbm9ybWFsIGVkZ2UtcGF0dGVybi1zb2xpZCBmbG93Y2hhcnQtbGluayIgc3R5bGU9IjsiIGRhdGEtZWRnZT0idHJ1ZSIgZGF0YS1ldD0iZWRnZSIgZGF0YS1pZD0iTF9DX0VfMCIgZGF0YS1wb2ludHM9Ilczc2llQ0k2TWpJekxqVTJNalVzSW5raU9qTTROQzR5TVRnM05YMHNleUo0SWpveU1qTXVOVFl5TlN3aWVTSTZOREl4TGpJeE9EYzFmU3g3SW5naU9qSXlNeTQxTmpJMUxDSjVJam8wTlRndU1qRTROelY5WFE9PSIgbWFya2VyLWVuZD0idXJsKCNjb250YWluZXJfZmxvd2NoYXJ0LXYyLXBvaW50RW5kKSI+PC9wYXRoPjxwYXRoIGQ9Ik0yNjQuOTUxLDM0Mi44M0wyODIuNTUzLDM1NS44OTVDMzAwLjE1NSwzNjguOTYsMzM1LjM1OSwzOTUuMDg5LDM1Mi45NjEsNDEzLjY1NEMzNzAuNTYzLDQzMi4yMTksMzcwLjU2Myw0NDMuMjE5LDM3MC41NjMsNDQ4LjcxOUwzNzAuNTYzLDQ1NC4yMTkiIGlkPSJMX0NfRl8wIiBjbGFzcz0iZWRnZS10aGlja25lc3Mtbm9ybWFsIGVkZ2UtcGF0dGVybi1zb2xpZCBlZGdlLXRoaWNrbmVzcy1ub3JtYWwgZWRnZS1wYXR0ZXJuLXNvbGlkIGZsb3djaGFydC1saW5rIiBzdHlsZT0iOyIgZGF0YS1lZGdlPSJ0cnVlIiBkYXRhLWV0PSJlZGdlIiBkYXRhLWlkPSJMX0NfRl8wIiBkYXRhLXBvaW50cz0iVzNzaWVDSTZNalkwTGprMU1UTTNNVGswTVRreE9UTXNJbmtpT2pNME1pNDRNams0Tnpnd05UZ3dPREEzZlN4N0luZ2lPak0zTUM0MU5qSTFMQ0o1SWpvME1qRXVNakU0TnpWOUxIc2llQ0k2TXpjd0xqVTJNalVzSW5raU9qUTFPQzR5TVRnM05YMWQiIG1hcmtlci1lbmQ9InVybCgjY29udGFpbmVyX2Zsb3djaGFydC12Mi1wb2ludEVuZCkiPjwvcGF0aD48L2c+PGcgY2xhc3M9ImVkZ2VMYWJlbHMiPjxnIGNsYXNzPSJlZGdlTGFiZWwiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDIyMy41NjI1LCA5OSkiPjxnIGNsYXNzPSJsYWJlbCIgZGF0YS1pZD0iTF9BX0JfMCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTM4Ljg5ODQzNzUsIC0xMikiPjxmb3JlaWduT2JqZWN0IHdpZHRoPSI3Ny43OTY4NzUiIGhlaWdodD0iMjQiPjxkaXYgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGh0bWwiIGNsYXNzPSJsYWJlbEJrZyIgc3R5bGU9ImRpc3BsYXk6IHRhYmxlLWNlbGw7IHdoaXRlLXNwYWNlOiBub3dyYXA7IGxpbmUtaGVpZ2h0OiAxLjU7IG1heC13aWR0aDogMjAwcHg7IHRleHQtYWxpZ246IGNlbnRlcjsiPjxzcGFuIGNsYXNzPSJlZGdlTGFiZWwiPjxwPkdldCBtb25leTwvcD48L3NwYW4+PC9kaXY+PC9mb3JlaWduT2JqZWN0PjwvZz48L2c+PGcgY2xhc3M9ImVkZ2VMYWJlbCI+PGcgY2xhc3M9ImxhYmVsIiBkYXRhLWlkPSJMX0JfQ18wIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwLCAwKSI+PGZvcmVpZ25PYmplY3Qgd2lkdGg9IjAiIGhlaWdodD0iMCI+PGRpdiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94aHRtbCIgY2xhc3M9ImxhYmVsQmtnIiBzdHlsZT0iZGlzcGxheTogdGFibGUtY2VsbDsgd2hpdGUtc3BhY2U6IG5vd3JhcDsgbGluZS1oZWlnaHQ6IDEuNTsgbWF4LXdpZHRoOiAyMDBweDsgdGV4dC1hbGlnbjogY2VudGVyOyI+PHNwYW4gY2xhc3M9ImVkZ2VMYWJlbCI+PC9zcGFuPjwvZGl2PjwvZm9yZWlnbk9iamVjdD48L2c+PC9nPjxnIGNsYXNzPSJlZGdlTGFiZWwiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDYzLjIwMzEyNSwgNDIxLjIxODc1KSI+PGcgY2xhc3M9ImxhYmVsIiBkYXRhLWlkPSJMX0NfRF8wIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTQuNTg1OTM3NSwgLTEyKSI+PGZvcmVpZ25PYmplY3Qgd2lkdGg9IjI5LjE3MTg3NSIgaGVpZ2h0PSIyNCI+PGRpdiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94aHRtbCIgY2xhc3M9ImxhYmVsQmtnIiBzdHlsZT0iZGlzcGxheTogdGFibGUtY2VsbDsgd2hpdGUtc3BhY2U6IG5vd3JhcDsgbGluZS1oZWlnaHQ6IDEuNTsgbWF4LXdpZHRoOiAyMDBweDsgdGV4dC1hbGlnbjogY2VudGVyOyI+PHNwYW4gY2xhc3M9ImVkZ2VMYWJlbCI+PHA+T25lPC9wPjwvc3Bhbj48L2Rpdj48L2ZvcmVpZ25PYmplY3Q+PC9nPjwvZz48ZyBjbGFzcz0iZWRnZUxhYmVsIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgyMjMuNTYyNSwgNDIxLjIxODc1KSI+PGcgY2xhc3M9ImxhYmVsIiBkYXRhLWlkPSJMX0NfRV8wIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTQuMzA0Njg3NSwgLTEyKSI+PGZvcmVpZ25PYmplY3Qgd2lkdGg9IjI4LjYwOTM3NSIgaGVpZ2h0PSIyNCI+PGRpdiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94aHRtbCIgY2xhc3M9ImxhYmVsQmtnIiBzdHlsZT0iZGlzcGxheTogdGFibGUtY2VsbDsgd2hpdGUtc3BhY2U6IG5vd3JhcDsgbGluZS1oZWlnaHQ6IDEuNTsgbWF4LXdpZHRoOiAyMDBweDsgdGV4dC1hbGlnbjogY2VudGVyOyI+PHNwYW4gY2xhc3M9ImVkZ2VMYWJlbCI+PHA+VHdvPC9wPjwvc3Bhbj48L2Rpdj48L2ZvcmVpZ25PYmplY3Q+PC9nPjwvZz48ZyBjbGFzcz0iZWRnZUxhYmVsIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgzNzAuNTYyNSwgNDIxLjIxODc1KSI+PGcgY2xhc3M9ImxhYmVsIiBkYXRhLWlkPSJMX0NfRl8wIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMjAuMzk4NDM3NSwgLTEyKSI+PGZvcmVpZ25PYmplY3Qgd2lkdGg9IjQwLjc5Njg3NSIgaGVpZ2h0PSIyNCI+PGRpdiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94aHRtbCIgY2xhc3M9ImxhYmVsQmtnIiBzdHlsZT0iZGlzcGxheTogdGFibGUtY2VsbDsgd2hpdGUtc3BhY2U6IG5vd3JhcDsgbGluZS1oZWlnaHQ6IDEuNTsgbWF4LXdpZHRoOiAyMDBweDsgdGV4dC1hbGlnbjogY2VudGVyOyI+PHNwYW4gY2xhc3M9ImVkZ2VMYWJlbCI+PHA+VGhyZWU8L3A+PC9zcGFuPjwvZGl2PjwvZm9yZWlnbk9iamVjdD48L2c+PC9nPjwvZz48ZyBjbGFzcz0ibm9kZXMiPjxnIGNsYXNzPSJub2RlIGRlZmF1bHQiIGlkPSJmbG93Y2hhcnQtQS0wIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgyMjMuNTYyNSwgMzUpIj48cmVjdCBjbGFzcz0iYmFzaWMgbGFiZWwtY29udGFpbmVyIiBzdHlsZT0iIiB4PSItNjUuOTIxODc1IiB5PSItMjciIHdpZHRoPSIxMzEuODQzNzUiIGhlaWdodD0iNTQiPjwvcmVjdD48ZyBjbGFzcz0ibGFiZWwiIHN0eWxlPSIiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0zNS45MjE4NzUsIC0xMikiPjxyZWN0PjwvcmVjdD48Zm9yZWlnbk9iamVjdCB3aWR0aD0iNzEuODQzNzUiIGhlaWdodD0iMjQiPjxkaXYgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGh0bWwiIHN0eWxlPSJkaXNwbGF5OiB0YWJsZS1jZWxsOyB3aGl0ZS1zcGFjZTogbm93cmFwOyBsaW5lLWhlaWdodDogMS41OyBtYXgtd2lkdGg6IDIwMHB4OyB0ZXh0LWFsaWduOiBjZW50ZXI7Ij48c3BhbiBjbGFzcz0ibm9kZUxhYmVsIj48cD5DaHJpc3RtYXM8L3A+PC9zcGFuPjwvZGl2PjwvZm9yZWlnbk9iamVjdD48L2c+PC9nPjxnIGNsYXNzPSJub2RlIGRlZmF1bHQiIGlkPSJmbG93Y2hhcnQtQi0xIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgyMjMuNTYyNSwgMTYzKSI+PGcgY2xhc3M9ImJhc2ljIGxhYmVsLWNvbnRhaW5lciBvdXRlci1wYXRoIj48cGF0aCBkPSJNLTU1LjU0Njg3NSAtMjcgQy0xMy42NzQ5Nzg1ODY5NjI0MSAtMjcsIDI4LjE5NjkxNzgyNjA3NTE4IC0yNywgNTUuNTQ2ODc1IC0yNyBDNTUuNTQ2ODc1IC0yNywgNTUuNTQ2ODc1IC0yNywgNTUuNTQ2ODc1IC0yNyBDNTUuNjQ4NTgxMjEyMjUxOTkgLTI2Ljk5NTc5MzM5OTk0NDkyNiwgNTUuNzUwMjg3NDI0NTAzOTggLTI2Ljk5MTU4Njc5OTg4OTg1MiwgNTUuOTU5NzcxNzI3MzYxNjYgLTI2Ljk4MjkyMjQ2NTAzMzM0NyBDNTYuMDczOTEzNDMxNzc0NTEgLTI2Ljk2ODY5NDcxNjQxNTkyNSwgNTYuMTg4MDU1MTM2MTg3MzUgLTI2Ljk1NDQ2Njk2Nzc5ODUwNiwgNTYuMzY5ODQ3OTUxNDAzNjcgLTI2LjkzMTgwNjUxNzAxMzYxMiBDNTYuNDYwMTA2MzI2MjY0NDUgLTI2LjkxMjg4MTMyNTk2MjE4LCA1Ni41NTAzNjQ3MDExMjUyMyAtMjYuODkzOTU2MTM0OTEwNzQ1LCA1Ni43NzQzMDI0MzU3MDM5OTQgLTI2Ljg0NzAwMTMyOTY5NjY1MyBDNTYuODgwMzg0NjcxNTQ3ODEgLTI2LjgxNTQxOTI3OTczNTA0MywgNTYuOTg2NDY2OTA3MzkxNjMgLTI2Ljc4MzgzNzIyOTc3MzQzLCA1Ny4xNzAzNzIzNDYwMjM0MiAtMjYuNzI5MDg2MjA4NTAzMTczIEM1Ny4zMTU2MjkzMjQyMzQyOSAtMjYuNjcyNDA2NzU5NzIyNTczLCA1Ny40NjA4ODYzMDI0NDUxNSAtMjYuNjE1NzI3MzEwOTQxOTcsIDU3LjU1NTM1MjEyMzI2NDg0NiAtMjYuNTc4ODY2NjMzMjc1Mjg2IEM1Ny42OTcxODY4MzY5MzU3MiAtMjYuNTA5NTI3ODY2ODgwNzI0LCA1Ny44MzkwMjE1NTA2MDY1OSAtMjYuNDQwMTg5MTAwNDg2MTYsIDU3LjkyNjYxMTk2NTE4NTM2NiAtMjYuMzk3MzY4NzU2MDMyNDQ2IEM1OC4wMzk1ODIxMzEzMDE5NSAtMjYuMzMwMDUzMTQ4MjgyNjgzLCA1OC4xNTI1NTIyOTc0MTg1MjQgLTI2LjI2MjczNzU0MDUzMjkyLCA1OC4yODE2MTU3OTA2MTIxMzYgLTI2LjE4NTgzMjM5MTMxMjY0NCBDNTguNDExNzgyNzIxMjE2MjkgLTI2LjA5Mjg5NDk4OTE4NzMzNiwgNTguNTQxOTQ5NjUxODIwNDQgLTI1Ljk5OTk1NzU4NzA2MjAyOCwgNTguNjE3OTM4NTYzNDQ4MzQgLTI1Ljk0NTcwMjU0Njk4MTk3IEM1OC43MzI4OTczOTM5ODU3NDUgLTI1Ljg0ODMzNzM4NDE0MDg4NywgNTguODQ3ODU2MjI0NTIzMTUgLTI1Ljc1MDk3MjIyMTI5OTgsIDU4LjkzMzI4Mjg1ODEyODcwNiAtMjUuNjc4NjE5NTUzMzY1NjU3IEM1OS4wMTY1MzcxMzQ4NDczOCAtMjUuNTk1MzY1Mjc2NjQ2OTg0LCA1OS4wOTk3OTE0MTE1NjYwNSAtMjUuNTEyMTEwOTk5OTI4MzA3LCA1OS4yMjU0OTQ1NTMzNjU2NiAtMjUuMzg2NDA3ODU4MTI4NzA2IEM1OS4yODA3ODI5MzkyNTUwOSAtMjUuMzIxMTI4OTg0MjYxNjM3LCA1OS4zMzYwNzEzMjUxNDQ1MTYgLTI1LjI1NTg1MDExMDM5NDU3LCA1OS40OTI1Nzc1NDY5ODE5NyAtMjUuMDcxMDYzNTYzNDQ4MzQgQzU5LjU1NzIxODk1NzkwODIzIC0yNC45ODA1Mjc2MzQzNDE0NzQsIDU5LjYyMTg2MDM2ODgzNDQ5IC0yNC44ODk5OTE3MDUyMzQ2MDcsIDU5LjczMjcwNzM5MTMxMjY0NCAtMjQuNzM0NzQwNzkwNjEyMTM2IEM1OS44MDE4NjI4MjcyMDQzIC0yNC42MTg2ODI5OTQ0ODI3OTMsIDU5Ljg3MTAxODI2MzA5NTk1NSAtMjQuNTAyNjI1MTk4MzUzNDUzLCA1OS45NDQyNDM3NTYwMzI0NSAtMjQuMzc5NzM2OTY1MTg1MzcgQzYwLjAwOTQ5NjYyMDcyMzU2IC0yNC4yNDYyNjAwOTcwMzYyNywgNjAuMDc0NzQ5NDg1NDE0NjggLTI0LjExMjc4MzIyODg4NzE3LCA2MC4xMjU3NDE2MzMyNzUyOSAtMjQuMDA4NDc3MTIzMjY0ODQ2IEM2MC4xNzgwMjY2NjE2ODg2NSAtMjMuODc0NDgyMDc5MjY5NDAyLCA2MC4yMzAzMTE2OTAxMDIwMSAtMjMuNzQwNDg3MDM1MjczOTYsIDYwLjI3NTk2MTIwODUwMzE3NiAtMjMuNjIzNDk3MzQ2MDIzNDE3IEM2MC4zMDk0Nzc2NzYxODQ3IC0yMy41MTA5MTc1MTU1OTU1MzcsIDYwLjM0Mjk5NDE0Mzg2NjIyIC0yMy4zOTgzMzc2ODUxNjc2NiwgNjAuMzkzODc2MzI5Njk2NjUgLTIzLjIyNzQyNzQzNTcwMzk5NCBDNjAuNDIxOTMzNDYyOTU5ODIgLTIzLjA5MzYxNjgzMTM3NzU0NSwgNjAuNDQ5OTkwNTk2MjIyOTkgLTIyLjk1OTgwNjIyNzA1MTA5MywgNjAuNDc4NjgxNTE3MDEzNjEgLTIyLjgyMjk3Mjk1MTQwMzY3IEM2MC40OTkwMDYwNTQzNjY1MiAtMjIuNjU5OTE5OTM0NTY3MSwgNjAuNTE5MzMwNTkxNzE5NDIgLTIyLjQ5Njg2NjkxNzczMDUzLCA2MC41Mjk3OTc0NjUwMzMzNSAtMjIuNDEyODk2NzI3MzYxNjYyIEM2MC41MzU3OTMwMDIwOTM4NCAtMjIuMjY3OTM4MDA2MTAwNzk1LCA2MC41NDE3ODg1MzkxNTQzNCAtMjIuMTIyOTc5Mjg0ODM5OTI1LCA2MC41NDY4NzUgLTIyIEM2MC41NDY4NzUgLTIyLCA2MC41NDY4NzUgLTIyLCA2MC41NDY4NzUgLTIyIEM2MC41NDY4NzUgLTEwLjgwNDc4ODg1MjczMzg1MiwgNjAuNTQ2ODc1IDAuMzkwNDIyMjk0NTMyMjk2NCwgNjAuNTQ2ODc1IDIyIEM2MC41NDY4NzUgMjIsIDYwLjU0Njg3NSAyMiwgNjAuNTQ2ODc1IDIyIEM2MC41NDAxNTgxMTgzNDE2NiAyMi4xNjIzOTkyMjU2NjIyMSwgNjAuNTMzNDQxMjM2NjgzMzIgMjIuMzI0Nzk4NDUxMzI0NDIsIDYwLjUyOTc5NzQ2NTAzMzM1IDIyLjQxMjg5NjcyNzM2MTY2MiBDNjAuNTEzNzE2Nzc0NjgxMTkgMjIuNTQxOTAzNjAzNTkyNDMsIDYwLjQ5NzYzNjA4NDMyOTA0IDIyLjY3MDkxMDQ3OTgyMzIsIDYwLjQ3ODY4MTUxNzAxMzYxIDIyLjgyMjk3Mjk1MTQwMzY3IEM2MC40NTU0ODUwNjQwODE3NiAyMi45MzM2MDE5MDgyNTEzNDcsIDYwLjQzMjI4ODYxMTE0OTkxNCAyMy4wNDQyMzA4NjUwOTkwMjQsIDYwLjM5Mzg3NjMyOTY5NjY1IDIzLjIyNzQyNzQzNTcwMzk5NCBDNjAuMzQ5NDkyOTMxNzU1Mzg1IDIzLjM3NjUwODY0MTQ0MjAyLCA2MC4zMDUxMDk1MzM4MTQxMiAyMy41MjU1ODk4NDcxODAwNDQsIDYwLjI3NTk2MTIwODUwMzE3NiAyMy42MjM0OTczNDYwMjM0MTcgQzYwLjIzMjgzMDg0MDYyMDUgMjMuNzM0MDMxMDA1Nzk4NjAyLCA2MC4xODk3MDA0NzI3Mzc4MyAyMy44NDQ1NjQ2NjU1NzM3ODgsIDYwLjEyNTc0MTYzMzI3NTI5IDI0LjAwODQ3NzEyMzI2NDg0NiBDNjAuMDg4NjY0Mjk1Mjc5OSAyNC4wODQzMjAwMzAzNjcyLCA2MC4wNTE1ODY5NTcyODQ1MDYgMjQuMTYwMTYyOTM3NDY5NTUsIDU5Ljk0NDI0Mzc1NjAzMjQ1IDI0LjM3OTczNjk2NTE4NTM2NiBDNTkuODc2MDAwMjg0ODM5MjcgMjQuNDk0MjY0Mjg3MDc2NjU3LCA1OS44MDc3NTY4MTM2NDYxIDI0LjYwODc5MTYwODk2Nzk0NSwgNTkuNzMyNzA3MzkxMzEyNjQ0IDI0LjczNDc0MDc5MDYxMjEzMyBDNTkuNjQxMDQ1OTA4MjE0ODE0IDI0Ljg2MzEyMDY4NTQyOTc0LCA1OS41NDkzODQ0MjUxMTY5OCAyNC45OTE1MDA1ODAyNDczNTQsIDU5LjQ5MjU3NzU0Njk4MTk3IDI1LjA3MTA2MzU2MzQ0ODM0IEM1OS40MTQ0MDkxNDk2ODk5NDUgMjUuMTYzMzU2ODE1NzMxNDEsIDU5LjMzNjI0MDc1MjM5NzkyIDI1LjI1NTY1MDA2ODAxNDQ4NiwgNTkuMjI1NDk0NTUzMzY1NjYgMjUuMzg2NDA3ODU4MTI4NzA2IEM1OS4xMTc3MTUyODk4NDUzNCAyNS40OTQxODcxMjE2NDkwMiwgNTkuMDA5OTM2MDI2MzI1MDMgMjUuNjAxOTY2Mzg1MTY5MzMsIDU4LjkzMzI4Mjg1ODEyODcwNiAyNS42Nzg2MTk1NTMzNjU2NTcgQzU4LjgxMzU0NzgzNDM4MTA2IDI1Ljc4MDAyOTk0NTUwNzU0LCA1OC42OTM4MTI4MTA2MzM0MiAyNS44ODE0NDAzMzc2NDk0MjMsIDU4LjYxNzkzODU2MzQ0ODM0IDI1Ljk0NTcwMjU0Njk4MTk3IEM1OC40OTc4MzA2NjEyMjUxMyAyNi4wMzE0NTc5NDA4ODY4MywgNTguMzc3NzIyNzU5MDAxOTEgMjYuMTE3MjEzMzM0NzkxNjkzLCA1OC4yODE2MTU3OTA2MTIxMzYgMjYuMTg1ODMyMzkxMzEyNjQ0IEM1OC4xODkzNzY2NDU1NTI3OSAyNi4yNDA3OTQ5OTE4MDY0OCwgNTguMDk3MTM3NTAwNDkzNDUgMjYuMjk1NzU3NTkyMzAwMzIsIDU3LjkyNjYxMTk2NTE4NTM2NiAyNi4zOTczNjg3NTYwMzI0NDYgQzU3Ljc3OTc4NjEzMjI3NzIxNiAyNi40NjkxNDc1MzE5NDE0OSwgNTcuNjMyOTYwMjk5MzY5MDc0IDI2LjU0MDkyNjMwNzg1MDUzNywgNTcuNTU1MzUyMTIzMjY0ODQ2IDI2LjU3ODg2NjYzMzI3NTI4NiBDNTcuNDE2NzI3MDg2NzQzODMgMjYuNjMyOTU4MjkwMzc4Mjk4LCA1Ny4yNzgxMDIwNTAyMjI4MiAyNi42ODcwNDk5NDc0ODEzMSwgNTcuMTcwMzcyMzQ2MDIzNDIgMjYuNzI5MDg2MjA4NTAzMTczIEM1Ny4wNjAwNjY3MDM2Mjc4MDQgMjYuNzYxOTI1NjIxMDcyODIzLCA1Ni45NDk3NjEwNjEyMzIxOTUgMjYuNzk0NzY1MDMzNjQyNDcsIDU2Ljc3NDMwMjQzNTcwMzk5NCAyNi44NDcwMDEzMjk2OTY2NTMgQzU2LjY3ODk4NjM2OTY2MTExIDI2Ljg2Njk4NzAwNzA2OTE0LCA1Ni41ODM2NzAzMDM2MTgyMzQgMjYuODg2OTcyNjg0NDQxNjI3LCA1Ni4zNjk4NDc5NTE0MDM2NyAyNi45MzE4MDY1MTcwMTM2MTIgQzU2LjIzMzgyNjkwNDA3OTA1IDI2Ljk0ODc2MTUyMjg4MDgyMiwgNTYuMDk3ODA1ODU2NzU0NDQgMjYuOTY1NzE2NTI4NzQ4MDMsIDU1Ljk1OTc3MTcyNzM2MTY2IDI2Ljk4MjkyMjQ2NTAzMzM0NyBDNTUuODU5NzgzNTA0MDQxNjIgMjYuOTg3MDU4MDA4NTQwNTg2LCA1NS43NTk3OTUyODA3MjE1OSAyNi45OTExOTM1NTIwNDc4MywgNTUuNTQ2ODc1IDI3IEM1NS41NDY4NzUgMjcsIDU1LjU0Njg3NSAyNywgNTUuNTQ2ODc1IDI3IEMxNS41NzM3OTA5NjU2Nzk5MzMgMjcsIC0yNC4zOTkyOTMwNjg2NDAxMzQgMjcsIC01NS41NDY4NzUgMjcgQy01NS41NDY4NzUgMjcsIC01NS41NDY4NzUgMjcsIC01NS41NDY4NzUgMjcgQy01NS42Nzc0OTU2NTE3ODI4MDYgMjYuOTk0NTk3NDg5ODc5NzU1LCAtNTUuODA4MTE2MzAzNTY1NjEgMjYuOTg5MTk0OTc5NzU5NTA3LCAtNTUuOTU5NzcxNzI3MzYxNjYgMjYuOTgyOTIyNDY1MDMzMzQ3IEMtNTYuMDQ5NTI1NzQ4NjI0MjA1IDI2Ljk3MTczNDYzNzk1MDM4LCAtNTYuMTM5Mjc5NzY5ODg2NzU1IDI2Ljk2MDU0NjgxMDg2NzQxLCAtNTYuMzY5ODQ3OTUxNDAzNjcgMjYuOTMxODA2NTE3MDEzNjEyIEMtNTYuNDY2ODgyODgyMzI2NzkgMjYuOTExNDYwNDMxNTc1OTQsIC01Ni41NjM5MTc4MTMyNDk5MiAyNi44OTExMTQzNDYxMzgyNywgLTU2Ljc3NDMwMjQzNTcwMzk5NCAyNi44NDcwMDEzMjk2OTY2NTMgQy01Ni44NjQ5MTI0MjMyMTIzOSAyNi44MjAwMjU1Njc2NDA0NjcsIC01Ni45NTU1MjI0MTA3MjA3OCAyNi43OTMwNDk4MDU1ODQyOCwgLTU3LjE3MDM3MjM0NjAyMzQyIDI2LjcyOTA4NjIwODUwMzE3MyBDLTU3LjMwNjYxMTYxOTExMTMgMjYuNjc1OTI1NDc5MTgwNTQyLCAtNTcuNDQyODUwODkyMTk5MTkgMjYuNjIyNzY0NzQ5ODU3OTE1LCAtNTcuNTU1MzUyMTIzMjY0ODQ2IDI2LjU3ODg2NjYzMzI3NTI4NiBDLTU3LjY0NDYwNTkwNTg0OTQzIDI2LjUzNTIzMzExNzc1MzY2LCAtNTcuNzMzODU5Njg4NDM0MDEgMjYuNDkxNTk5NjAyMjMyMDMyLCAtNTcuOTI2NjExOTY1MTg1MzY2IDI2LjM5NzM2ODc1NjAzMjQ0NiBDLTU4LjAzNjkyNzg5MDQ4ODU3IDI2LjMzMTYzNDczMjUxNTEyNywgLTU4LjE0NzI0MzgxNTc5MTc4IDI2LjI2NTkwMDcwODk5NzgxLCAtNTguMjgxNjE1NzkwNjEyMTM2IDI2LjE4NTgzMjM5MTMxMjY0NCBDLTU4LjM2MDk5OTA0NjIwMTg1NCAyNi4xMjkxNTM4MzYyMjU2MSwgLTU4LjQ0MDM4MjMwMTc5MTU4IDI2LjA3MjQ3NTI4MTEzODU3LCAtNTguNjE3OTM4NTYzNDQ4MzQgMjUuOTQ1NzAyNTQ2OTgxOTcgQy01OC43MzEwNTE3MzgwOTIwNSAyNS44NDk5MDA1NzQ5NDQwNSwgLTU4Ljg0NDE2NDkxMjczNTc1NiAyNS43NTQwOTg2MDI5MDYxMzMsIC01OC45MzMyODI4NTgxMjg3MDYgMjUuNjc4NjE5NTUzMzY1NjYgQy01OC45OTI4NDUzODQxMTUzNCAyNS42MTkwNTcwMjczNzkwMjUsIC01OS4wNTI0MDc5MTAxMDE5NyAyNS41NTk0OTQ1MDEzOTIzOTMsIC01OS4yMjU0OTQ1NTMzNjU2NiAyNS4zODY0MDc4NTgxMjg3MDYgQy01OS4yODA5Njk2NzA2ODQzMTQgMjUuMzIwOTA4NTEwODc5NzU3LCAtNTkuMzM2NDQ0Nzg4MDAyOTggMjUuMjU1NDA5MTYzNjMwODEyLCAtNTkuNDkyNTc3NTQ2OTgxOTcgMjUuMDcxMDYzNTYzNDQ4MzQgQy01OS41NjY5OTMxNDkxMjM1OSAyNC45NjY4MzgwMjc3ODMyMjYsIC01OS42NDE0MDg3NTEyNjUyMSAyNC44NjI2MTI0OTIxMTgxMDcsIC01OS43MzI3MDczOTEzMTI2NDQgMjQuNzM0NzQwNzkwNjEyMTMzIEMtNTkuODEzNTA5MDgxNzc5NzM1IDI0LjU5OTEzODA1NzQ2MTQzOCwgLTU5Ljg5NDMxMDc3MjI0NjgyNiAyNC40NjM1MzUzMjQzMTA3NCwgLTU5Ljk0NDI0Mzc1NjAzMjQ0IDI0LjM3OTczNjk2NTE4NTM3IEMtNjAuMDEwNzIwOTI4NTU5MjYgMjQuMjQzNzU1NzM1MzI5OTU1LCAtNjAuMDc3MTk4MTAxMDg2MDc2IDI0LjEwNzc3NDUwNTQ3NDU0LCAtNjAuMTI1NzQxNjMzMjc1MjggMjQuMDA4NDc3MTIzMjY0ODUgQy02MC4xNzQ5MzAxNTc2NTQ0NCAyMy44ODI0MTc3Mzg5OTYyMywgLTYwLjIyNDExODY4MjAzMzU5IDIzLjc1NjM1ODM1NDcyNzYxMywgLTYwLjI3NTk2MTIwODUwMzE3NiAyMy42MjM0OTczNDYwMjM0MTcgQy02MC4zMTQ0NDEyNTgyODI4NzYgMjMuNDk0MjQ1MTM3MjIyMDk0LCAtNjAuMzUyOTIxMzA4MDYyNTggMjMuMzY0OTkyOTI4NDIwNzc2LCAtNjAuMzkzODc2MzI5Njk2NjUgMjMuMjI3NDI3NDM1NzAzOTk0IEMtNjAuNDI2NTI2MDQ4Mjc4NTEgMjMuMDcxNzEzNzg3NjQzNTA3LCAtNjAuNDU5MTc1NzY2ODYwMzcgMjIuOTE2MDAwMTM5NTgzMDIsIC02MC40Nzg2ODE1MTcwMTM2MSAyMi44MjI5NzI5NTE0MDM2NyBDLTYwLjQ5NzQ2MDYzODA0NTY2NiAyMi42NzIzMTc5OTI1ODk2MywgLTYwLjUxNjIzOTc1OTA3NzczIDIyLjUyMTY2MzAzMzc3NTU5LCAtNjAuNTI5Nzk3NDY1MDMzMzUgMjIuNDEyODk2NzI3MzYxNjYyIEMtNjAuNTM2MDAzODcyNTg3NTg1IDIyLjI2MjgzOTYyNzYyMDg4NSwgLTYwLjU0MjIxMDI4MDE0MTgyIDIyLjExMjc4MjUyNzg4MDExLCAtNjAuNTQ2ODc1IDIyIEMtNjAuNTQ2ODc1IDIyLCAtNjAuNTQ2ODc1IDIyLCAtNjAuNTQ2ODc1IDIyIEMtNjAuNTQ2ODc1IDExLjg4MDcwMzkxMTA0ODIyNCwgLTYwLjU0Njg3NSAxLjc2MTQwNzgyMjA5NjQ0OCwgLTYwLjU0Njg3NSAtMjIgQy02MC41NDY4NzUgLTIyLCAtNjAuNTQ2ODc1IC0yMiwgLTYwLjU0Njg3NSAtMjIgQy02MC41NDEyNTYxMjk1NTkgLTIyLjEzNTg1MTc2MjA0MjI2OCwgLTYwLjUzNTYzNzI1OTExOCAtMjIuMjcxNzAzNTI0MDg0NTM2LCAtNjAuNTI5Nzk3NDY1MDMzMzUgLTIyLjQxMjg5NjcyNzM2MTY2IEMtNjAuNTExMDA2NDk3MjY4NyAtMjIuNTYzNjQ2NzI2MjQ3NDM0LCAtNjAuNDkyMjE1NTI5NTA0MDYgLTIyLjcxNDM5NjcyNTEzMzIwNiwgLTYwLjQ3ODY4MTUxNzAxMzYxIC0yMi44MjI5NzI5NTE0MDM2NyBDLTYwLjQ0NTY5OTU2MDk4ODQgLTIyLjk4MDI3MTExMjQ4ODI1MywgLTYwLjQxMjcxNzYwNDk2MzIgLTIzLjEzNzU2OTI3MzU3Mjg0LCAtNjAuMzkzODc2MzI5Njk2NjUgLTIzLjIyNzQyNzQzNTcwMzk5NCBDLTYwLjM2MjYwNzQyMDM2NTA4IC0yMy4zMzI0NTc4NTA3MTI2LCAtNjAuMzMxMzM4NTExMDMzNTE1IC0yMy40Mzc0ODgyNjU3MjEyMDMsIC02MC4yNzU5NjEyMDg1MDMxNzYgLTIzLjYyMzQ5NzM0NjAyMzQxNyBDLTYwLjIyMDc1MzcyMjIwNTQ3IC0yMy43NjQ5ODIwMDc2OTA4OTMsIC02MC4xNjU1NDYyMzU5MDc3NyAtMjMuOTA2NDY2NjY5MzU4MzczLCAtNjAuMTI1NzQxNjMzMjc1MjkgLTI0LjAwODQ3NzEyMzI2NDg0NiBDLTYwLjA1NTU2MDUyODUxODgxIC0yNC4xNTIwMzQ4Njc1OTUwMywgLTU5Ljk4NTM3OTQyMzc2MjMyNSAtMjQuMjk1NTkyNjExOTI1MjEyLCAtNTkuOTQ0MjQzNzU2MDMyNDUgLTI0LjM3OTczNjk2NTE4NTM2NiBDLTU5Ljg5MjU5NTI5NTQyMzIwNiAtMjQuNDY2NDE0MjY1ODg0ODA4LCAtNTkuODQwOTQ2ODM0ODEzOTYgLTI0LjU1MzA5MTU2NjU4NDI1LCAtNTkuNzMyNzA3MzkxMzEyNjQ0IC0yNC43MzQ3NDA3OTA2MTIxMzMgQy01OS42ODQ0ODQ2MDkzMjY1NSAtMjQuODAyMjgwOTk5MTA0OTM3LCAtNTkuNjM2MjYxODI3MzQwNDYgLTI0Ljg2OTgyMTIwNzU5Nzc0LCAtNTkuNDkyNTc3NTQ2OTgxOTcgLTI1LjA3MTA2MzU2MzQ0ODM0IEMtNTkuMzg4Njg0NzAyMDQ5MjEgLTI1LjE5MzcyOTYxMzY2NzYzNSwgLTU5LjI4NDc5MTg1NzExNjQ0NiAtMjUuMzE2Mzk1NjYzODg2OTMsIC01OS4yMjU0OTQ1NTMzNjU2NiAtMjUuMzg2NDA3ODU4MTI4NzA2IEMtNTkuMTIxNjM3MzU1MTkwOSAtMjUuNDkwMjY1MDU2MzAzNDY4LCAtNTkuMDE3NzgwMTU3MDE2MTM0IC0yNS41OTQxMjIyNTQ0NzgyMjYsIC01OC45MzMyODI4NTgxMjg3MDYgLTI1LjY3ODYxOTU1MzM2NTY1NyBDLTU4Ljg2OTQxMzc3NDMwMDE1NiAtMjUuNzMyNzEzOTA4MDAwMzI4LCAtNTguODA1NTQ0NjkwNDcxNjA2IC0yNS43ODY4MDgyNjI2MzUwMDIsIC01OC42MTc5Mzg1NjM0NDgzNCAtMjUuOTQ1NzAyNTQ2OTgxOTY2IEMtNTguNTMwMDg1MjMzMTkxODYgLTI2LjAwODQyODYxOTE0MzI4OCwgLTU4LjQ0MjIzMTkwMjkzNTM4IC0yNi4wNzExNTQ2OTEzMDQ2MSwgLTU4LjI4MTYxNTc5MDYxMjEzNiAtMjYuMTg1ODMyMzkxMzEyNjQ0IEMtNTguMTY0Nzc5NDcwMDE5OTE2IC0yNi4yNTU0NTE3MjcwOTE5MjMsIC01OC4wNDc5NDMxNDk0Mjc3IC0yNi4zMjUwNzEwNjI4NzEyMDMsIC01Ny45MjY2MTE5NjUxODUzNjYgLTI2LjM5NzM2ODc1NjAzMjQ0NiBDLTU3Ljc3ODUxMzQxNzgyODMzIC0yNi40Njk3Njk3MjQxMjI2MiwgLTU3LjYzMDQxNDg3MDQ3MTI4NCAtMjYuNTQyMTcwNjkyMjEyNzk2LCAtNTcuNTU1MzUyMTIzMjY0ODQ2IC0yNi41Nzg4NjY2MzMyNzUyODYgQy01Ny40MTEzMTI4NzI4NTIwMSAtMjYuNjM1MDcwOTIzMTk0NjI1LCAtNTcuMjY3MjczNjIyNDM5MTcgLTI2LjY5MTI3NTIxMzExMzk2NSwgLTU3LjE3MDM3MjM0NjAyMzQyIC0yNi43MjkwODYyMDg1MDMxNzMgQy01Ny4wMjA4OTA3MzUzMjUwMjUgLTI2Ljc3MzU4ODgxMjE2NTk1LCAtNTYuODcxNDA5MTI0NjI2NjMgLTI2LjgxODA5MTQxNTgyODczLCAtNTYuNzc0MzAyNDM1NzAzOTk0IC0yNi44NDcwMDEzMjk2OTY2NTMgQy01Ni42ODc2Mzk0ODQxMTM1IC0yNi44NjUxNzI2Mzk3NjU3ODUsIC01Ni42MDA5NzY1MzI1MjMwMDYgLTI2Ljg4MzM0Mzk0OTgzNDkyLCAtNTYuMzY5ODQ3OTUxNDAzNjcgLTI2LjkzMTgwNjUxNzAxMzYxMiBDLTU2LjIzMTk4MzA1NjIyNTQ0IC0yNi45NDg5OTEzNTgyNzYwODYsIC01Ni4wOTQxMTgxNjEwNDcyMSAtMjYuOTY2MTc2MTk5NTM4NTYsIC01NS45NTk3NzE3MjczNjE2NiAtMjYuOTgyOTIyNDY1MDMzMzQ3IEMtNTUuODYzMTQzODc1NDgzMzEgLTI2Ljk4NjkxOTAyMjU0OTY3OCwgLTU1Ljc2NjUxNjAyMzYwNDk2NCAtMjYuOTkwOTE1NTgwMDY2MDA1LCAtNTUuNTQ2ODc1IC0yNyBDLTU1LjU0Njg3NSAtMjcsIC01NS41NDY4NzUgLTI3LCAtNTUuNTQ2ODc1IC0yNyIgc3Ryb2tlPSJub25lIiBzdHJva2Utd2lkdGg9IjAiIGZpbGw9IiNFQ0VDRkYiIHN0eWxlPSIiPjwvcGF0aD48cGF0aCBkPSJNLTU1LjU0Njg3NSAtMjcgQy0xMi44OTA2ODc1NjY0MTQyMDggLTI3LCAyOS43NjU0OTk4NjcxNzE1ODQgLTI3LCA1NS41NDY4NzUgLTI3IE0tNTUuNTQ2ODc1IC0yNyBDLTI3LjAwMDA1ODI3NDQzODk5IC0yNywgMS41NDY3NTg0NTExMjIwMTY3IC0yNywgNTUuNTQ2ODc1IC0yNyBNNTUuNTQ2ODc1IC0yNyBDNTUuNTQ2ODc1IC0yNywgNTUuNTQ2ODc1IC0yNywgNTUuNTQ2ODc1IC0yNyBNNTUuNTQ2ODc1IC0yNyBDNTUuNTQ2ODc1IC0yNywgNTUuNTQ2ODc1IC0yNywgNTUuNTQ2ODc1IC0yNyBNNTUuNTQ2ODc1IC0yNyBDNTUuNjc4MzEzNzAxNTkzMTUgLTI2Ljk5NDU2MzY1NTA4OTMyMywgNTUuODA5NzUyNDAzMTg2MyAtMjYuOTg5MTI3MzEwMTc4NjQsIDU1Ljk1OTc3MTcyNzM2MTY2IC0yNi45ODI5MjI0NjUwMzMzNDcgTTU1LjU0Njg3NSAtMjcgQzU1LjY4MDgyNjgzOTQxMjU4IC0yNi45OTQ0NTk3MTA5NDAyMywgNTUuODE0Nzc4Njc4ODI1MTY0IC0yNi45ODg5MTk0MjE4ODA0NjQsIDU1Ljk1OTc3MTcyNzM2MTY2IC0yNi45ODI5MjI0NjUwMzMzNDcgTTU1Ljk1OTc3MTcyNzM2MTY2IC0yNi45ODI5MjI0NjUwMzMzNDcgQzU2LjA0NjcwMzM2NDIyNTM2NSAtMjYuOTcyMDg2NDQ3Nzk4ODA1LCA1Ni4xMzM2MzUwMDEwODkwNzUgLTI2Ljk2MTI1MDQzMDU2NDI2LCA1Ni4zNjk4NDc5NTE0MDM2NyAtMjYuOTMxODA2NTE3MDEzNjEyIE01NS45NTk3NzE3MjczNjE2NiAtMjYuOTgyOTIyNDY1MDMzMzQ3IEM1Ni4xMDkxNTA3OTQzNjc5MDYgLTI2Ljk2NDMwMjM4Mzc0OTI3NCwgNTYuMjU4NTI5ODYxMzc0MTUgLTI2Ljk0NTY4MjMwMjQ2NTIsIDU2LjM2OTg0Nzk1MTQwMzY3IC0yNi45MzE4MDY1MTcwMTM2MTIgTTU2LjM2OTg0Nzk1MTQwMzY3IC0yNi45MzE4MDY1MTcwMTM2MTIgQzU2LjUxNjU1NjE0MDE5MTk1IC0yNi45MDEwNDUwNDQ2NzQ3NzgsIDU2LjY2MzI2NDMyODk4MDI0IC0yNi44NzAyODM1NzIzMzU5NDMsIDU2Ljc3NDMwMjQzNTcwMzk5NCAtMjYuODQ3MDAxMzI5Njk2NjUzIE01Ni4zNjk4NDc5NTE0MDM2NyAtMjYuOTMxODA2NTE3MDEzNjEyIEM1Ni40ODMxMjE2NDUwNjkwNCAtMjYuOTA4MDU1NTIxMDg2MzYsIDU2LjU5NjM5NTMzODczNDQxIC0yNi44ODQzMDQ1MjUxNTkxMSwgNTYuNzc0MzAyNDM1NzAzOTk0IC0yNi44NDcwMDEzMjk2OTY2NTMgTTU2Ljc3NDMwMjQzNTcwMzk5NCAtMjYuODQ3MDAxMzI5Njk2NjUzIEM1Ni45MjI0Mzg3MTY2MDI4MiAtMjYuODAyODk5MjQ4MDY4NTY3LCA1Ny4wNzA1NzQ5OTc1MDE2NSAtMjYuNzU4Nzk3MTY2NDQwNDc3LCA1Ny4xNzAzNzIzNDYwMjM0MiAtMjYuNzI5MDg2MjA4NTAzMTczIE01Ni43NzQzMDI0MzU3MDM5OTQgLTI2Ljg0NzAwMTMyOTY5NjY1MyBDNTYuODU1NTc4Mjk4NTg5NzkgLTI2LjgyMjgwNDQ1Njk0MTE1LCA1Ni45MzY4NTQxNjE0NzU1OSAtMjYuNzk4NjA3NTg0MTg1NjQzLCA1Ny4xNzAzNzIzNDYwMjM0MiAtMjYuNzI5MDg2MjA4NTAzMTczIE01Ny4xNzAzNzIzNDYwMjM0MiAtMjYuNzI5MDg2MjA4NTAzMTczIEM1Ny4yNjAzNTUyODM0MzIzMyAtMjYuNjkzOTc0NzU3MzcxNDI1LCA1Ny4zNTAzMzgyMjA4NDEyNCAtMjYuNjU4ODYzMzA2MjM5Njc0LCA1Ny41NTUzNTIxMjMyNjQ4NDYgLTI2LjU3ODg2NjYzMzI3NTI4NiBNNTcuMTcwMzcyMzQ2MDIzNDIgLTI2LjcyOTA4NjIwODUwMzE3MyBDNTcuMjUzMTA5MjA2MzM5NzkgLTI2LjY5NjgwMjE4NTQyNjc2LCA1Ny4zMzU4NDYwNjY2NTYxNyAtMjYuNjY0NTE4MTYyMzUwMzQ4LCA1Ny41NTUzNTIxMjMyNjQ4NDYgLTI2LjU3ODg2NjYzMzI3NTI4NiBNNTcuNTU1MzUyMTIzMjY0ODQ2IC0yNi41Nzg4NjY2MzMyNzUyODYgQzU3LjY1NDgwMjk5ODgyMjkgLTI2LjUzMDI0ODA2Mjc2MDQ3OCwgNTcuNzU0MjUzODc0MzgwOTYgLTI2LjQ4MTYyOTQ5MjI0NTY3LCA1Ny45MjY2MTE5NjUxODUzNjYgLTI2LjM5NzM2ODc1NjAzMjQ0NiBNNTcuNTU1MzUyMTIzMjY0ODQ2IC0yNi41Nzg4NjY2MzMyNzUyODYgQzU3LjY5MTEwMTAwMjU0MDQzIC0yNi41MTI1MDMwNTAwMjU0NSwgNTcuODI2ODQ5ODgxODE2MDEgLTI2LjQ0NjEzOTQ2Njc3NTYxNiwgNTcuOTI2NjExOTY1MTg1MzY2IC0yNi4zOTczNjg3NTYwMzI0NDYgTTU3LjkyNjYxMTk2NTE4NTM2NiAtMjYuMzk3MzY4NzU2MDMyNDQ2IEM1OC4wNDQ0MzczMzE1NDEyNDYgLTI2LjMyNzE2MDA3Njg5ODUyNiwgNTguMTYyMjYyNjk3ODk3MTM0IC0yNi4yNTY5NTEzOTc3NjQ2MDYsIDU4LjI4MTYxNTc5MDYxMjEzNiAtMjYuMTg1ODMyMzkxMzEyNjQ0IE01Ny45MjY2MTE5NjUxODUzNjYgLTI2LjM5NzM2ODc1NjAzMjQ0NiBDNTguMDYxNjA1MDM5OTczNjMgLTI2LjMxNjkzMDM0MzA5NzgsIDU4LjE5NjU5ODExNDc2MTkgLTI2LjIzNjQ5MTkzMDE2MzE1LCA1OC4yODE2MTU3OTA2MTIxMzYgLTI2LjE4NTgzMjM5MTMxMjY0NCBNNTguMjgxNjE1NzkwNjEyMTM2IC0yNi4xODU4MzIzOTEzMTI2NDQgQzU4LjQwMjg2OTEyMjUxNjc3IC0yNi4wOTkyNTkxNzYzMzQzOCwgNTguNTI0MTIyNDU0NDIxMzk1IC0yNi4wMTI2ODU5NjEzNTYxMTYsIDU4LjYxNzkzODU2MzQ0ODM0IC0yNS45NDU3MDI1NDY5ODE5NyBNNTguMjgxNjE1NzkwNjEyMTM2IC0yNi4xODU4MzIzOTEzMTI2NDQgQzU4LjM1ODI1NTk3Mjc3NjQ4IC0yNi4xMzExMTIzNTMwMDcxNzUsIDU4LjQzNDg5NjE1NDk0MDgzIC0yNi4wNzYzOTIzMTQ3MDE3MDMsIDU4LjYxNzkzODU2MzQ0ODM0IC0yNS45NDU3MDI1NDY5ODE5NyBNNTguNjE3OTM4NTYzNDQ4MzQgLTI1Ljk0NTcwMjU0Njk4MTk3IEM1OC43Mzg1OTE2NjM5MzQ5OSAtMjUuODQzNTE0NTgzNTA2MjgsIDU4Ljg1OTI0NDc2NDQyMTY0IC0yNS43NDEzMjY2MjAwMzA1OTUsIDU4LjkzMzI4Mjg1ODEyODcwNiAtMjUuNjc4NjE5NTUzMzY1NjU3IE01OC42MTc5Mzg1NjM0NDgzNCAtMjUuOTQ1NzAyNTQ2OTgxOTcgQzU4LjcwNDg2OTM1NzQwOTIzIC0yNS44NzIwNzU5MjAyMTE4NTIsIDU4Ljc5MTgwMDE1MTM3MDEyNiAtMjUuNzk4NDQ5MjkzNDQxNzMsIDU4LjkzMzI4Mjg1ODEyODcwNiAtMjUuNjc4NjE5NTUzMzY1NjU3IE01OC45MzMyODI4NTgxMjg3MDYgLTI1LjY3ODYxOTU1MzM2NTY1NyBDNTkuMDIxNjI4MDc0NzA1MTggLTI1LjU5MDI3NDMzNjc4OTE4NiwgNTkuMTA5OTczMjkxMjgxNjUgLTI1LjUwMTkyOTEyMDIxMjcxLCA1OS4yMjU0OTQ1NTMzNjU2NiAtMjUuMzg2NDA3ODU4MTI4NzA2IE01OC45MzMyODI4NTgxMjg3MDYgLTI1LjY3ODYxOTU1MzM2NTY1NyBDNTkuMDQwNjcxMTI5MzcyNCAtMjUuNTcxMjMxMjgyMTIxOTY4LCA1OS4xNDgwNTk0MDA2MTYwODQgLTI1LjQ2Mzg0MzAxMDg3ODI4LCA1OS4yMjU0OTQ1NTMzNjU2NiAtMjUuMzg2NDA3ODU4MTI4NzA2IE01OS4yMjU0OTQ1NTMzNjU2NiAtMjUuMzg2NDA3ODU4MTI4NzA2IEM1OS4zMjU0MjQ0NzAzODcwNCAtMjUuMjY4NDIwODI4MDUyNzgsIDU5LjQyNTM1NDM4NzQwODQyNiAtMjUuMTUwNDMzNzk3OTc2ODU2LCA1OS40OTI1Nzc1NDY5ODE5NyAtMjUuMDcxMDYzNTYzNDQ4MzQgTTU5LjIyNTQ5NDU1MzM2NTY2IC0yNS4zODY0MDc4NTgxMjg3MDYgQzU5LjI5NjU1NjE4NDEwMjcgLTI1LjMwMjUwNTU0OTI2MTM2NSwgNTkuMzY3NjE3ODE0ODM5NzUgLTI1LjIxODYwMzI0MDM5NDAyNSwgNTkuNDkyNTc3NTQ2OTgxOTcgLTI1LjA3MTA2MzU2MzQ0ODM0IE01OS40OTI1Nzc1NDY5ODE5NyAtMjUuMDcxMDYzNTYzNDQ4MzQgQzU5LjU4NDYzNjA5MTkwNjA2IC0yNC45NDIxMjc1NDg5NDE1MywgNTkuNjc2Njk0NjM2ODMwMTU2IC0yNC44MTMxOTE1MzQ0MzQ3MjIsIDU5LjczMjcwNzM5MTMxMjY0NCAtMjQuNzM0NzQwNzkwNjEyMTM2IE01OS40OTI1Nzc1NDY5ODE5NyAtMjUuMDcxMDYzNTYzNDQ4MzQgQzU5LjU2MzYwMjIwMjk5NTY5IC0yNC45NzE1ODczNDMzMzU2MjQsIDU5LjYzNDYyNjg1OTAwOTQgLTI0Ljg3MjExMTEyMzIyMjkwNCwgNTkuNzMyNzA3MzkxMzEyNjQ0IC0yNC43MzQ3NDA3OTA2MTIxMzYgTTU5LjczMjcwNzM5MTMxMjY0NCAtMjQuNzM0NzQwNzkwNjEyMTM2IEM1OS43NzcyOTAxNDk2NjU5OTQgLTI0LjY1OTkyMTI2ODI4MzI4OCwgNTkuODIxODcyOTA4MDE5MzQ0IC0yNC41ODUxMDE3NDU5NTQ0NDMsIDU5Ljk0NDI0Mzc1NjAzMjQ1IC0yNC4zNzk3MzY5NjUxODUzNyBNNTkuNzMyNzA3MzkxMzEyNjQ0IC0yNC43MzQ3NDA3OTA2MTIxMzYgQzU5Ljc4MzEyOTMyNjI2ODg4IC0yNC42NTAxMjE4NjU1NDY1MDIsIDU5LjgzMzU1MTI2MTIyNTEwNCAtMjQuNTY1NTAyOTQwNDgwODcyLCA1OS45NDQyNDM3NTYwMzI0NSAtMjQuMzc5NzM2OTY1MTg1MzcgTTU5Ljk0NDI0Mzc1NjAzMjQ1IC0yNC4zNzk3MzY5NjUxODUzNyBDNjAuMDE0ODU3NjAzNjQ5OTQgLTI0LjIzNTI5NDAzMTE4NDU4LCA2MC4wODU0NzE0NTEyNjc0NCAtMjQuMDkwODUxMDk3MTgzNzk0LCA2MC4xMjU3NDE2MzMyNzUyOSAtMjQuMDA4NDc3MTIzMjY0ODQ2IE01OS45NDQyNDM3NTYwMzI0NSAtMjQuMzc5NzM2OTY1MTg1MzcgQzYwLjAwNjk5NzQ4Mzk2MjIxNiAtMjQuMjUxMzcyMTYyOTkwMTQ4LCA2MC4wNjk3NTEyMTE4OTE5OSAtMjQuMTIzMDA3MzYwNzk0OTI2LCA2MC4xMjU3NDE2MzMyNzUyOSAtMjQuMDA4NDc3MTIzMjY0ODQ2IE02MC4xMjU3NDE2MzMyNzUyOSAtMjQuMDA4NDc3MTIzMjY0ODQ2IEM2MC4xNzYzNTExMTc2ODQ2MSAtMjMuODc4Nzc2MTMwNTM3MDU1LCA2MC4yMjY5NjA2MDIwOTM5MyAtMjMuNzQ5MDc1MTM3ODA5MjY0LCA2MC4yNzU5NjEyMDg1MDMxNzYgLTIzLjYyMzQ5NzM0NjAyMzQxNyBNNjAuMTI1NzQxNjMzMjc1MjkgLTI0LjAwODQ3NzEyMzI2NDg0NiBDNjAuMTc2NjE4NzI4OTgxMzYgLTIzLjg3ODA5MDMwMTU2MTM1LCA2MC4yMjc0OTU4MjQ2ODc0MyAtMjMuNzQ3NzAzNDc5ODU3ODUsIDYwLjI3NTk2MTIwODUwMzE3NiAtMjMuNjIzNDk3MzQ2MDIzNDE3IE02MC4yNzU5NjEyMDg1MDMxNzYgLTIzLjYyMzQ5NzM0NjAyMzQxNyBDNjAuMzIxMTQyMTM0MzI1NTMgLTIzLjQ3MTczNzI5MTM1NDU1LCA2MC4zNjYzMjMwNjAxNDc4ODQgLTIzLjMxOTk3NzIzNjY4NTY4LCA2MC4zOTM4NzYzMjk2OTY2NSAtMjMuMjI3NDI3NDM1NzAzOTk0IE02MC4yNzU5NjEyMDg1MDMxNzYgLTIzLjYyMzQ5NzM0NjAyMzQxNyBDNjAuMzEzODc1MjkzNjY5MzcgLTIzLjQ5NjE0NjE3ODg0ODAyMiwgNjAuMzUxNzg5Mzc4ODM1NTYgLTIzLjM2ODc5NTAxMTY3MjYyOCwgNjAuMzkzODc2MzI5Njk2NjUgLTIzLjIyNzQyNzQzNTcwMzk5NCBNNjAuMzkzODc2MzI5Njk2NjUgLTIzLjIyNzQyNzQzNTcwMzk5NCBDNjAuNDE1OTQ1MzE0MjY3OTggLTIzLjEyMjE3NTYyMjAzMjg4LCA2MC40MzgwMTQyOTg4MzkzMSAtMjMuMDE2OTIzODA4MzYxNzY3LCA2MC40Nzg2ODE1MTcwMTM2MSAtMjIuODIyOTcyOTUxNDAzNjcgTTYwLjM5Mzg3NjMyOTY5NjY1IC0yMy4yMjc0Mjc0MzU3MDM5OTQgQzYwLjQyMTQ5MDU2MDc4MzQ3NSAtMjMuMDk1NzI5MTI4NzE0NDk1LCA2MC40NDkxMDQ3OTE4NzAzIC0yMi45NjQwMzA4MjE3MjUsIDYwLjQ3ODY4MTUxNzAxMzYxIC0yMi44MjI5NzI5NTE0MDM2NyBNNjAuNDc4NjgxNTE3MDEzNjEgLTIyLjgyMjk3Mjk1MTQwMzY3IEM2MC40OTY3NzE2ODQxMDIwMzYgLTIyLjY3Nzg0NTEwNTgwMzU3MywgNjAuNTE0ODYxODUxMTkwNDcgLTIyLjUzMjcxNzI2MDIwMzQ4LCA2MC41Mjk3OTc0NjUwMzMzNSAtMjIuNDEyODk2NzI3MzYxNjYyIE02MC40Nzg2ODE1MTcwMTM2MSAtMjIuODIyOTcyOTUxNDAzNjcgQzYwLjQ5MTQ0MzgxNTU1MDYyNSAtMjIuNzIwNTg3Nzc4MTQ3NTA3LCA2MC41MDQyMDYxMTQwODc2MyAtMjIuNjE4MjAyNjA0ODkxMzQ4LCA2MC41Mjk3OTc0NjUwMzMzNSAtMjIuNDEyODk2NzI3MzYxNjYyIE02MC41Mjk3OTc0NjUwMzMzNSAtMjIuNDEyODk2NzI3MzYxNjYyIEM2MC41MzU1NTMxNjM3MzYyOCAtMjIuMjczNzM2NzYyOTU0NTI4LCA2MC41NDEzMDg4NjI0MzkyMTYgLTIyLjEzNDU3Njc5ODU0NzM5LCA2MC41NDY4NzUgLTIyIE02MC41Mjk3OTc0NjUwMzMzNSAtMjIuNDEyODk2NzI3MzYxNjYyIEM2MC41MzU0NTA5NzU5OTkxOCAtMjIuMjc2MjA3NDM0NjQ3MzEsIDYwLjU0MTEwNDQ4Njk2NTAyIC0yMi4xMzk1MTgxNDE5MzI5NTcsIDYwLjU0Njg3NSAtMjIgTTYwLjU0Njg3NSAtMjIgQzYwLjU0Njg3NSAtMjIsIDYwLjU0Njg3NSAtMjIsIDYwLjU0Njg3NSAtMjIgTTYwLjU0Njg3NSAtMjIgQzYwLjU0Njg3NSAtMjIsIDYwLjU0Njg3NSAtMjIsIDYwLjU0Njg3NSAtMjIgTTYwLjU0Njg3NSAtMjIgQzYwLjU0Njg3NSAtOS4zMDcwNjkxMTkyMjUyMTUsIDYwLjU0Njg3NSAzLjM4NTg2MTc2MTU0OTU3MDMsIDYwLjU0Njg3NSAyMiBNNjAuNTQ2ODc1IC0yMiBDNjAuNTQ2ODc1IC0xMS4xMjY5NTYxMzQ3NTkzODIsIDYwLjU0Njg3NSAtMC4yNTM5MTIyNjk1MTg3NjQzMywgNjAuNTQ2ODc1IDIyIE02MC41NDY4NzUgMjIgQzYwLjU0Njg3NSAyMiwgNjAuNTQ2ODc1IDIyLCA2MC41NDY4NzUgMjIgTTYwLjU0Njg3NSAyMiBDNjAuNTQ2ODc1IDIyLCA2MC41NDY4NzUgMjIsIDYwLjU0Njg3NSAyMiBNNjAuNTQ2ODc1IDIyIEM2MC41NDIxMjY4MTk3MTAyNTUgMjIuMTE0ODAwNDE1MDcwOTI4LCA2MC41MzczNzg2Mzk0MjA1IDIyLjIyOTYwMDgzMDE0MTg2LCA2MC41Mjk3OTc0NjUwMzMzNSAyMi40MTI4OTY3MjczNjE2NjIgTTYwLjU0Njg3NSAyMiBDNjAuNTQxNzkwODc1ODIzNTkgMjIuMTIyOTIyNzg5Mzg2MTQsIDYwLjUzNjcwNjc1MTY0NzE4IDIyLjI0NTg0NTU3ODc3MjI4LCA2MC41Mjk3OTc0NjUwMzMzNSAyMi40MTI4OTY3MjczNjE2NjIgTTYwLjUyOTc5NzQ2NTAzMzM1IDIyLjQxMjg5NjcyNzM2MTY2MiBDNjAuNTE1MzMyMDkzNzA3Njg0IDIyLjUyODk0NDc1MzEyMjcxMiwgNjAuNTAwODY2NzIyMzgyMDE0IDIyLjY0NDk5Mjc3ODg4Mzc2NSwgNjAuNDc4NjgxNTE3MDEzNjEgMjIuODIyOTcyOTUxNDAzNjcgTTYwLjUyOTc5NzQ2NTAzMzM1IDIyLjQxMjg5NjcyNzM2MTY2MiBDNjAuNTE4NjQ1MDE4OTY1NDcgMjIuNTAyMzY2OTA1NDQ3NDA2LCA2MC41MDc0OTI1NzI4OTc1OSAyMi41OTE4MzcwODM1MzMxNDcsIDYwLjQ3ODY4MTUxNzAxMzYxIDIyLjgyMjk3Mjk1MTQwMzY3IE02MC40Nzg2ODE1MTcwMTM2MSAyMi44MjI5NzI5NTE0MDM2NyBDNjAuNDYwNjAxNzU1NDg1NjM0IDIyLjkwOTE5OTI4Nzk4MDcwNSwgNjAuNDQyNTIxOTkzOTU3NjYgMjIuOTk1NDI1NjI0NTU3NzM2LCA2MC4zOTM4NzYzMjk2OTY2NSAyMy4yMjc0Mjc0MzU3MDM5OTQgTTYwLjQ3ODY4MTUxNzAxMzYxIDIyLjgyMjk3Mjk1MTQwMzY3IEM2MC40NTAwMzc3MDExMzg5NSAyMi45NTk1ODE1NzM0MDU1MjYsIDYwLjQyMTM5Mzg4NTI2NDI5NCAyMy4wOTYxOTAxOTU0MDczOCwgNjAuMzkzODc2MzI5Njk2NjUgMjMuMjI3NDI3NDM1NzAzOTk0IE02MC4zOTM4NzYzMjk2OTY2NSAyMy4yMjc0Mjc0MzU3MDM5OTQgQzYwLjM2MzA5MzEwODUzMTk5IDIzLjMzMDgyNjQ1MjkxNzYyMywgNjAuMzMyMzA5ODg3MzY3MzIgMjMuNDM0MjI1NDcwMTMxMjUzLCA2MC4yNzU5NjEyMDg1MDMxNzYgMjMuNjIzNDk3MzQ2MDIzNDE3IE02MC4zOTM4NzYzMjk2OTY2NSAyMy4yMjc0Mjc0MzU3MDM5OTQgQzYwLjM0NzQwOTgzMDc5NTQyIDIzLjM4MzUwNTY1NDIyNTg2LCA2MC4zMDA5NDMzMzE4OTQxOTYgMjMuNTM5NTgzODcyNzQ3NzIzLCA2MC4yNzU5NjEyMDg1MDMxNzYgMjMuNjIzNDk3MzQ2MDIzNDE3IE02MC4yNzU5NjEyMDg1MDMxNzYgMjMuNjIzNDk3MzQ2MDIzNDE3IEM2MC4yNDAwODk2OTMwNDQ5ODYgMjMuNzE1NDI4MTYxMzYxMjc3LCA2MC4yMDQyMTgxNzc1ODY3OTYgMjMuODA3MzU4OTc2Njk5MTQyLCA2MC4xMjU3NDE2MzMyNzUyOSAyNC4wMDg0NzcxMjMyNjQ4NDYgTTYwLjI3NTk2MTIwODUwMzE3NiAyMy42MjM0OTczNDYwMjM0MTcgQzYwLjIzNTk3NjU3ODI3OTEgMjMuNzI1OTY5MTcxMTA2MDgzLCA2MC4xOTU5OTE5NDgwNTUwMyAyMy44Mjg0NDA5OTYxODg3NTMsIDYwLjEyNTc0MTYzMzI3NTI5IDI0LjAwODQ3NzEyMzI2NDg0NiBNNjAuMTI1NzQxNjMzMjc1MjkgMjQuMDA4NDc3MTIzMjY0ODQ2IEM2MC4wNjU5MDIxOTQwMjY5MSAyNC4xMzA4ODA2NTI2ODA0NTcsIDYwLjAwNjA2Mjc1NDc3ODU0IDI0LjI1MzI4NDE4MjA5NjA2NywgNTkuOTQ0MjQzNzU2MDMyNDUgMjQuMzc5NzM2OTY1MTg1MzY2IE02MC4xMjU3NDE2MzMyNzUyOSAyNC4wMDg0NzcxMjMyNjQ4NDYgQzYwLjA1NTM3NDE4MjYzMDkgMjQuMTUyNDE2MDQ0MjAxMjc4LCA1OS45ODUwMDY3MzE5ODY1IDI0LjI5NjM1NDk2NTEzNzcxLCA1OS45NDQyNDM3NTYwMzI0NSAyNC4zNzk3MzY5NjUxODUzNjYgTTU5Ljk0NDI0Mzc1NjAzMjQ1IDI0LjM3OTczNjk2NTE4NTM2NiBDNTkuODYxNzMzOTcxODUyMjQ2IDI0LjUxODIwNjI0OTQ1MTk2NCwgNTkuNzc5MjI0MTg3NjcyMDQgMjQuNjU2Njc1NTMzNzE4NTYzLCA1OS43MzI3MDczOTEzMTI2NDQgMjQuNzM0NzQwNzkwNjEyMTMzIE01OS45NDQyNDM3NTYwMzI0NSAyNC4zNzk3MzY5NjUxODUzNjYgQzU5Ljg3MTY1MTI5NjIwMzY1IDI0LjUwMTU2MjgzMTcyMzcxLCA1OS43OTkwNTg4MzYzNzQ4NTQgMjQuNjIzMzg4Njk4MjYyMDYsIDU5LjczMjcwNzM5MTMxMjY0NCAyNC43MzQ3NDA3OTA2MTIxMzMgTTU5LjczMjcwNzM5MTMxMjY0NCAyNC43MzQ3NDA3OTA2MTIxMzMgQzU5LjY4NDEyMTc5NjIzNTI2IDI0LjgwMjc4OTE1MDQ1NjI0MiwgNTkuNjM1NTM2MjAxMTU3ODcgMjQuODcwODM3NTEwMzAwMzUsIDU5LjQ5MjU3NzU0Njk4MTk3IDI1LjA3MTA2MzU2MzQ0ODM0IE01OS43MzI3MDczOTEzMTI2NDQgMjQuNzM0NzQwNzkwNjEyMTMzIEM1OS42NDMxMzUwMTEzNTI5IDI0Ljg2MDE5NDcxNDQzMjI1NywgNTkuNTUzNTYyNjMxMzkzMTYgMjQuOTg1NjQ4NjM4MjUyMzgsIDU5LjQ5MjU3NzU0Njk4MTk3IDI1LjA3MTA2MzU2MzQ0ODM0IE01OS40OTI1Nzc1NDY5ODE5NyAyNS4wNzEwNjM1NjM0NDgzNCBDNTkuMzkyNjAwMTg2NTcwMTkgMjUuMTg5MTA2NjA5ODI5NDQ4LCA1OS4yOTI2MjI4MjYxNTg0MiAyNS4zMDcxNDk2NTYyMTA1NiwgNTkuMjI1NDk0NTUzMzY1NjYgMjUuMzg2NDA3ODU4MTI4NzA2IE01OS40OTI1Nzc1NDY5ODE5NyAyNS4wNzEwNjM1NjM0NDgzNCBDNTkuNDI1NTE4NTQ2ODE0Njg2IDI1LjE1MDIzOTk3NTMzMjE0LCA1OS4zNTg0NTk1NDY2NDczOTYgMjUuMjI5NDE2Mzg3MjE1OTM4LCA1OS4yMjU0OTQ1NTMzNjU2NiAyNS4zODY0MDc4NTgxMjg3MDYgTTU5LjIyNTQ5NDU1MzM2NTY2IDI1LjM4NjQwNzg1ODEyODcwNiBDNTkuMTMwOTc4Mzc3NDQwODggMjUuNDgwOTI0MDM0MDUzNDc2LCA1OS4wMzY0NjIyMDE1MTYxMTYgMjUuNTc1NDQwMjA5OTc4MjQ3LCA1OC45MzMyODI4NTgxMjg3MDYgMjUuNjc4NjE5NTUzMzY1NjU3IE01OS4yMjU0OTQ1NTMzNjU2NiAyNS4zODY0MDc4NTgxMjg3MDYgQzU5LjEzOTk5MDg3MTg0NzEzIDI1LjQ3MTkxMTUzOTY0NzIyNywgNTkuMDU0NDg3MTkwMzI4NjE1IDI1LjU1NzQxNTIyMTE2NTc1LCA1OC45MzMyODI4NTgxMjg3MDYgMjUuNjc4NjE5NTUzMzY1NjU3IE01OC45MzMyODI4NTgxMjg3MDYgMjUuNjc4NjE5NTUzMzY1NjU3IEM1OC44MzYzMzU1NDUyMTU1MDUgMjUuNzYwNzI5NzM4OTM5NjgsIDU4LjczOTM4ODIzMjMwMjMxIDI1Ljg0MjgzOTkyNDUxMzcwNCwgNTguNjE3OTM4NTYzNDQ4MzQgMjUuOTQ1NzAyNTQ2OTgxOTcgTTU4LjkzMzI4Mjg1ODEyODcwNiAyNS42Nzg2MTk1NTMzNjU2NTcgQzU4LjgyOTkyNTgzNzE4MTI3IDI1Ljc2NjE1ODQ4NDcyMjg0NCwgNTguNzI2NTY4ODE2MjMzODM2IDI1Ljg1MzY5NzQxNjA4MDAyOCwgNTguNjE3OTM4NTYzNDQ4MzQgMjUuOTQ1NzAyNTQ2OTgxOTcgTTU4LjYxNzkzODU2MzQ0ODM0IDI1Ljk0NTcwMjU0Njk4MTk3IEM1OC41MTc2NjIwMTA2NjI5OSAyNi4wMTcyOTg2Mjk1NDA3MDQsIDU4LjQxNzM4NTQ1Nzg3NzYzNiAyNi4wODg4OTQ3MTIwOTk0NDIsIDU4LjI4MTYxNTc5MDYxMjEzNiAyNi4xODU4MzIzOTEzMTI2NDQgTTU4LjYxNzkzODU2MzQ0ODM0IDI1Ljk0NTcwMjU0Njk4MTk3IEM1OC41MzkxNDk1OTAxNDEwNjUgMjYuMDAxOTU2NzkyNjc0ODgyLCA1OC40NjAzNjA2MTY4MzM3OTYgMjYuMDU4MjExMDM4MzY3Nzk0LCA1OC4yODE2MTU3OTA2MTIxMzYgMjYuMTg1ODMyMzkxMzEyNjQ0IE01OC4yODE2MTU3OTA2MTIxMzYgMjYuMTg1ODMyMzkxMzEyNjQ0IEM1OC4xOTg4Njg1ODk3ODU4NzQgMjYuMjM1MTM5MDIwNzA1OTU4LCA1OC4xMTYxMjEzODg5NTk2MiAyNi4yODQ0NDU2NTAwOTkyNzIsIDU3LjkyNjYxMTk2NTE4NTM2NiAyNi4zOTczNjg3NTYwMzI0NDYgTTU4LjI4MTYxNTc5MDYxMjEzNiAyNi4xODU4MzIzOTEzMTI2NDQgQzU4LjE0NzQ1NzI0NjIxODE1IDI2LjI2NTc3MzUzMjA2ODIwMiwgNTguMDEzMjk4NzAxODI0MTc2IDI2LjM0NTcxNDY3MjgyMzc2LCA1Ny45MjY2MTE5NjUxODUzNjYgMjYuMzk3MzY4NzU2MDMyNDQ2IE01Ny45MjY2MTE5NjUxODUzNjYgMjYuMzk3MzY4NzU2MDMyNDQ2IEM1Ny44MDI0NzkxMDgyMjM3IDI2LjQ1ODA1MzYxMjAwMzA5LCA1Ny42NzgzNDYyNTEyNjIwMzUgMjYuNTE4NzM4NDY3OTczNzQsIDU3LjU1NTM1MjEyMzI2NDg0NiAyNi41Nzg4NjY2MzMyNzUyODYgTTU3LjkyNjYxMTk2NTE4NTM2NiAyNi4zOTczNjg3NTYwMzI0NDYgQzU3Ljc4NzYwOTQ4MTg3NTcxIDI2LjQ2NTMyMjkyOTM3MjgxNywgNTcuNjQ4NjA2OTk4NTY2MDUgMjYuNTMzMjc3MTAyNzEzMTksIDU3LjU1NTM1MjEyMzI2NDg0NiAyNi41Nzg4NjY2MzMyNzUyODYgTTU3LjU1NTM1MjEyMzI2NDg0NiAyNi41Nzg4NjY2MzMyNzUyODYgQzU3LjQ1Mzg2NzE3OTQyNDc1IDI2LjYxODQ2NjE4MTI0MzU1NSwgNTcuMzUyMzgyMjM1NTg0NjU2IDI2LjY1ODA2NTcyOTIxMTgyNCwgNTcuMTcwMzcyMzQ2MDIzNDIgMjYuNzI5MDg2MjA4NTAzMTczIE01Ny41NTUzNTIxMjMyNjQ4NDYgMjYuNTc4ODY2NjMzMjc1Mjg2IEM1Ny40NTgxMDA4NDM0MjY0MzYgMjYuNjE2ODE0MjAwNDIzODE0LCA1Ny4zNjA4NDk1NjM1ODgwMyAyNi42NTQ3NjE3Njc1NzIzNCwgNTcuMTcwMzcyMzQ2MDIzNDIgMjYuNzI5MDg2MjA4NTAzMTczIE01Ny4xNzAzNzIzNDYwMjM0MiAyNi43MjkwODYyMDg1MDMxNzMgQzU3LjAxODY0NDIxODQ5NzM0IDI2Ljc3NDI1NzYyOTIwMzI5LCA1Ni44NjY5MTYwOTA5NzEyNyAyNi44MTk0MjkwNDk5MDM0MSwgNTYuNzc0MzAyNDM1NzAzOTk0IDI2Ljg0NzAwMTMyOTY5NjY1MyBNNTcuMTcwMzcyMzQ2MDIzNDIgMjYuNzI5MDg2MjA4NTAzMTczIEM1Ny4wNzAzNTMzOTMwMTcyMzYgMjYuNzU4ODYzMTQwOTUzOTU2LCA1Ni45NzAzMzQ0NDAwMTEwNDQgMjYuNzg4NjQwMDczNDA0NzQsIDU2Ljc3NDMwMjQzNTcwMzk5NCAyNi44NDcwMDEzMjk2OTY2NTMgTTU2Ljc3NDMwMjQzNTcwMzk5NCAyNi44NDcwMDEzMjk2OTY2NTMgQzU2LjY0NTgzOTc2NzU2MTA2IDI2Ljg3MzkzNzExODY2MTc0MywgNTYuNTE3Mzc3MDk5NDE4MTIgMjYuOTAwODcyOTA3NjI2ODM0LCA1Ni4zNjk4NDc5NTE0MDM2NyAyNi45MzE4MDY1MTcwMTM2MTIgTTU2Ljc3NDMwMjQzNTcwMzk5NCAyNi44NDcwMDEzMjk2OTY2NTMgQzU2LjY0NTUxOTExNDQwMTMyIDI2Ljg3NDAwNDM1MjU1OTA4MywgNTYuNTE2NzM1NzkzMDk4NjMgMjYuOTAxMDA3Mzc1NDIxNTE0LCA1Ni4zNjk4NDc5NTE0MDM2NyAyNi45MzE4MDY1MTcwMTM2MTIgTTU2LjM2OTg0Nzk1MTQwMzY3IDI2LjkzMTgwNjUxNzAxMzYxMiBDNTYuMjc4ODQ4NzYzMzU1ODIgMjYuOTQzMTQ5NTUzOTc0NTA3LCA1Ni4xODc4NDk1NzUzMDc5NyAyNi45NTQ0OTI1OTA5MzU0LCA1NS45NTk3NzE3MjczNjE2NiAyNi45ODI5MjI0NjUwMzMzNDcgTTU2LjM2OTg0Nzk1MTQwMzY3IDI2LjkzMTgwNjUxNzAxMzYxMiBDNTYuMjgyNzUxODE4NTAzNzkgMjYuOTQyNjYzMDM4NjU3NjgsIDU2LjE5NTY1NTY4NTYwMzkxIDI2Ljk1MzUxOTU2MDMwMTc1LCA1NS45NTk3NzE3MjczNjE2NiAyNi45ODI5MjI0NjUwMzMzNDcgTTU1Ljk1OTc3MTcyNzM2MTY2IDI2Ljk4MjkyMjQ2NTAzMzM0NyBDNTUuODM0MDczNDU1ODYxODQgMjYuOTg4MTIxMzgzOTk5MTIsIDU1LjcwODM3NTE4NDM2MjAxIDI2Ljk5MzMyMDMwMjk2NDg5MywgNTUuNTQ2ODc1IDI3IE01NS45NTk3NzE3MjczNjE2NiAyNi45ODI5MjI0NjUwMzMzNDcgQzU1Ljg0MTU1MjYyNjk0MjA5IDI2Ljk4NzgxMjA0MzE5NTA0LCA1NS43MjMzMzM1MjY1MjI1MiAyNi45OTI3MDE2MjEzNTY3MywgNTUuNTQ2ODc1IDI3IE01NS41NDY4NzUgMjcgQzU1LjU0Njg3NSAyNywgNTUuNTQ2ODc1IDI3LCA1NS41NDY4NzUgMjcgTTU1LjU0Njg3NSAyNyBDNTUuNTQ2ODc1IDI3LCA1NS41NDY4NzUgMjcsIDU1LjU0Njg3NSAyNyBNNTUuNTQ2ODc1IDI3IEMzMC43ODIzMDU1NzkxMDM1OTMgMjcsIDYuMDE3NzM2MTU4MjA3MTg1IDI3LCAtNTUuNTQ2ODc1IDI3IE01NS41NDY4NzUgMjcgQzE3LjY1NjI4NjQ4Nzc5MDE1NSAyNywgLTIwLjIzNDMwMjAyNDQxOTY5IDI3LCAtNTUuNTQ2ODc1IDI3IE0tNTUuNTQ2ODc1IDI3IEMtNTUuNTQ2ODc1IDI3LCAtNTUuNTQ2ODc1IDI3LCAtNTUuNTQ2ODc1IDI3IE0tNTUuNTQ2ODc1IDI3IEMtNTUuNTQ2ODc1IDI3LCAtNTUuNTQ2ODc1IDI3LCAtNTUuNTQ2ODc1IDI3IE0tNTUuNTQ2ODc1IDI3IEMtNTUuNjg3MTk0NTgwODIyMDYgMjYuOTk0MTk2MzM5MjA3MzcsIC01NS44Mjc1MTQxNjE2NDQxMiAyNi45ODgzOTI2Nzg0MTQ3MzcsIC01NS45NTk3NzE3MjczNjE2NiAyNi45ODI5MjI0NjUwMzMzNDcgTS01NS41NDY4NzUgMjcgQy01NS42Nzk1NjI2OTk0MzE2MSAyNi45OTQ1MTE5OTYxNTY2LCAtNTUuODEyMjUwMzk4ODYzMjEgMjYuOTg5MDIzOTkyMzEzMiwgLTU1Ljk1OTc3MTcyNzM2MTY2IDI2Ljk4MjkyMjQ2NTAzMzM0NyBNLTU1Ljk1OTc3MTcyNzM2MTY2IDI2Ljk4MjkyMjQ2NTAzMzM0NyBDLTU2LjA1NDIwMjczMDIxNjk0IDI2Ljk3MTE1MTY1MjgwMjgyLCAtNTYuMTQ4NjMzNzMzMDcyMjI0IDI2Ljk1OTM4MDg0MDU3MjI5NCwgLTU2LjM2OTg0Nzk1MTQwMzY3IDI2LjkzMTgwNjUxNzAxMzYxMiBNLTU1Ljk1OTc3MTcyNzM2MTY2IDI2Ljk4MjkyMjQ2NTAzMzM0NyBDLTU2LjA5MDk1Njk3MjIxNTkxIDI2Ljk2NjU3MDI0MTMxNTQ1LCAtNTYuMjIyMTQyMjE3MDcwMTYgMjYuOTUwMjE4MDE3NTk3NTUsIC01Ni4zNjk4NDc5NTE0MDM2NyAyNi45MzE4MDY1MTcwMTM2MTIgTS01Ni4zNjk4NDc5NTE0MDM2NyAyNi45MzE4MDY1MTcwMTM2MTIgQy01Ni40NTkyMTQ0Nzk2NTIxNTQgMjYuOTEzMDY4MzI2NTMxOTU2LCAtNTYuNTQ4NTgxMDA3OTAwNjQ0IDI2Ljg5NDMzMDEzNjA1MDMsIC01Ni43NzQzMDI0MzU3MDM5OTQgMjYuODQ3MDAxMzI5Njk2NjUzIE0tNTYuMzY5ODQ3OTUxNDAzNjcgMjYuOTMxODA2NTE3MDEzNjEyIEMtNTYuNDkzMTU3NTYzODgxNDU0IDI2LjkwNTk1MTIxMDE5ODkzLCAtNTYuNjE2NDY3MTc2MzU5MjQgMjYuODgwMDk1OTAzMzg0MjQsIC01Ni43NzQzMDI0MzU3MDM5OTQgMjYuODQ3MDAxMzI5Njk2NjUzIE0tNTYuNzc0MzAyNDM1NzAzOTk0IDI2Ljg0NzAwMTMyOTY5NjY1MyBDLTU2Ljg3NDA3MjY2NzU4ODk1IDI2LjgxNzI5ODQ0NDczMTkwNCwgLTU2Ljk3Mzg0Mjg5OTQ3MzkgMjYuNzg3NTk1NTU5NzY3MTYsIC01Ny4xNzAzNzIzNDYwMjM0MiAyNi43MjkwODYyMDg1MDMxNzMgTS01Ni43NzQzMDI0MzU3MDM5OTQgMjYuODQ3MDAxMzI5Njk2NjUzIEMtNTYuODcyMDY1ODk3ODI4MzY0IDI2LjgxNzg5NTg4NTk3NDg4LCAtNTYuOTY5ODI5MzU5OTUyNzMgMjYuNzg4NzkwNDQyMjUzMTEsIC01Ny4xNzAzNzIzNDYwMjM0MiAyNi43MjkwODYyMDg1MDMxNzMgTS01Ny4xNzAzNzIzNDYwMjM0MiAyNi43MjkwODYyMDg1MDMxNzMgQy01Ny4yODA3MzYyNzg2NDA1NSAyNi42ODYwMjIwNjgzNjMwODQsIC01Ny4zOTExMDAyMTEyNTc2OSAyNi42NDI5NTc5MjgyMjI5OTQsIC01Ny41NTUzNTIxMjMyNjQ4NDYgMjYuNTc4ODY2NjMzMjc1Mjg2IE0tNTcuMTcwMzcyMzQ2MDIzNDIgMjYuNzI5MDg2MjA4NTAzMTczIEMtNTcuMjg0MDY5OTgyNjExODcgMjYuNjg0NzIxMjUzMDM2ODk4LCAtNTcuMzk3NzY3NjE5MjAwMzIgMjYuNjQwMzU2Mjk3NTcwNjI3LCAtNTcuNTU1MzUyMTIzMjY0ODQ2IDI2LjU3ODg2NjYzMzI3NTI4NiBNLTU3LjU1NTM1MjEyMzI2NDg0NiAyNi41Nzg4NjY2MzMyNzUyODYgQy01Ny42NjkwOTQwMzI4OTA3NTQgMjYuNTIzMjYxNjAxOTIwODA4LCAtNTcuNzgyODM1OTQyNTE2NjYgMjYuNDY3NjU2NTcwNTY2MzI2LCAtNTcuOTI2NjExOTY1MTg1MzY2IDI2LjM5NzM2ODc1NjAzMjQ0NiBNLTU3LjU1NTM1MjEyMzI2NDg0NiAyNi41Nzg4NjY2MzMyNzUyODYgQy01Ny42NDUxNDA1NTAwMzg1NTUgMjYuNTM0OTcxNzQ2MTM2MTMsIC01Ny43MzQ5Mjg5NzY4MTIyNiAyNi40OTEwNzY4NTg5OTY5NzMsIC01Ny45MjY2MTE5NjUxODUzNjYgMjYuMzk3MzY4NzU2MDMyNDQ2IE0tNTcuOTI2NjExOTY1MTg1MzY2IDI2LjM5NzM2ODc1NjAzMjQ0NiBDLTU4LjA2MzkwOTU4MzQ5ODk5IDI2LjMxNTU1NzEzMzIyMDExLCAtNTguMjAxMjA3MjAxODEyNjIgMjYuMjMzNzQ1NTEwNDA3NzcyLCAtNTguMjgxNjE1NzkwNjEyMTM2IDI2LjE4NTgzMjM5MTMxMjY0NCBNLTU3LjkyNjYxMTk2NTE4NTM2NiAyNi4zOTczNjg3NTYwMzI0NDYgQy01OC4wMjczODUzOTYyNzI4IDI2LjMzNzMyMDgyNDg2NzMzNywgLTU4LjEyODE1ODgyNzM2MDIzIDI2LjI3NzI3Mjg5MzcwMjIzLCAtNTguMjgxNjE1NzkwNjEyMTM2IDI2LjE4NTgzMjM5MTMxMjY0NCBNLTU4LjI4MTYxNTc5MDYxMjEzNiAyNi4xODU4MzIzOTEzMTI2NDQgQy01OC4zODQ1ODUzMTgzMTI2MSAyNi4xMTIzMTM1NjE2MjExNDYsIC01OC40ODc1NTQ4NDYwMTMwNzYgMjYuMDM4Nzk0NzMxOTI5NjUsIC01OC42MTc5Mzg1NjM0NDgzNCAyNS45NDU3MDI1NDY5ODE5NyBNLTU4LjI4MTYxNTc5MDYxMjEzNiAyNi4xODU4MzIzOTEzMTI2NDQgQy01OC4zOTk3MDQ2NTgyOTk1MzQgMjYuMTAxNTE4NTYwMzU4NDQsIC01OC41MTc3OTM1MjU5ODY5NCAyNi4wMTcyMDQ3Mjk0MDQyMzgsIC01OC42MTc5Mzg1NjM0NDgzNCAyNS45NDU3MDI1NDY5ODE5NyBNLTU4LjYxNzkzODU2MzQ0ODM0IDI1Ljk0NTcwMjU0Njk4MTk3IEMtNTguNzI0OTg3ODc4NTUxMDUgMjUuODU1MDM2NDAxOTk1MzEsIC01OC44MzIwMzcxOTM2NTM3NSAyNS43NjQzNzAyNTcwMDg2NTQsIC01OC45MzMyODI4NTgxMjg3MDYgMjUuNjc4NjE5NTUzMzY1NjYgTS01OC42MTc5Mzg1NjM0NDgzNCAyNS45NDU3MDI1NDY5ODE5NyBDLTU4LjcwMTkyMDQ5NDMxNjQyNSAyNS44NzQ1NzM0Nzk4NTAyNSwgLTU4Ljc4NTkwMjQyNTE4NDUgMjUuODAzNDQ0NDEyNzE4NTI3LCAtNTguOTMzMjgyODU4MTI4NzA2IDI1LjY3ODYxOTU1MzM2NTY2IE0tNTguOTMzMjgyODU4MTI4NzA2IDI1LjY3ODYxOTU1MzM2NTY2IEMtNTkuMDAwNTM2NDQxMjg2MjUgMjUuNjExMzY1OTcwMjA4MTE2LCAtNTkuMDY3NzkwMDI0NDQzNzk1IDI1LjU0NDExMjM4NzA1MDU2OCwgLTU5LjIyNTQ5NDU1MzM2NTY2IDI1LjM4NjQwNzg1ODEyODcwNiBNLTU4LjkzMzI4Mjg1ODEyODcwNiAyNS42Nzg2MTk1NTMzNjU2NiBDLTU5LjAwNTc3NTc1MDUxNTY3NCAyNS42MDYxMjY2NjA5Nzg2OTIsIC01OS4wNzgyNjg2NDI5MDI2NCAyNS41MzM2MzM3Njg1OTE3MjQsIC01OS4yMjU0OTQ1NTMzNjU2NiAyNS4zODY0MDc4NTgxMjg3MDYgTS01OS4yMjU0OTQ1NTMzNjU2NiAyNS4zODY0MDc4NTgxMjg3MDYgQy01OS4zMTIyMzEyNzIwMzQ0MjQgMjUuMjgzOTk4MDA3OTEyNjksIC01OS4zOTg5Njc5OTA3MDMxOSAyNS4xODE1ODgxNTc2OTY2NywgLTU5LjQ5MjU3NzU0Njk4MTk3IDI1LjA3MTA2MzU2MzQ0ODM0IE0tNTkuMjI1NDk0NTUzMzY1NjYgMjUuMzg2NDA3ODU4MTI4NzA2IEMtNTkuMzIwOTUwOTQwMjcwODUgMjUuMjczNzAyNzE1MDgwMTg0LCAtNTkuNDE2NDA3MzI3MTc2MDQ2IDI1LjE2MDk5NzU3MjAzMTY2LCAtNTkuNDkyNTc3NTQ2OTgxOTcgMjUuMDcxMDYzNTYzNDQ4MzQgTS01OS40OTI1Nzc1NDY5ODE5NyAyNS4wNzEwNjM1NjM0NDgzNCBDLTU5LjU1MTMwNDI1OTMxMzM1IDI0Ljk4ODgxMTY4NTE1MzUxNCwgLTU5LjYxMDAzMDk3MTY0NDcyIDI0LjkwNjU1OTgwNjg1ODY4NCwgLTU5LjczMjcwNzM5MTMxMjY0NCAyNC43MzQ3NDA3OTA2MTIxMzMgTS01OS40OTI1Nzc1NDY5ODE5NyAyNS4wNzEwNjM1NjM0NDgzNCBDLTU5LjU4Mjk0MTgyOTg2MzY2NCAyNC45NDQ1MDA1MTA1NzY1MSwgLTU5LjY3MzMwNjExMjc0NTM2IDI0LjgxNzkzNzQ1NzcwNDY4NCwgLTU5LjczMjcwNzM5MTMxMjY0NCAyNC43MzQ3NDA3OTA2MTIxMzMgTS01OS43MzI3MDczOTEzMTI2NDQgMjQuNzM0NzQwNzkwNjEyMTMzIEMtNTkuNzkwNDk3Mzc5NzM2MjE2IDI0LjYzNzc1NjY3NjM3MjkwMiwgLTU5Ljg0ODI4NzM2ODE1OTc5IDI0LjU0MDc3MjU2MjEzMzY3LCAtNTkuOTQ0MjQzNzU2MDMyNDQgMjQuMzc5NzM2OTY1MTg1MzcgTS01OS43MzI3MDczOTEzMTI2NDQgMjQuNzM0NzQwNzkwNjEyMTMzIEMtNTkuNzgwNTIzMzA5OTM1OTk0IDI0LjY1NDQ5NTMyNTI1MTc0NCwgLTU5LjgyODMzOTIyODU1OTM1IDI0LjU3NDI0OTg1OTg5MTM2LCAtNTkuOTQ0MjQzNzU2MDMyNDQgMjQuMzc5NzM2OTY1MTg1MzcgTS01OS45NDQyNDM3NTYwMzI0NCAyNC4zNzk3MzY5NjUxODUzNyBDLTU5Ljk5MTg4MjAyNjI1NjIgMjQuMjgyMjkxMzI1OTIzMjgyLCAtNjAuMDM5NTIwMjk2NDc5OTY0IDI0LjE4NDg0NTY4NjY2MTIsIC02MC4xMjU3NDE2MzMyNzUyOCAyNC4wMDg0NzcxMjMyNjQ4NSBNLTU5Ljk0NDI0Mzc1NjAzMjQ0IDI0LjM3OTczNjk2NTE4NTM3IEMtNjAuMDA1MzUzNzY3MDk1NTggMjQuMjU0NzM0NDM5NTgxNjkzLCAtNjAuMDY2NDYzNzc4MTU4NzEgMjQuMTI5NzMxOTEzOTc4MDE2LCAtNjAuMTI1NzQxNjMzMjc1MjggMjQuMDA4NDc3MTIzMjY0ODUgTS02MC4xMjU3NDE2MzMyNzUyOCAyNC4wMDg0NzcxMjMyNjQ4NSBDLTYwLjE3NTA1OTU5NTk5NDc0IDIzLjg4MjA4NjAxNjk1OTczMiwgLTYwLjIyNDM3NzU1ODcxNDE5NSAyMy43NTU2OTQ5MTA2NTQ2MiwgLTYwLjI3NTk2MTIwODUwMzE3NiAyMy42MjM0OTczNDYwMjM0MTcgTS02MC4xMjU3NDE2MzMyNzUyOCAyNC4wMDg0NzcxMjMyNjQ4NSBDLTYwLjE3ODc3ODY0NDQ1NjM1IDIzLjg3MjU1NDkxMjYwMDQ3LCAtNjAuMjMxODE1NjU1NjM3NDIgMjMuNzM2NjMyNzAxOTM2MDksIC02MC4yNzU5NjEyMDg1MDMxNzYgMjMuNjIzNDk3MzQ2MDIzNDE3IE0tNjAuMjc1OTYxMjA4NTAzMTc2IDIzLjYyMzQ5NzM0NjAyMzQxNyBDLTYwLjMyMDAxMTcxMDM0Mjg4IDIzLjQ3NTUzNDMxODU3OTcwOCwgLTYwLjM2NDA2MjIxMjE4MjU5IDIzLjMyNzU3MTI5MTEzNiwgLTYwLjM5Mzg3NjMyOTY5NjY1IDIzLjIyNzQyNzQzNTcwMzk5NCBNLTYwLjI3NTk2MTIwODUwMzE3NiAyMy42MjM0OTczNDYwMjM0MTcgQy02MC4zMDMwODAyODg0MDIxMSAyMy41MzI0MDU5NjIzNjcxMDgsIC02MC4zMzAxOTkzNjgzMDEwNCAyMy40NDEzMTQ1Nzg3MTA4MDIsIC02MC4zOTM4NzYzMjk2OTY2NSAyMy4yMjc0Mjc0MzU3MDM5OTQgTS02MC4zOTM4NzYzMjk2OTY2NSAyMy4yMjc0Mjc0MzU3MDM5OTQgQy02MC40MjI0ODc1MzY3Mjk2NCAyMy4wOTA5NzQzMzIzOTkyODgsIC02MC40NTEwOTg3NDM3NjI2MyAyMi45NTQ1MjEyMjkwOTQ1NzgsIC02MC40Nzg2ODE1MTcwMTM2MSAyMi44MjI5NzI5NTE0MDM2NyBNLTYwLjM5Mzg3NjMyOTY5NjY1IDIzLjIyNzQyNzQzNTcwMzk5NCBDLTYwLjQyMDc0MTI4MTY2MDk2IDIzLjA5OTMwMjYwNDcwOTA4LCAtNjAuNDQ3NjA2MjMzNjI1MjcgMjIuOTcxMTc3NzczNzE0MTY3LCAtNjAuNDc4NjgxNTE3MDEzNjEgMjIuODIyOTcyOTUxNDAzNjcgTS02MC40Nzg2ODE1MTcwMTM2MSAyMi44MjI5NzI5NTE0MDM2NyBDLTYwLjQ4OTIwNDQ5Njk4NTQ2NSAyMi43Mzg1NTI2NDY5ODM5OTQsIC02MC40OTk3Mjc0NzY5NTczMiAyMi42NTQxMzIzNDI1NjQzMTcsIC02MC41Mjk3OTc0NjUwMzMzNSAyMi40MTI4OTY3MjczNjE2NjIgTS02MC40Nzg2ODE1MTcwMTM2MSAyMi44MjI5NzI5NTE0MDM2NyBDLTYwLjQ5NjExMzY4NDA3NjI4NSAyMi42ODMxMjM4OTIxMDA5LCAtNjAuNTEzNTQ1ODUxMTM4OTUgMjIuNTQzMjc0ODMyNzk4MTMzLCAtNjAuNTI5Nzk3NDY1MDMzMzUgMjIuNDEyODk2NzI3MzYxNjYyIE0tNjAuNTI5Nzk3NDY1MDMzMzUgMjIuNDEyODk2NzI3MzYxNjYyIEMtNjAuNTM0MzE2MjIyMzgzOTYgMjIuMzAzNjQzMjQ3NTQ0MzQsIC02MC41Mzg4MzQ5Nzk3MzQ1NyAyMi4xOTQzODk3Njc3MjcwMTQsIC02MC41NDY4NzUgMjIgTS02MC41Mjk3OTc0NjUwMzMzNSAyMi40MTI4OTY3MjczNjE2NjIgQy02MC41MzM2NDQxMTY1ODE0NyAyMi4zMTk4OTMyNjc2MzM5NDcsIC02MC41Mzc0OTA3NjgxMjk1OSAyMi4yMjY4ODk4MDc5MDYyMzYsIC02MC41NDY4NzUgMjIgTS02MC41NDY4NzUgMjIgQy02MC41NDY4NzUgMjIsIC02MC41NDY4NzUgMjIsIC02MC41NDY4NzUgMjIgTS02MC41NDY4NzUgMjIgQy02MC41NDY4NzUgMjIsIC02MC41NDY4NzUgMjIsIC02MC41NDY4NzUgMjIgTS02MC41NDY4NzUgMjIgQy02MC41NDY4NzUgNS4yNzk5OTI5Njc5NzkzMjIsIC02MC41NDY4NzUgLTExLjQ0MDAxNDA2NDA0MTM1NywgLTYwLjU0Njg3NSAtMjIgTS02MC41NDY4NzUgMjIgQy02MC41NDY4NzUgOC42ODU0ODg0OTEwNzg1NDQsIC02MC41NDY4NzUgLTQuNjI5MDIzMDE3ODQyOTExLCAtNjAuNTQ2ODc1IC0yMiBNLTYwLjU0Njg3NSAtMjIgQy02MC41NDY4NzUgLTIyLCAtNjAuNTQ2ODc1IC0yMiwgLTYwLjU0Njg3NSAtMjIgTS02MC41NDY4NzUgLTIyIEMtNjAuNTQ2ODc1IC0yMiwgLTYwLjU0Njg3NSAtMjIsIC02MC41NDY4NzUgLTIyIE0tNjAuNTQ2ODc1IC0yMiBDLTYwLjU0MjE5NDE4MjkzMDUzIC0yMi4xMTMxNzE3MjI1NjYzOTIsIC02MC41Mzc1MTMzNjU4NjEwNSAtMjIuMjI2MzQzNDQ1MTMyNzg0LCAtNjAuNTI5Nzk3NDY1MDMzMzUgLTIyLjQxMjg5NjcyNzM2MTY2IE0tNjAuNTQ2ODc1IC0yMiBDLTYwLjU0MDI1ODU5NjA0NjU5IC0yMi4xNTk5Njk4OTg3MzU0MTUsIC02MC41MzM2NDIxOTIwOTMxNzYgLTIyLjMxOTkzOTc5NzQ3MDgyNywgLTYwLjUyOTc5NzQ2NTAzMzM1IC0yMi40MTI4OTY3MjczNjE2NiBNLTYwLjUyOTc5NzQ2NTAzMzM1IC0yMi40MTI4OTY3MjczNjE2NiBDLTYwLjUxMTIyNTk2NzI3MjI5IC0yMi41NjE4ODYwMzQ0NTA3MiwgLTYwLjQ5MjY1NDQ2OTUxMTIyIC0yMi43MTA4NzUzNDE1Mzk3ODMsIC02MC40Nzg2ODE1MTcwMTM2MSAtMjIuODIyOTcyOTUxNDAzNjcgTS02MC41Mjk3OTc0NjUwMzMzNSAtMjIuNDEyODk2NzI3MzYxNjYgQy02MC41MTUyMDA4ODc5ODk0OCAtMjIuNTI5OTk3MzQ3MjI2NDUsIC02MC41MDA2MDQzMTA5NDU2MSAtMjIuNjQ3MDk3OTY3MDkxMjM4LCAtNjAuNDc4NjgxNTE3MDEzNjEgLTIyLjgyMjk3Mjk1MTQwMzY3IE0tNjAuNDc4NjgxNTE3MDEzNjEgLTIyLjgyMjk3Mjk1MTQwMzY3IEMtNjAuNDU2ODIyMTczODk4NTEgLTIyLjkyNzIyNDkzOTEyMzcxLCAtNjAuNDM0OTYyODMwNzgzNDA1IC0yMy4wMzE0NzY5MjY4NDM3NTUsIC02MC4zOTM4NzYzMjk2OTY2NSAtMjMuMjI3NDI3NDM1NzAzOTk0IE0tNjAuNDc4NjgxNTE3MDEzNjEgLTIyLjgyMjk3Mjk1MTQwMzY3IEMtNjAuNDU5ODE4NjM5NTE0OTIgLTIyLjkxMjkzNDEzOTMwNDEyNywgLTYwLjQ0MDk1NTc2MjAxNjIyIC0yMy4wMDI4OTUzMjcyMDQ1ODgsIC02MC4zOTM4NzYzMjk2OTY2NSAtMjMuMjI3NDI3NDM1NzAzOTk0IE0tNjAuMzkzODc2MzI5Njk2NjUgLTIzLjIyNzQyNzQzNTcwMzk5NCBDLTYwLjM2MDQyNjA4NjY5MDE0IC0yMy4zMzk3ODQ4MjEzNjk2OTQsIC02MC4zMjY5NzU4NDM2ODM2MyAtMjMuNDUyMTQyMjA3MDM1Mzk0LCAtNjAuMjc1OTYxMjA4NTAzMTc2IC0yMy42MjM0OTczNDYwMjM0MTcgTS02MC4zOTM4NzYzMjk2OTY2NSAtMjMuMjI3NDI3NDM1NzAzOTk0IEMtNjAuMzQ4MTk0OTU3MjI5OTQgLTIzLjM4MDg2ODQ2MTAxOTc0OCwgLTYwLjMwMjUxMzU4NDc2MzIzIC0yMy41MzQzMDk0ODYzMzU1MDIsIC02MC4yNzU5NjEyMDg1MDMxNzYgLTIzLjYyMzQ5NzM0NjAyMzQxNyBNLTYwLjI3NTk2MTIwODUwMzE3NiAtMjMuNjIzNDk3MzQ2MDIzNDE3IEMtNjAuMjIyNTMxMTU4NzUyODY1IC0yMy43NjA0MjY4MjgyMTQyMDQsIC02MC4xNjkxMDExMDkwMDI1NSAtMjMuODk3MzU2MzEwNDA0OTg3LCAtNjAuMTI1NzQxNjMzMjc1MjkgLTI0LjAwODQ3NzEyMzI2NDg0NiBNLTYwLjI3NTk2MTIwODUwMzE3NiAtMjMuNjIzNDk3MzQ2MDIzNDE3IEMtNjAuMjIxNDY3NzAyNjUyNTEgLTIzLjc2MzE1MjIzMjYyMzAzNSwgLTYwLjE2Njk3NDE5NjgwMTg0NiAtMjMuOTAyODA3MTE5MjIyNjUsIC02MC4xMjU3NDE2MzMyNzUyOSAtMjQuMDA4NDc3MTIzMjY0ODQ2IE0tNjAuMTI1NzQxNjMzMjc1MjkgLTI0LjAwODQ3NzEyMzI2NDg0NiBDLTYwLjA1Nzg0MTA1OTM0MDQ3NSAtMjQuMTQ3MzY5OTY3MjM4MDE3LCAtNTkuOTg5OTQwNDg1NDA1NjYgLTI0LjI4NjI2MjgxMTIxMTE4OCwgLTU5Ljk0NDI0Mzc1NjAzMjQ1IC0yNC4zNzk3MzY5NjUxODUzNjYgTS02MC4xMjU3NDE2MzMyNzUyOSAtMjQuMDA4NDc3MTIzMjY0ODQ2IEMtNjAuMDU0NDE2MzM0OTIxNjUgLTI0LjE1NDM3NTM1MzAwNzEwNCwgLTU5Ljk4MzA5MTAzNjU2OCAtMjQuMzAwMjczNTgyNzQ5MzYyLCAtNTkuOTQ0MjQzNzU2MDMyNDUgLTI0LjM3OTczNjk2NTE4NTM2NiBNLTU5Ljk0NDI0Mzc1NjAzMjQ1IC0yNC4zNzk3MzY5NjUxODUzNjYgQy01OS44OTI5Njk2NDU5MDE0MSAtMjQuNDY1Nzg2MDI0NzIxNjk4LCAtNTkuODQxNjk1NTM1NzcwMzYgLTI0LjU1MTgzNTA4NDI1ODAyNywgLTU5LjczMjcwNzM5MTMxMjY0NCAtMjQuNzM0NzQwNzkwNjEyMTMzIE0tNTkuOTQ0MjQzNzU2MDMyNDUgLTI0LjM3OTczNjk2NTE4NTM2NiBDLTU5Ljg4MDA1NTM5NDExOTM3NCAtMjQuNDg3NDU4OTM1NjIzODg4LCAtNTkuODE1ODY3MDMyMjA2MyAtMjQuNTk1MTgwOTA2MDYyNDEsIC01OS43MzI3MDczOTEzMTI2NDQgLTI0LjczNDc0MDc5MDYxMjEzMyBNLTU5LjczMjcwNzM5MTMxMjY0NCAtMjQuNzM0NzQwNzkwNjEyMTMzIEMtNTkuNjU1NjE4OTE3MjE5NTU2IC0yNC44NDI3MDk5MTYzNjk3NjMsIC01OS41Nzg1MzA0NDMxMjY0NzUgLTI0Ljk1MDY3OTA0MjEyNzM5NCwgLTU5LjQ5MjU3NzU0Njk4MTk3IC0yNS4wNzEwNjM1NjM0NDgzNCBNLTU5LjczMjcwNzM5MTMxMjY0NCAtMjQuNzM0NzQwNzkwNjEyMTMzIEMtNTkuNjgyNzc1NDkyNjU4NjM1IC0yNC44MDQ2NzQ3NjU5Mzc0NSwgLTU5LjYzMjg0MzU5NDAwNDYyIC0yNC44NzQ2MDg3NDEyNjI3NjUsIC01OS40OTI1Nzc1NDY5ODE5NyAtMjUuMDcxMDYzNTYzNDQ4MzQgTS01OS40OTI1Nzc1NDY5ODE5NyAtMjUuMDcxMDYzNTYzNDQ4MzQgQy01OS40MDY0NDE0ODQ3MDE0NSAtMjUuMTcyNzY0MjIwMDA2ODYsIC01OS4zMjAzMDU0MjI0MjA5NCAtMjUuMjc0NDY0ODc2NTY1MzgsIC01OS4yMjU0OTQ1NTMzNjU2NiAtMjUuMzg2NDA3ODU4MTI4NzA2IE0tNTkuNDkyNTc3NTQ2OTgxOTcgLTI1LjA3MTA2MzU2MzQ0ODM0IEMtNTkuMzg5MDYwMDMzOTA1MDEgLTI1LjE5MzI4NjQ2MDE4Mjg4LCAtNTkuMjg1NTQyNTIwODI4MDYgLTI1LjMxNTUwOTM1NjkxNzQxNywgLTU5LjIyNTQ5NDU1MzM2NTY2IC0yNS4zODY0MDc4NTgxMjg3MDYgTS01OS4yMjU0OTQ1NTMzNjU2NiAtMjUuMzg2NDA3ODU4MTI4NzA2IEMtNTkuMTIzNzQ3MzExODk4MSAtMjUuNDg4MTU1MDk5NTk2MjYzLCAtNTkuMDIyMDAwMDcwNDMwNTQgLTI1LjU4OTkwMjM0MTA2MzgyLCAtNTguOTMzMjgyODU4MTI4NzA2IC0yNS42Nzg2MTk1NTMzNjU2NTcgTS01OS4yMjU0OTQ1NTMzNjU2NiAtMjUuMzg2NDA3ODU4MTI4NzA2IEMtNTkuMTQxNDEzOTM0NjA5ODcgLTI1LjQ3MDQ4ODQ3Njg4NDQ5NCwgLTU5LjA1NzMzMzMxNTg1NDA4IC0yNS41NTQ1NjkwOTU2NDAyOCwgLTU4LjkzMzI4Mjg1ODEyODcwNiAtMjUuNjc4NjE5NTUzMzY1NjU3IE0tNTguOTMzMjgyODU4MTI4NzA2IC0yNS42Nzg2MTk1NTMzNjU2NTcgQy01OC44NTA5MTg0MTk4MzgxMTYgLTI1Ljc0ODM3ODY3NDE2NTU4NiwgLTU4Ljc2ODU1Mzk4MTU0NzUzNCAtMjUuODE4MTM3Nzk0OTY1NTE2LCAtNTguNjE3OTM4NTYzNDQ4MzQgLTI1Ljk0NTcwMjU0Njk4MTk2NiBNLTU4LjkzMzI4Mjg1ODEyODcwNiAtMjUuNjc4NjE5NTUzMzY1NjU3IEMtNTguODYwMjc2ODI3MzkzMjUgLTI1Ljc0MDQ1MjUwNzI4MjM3LCAtNTguNzg3MjcwNzk2NjU3OCAtMjUuODAyMjg1NDYxMTk5MDgsIC01OC42MTc5Mzg1NjM0NDgzNCAtMjUuOTQ1NzAyNTQ2OTgxOTY2IE0tNTguNjE3OTM4NTYzNDQ4MzQgLTI1Ljk0NTcwMjU0Njk4MTk2NiBDLTU4LjUyMDA0NjUyODg0Njc5IC0yNi4wMTU1OTYxMTYyODEwNCwgLTU4LjQyMjE1NDQ5NDI0NTI1IC0yNi4wODU0ODk2ODU1ODAxMDcsIC01OC4yODE2MTU3OTA2MTIxMzYgLTI2LjE4NTgzMjM5MTMxMjY0NCBNLTU4LjYxNzkzODU2MzQ0ODM0IC0yNS45NDU3MDI1NDY5ODE5NjYgQy01OC41MjQxODE2MjYzNzg5NiAtMjYuMDEyNjQzNzEzMzkwNDUsIC01OC40MzA0MjQ2ODkzMDk1OSAtMjYuMDc5NTg0ODc5Nzk4OTMzLCAtNTguMjgxNjE1NzkwNjEyMTM2IC0yNi4xODU4MzIzOTEzMTI2NDQgTS01OC4yODE2MTU3OTA2MTIxMzYgLTI2LjE4NTgzMjM5MTMxMjY0NCBDLTU4LjE5NDgxMTk5NDMwMDA2IC0yNi4yMzc1NTYyMjY5NDY0MTcsIC01OC4xMDgwMDgxOTc5ODc5ODUgLTI2LjI4OTI4MDA2MjU4MDE5LCAtNTcuOTI2NjExOTY1MTg1MzY2IC0yNi4zOTczNjg3NTYwMzI0NDYgTS01OC4yODE2MTU3OTA2MTIxMzYgLTI2LjE4NTgzMjM5MTMxMjY0NCBDLTU4LjIwNDY5NDY1MDMyMzY1IC0yNi4yMzE2Njc0NDIxNTIzNTcsIC01OC4xMjc3NzM1MTAwMzUxNiAtMjYuMjc3NTAyNDkyOTkyMDY3LCAtNTcuOTI2NjExOTY1MTg1MzY2IC0yNi4zOTczNjg3NTYwMzI0NDYgTS01Ny45MjY2MTE5NjUxODUzNjYgLTI2LjM5NzM2ODc1NjAzMjQ0NiBDLTU3Ljg0ODU0MDI0MDkyMzUxIC0yNi40MzU1MzU2OTYzNDI4MDcsIC01Ny43NzA0Njg1MTY2NjE2NjYgLTI2LjQ3MzcwMjYzNjY1MzE2NSwgLTU3LjU1NTM1MjEyMzI2NDg0NiAtMjYuNTc4ODY2NjMzMjc1Mjg2IE0tNTcuOTI2NjExOTY1MTg1MzY2IC0yNi4zOTczNjg3NTYwMzI0NDYgQy01Ny44MTA2OTYyMDMxMTk1OSAtMjYuNDU0MDM2NTE5MDg3ODEsIC01Ny42OTQ3ODA0NDEwNTM4MTUgLTI2LjUxMDcwNDI4MjE0MzE3NywgLTU3LjU1NTM1MjEyMzI2NDg0NiAtMjYuNTc4ODY2NjMzMjc1Mjg2IE0tNTcuNTU1MzUyMTIzMjY0ODQ2IC0yNi41Nzg4NjY2MzMyNzUyODYgQy01Ny40NDYwNjQ5NjQwMTMyMyAtMjYuNjIxNTEwNjE1MTQ0MzUsIC01Ny4zMzY3Nzc4MDQ3NjE2MjQgLTI2LjY2NDE1NDU5NzAxMzQxNSwgLTU3LjE3MDM3MjM0NjAyMzQyIC0yNi43MjkwODYyMDg1MDMxNzMgTS01Ny41NTUzNTIxMjMyNjQ4NDYgLTI2LjU3ODg2NjYzMzI3NTI4NiBDLTU3LjQ1OTk0MzQ1NjUzMjg0IC0yNi42MTYwOTUyMTA1NTg1ODcsIC01Ny4zNjQ1MzQ3ODk4MDA4MzUgLTI2LjY1MzMyMzc4Nzg0MTg5LCAtNTcuMTcwMzcyMzQ2MDIzNDIgLTI2LjcyOTA4NjIwODUwMzE3MyBNLTU3LjE3MDM3MjM0NjAyMzQyIC0yNi43MjkwODYyMDg1MDMxNzMgQy01Ny4wNzUyOTgxMDUyMDk0NTUgLTI2Ljc1NzM5MTAzNjM1MjY3LCAtNTYuOTgwMjIzODY0Mzk1NDg0IC0yNi43ODU2OTU4NjQyMDIxNjgsIC01Ni43NzQzMDI0MzU3MDM5OTQgLTI2Ljg0NzAwMTMyOTY5NjY1MyBNLTU3LjE3MDM3MjM0NjAyMzQyIC0yNi43MjkwODYyMDg1MDMxNzMgQy01Ny4wNzU0NjI4NzExMTMzOSAtMjYuNzU3MzQxOTgzNDE3NzU2LCAtNTYuOTgwNTUzMzk2MjAzMzYgLTI2Ljc4NTU5Nzc1ODMzMjM0NCwgLTU2Ljc3NDMwMjQzNTcwMzk5NCAtMjYuODQ3MDAxMzI5Njk2NjUzIE0tNTYuNzc0MzAyNDM1NzAzOTk0IC0yNi44NDcwMDEzMjk2OTY2NTMgQy01Ni42Nzk2NzIwMjcyMDk4NyAtMjYuODY2ODQzMjM5Nzk5NjEsIC01Ni41ODUwNDE2MTg3MTU3NCAtMjYuODg2Njg1MTQ5OTAyNTcsIC01Ni4zNjk4NDc5NTE0MDM2NyAtMjYuOTMxODA2NTE3MDEzNjEyIE0tNTYuNzc0MzAyNDM1NzAzOTk0IC0yNi44NDcwMDEzMjk2OTY2NTMgQy01Ni42ODgxOTE1Nzc5MjU3ODUgLTI2Ljg2NTA1Njg3Nzg2Njc3LCAtNTYuNjAyMDgwNzIwMTQ3NTc2IC0yNi44ODMxMTI0MjYwMzY4OSwgLTU2LjM2OTg0Nzk1MTQwMzY3IC0yNi45MzE4MDY1MTcwMTM2MTIgTS01Ni4zNjk4NDc5NTE0MDM2NyAtMjYuOTMxODA2NTE3MDEzNjEyIEMtNTYuMjM5NDg3NzcwMzUzNzIgLTI2Ljk0ODA1NTg5NjYzNTU2LCAtNTYuMTA5MTI3NTg5MzAzNzcgLTI2Ljk2NDMwNTI3NjI1NzUsIC01NS45NTk3NzE3MjczNjE2NiAtMjYuOTgyOTIyNDY1MDMzMzQ3IE0tNTYuMzY5ODQ3OTUxNDAzNjcgLTI2LjkzMTgwNjUxNzAxMzYxMiBDLTU2LjIzMDc3NTE5MzcyNzAzIC0yNi45NDkxNDE5MTgxNzk1LCAtNTYuMDkxNzAyNDM2MDUwMzc2IC0yNi45NjY0NzczMTkzNDUzODcsIC01NS45NTk3NzE3MjczNjE2NiAtMjYuOTgyOTIyNDY1MDMzMzQ3IE0tNTUuOTU5NzcxNzI3MzYxNjYgLTI2Ljk4MjkyMjQ2NTAzMzM0NyBDLTU1Ljg0MjI2NDI0NzI4MTYgLTI2Ljk4Nzc4MjYxMDM2MDA4LCAtNTUuNzI0NzU2NzY3MjAxNTQgLTI2Ljk5MjY0Mjc1NTY4NjgxNiwgLTU1LjU0Njg3NSAtMjcgTS01NS45NTk3NzE3MjczNjE2NiAtMjYuOTgyOTIyNDY1MDMzMzQ3IEMtNTUuODcwODk4NDAxNDAxODQ1IC0yNi45ODY1OTgyOTI5ODUyNDIsIC01NS43ODIwMjUwNzU0NDIwMzYgLTI2Ljk5MDI3NDEyMDkzNzEzNCwgLTU1LjU0Njg3NSAtMjcgTS01NS41NDY4NzUgLTI3IEMtNTUuNTQ2ODc1IC0yNywgLTU1LjU0Njg3NSAtMjcsIC01NS41NDY4NzUgLTI3IE0tNTUuNTQ2ODc1IC0yNyBDLTU1LjU0Njg3NSAtMjcsIC01NS41NDY4NzUgLTI3LCAtNTUuNTQ2ODc1IC0yNyIgc3Ryb2tlPSIjOTM3MERCIiBzdHJva2Utd2lkdGg9IjEuMyIgZmlsbD0ibm9uZSIgc3Ryb2tlLWRhc2hhcnJheT0iMCAwIiBzdHlsZT0iIj48L3BhdGg+PC9nPjxnIGNsYXNzPSJsYWJlbCIgc3R5bGU9IiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTQ1LjU0Njg3NSwgLTEyKSI+PHJlY3Q+PC9yZWN0Pjxmb3JlaWduT2JqZWN0IHdpZHRoPSI5MS4wOTM3NSIgaGVpZ2h0PSIyNCI+PGRpdiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94aHRtbCIgc3R5bGU9ImRpc3BsYXk6IHRhYmxlLWNlbGw7IHdoaXRlLXNwYWNlOiBub3dyYXA7IGxpbmUtaGVpZ2h0OiAxLjU7IG1heC13aWR0aDogMjAwcHg7IHRleHQtYWxpZ246IGNlbnRlcjsiPjxzcGFuIGNsYXNzPSJub2RlTGFiZWwiPjxwPkdvIHNob3BwaW5nPC9wPjwvc3Bhbj48L2Rpdj48L2ZvcmVpZ25PYmplY3Q+PC9nPjwvZz48ZyBjbGFzcz0ibm9kZSBkZWZhdWx0IiBpZD0iZmxvd2NoYXJ0LUMtMyIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMjIzLjU2MjUsIDMxMi4xMDkzNzUpIj48cG9seWdvbiBwb2ludHM9IjcyLjEwOTM3NSwwIDE0NC4yMTg3NSwtNzIuMTA5Mzc1IDcyLjEwOTM3NSwtMTQ0LjIxODc1IDAsLTcyLjEwOTM3NSIgY2xhc3M9ImxhYmVsLWNvbnRhaW5lciIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTcxLjYwOTM3NSwgNzIuMTA5Mzc1KSI+PC9wb2x5Z29uPjxnIGNsYXNzPSJsYWJlbCIgc3R5bGU9IiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTQ1LjEwOTM3NSwgLTEyKSI+PHJlY3Q+PC9yZWN0Pjxmb3JlaWduT2JqZWN0IHdpZHRoPSI5MC4yMTg3NSIgaGVpZ2h0PSIyNCI+PGRpdiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94aHRtbCIgc3R5bGU9ImRpc3BsYXk6IHRhYmxlLWNlbGw7IHdoaXRlLXNwYWNlOiBub3dyYXA7IGxpbmUtaGVpZ2h0OiAxLjU7IG1heC13aWR0aDogMjAwcHg7IHRleHQtYWxpZ246IGNlbnRlcjsiPjxzcGFuIGNsYXNzPSJub2RlTGFiZWwiPjxwPkxldCBtZSB0aGluazwvcD48L3NwYW4+PC9kaXY+PC9mb3JlaWduT2JqZWN0PjwvZz48L2c+PGcgY2xhc3M9Im5vZGUgZGVmYXVsdCIgaWQ9ImZsb3djaGFydC1ELTUiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDYzLjIwMzEyNSwgNDg1LjIxODc1KSI+PHJlY3QgY2xhc3M9ImJhc2ljIGxhYmVsLWNvbnRhaW5lciIgc3R5bGU9IiIgeD0iLTU1LjIwMzEyNSIgeT0iLTI3IiB3aWR0aD0iMTEwLjQwNjI1IiBoZWlnaHQ9IjU0Ij48L3JlY3Q+PGcgY2xhc3M9ImxhYmVsIiBzdHlsZT0iIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMjUuMjAzMTI1LCAtMTIpIj48cmVjdD48L3JlY3Q+PGZvcmVpZ25PYmplY3Qgd2lkdGg9IjUwLjQwNjI1IiBoZWlnaHQ9IjI0Ij48ZGl2IHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hodG1sIiBzdHlsZT0iZGlzcGxheTogdGFibGUtY2VsbDsgd2hpdGUtc3BhY2U6IG5vd3JhcDsgbGluZS1oZWlnaHQ6IDEuNTsgbWF4LXdpZHRoOiAyMDBweDsgdGV4dC1hbGlnbjogY2VudGVyOyI+PHNwYW4gY2xhc3M9Im5vZGVMYWJlbCI+PHA+TGFwdG9wPC9wPjwvc3Bhbj48L2Rpdj48L2ZvcmVpZ25PYmplY3Q+PC9nPjwvZz48ZyBjbGFzcz0ibm9kZSBkZWZhdWx0IiBpZD0iZmxvd2NoYXJ0LUUtNyIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMjIzLjU2MjUsIDQ4NS4yMTg3NSkiPjxyZWN0IGNsYXNzPSJiYXNpYyBsYWJlbC1jb250YWluZXIiIHN0eWxlPSIiIHg9Ii01NS4xNTYyNSIgeT0iLTI3IiB3aWR0aD0iMTEwLjMxMjUiIGhlaWdodD0iNTQiPjwvcmVjdD48ZyBjbGFzcz0ibGFiZWwiIHN0eWxlPSIiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yNS4xNTYyNSwgLTEyKSI+PHJlY3Q+PC9yZWN0Pjxmb3JlaWduT2JqZWN0IHdpZHRoPSI1MC4zMTI1IiBoZWlnaHQ9IjI0Ij48ZGl2IHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hodG1sIiBzdHlsZT0iZGlzcGxheTogdGFibGUtY2VsbDsgd2hpdGUtc3BhY2U6IG5vd3JhcDsgbGluZS1oZWlnaHQ6IDEuNTsgbWF4LXdpZHRoOiAyMDBweDsgdGV4dC1hbGlnbjogY2VudGVyOyI+PHNwYW4gY2xhc3M9Im5vZGVMYWJlbCI+PHA+aVBob25lPC9wPjwvc3Bhbj48L2Rpdj48L2ZvcmVpZ25PYmplY3Q+PC9nPjwvZz48ZyBjbGFzcz0ibm9kZSBkZWZhdWx0IiBpZD0iZmxvd2NoYXJ0LUYtOSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMzcwLjU2MjUsIDQ4NS4yMTg3NSkiPjxyZWN0IGNsYXNzPSJiYXNpYyBsYWJlbC1jb250YWluZXIiIHN0eWxlPSIiIHg9Ii00MS44NDM3NSIgeT0iLTI3IiB3aWR0aD0iODMuNjg3NSIgaGVpZ2h0PSI1NCI+PC9yZWN0PjxnIGNsYXNzPSJsYWJlbCIgc3R5bGU9IiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTExLjg0Mzc1LCAtMTIpIj48cmVjdD48L3JlY3Q+PGZvcmVpZ25PYmplY3Qgd2lkdGg9IjIzLjY4NzUiIGhlaWdodD0iMjQiPjxkaXYgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGh0bWwiIHN0eWxlPSJkaXNwbGF5OiB0YWJsZS1jZWxsOyB3aGl0ZS1zcGFjZTogbm93cmFwOyBsaW5lLWhlaWdodDogMS41OyBtYXgtd2lkdGg6IDIwMHB4OyB0ZXh0LWFsaWduOiBjZW50ZXI7Ij48c3BhbiBjbGFzcz0ibm9kZUxhYmVsIj48cD48aSBjbGFzcz0iZmEgZmEtY2FyIj48L2k+IENhcjwvcD48L3NwYW4+PC9kaXY+PC9mb3JlaWduT2JqZWN0PjwvZz48L2c+PC9nPjwvZz48L2c+PC9zdmc+"><p>Mermaid 코드 (Flowchart):</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">flowchart TD</span><br><span class="line">    A[Christmas] --&gt;|Get money| B(Go shopping)</span><br><span class="line">    B --&gt; C&#123;Let me think&#125;</span><br><span class="line">    C --&gt;|One| D[Laptop]</span><br><span class="line">    C --&gt;|Two| E[iPhone]</span><br><span class="line">    C --&gt;|Three| F[fa:fa-car Car]</span><br></pre></td></tr></table></figure><h3><span id="class-diagram">Class Diagram</span><a href="#class-diagram" class="header-anchor">#</a></h3><p>객체지향 프로그램의 클래스 구조를 표현할 수 있습니다.</p><img class="kroki" src="data:image/svg+xml;base64,PHN2ZyBpZD0iY29udGFpbmVyIiB3aWR0aD0iNTY2LjE3MTg3NSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiBjbGFzcz0iY2xhc3NEaWFncmFtIiBoZWlnaHQ9IjQyNiIgdmlld0JveD0iMCAwIDU2Ni4xNzE4NzUgNDI2IiByb2xlPSJncmFwaGljcy1kb2N1bWVudCBkb2N1bWVudCIgYXJpYS1yb2xlZGVzY3JpcHRpb249ImNsYXNzIj48c3R5bGU+I2NvbnRhaW5lcntmb250LWZhbWlseToidHJlYnVjaGV0IG1zIix2ZXJkYW5hLGFyaWFsLHNhbnMtc2VyaWY7Zm9udC1zaXplOjE2cHg7ZmlsbDojMzMzO31Aa2V5ZnJhbWVzIGVkZ2UtYW5pbWF0aW9uLWZyYW1le2Zyb217c3Ryb2tlLWRhc2hvZmZzZXQ6MDt9fUBrZXlmcmFtZXMgZGFzaHt0b3tzdHJva2UtZGFzaG9mZnNldDowO319I2NvbnRhaW5lciAuZWRnZS1hbmltYXRpb24tc2xvd3tzdHJva2UtZGFzaGFycmF5OjksNSFpbXBvcnRhbnQ7c3Ryb2tlLWRhc2hvZmZzZXQ6OTAwO2FuaW1hdGlvbjpkYXNoIDUwcyBsaW5lYXIgaW5maW5pdGU7c3Ryb2tlLWxpbmVjYXA6cm91bmQ7fSNjb250YWluZXIgLmVkZ2UtYW5pbWF0aW9uLWZhc3R7c3Ryb2tlLWRhc2hhcnJheTo5LDUhaW1wb3J0YW50O3N0cm9rZS1kYXNob2Zmc2V0OjkwMDthbmltYXRpb246ZGFzaCAyMHMgbGluZWFyIGluZmluaXRlO3N0cm9rZS1saW5lY2FwOnJvdW5kO30jY29udGFpbmVyIC5lcnJvci1pY29ue2ZpbGw6IzU1MjIyMjt9I2NvbnRhaW5lciAuZXJyb3ItdGV4dHtmaWxsOiM1NTIyMjI7c3Ryb2tlOiM1NTIyMjI7fSNjb250YWluZXIgLmVkZ2UtdGhpY2tuZXNzLW5vcm1hbHtzdHJva2Utd2lkdGg6MXB4O30jY29udGFpbmVyIC5lZGdlLXRoaWNrbmVzcy10aGlja3tzdHJva2Utd2lkdGg6My41cHg7fSNjb250YWluZXIgLmVkZ2UtcGF0dGVybi1zb2xpZHtzdHJva2UtZGFzaGFycmF5OjA7fSNjb250YWluZXIgLmVkZ2UtdGhpY2tuZXNzLWludmlzaWJsZXtzdHJva2Utd2lkdGg6MDtmaWxsOm5vbmU7fSNjb250YWluZXIgLmVkZ2UtcGF0dGVybi1kYXNoZWR7c3Ryb2tlLWRhc2hhcnJheTozO30jY29udGFpbmVyIC5lZGdlLXBhdHRlcm4tZG90dGVke3N0cm9rZS1kYXNoYXJyYXk6Mjt9I2NvbnRhaW5lciAubWFya2Vye2ZpbGw6IzMzMzMzMztzdHJva2U6IzMzMzMzMzt9I2NvbnRhaW5lciAubWFya2VyLmNyb3Nze3N0cm9rZTojMzMzMzMzO30jY29udGFpbmVyIHN2Z3tmb250LWZhbWlseToidHJlYnVjaGV0IG1zIix2ZXJkYW5hLGFyaWFsLHNhbnMtc2VyaWY7Zm9udC1zaXplOjE2cHg7fSNjb250YWluZXIgcHttYXJnaW46MDt9I2NvbnRhaW5lciBnLmNsYXNzR3JvdXAgdGV4dHtmaWxsOiM5MzcwREI7c3Ryb2tlOm5vbmU7Zm9udC1mYW1pbHk6InRyZWJ1Y2hldCBtcyIsdmVyZGFuYSxhcmlhbCxzYW5zLXNlcmlmO2ZvbnQtc2l6ZToxMHB4O30jY29udGFpbmVyIGcuY2xhc3NHcm91cCB0ZXh0IC50aXRsZXtmb250LXdlaWdodDpib2xkZXI7fSNjb250YWluZXIgLm5vZGVMYWJlbCwjY29udGFpbmVyIC5lZGdlTGFiZWx7Y29sb3I6IzEzMTMwMDt9I2NvbnRhaW5lciAuZWRnZUxhYmVsIC5sYWJlbCByZWN0e2ZpbGw6I0VDRUNGRjt9I2NvbnRhaW5lciAubGFiZWwgdGV4dHtmaWxsOiMxMzEzMDA7fSNjb250YWluZXIgLmxhYmVsQmtne2JhY2tncm91bmQ6I0VDRUNGRjt9I2NvbnRhaW5lciAuZWRnZUxhYmVsIC5sYWJlbCBzcGFue2JhY2tncm91bmQ6I0VDRUNGRjt9I2NvbnRhaW5lciAuY2xhc3NUaXRsZXtmb250LXdlaWdodDpib2xkZXI7fSNjb250YWluZXIgLm5vZGUgcmVjdCwjY29udGFpbmVyIC5ub2RlIGNpcmNsZSwjY29udGFpbmVyIC5ub2RlIGVsbGlwc2UsI2NvbnRhaW5lciAubm9kZSBwb2x5Z29uLCNjb250YWluZXIgLm5vZGUgcGF0aHtmaWxsOiNFQ0VDRkY7c3Ryb2tlOiM5MzcwREI7c3Ryb2tlLXdpZHRoOjFweDt9I2NvbnRhaW5lciAuZGl2aWRlcntzdHJva2U6IzkzNzBEQjtzdHJva2Utd2lkdGg6MTt9I2NvbnRhaW5lciBnLmNsaWNrYWJsZXtjdXJzb3I6cG9pbnRlcjt9I2NvbnRhaW5lciBnLmNsYXNzR3JvdXAgcmVjdHtmaWxsOiNFQ0VDRkY7c3Ryb2tlOiM5MzcwREI7fSNjb250YWluZXIgZy5jbGFzc0dyb3VwIGxpbmV7c3Ryb2tlOiM5MzcwREI7c3Ryb2tlLXdpZHRoOjE7fSNjb250YWluZXIgLmNsYXNzTGFiZWwgLmJveHtzdHJva2U6bm9uZTtzdHJva2Utd2lkdGg6MDtmaWxsOiNFQ0VDRkY7b3BhY2l0eTowLjU7fSNjb250YWluZXIgLmNsYXNzTGFiZWwgLmxhYmVse2ZpbGw6IzkzNzBEQjtmb250LXNpemU6MTBweDt9I2NvbnRhaW5lciAucmVsYXRpb257c3Ryb2tlOiMzMzMzMzM7c3Ryb2tlLXdpZHRoOjE7ZmlsbDpub25lO30jY29udGFpbmVyIC5kYXNoZWQtbGluZXtzdHJva2UtZGFzaGFycmF5OjM7fSNjb250YWluZXIgLmRvdHRlZC1saW5le3N0cm9rZS1kYXNoYXJyYXk6MSAyO30jY29udGFpbmVyICNjb21wb3NpdGlvblN0YXJ0LCNjb250YWluZXIgLmNvbXBvc2l0aW9ue2ZpbGw6IzMzMzMzMyFpbXBvcnRhbnQ7c3Ryb2tlOiMzMzMzMzMhaW1wb3J0YW50O3N0cm9rZS13aWR0aDoxO30jY29udGFpbmVyICNjb21wb3NpdGlvbkVuZCwjY29udGFpbmVyIC5jb21wb3NpdGlvbntmaWxsOiMzMzMzMzMhaW1wb3J0YW50O3N0cm9rZTojMzMzMzMzIWltcG9ydGFudDtzdHJva2Utd2lkdGg6MTt9I2NvbnRhaW5lciAjZGVwZW5kZW5jeVN0YXJ0LCNjb250YWluZXIgLmRlcGVuZGVuY3l7ZmlsbDojMzMzMzMzIWltcG9ydGFudDtzdHJva2U6IzMzMzMzMyFpbXBvcnRhbnQ7c3Ryb2tlLXdpZHRoOjE7fSNjb250YWluZXIgI2RlcGVuZGVuY3lTdGFydCwjY29udGFpbmVyIC5kZXBlbmRlbmN5e2ZpbGw6IzMzMzMzMyFpbXBvcnRhbnQ7c3Ryb2tlOiMzMzMzMzMhaW1wb3J0YW50O3N0cm9rZS13aWR0aDoxO30jY29udGFpbmVyICNleHRlbnNpb25TdGFydCwjY29udGFpbmVyIC5leHRlbnNpb257ZmlsbDp0cmFuc3BhcmVudCFpbXBvcnRhbnQ7c3Ryb2tlOiMzMzMzMzMhaW1wb3J0YW50O3N0cm9rZS13aWR0aDoxO30jY29udGFpbmVyICNleHRlbnNpb25FbmQsI2NvbnRhaW5lciAuZXh0ZW5zaW9ue2ZpbGw6dHJhbnNwYXJlbnQhaW1wb3J0YW50O3N0cm9rZTojMzMzMzMzIWltcG9ydGFudDtzdHJva2Utd2lkdGg6MTt9I2NvbnRhaW5lciAjYWdncmVnYXRpb25TdGFydCwjY29udGFpbmVyIC5hZ2dyZWdhdGlvbntmaWxsOnRyYW5zcGFyZW50IWltcG9ydGFudDtzdHJva2U6IzMzMzMzMyFpbXBvcnRhbnQ7c3Ryb2tlLXdpZHRoOjE7fSNjb250YWluZXIgI2FnZ3JlZ2F0aW9uRW5kLCNjb250YWluZXIgLmFnZ3JlZ2F0aW9ue2ZpbGw6dHJhbnNwYXJlbnQhaW1wb3J0YW50O3N0cm9rZTojMzMzMzMzIWltcG9ydGFudDtzdHJva2Utd2lkdGg6MTt9I2NvbnRhaW5lciAjbG9sbGlwb3BTdGFydCwjY29udGFpbmVyIC5sb2xsaXBvcHtmaWxsOiNFQ0VDRkYhaW1wb3J0YW50O3N0cm9rZTojMzMzMzMzIWltcG9ydGFudDtzdHJva2Utd2lkdGg6MTt9I2NvbnRhaW5lciAjbG9sbGlwb3BFbmQsI2NvbnRhaW5lciAubG9sbGlwb3B7ZmlsbDojRUNFQ0ZGIWltcG9ydGFudDtzdHJva2U6IzMzMzMzMyFpbXBvcnRhbnQ7c3Ryb2tlLXdpZHRoOjE7fSNjb250YWluZXIgLmVkZ2VUZXJtaW5hbHN7Zm9udC1zaXplOjExcHg7bGluZS1oZWlnaHQ6aW5pdGlhbDt9I2NvbnRhaW5lciAuY2xhc3NUaXRsZVRleHR7dGV4dC1hbmNob3I6bWlkZGxlO2ZvbnQtc2l6ZToxOHB4O2ZpbGw6IzMzMzt9I2NvbnRhaW5lciAubGFiZWwtaWNvbntkaXNwbGF5OmlubGluZS1ibG9jaztoZWlnaHQ6MWVtO292ZXJmbG93OnZpc2libGU7dmVydGljYWwtYWxpZ246LTAuMTI1ZW07fSNjb250YWluZXIgLm5vZGUgLmxhYmVsLWljb24gcGF0aHtmaWxsOmN1cnJlbnRDb2xvcjtzdHJva2U6cmV2ZXJ0O3N0cm9rZS13aWR0aDpyZXZlcnQ7fSNjb250YWluZXIgOnJvb3R7LS1tZXJtYWlkLWZvbnQtZmFtaWx5OiJ0cmVidWNoZXQgbXMiLHZlcmRhbmEsYXJpYWwsc2Fucy1zZXJpZjt9PC9zdHlsZT48Zz48ZGVmcz48bWFya2VyIGlkPSJjb250YWluZXJfY2xhc3MtYWdncmVnYXRpb25TdGFydCIgY2xhc3M9Im1hcmtlciBhZ2dyZWdhdGlvbiBjbGFzcyIgcmVmWD0iMTgiIHJlZlk9IjciIG1hcmtlcldpZHRoPSIxOTAiIG1hcmtlckhlaWdodD0iMjQwIiBvcmllbnQ9ImF1dG8iPjxwYXRoIGQ9Ik0gMTgsNyBMOSwxMyBMMSw3IEw5LDEgWiI+PC9wYXRoPjwvbWFya2VyPjwvZGVmcz48ZGVmcz48bWFya2VyIGlkPSJjb250YWluZXJfY2xhc3MtYWdncmVnYXRpb25FbmQiIGNsYXNzPSJtYXJrZXIgYWdncmVnYXRpb24gY2xhc3MiIHJlZlg9IjEiIHJlZlk9IjciIG1hcmtlcldpZHRoPSIyMCIgbWFya2VySGVpZ2h0PSIyOCIgb3JpZW50PSJhdXRvIj48cGF0aCBkPSJNIDE4LDcgTDksMTMgTDEsNyBMOSwxIFoiPjwvcGF0aD48L21hcmtlcj48L2RlZnM+PGRlZnM+PG1hcmtlciBpZD0iY29udGFpbmVyX2NsYXNzLWV4dGVuc2lvblN0YXJ0IiBjbGFzcz0ibWFya2VyIGV4dGVuc2lvbiBjbGFzcyIgcmVmWD0iMTgiIHJlZlk9IjciIG1hcmtlcldpZHRoPSIxOTAiIG1hcmtlckhlaWdodD0iMjQwIiBvcmllbnQ9ImF1dG8iPjxwYXRoIGQ9Ik0gMSw3IEwxOCwxMyBWIDEgWiI+PC9wYXRoPjwvbWFya2VyPjwvZGVmcz48ZGVmcz48bWFya2VyIGlkPSJjb250YWluZXJfY2xhc3MtZXh0ZW5zaW9uRW5kIiBjbGFzcz0ibWFya2VyIGV4dGVuc2lvbiBjbGFzcyIgcmVmWD0iMSIgcmVmWT0iNyIgbWFya2VyV2lkdGg9IjIwIiBtYXJrZXJIZWlnaHQ9IjI4IiBvcmllbnQ9ImF1dG8iPjxwYXRoIGQ9Ik0gMSwxIFYgMTMgTDE4LDcgWiI+PC9wYXRoPjwvbWFya2VyPjwvZGVmcz48ZGVmcz48bWFya2VyIGlkPSJjb250YWluZXJfY2xhc3MtY29tcG9zaXRpb25TdGFydCIgY2xhc3M9Im1hcmtlciBjb21wb3NpdGlvbiBjbGFzcyIgcmVmWD0iMTgiIHJlZlk9IjciIG1hcmtlcldpZHRoPSIxOTAiIG1hcmtlckhlaWdodD0iMjQwIiBvcmllbnQ9ImF1dG8iPjxwYXRoIGQ9Ik0gMTgsNyBMOSwxMyBMMSw3IEw5LDEgWiI+PC9wYXRoPjwvbWFya2VyPjwvZGVmcz48ZGVmcz48bWFya2VyIGlkPSJjb250YWluZXJfY2xhc3MtY29tcG9zaXRpb25FbmQiIGNsYXNzPSJtYXJrZXIgY29tcG9zaXRpb24gY2xhc3MiIHJlZlg9IjEiIHJlZlk9IjciIG1hcmtlcldpZHRoPSIyMCIgbWFya2VySGVpZ2h0PSIyOCIgb3JpZW50PSJhdXRvIj48cGF0aCBkPSJNIDE4LDcgTDksMTMgTDEsNyBMOSwxIFoiPjwvcGF0aD48L21hcmtlcj48L2RlZnM+PGRlZnM+PG1hcmtlciBpZD0iY29udGFpbmVyX2NsYXNzLWRlcGVuZGVuY3lTdGFydCIgY2xhc3M9Im1hcmtlciBkZXBlbmRlbmN5IGNsYXNzIiByZWZYPSI2IiByZWZZPSI3IiBtYXJrZXJXaWR0aD0iMTkwIiBtYXJrZXJIZWlnaHQ9IjI0MCIgb3JpZW50PSJhdXRvIj48cGF0aCBkPSJNIDUsNyBMOSwxMyBMMSw3IEw5LDEgWiI+PC9wYXRoPjwvbWFya2VyPjwvZGVmcz48ZGVmcz48bWFya2VyIGlkPSJjb250YWluZXJfY2xhc3MtZGVwZW5kZW5jeUVuZCIgY2xhc3M9Im1hcmtlciBkZXBlbmRlbmN5IGNsYXNzIiByZWZYPSIxMyIgcmVmWT0iNyIgbWFya2VyV2lkdGg9IjIwIiBtYXJrZXJIZWlnaHQ9IjI4IiBvcmllbnQ9ImF1dG8iPjxwYXRoIGQ9Ik0gMTgsNyBMOSwxMyBMMTQsNyBMOSwxIFoiPjwvcGF0aD48L21hcmtlcj48L2RlZnM+PGRlZnM+PG1hcmtlciBpZD0iY29udGFpbmVyX2NsYXNzLWxvbGxpcG9wU3RhcnQiIGNsYXNzPSJtYXJrZXIgbG9sbGlwb3AgY2xhc3MiIHJlZlg9IjEzIiByZWZZPSI3IiBtYXJrZXJXaWR0aD0iMTkwIiBtYXJrZXJIZWlnaHQ9IjI0MCIgb3JpZW50PSJhdXRvIj48Y2lyY2xlIHN0cm9rZT0iYmxhY2siIGZpbGw9InRyYW5zcGFyZW50IiBjeD0iNyIgY3k9IjciIHI9IjYiPjwvY2lyY2xlPjwvbWFya2VyPjwvZGVmcz48ZGVmcz48bWFya2VyIGlkPSJjb250YWluZXJfY2xhc3MtbG9sbGlwb3BFbmQiIGNsYXNzPSJtYXJrZXIgbG9sbGlwb3AgY2xhc3MiIHJlZlg9IjEiIHJlZlk9IjciIG1hcmtlcldpZHRoPSIxOTAiIG1hcmtlckhlaWdodD0iMjQwIiBvcmllbnQ9ImF1dG8iPjxjaXJjbGUgc3Ryb2tlPSJibGFjayIgZmlsbD0idHJhbnNwYXJlbnQiIGN4PSI3IiBjeT0iNyIgcj0iNiI+PC9jaXJjbGU+PC9tYXJrZXI+PC9kZWZzPjxnIGNsYXNzPSJyb290Ij48ZyBjbGFzcz0iY2x1c3RlcnMiPjwvZz48ZyBjbGFzcz0iZWRnZVBhdGhzIj48cGF0aCBkPSJNMjA2LjEzNiwxNTguMTg4TDE4Ny4yNDUsMTY5LjMyM0MxNjguMzU1LDE4MC40NTksMTMwLjU3NCwyMDIuNzI5LDExMS42ODMsMjE4LjAzMUM5Mi43OTMsMjMzLjMzMyw5Mi43OTMsMjQxLjY2Nyw5Mi43OTMsMjQ1LjgzM0w5Mi43OTMsMjUwIiBpZD0iaWRfQW5pbWFsX0R1Y2tfMSIgY2xhc3M9ImVkZ2UtdGhpY2tuZXNzLW5vcm1hbCBlZGdlLXBhdHRlcm4tc29saWQgcmVsYXRpb24iIHN0eWxlPSI7OzsiIGRhdGEtZWRnZT0idHJ1ZSIgZGF0YS1ldD0iZWRnZSIgZGF0YS1pZD0iaWRfQW5pbWFsX0R1Y2tfMSIgZGF0YS1wb2ludHM9Ilczc2llQ0k2TWpJd0xqazVOakE1TXpjMUxDSjVJam94TkRrdU5ESTRNalEzT1RJd09UZzRNRFI5TEhzaWVDSTZPVEl1TnpreU9UWTROelVzSW5raU9qSXlOWDBzZXlKNElqbzVNaTQzT1RJNU5qZzNOU3dpZVNJNk1qVXdmVjA9IiBtYXJrZXItc3RhcnQ9InVybCgjY29udGFpbmVyX2NsYXNzLWV4dGVuc2lvblN0YXJ0KSI+PC9wYXRoPjxwYXRoIGQ9Ik0yOTguMDYzLDIxNy4yNUwyOTguMDYzLDIxOC41NDJDMjk4LjA2MywyMTkuODMzLDI5OC4wNjMsMjIyLjQxNywyOTguMDYzLDIyOS44NzVDMjk4LjA2MywyMzcuMzMzLDI5OC4wNjMsMjQ5LjY2NywyOTguMDYzLDI1NS44MzNMMjk4LjA2MywyNjIiIGlkPSJpZF9BbmltYWxfRmlzaF8yIiBjbGFzcz0iZWRnZS10aGlja25lc3Mtbm9ybWFsIGVkZ2UtcGF0dGVybi1zb2xpZCByZWxhdGlvbiIgc3R5bGU9Ijs7OyIgZGF0YS1lZGdlPSJ0cnVlIiBkYXRhLWV0PSJlZGdlIiBkYXRhLWlkPSJpZF9BbmltYWxfRmlzaF8yIiBkYXRhLXBvaW50cz0iVzNzaWVDSTZNams0TGpBMk1qVXNJbmtpT2pJd01IMHNleUo0SWpveU9UZ3VNRFl5TlN3aWVTSTZNakkxZlN4N0luZ2lPakk1T0M0d05qSTFMQ0o1SWpveU5qSjlYUT09IiBtYXJrZXItc3RhcnQ9InVybCgjY29udGFpbmVyX2NsYXNzLWV4dGVuc2lvblN0YXJ0KSI+PC9wYXRoPjxwYXRoIGQ9Ik0zODkuNjg1LDE2Mi4yNTlMNDA2LjEzLDE3Mi43MTZDNDIyLjU3NSwxODMuMTczLDQ1NS40NjUsMjA0LjA4Niw0NzEuOTEsMjIwLjcxQzQ4OC4zNTUsMjM3LjMzMyw0ODguMzU1LDI0OS42NjcsNDg4LjM1NSwyNTUuODMzTDQ4OC4zNTUsMjYyIiBpZD0iaWRfQW5pbWFsX1plYnJhXzMiIGNsYXNzPSJlZGdlLXRoaWNrbmVzcy1ub3JtYWwgZWRnZS1wYXR0ZXJuLXNvbGlkIHJlbGF0aW9uIiBzdHlsZT0iOzs7IiBkYXRhLWVkZ2U9InRydWUiIGRhdGEtZXQ9ImVkZ2UiIGRhdGEtaWQ9ImlkX0FuaW1hbF9aZWJyYV8zIiBkYXRhLXBvaW50cz0iVzNzaWVDSTZNemMxTGpFeU9Ea3dOakkxTENKNUlqb3hOVE11TURBek5UY3hOemsxTVRNME9UZDlMSHNpZUNJNk5EZzRMak0xTlRRMk9EYzFMQ0o1SWpveU1qVjlMSHNpZUNJNk5EZzRMak0xTlRRMk9EYzFMQ0o1SWpveU5qSjlYUT09IiBtYXJrZXItc3RhcnQ9InVybCgjY29udGFpbmVyX2NsYXNzLWV4dGVuc2lvblN0YXJ0KSI+PC9wYXRoPjwvZz48ZyBjbGFzcz0iZWRnZUxhYmVscyI+PGcgY2xhc3M9ImVkZ2VMYWJlbCI+PGcgY2xhc3M9ImxhYmVsIiBkYXRhLWlkPSJpZF9BbmltYWxfRHVja18xIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwLCAwKSI+PGZvcmVpZ25PYmplY3Qgd2lkdGg9IjAiIGhlaWdodD0iMCI+PGRpdiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94aHRtbCIgY2xhc3M9ImxhYmVsQmtnIiBzdHlsZT0iZGlzcGxheTogdGFibGUtY2VsbDsgd2hpdGUtc3BhY2U6IG5vd3JhcDsgbGluZS1oZWlnaHQ6IDEuNTsgbWF4LXdpZHRoOiAyMDBweDsgdGV4dC1hbGlnbjogY2VudGVyOyI+PHNwYW4gY2xhc3M9ImVkZ2VMYWJlbCI+PC9zcGFuPjwvZGl2PjwvZm9yZWlnbk9iamVjdD48L2c+PC9nPjxnIGNsYXNzPSJlZGdlTGFiZWwiPjxnIGNsYXNzPSJsYWJlbCIgZGF0YS1pZD0iaWRfQW5pbWFsX0Zpc2hfMiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCwgMCkiPjxmb3JlaWduT2JqZWN0IHdpZHRoPSIwIiBoZWlnaHQ9IjAiPjxkaXYgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGh0bWwiIGNsYXNzPSJsYWJlbEJrZyIgc3R5bGU9ImRpc3BsYXk6IHRhYmxlLWNlbGw7IHdoaXRlLXNwYWNlOiBub3dyYXA7IGxpbmUtaGVpZ2h0OiAxLjU7IG1heC13aWR0aDogMjAwcHg7IHRleHQtYWxpZ246IGNlbnRlcjsiPjxzcGFuIGNsYXNzPSJlZGdlTGFiZWwiPjwvc3Bhbj48L2Rpdj48L2ZvcmVpZ25PYmplY3Q+PC9nPjwvZz48ZyBjbGFzcz0iZWRnZUxhYmVsIj48ZyBjbGFzcz0ibGFiZWwiIGRhdGEtaWQ9ImlkX0FuaW1hbF9aZWJyYV8zIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwLCAwKSI+PGZvcmVpZ25PYmplY3Qgd2lkdGg9IjAiIGhlaWdodD0iMCI+PGRpdiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94aHRtbCIgY2xhc3M9ImxhYmVsQmtnIiBzdHlsZT0iZGlzcGxheTogdGFibGUtY2VsbDsgd2hpdGUtc3BhY2U6IG5vd3JhcDsgbGluZS1oZWlnaHQ6IDEuNTsgbWF4LXdpZHRoOiAyMDBweDsgdGV4dC1hbGlnbjogY2VudGVyOyI+PHNwYW4gY2xhc3M9ImVkZ2VMYWJlbCI+PC9zcGFuPjwvZGl2PjwvZm9yZWlnbk9iamVjdD48L2c+PC9nPjwvZz48ZyBjbGFzcz0ibm9kZXMiPjxnIGNsYXNzPSJub2RlIGRlZmF1bHQiIGlkPSJjbGFzc0lkLUFuaW1hbC0wIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgyOTguMDYyNSwgMTA0KSI+PGcgY2xhc3M9ImJhc2ljIGxhYmVsLWNvbnRhaW5lciI+PHBhdGggZD0iTS03Ny4wNjY0MDYyNSAtOTYgTDc3LjA2NjQwNjI1IC05NiBMNzcuMDY2NDA2MjUgOTYgTC03Ny4wNjY0MDYyNSA5NiIgc3Ryb2tlPSJub25lIiBzdHJva2Utd2lkdGg9IjAiIGZpbGw9IiNFQ0VDRkYiIHN0eWxlPSIiPjwvcGF0aD48cGF0aCBkPSJNLTc3LjA2NjQwNjI1IC05NiBDLTE5Ljc0ODQ3ODI3MjM2MjUyNiAtOTYsIDM3LjU2OTQ0OTcwNTI3NDk1IC05NiwgNzcuMDY2NDA2MjUgLTk2IE0tNzcuMDY2NDA2MjUgLTk2IEMtMjcuNzcyODIwMzk1OTY2NDYzIC05NiwgMjEuNTIwNzY1NDU4MDY3MDc1IC05NiwgNzcuMDY2NDA2MjUgLTk2IE03Ny4wNjY0MDYyNSAtOTYgQzc3LjA2NjQwNjI1IC0zMC42Mjg4NjMxODk5ODA1MzUsIDc3LjA2NjQwNjI1IDM0Ljc0MjI3MzYyMDAzODkzLCA3Ny4wNjY0MDYyNSA5NiBNNzcuMDY2NDA2MjUgLTk2IEM3Ny4wNjY0MDYyNSAtMjkuNTg4MDI0NDg1NDM4NjM4LCA3Ny4wNjY0MDYyNSAzNi44MjM5NTEwMjkxMjI3MjUsIDc3LjA2NjQwNjI1IDk2IE03Ny4wNjY0MDYyNSA5NiBDMTYuNzg2ODY3NTA4MDM0IDk2LCAtNDMuNDkyNjcxMjMzOTMyIDk2LCAtNzcuMDY2NDA2MjUgOTYgTTc3LjA2NjQwNjI1IDk2IEM0Mi45MjgyMDIzMTIxMDg2NzQgOTYsIDguNzg5OTk4Mzc0MjE3MzQ4IDk2LCAtNzcuMDY2NDA2MjUgOTYgTS03Ny4wNjY0MDYyNSA5NiBDLTc3LjA2NjQwNjI1IDIwLjI5NTAxNTc1NjgwMzU5MywgLTc3LjA2NjQwNjI1IC01NS40MDk5Njg0ODYzOTI4MSwgLTc3LjA2NjQwNjI1IC05NiBNLTc3LjA2NjQwNjI1IDk2IEMtNzcuMDY2NDA2MjUgMzEuNDYxODU0NjE2NjQ3NTQzLCAtNzcuMDY2NDA2MjUgLTMzLjA3NjI5MDc2NjcwNDkxNSwgLTc3LjA2NjQwNjI1IC05NiIgc3Ryb2tlPSIjOTM3MERCIiBzdHJva2Utd2lkdGg9IjEuMyIgZmlsbD0ibm9uZSIgc3Ryb2tlLWRhc2hhcnJheT0iMCAwIiBzdHlsZT0iIj48L3BhdGg+PC9nPjxnIGNsYXNzPSJhbm5vdGF0aW9uLWdyb3VwIHRleHQiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAsIC03MikiPjwvZz48ZyBjbGFzcz0ibGFiZWwtZ3JvdXAgdGV4dCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTI1LjAzOTA2MjUsIC03MikiPjxnIGNsYXNzPSJsYWJlbCIgc3R5bGU9ImZvbnQtd2VpZ2h0OiBib2xkZXIiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAsLTEyKSI+PGZvcmVpZ25PYmplY3Qgd2lkdGg9IjUwLjA3ODEyNSIgaGVpZ2h0PSIyNCI+PGRpdiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94aHRtbCIgc3R5bGU9ImRpc3BsYXk6IHRhYmxlLWNlbGw7IHdoaXRlLXNwYWNlOiBub3dyYXA7IGxpbmUtaGVpZ2h0OiAxLjU7IG1heC13aWR0aDogMTAwcHg7IHRleHQtYWxpZ246IGNlbnRlcjsiPjxzcGFuIGNsYXNzPSJub2RlTGFiZWwgbWFya2Rvd24tbm9kZS1sYWJlbCIgc3R5bGU9IiI+PHA+QW5pbWFsPC9wPjwvc3Bhbj48L2Rpdj48L2ZvcmVpZ25PYmplY3Q+PC9nPjwvZz48ZyBjbGFzcz0ibWVtYmVycy1ncm91cCB0ZXh0IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtNjUuMDY2NDA2MjUsIC0yNCkiPjxnIGNsYXNzPSJsYWJlbCIgc3R5bGU9IiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCwtMTIpIj48Zm9yZWlnbk9iamVjdCB3aWR0aD0iNTcuMjE4NzUiIGhlaWdodD0iMjQiPjxkaXYgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGh0bWwiIHN0eWxlPSJkaXNwbGF5OiB0YWJsZS1jZWxsOyB3aGl0ZS1zcGFjZTogbm93cmFwOyBsaW5lLWhlaWdodDogMS41OyBtYXgtd2lkdGg6IDExNXB4OyB0ZXh0LWFsaWduOiBjZW50ZXI7Ij48c3BhbiBjbGFzcz0ibm9kZUxhYmVsIG1hcmtkb3duLW5vZGUtbGFiZWwiIHN0eWxlPSIiPjxwPitpbnQgYWdlPC9wPjwvc3Bhbj48L2Rpdj48L2ZvcmVpZ25PYmplY3Q+PC9nPjxnIGNsYXNzPSJsYWJlbCIgc3R5bGU9IiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCwxMikiPjxmb3JlaWduT2JqZWN0IHdpZHRoPSIxMDUuMDkzNzUiIGhlaWdodD0iMjQiPjxkaXYgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGh0bWwiIHN0eWxlPSJkaXNwbGF5OiB0YWJsZS1jZWxsOyB3aGl0ZS1zcGFjZTogbm93cmFwOyBsaW5lLWhlaWdodDogMS41OyBtYXgtd2lkdGg6IDE2M3B4OyB0ZXh0LWFsaWduOiBjZW50ZXI7Ij48c3BhbiBjbGFzcz0ibm9kZUxhYmVsIG1hcmtkb3duLW5vZGUtbGFiZWwiIHN0eWxlPSIiPjxwPitTdHJpbmcgZ2VuZGVyPC9wPjwvc3Bhbj48L2Rpdj48L2ZvcmVpZ25PYmplY3Q+PC9nPjwvZz48ZyBjbGFzcz0ibWV0aG9kcy1ncm91cCB0ZXh0IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtNjUuMDY2NDA2MjUsIDQ4KSI+PGcgY2xhc3M9ImxhYmVsIiBzdHlsZT0iIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwLC0xMikiPjxmb3JlaWduT2JqZWN0IHdpZHRoPSI5Mi4xNTYyNSIgaGVpZ2h0PSIyNCI+PGRpdiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94aHRtbCIgc3R5bGU9ImRpc3BsYXk6IHRhYmxlLWNlbGw7IHdoaXRlLXNwYWNlOiBub3dyYXA7IGxpbmUtaGVpZ2h0OiAxLjU7IG1heC13aWR0aDogMTUwcHg7IHRleHQtYWxpZ246IGNlbnRlcjsiPjxzcGFuIGNsYXNzPSJub2RlTGFiZWwgbWFya2Rvd24tbm9kZS1sYWJlbCIgc3R5bGU9IiI+PHA+K2lzTWFtbWFsKCk8L3A+PC9zcGFuPjwvZGl2PjwvZm9yZWlnbk9iamVjdD48L2c+PGcgY2xhc3M9ImxhYmVsIiBzdHlsZT0iIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwLDEyKSI+PGZvcmVpZ25PYmplY3Qgd2lkdGg9IjU1LjAzMTI1IiBoZWlnaHQ9IjI0Ij48ZGl2IHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hodG1sIiBzdHlsZT0iZGlzcGxheTogdGFibGUtY2VsbDsgd2hpdGUtc3BhY2U6IG5vd3JhcDsgbGluZS1oZWlnaHQ6IDEuNTsgbWF4LXdpZHRoOiAxMTJweDsgdGV4dC1hbGlnbjogY2VudGVyOyI+PHNwYW4gY2xhc3M9Im5vZGVMYWJlbCBtYXJrZG93bi1ub2RlLWxhYmVsIiBzdHlsZT0iIj48cD4rbWF0ZSgpPC9wPjwvc3Bhbj48L2Rpdj48L2ZvcmVpZ25PYmplY3Q+PC9nPjwvZz48ZyBjbGFzcz0iZGl2aWRlciIgc3R5bGU9IiI+PHBhdGggZD0iTS03Ny4wNjY0MDYyNSAtNDggQy0yMC40NzAyNTEyOTAxODA0NzggLTQ4LCAzNi4xMjU5MDM2Njk2MzkwNDQgLTQ4LCA3Ny4wNjY0MDYyNSAtNDggTS03Ny4wNjY0MDYyNSAtNDggQy0yMy4wMDU2NzAxOTE2MTM1MSAtNDgsIDMxLjA1NTA2NTg2Njc3Mjk3OCAtNDgsIDc3LjA2NjQwNjI1IC00OCIgc3Ryb2tlPSIjOTM3MERCIiBzdHJva2Utd2lkdGg9IjEuMyIgZmlsbD0ibm9uZSIgc3Ryb2tlLWRhc2hhcnJheT0iMCAwIiBzdHlsZT0iIj48L3BhdGg+PC9nPjxnIGNsYXNzPSJkaXZpZGVyIiBzdHlsZT0iIj48cGF0aCBkPSJNLTc3LjA2NjQwNjI1IDI0IEMtMzMuMDU4NzIwOTQ0MDU4NTY0IDI0LCAxMC45NDg5NjQzNjE4ODI4NzIgMjQsIDc3LjA2NjQwNjI1IDI0IE0tNzcuMDY2NDA2MjUgMjQgQy0xOS4yNjc3MzQ1NDc2MjA3MDUgMjQsIDM4LjUzMDkzNzE1NDc1ODU5IDI0LCA3Ny4wNjY0MDYyNSAyNCIgc3Ryb2tlPSIjOTM3MERCIiBzdHJva2Utd2lkdGg9IjEuMyIgZmlsbD0ibm9uZSIgc3Ryb2tlLWRhc2hhcnJheT0iMCAwIiBzdHlsZT0iIj48L3BhdGg+PC9nPjwvZz48ZyBjbGFzcz0ibm9kZSBkZWZhdWx0IiBpZD0iY2xhc3NJZC1EdWNrLTEiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDkyLjc5Mjk2ODc1LCAzMzQpIj48ZyBjbGFzcz0iYmFzaWMgbGFiZWwtY29udGFpbmVyIj48cGF0aCBkPSJNLTg0Ljc5Mjk2ODc1IC04NCBMODQuNzkyOTY4NzUgLTg0IEw4NC43OTI5Njg3NSA4NCBMLTg0Ljc5Mjk2ODc1IDg0IiBzdHJva2U9Im5vbmUiIHN0cm9rZS13aWR0aD0iMCIgZmlsbD0iI0VDRUNGRiIgc3R5bGU9IiI+PC9wYXRoPjxwYXRoIGQ9Ik0tODQuNzkyOTY4NzUgLTg0IEMtMzIuOTg4NTI1NzIyNzkyNTY2IC04NCwgMTguODE1OTE3MzA0NDE0ODY3IC04NCwgODQuNzkyOTY4NzUgLTg0IE0tODQuNzkyOTY4NzUgLTg0IEMtNDAuNjIwMDQyMDM1OTQ3NzggLTg0LCAzLjU1Mjg4NDY3ODEwNDQzMzYgLTg0LCA4NC43OTI5Njg3NSAtODQgTTg0Ljc5Mjk2ODc1IC04NCBDODQuNzkyOTY4NzUgLTM1LjAxMzA1NzM2MTU5MzI0LCA4NC43OTI5Njg3NSAxMy45NzM4ODUyNzY4MTM1MTksIDg0Ljc5Mjk2ODc1IDg0IE04NC43OTI5Njg3NSAtODQgQzg0Ljc5Mjk2ODc1IC00Mi41ODA1MTQ3NTQ5NzkwMiwgODQuNzkyOTY4NzUgLTEuMTYxMDI5NTA5OTU4MDQxMiwgODQuNzkyOTY4NzUgODQgTTg0Ljc5Mjk2ODc1IDg0IEM0MS43ODI2ODU4NzQzODE5NTQgODQsIC0xLjIyNzU5NzAwMTIzNjA5MTUgODQsIC04NC43OTI5Njg3NSA4NCBNODQuNzkyOTY4NzUgODQgQzQ5LjcyNTI2NDUyODU0MDQ1IDg0LCAxNC42NTc1NjAzMDcwODA5IDg0LCAtODQuNzkyOTY4NzUgODQgTS04NC43OTI5Njg3NSA4NCBDLTg0Ljc5Mjk2ODc1IDI0LjcwMDEzMzM0NDYyNDIyNywgLTg0Ljc5Mjk2ODc1IC0zNC41OTk3MzMzMTA3NTE1NDYsIC04NC43OTI5Njg3NSAtODQgTS04NC43OTI5Njg3NSA4NCBDLTg0Ljc5Mjk2ODc1IDE2LjgwODAzOTIxOTU5NTY0MiwgLTg0Ljc5Mjk2ODc1IC01MC4zODM5MjE1NjA4MDg3MTYsIC04NC43OTI5Njg3NSAtODQiIHN0cm9rZT0iIzkzNzBEQiIgc3Ryb2tlLXdpZHRoPSIxLjMiIGZpbGw9Im5vbmUiIHN0cm9rZS1kYXNoYXJyYXk9IjAgMCIgc3R5bGU9IiI+PC9wYXRoPjwvZz48ZyBjbGFzcz0iYW5ub3RhdGlvbi1ncm91cCB0ZXh0IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwLCAtNjApIj48L2c+PGcgY2xhc3M9ImxhYmVsLWdyb3VwIHRleHQiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0xOC4wMzkwNjI1LCAtNjApIj48ZyBjbGFzcz0ibGFiZWwiIHN0eWxlPSJmb250LXdlaWdodDogYm9sZGVyIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwLC0xMikiPjxmb3JlaWduT2JqZWN0IHdpZHRoPSIzNi4wNzgxMjUiIGhlaWdodD0iMjQiPjxkaXYgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGh0bWwiIHN0eWxlPSJkaXNwbGF5OiB0YWJsZS1jZWxsOyB3aGl0ZS1zcGFjZTogbm93cmFwOyBsaW5lLWhlaWdodDogMS41OyBtYXgtd2lkdGg6IDg2cHg7IHRleHQtYWxpZ246IGNlbnRlcjsiPjxzcGFuIGNsYXNzPSJub2RlTGFiZWwgbWFya2Rvd24tbm9kZS1sYWJlbCIgc3R5bGU9IiI+PHA+RHVjazwvcD48L3NwYW4+PC9kaXY+PC9mb3JlaWduT2JqZWN0PjwvZz48L2c+PGcgY2xhc3M9Im1lbWJlcnMtZ3JvdXAgdGV4dCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTcyLjc5Mjk2ODc1LCAtMTIpIj48ZyBjbGFzcz0ibGFiZWwiIHN0eWxlPSIiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAsLTEyKSI+PGZvcmVpZ25PYmplY3Qgd2lkdGg9IjEyNy41NDY4NzUiIGhlaWdodD0iMjQiPjxkaXYgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGh0bWwiIHN0eWxlPSJkaXNwbGF5OiB0YWJsZS1jZWxsOyB3aGl0ZS1zcGFjZTogbm93cmFwOyBsaW5lLWhlaWdodDogMS41OyBtYXgtd2lkdGg6IDE4NnB4OyB0ZXh0LWFsaWduOiBjZW50ZXI7Ij48c3BhbiBjbGFzcz0ibm9kZUxhYmVsIG1hcmtkb3duLW5vZGUtbGFiZWwiIHN0eWxlPSIiPjxwPitTdHJpbmcgYmVha0NvbG9yPC9wPjwvc3Bhbj48L2Rpdj48L2ZvcmVpZ25PYmplY3Q+PC9nPjwvZz48ZyBjbGFzcz0ibWV0aG9kcy1ncm91cCB0ZXh0IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtNzIuNzkyOTY4NzUsIDM2KSI+PGcgY2xhc3M9ImxhYmVsIiBzdHlsZT0iIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwLC0xMikiPjxmb3JlaWduT2JqZWN0IHdpZHRoPSI1NS40MjE4NzUiIGhlaWdodD0iMjQiPjxkaXYgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGh0bWwiIHN0eWxlPSJkaXNwbGF5OiB0YWJsZS1jZWxsOyB3aGl0ZS1zcGFjZTogbm93cmFwOyBsaW5lLWhlaWdodDogMS41OyBtYXgtd2lkdGg6IDExM3B4OyB0ZXh0LWFsaWduOiBjZW50ZXI7Ij48c3BhbiBjbGFzcz0ibm9kZUxhYmVsIG1hcmtkb3duLW5vZGUtbGFiZWwiIHN0eWxlPSIiPjxwPitzd2ltKCk8L3A+PC9zcGFuPjwvZGl2PjwvZm9yZWlnbk9iamVjdD48L2c+PGcgY2xhc3M9ImxhYmVsIiBzdHlsZT0iIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwLDEyKSI+PGZvcmVpZ25PYmplY3Qgd2lkdGg9IjYxLjc4MTI1IiBoZWlnaHQ9IjI0Ij48ZGl2IHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hodG1sIiBzdHlsZT0iZGlzcGxheTogdGFibGUtY2VsbDsgd2hpdGUtc3BhY2U6IG5vd3JhcDsgbGluZS1oZWlnaHQ6IDEuNTsgbWF4LXdpZHRoOiAxMTlweDsgdGV4dC1hbGlnbjogY2VudGVyOyI+PHNwYW4gY2xhc3M9Im5vZGVMYWJlbCBtYXJrZG93bi1ub2RlLWxhYmVsIiBzdHlsZT0iIj48cD4rcXVhY2soKTwvcD48L3NwYW4+PC9kaXY+PC9mb3JlaWduT2JqZWN0PjwvZz48L2c+PGcgY2xhc3M9ImRpdmlkZXIiIHN0eWxlPSIiPjxwYXRoIGQ9Ik0tODQuNzkyOTY4NzUgLTM2IEMtMzEuNjAyMDU0NTE2Nzk4MzA0IC0zNiwgMjEuNTg4ODU5NzE2NDAzMzkgLTM2LCA4NC43OTI5Njg3NSAtMzYgTS04NC43OTI5Njg3NSAtMzYgQy0zOS43NzkyMjY4NDY3NDA4NTUgLTM2LCA1LjIzNDUxNTA1NjUxODI5MDUgLTM2LCA4NC43OTI5Njg3NSAtMzYiIHN0cm9rZT0iIzkzNzBEQiIgc3Ryb2tlLXdpZHRoPSIxLjMiIGZpbGw9Im5vbmUiIHN0cm9rZS1kYXNoYXJyYXk9IjAgMCIgc3R5bGU9IiI+PC9wYXRoPjwvZz48ZyBjbGFzcz0iZGl2aWRlciIgc3R5bGU9IiI+PHBhdGggZD0iTS04NC43OTI5Njg3NSAxMiBDLTE4LjA3MDQxMjQ4NzA2NDczIDEyLCA0OC42NTIxNDM3NzU4NzA1NCAxMiwgODQuNzkyOTY4NzUgMTIgTS04NC43OTI5Njg3NSAxMiBDLTMzLjE3NjUxOTI2NjQzODI5IDEyLCAxOC40Mzk5MzAyMTcxMjM0MiAxMiwgODQuNzkyOTY4NzUgMTIiIHN0cm9rZT0iIzkzNzBEQiIgc3Ryb2tlLXdpZHRoPSIxLjMiIGZpbGw9Im5vbmUiIHN0cm9rZS1kYXNoYXJyYXk9IjAgMCIgc3R5bGU9IiI+PC9wYXRoPjwvZz48L2c+PGcgY2xhc3M9Im5vZGUgZGVmYXVsdCIgaWQ9ImNsYXNzSWQtRmlzaC0yIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgyOTguMDYyNSwgMzM0KSI+PGcgY2xhc3M9ImJhc2ljIGxhYmVsLWNvbnRhaW5lciI+PHBhdGggZD0iTS03MC40NzY1NjI1IC03MiBMNzAuNDc2NTYyNSAtNzIgTDcwLjQ3NjU2MjUgNzIgTC03MC40NzY1NjI1IDcyIiBzdHJva2U9Im5vbmUiIHN0cm9rZS13aWR0aD0iMCIgZmlsbD0iI0VDRUNGRiIgc3R5bGU9IiI+PC9wYXRoPjxwYXRoIGQ9Ik0tNzAuNDc2NTYyNSAtNzIgQy0zNi40ODE1NTcyNjI4OTQ1NCAtNzIsIC0yLjQ4NjU1MjAyNTc4OTA4MDcgLTcyLCA3MC40NzY1NjI1IC03MiBNLTcwLjQ3NjU2MjUgLTcyIEMtMTQuNjczMjE3MjY4ODA2MTc3IC03MiwgNDEuMTMwMTI3OTYyMzg3NjUgLTcyLCA3MC40NzY1NjI1IC03MiBNNzAuNDc2NTYyNSAtNzIgQzcwLjQ3NjU2MjUgLTE3LjUxNTk0NzA0MTk4NzE4MiwgNzAuNDc2NTYyNSAzNi45NjgxMDU5MTYwMjU2MzUsIDcwLjQ3NjU2MjUgNzIgTTcwLjQ3NjU2MjUgLTcyIEM3MC40NzY1NjI1IC0yNS4yOTg1Nzk1ODMwMjk0OCwgNzAuNDc2NTYyNSAyMS40MDI4NDA4MzM5NDEwNDIsIDcwLjQ3NjU2MjUgNzIgTTcwLjQ3NjU2MjUgNzIgQzQxLjU5OTkxNTc3ODEzNzkwNSA3MiwgMTIuNzIzMjY5MDU2Mjc1ODEgNzIsIC03MC40NzY1NjI1IDcyIE03MC40NzY1NjI1IDcyIEMxOS4wMTE2ODgyMjYxMjg2MiA3MiwgLTMyLjQ1MzE4NjA0Nzc0Mjc2IDcyLCAtNzAuNDc2NTYyNSA3MiBNLTcwLjQ3NjU2MjUgNzIgQy03MC40NzY1NjI1IDM0LjAwMzI1OTk1OTM2MTcsIC03MC40NzY1NjI1IC0zLjk5MzQ4MDA4MTI3NjYwMTQsIC03MC40NzY1NjI1IC03MiBNLTcwLjQ3NjU2MjUgNzIgQy03MC40NzY1NjI1IDMzLjYwMDE1NDQ1NDEwMjQ1LCAtNzAuNDc2NTYyNSAtNC43OTk2OTEwOTE3OTUwOTcsIC03MC40NzY1NjI1IC03MiIgc3Ryb2tlPSIjOTM3MERCIiBzdHJva2Utd2lkdGg9IjEuMyIgZmlsbD0ibm9uZSIgc3Ryb2tlLWRhc2hhcnJheT0iMCAwIiBzdHlsZT0iIj48L3BhdGg+PC9nPjxnIGNsYXNzPSJhbm5vdGF0aW9uLWdyb3VwIHRleHQiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAsIC00OCkiPjwvZz48ZyBjbGFzcz0ibGFiZWwtZ3JvdXAgdGV4dCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTE0LjQwNjI1LCAtNDgpIj48ZyBjbGFzcz0ibGFiZWwiIHN0eWxlPSJmb250LXdlaWdodDogYm9sZGVyIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwLC0xMikiPjxmb3JlaWduT2JqZWN0IHdpZHRoPSIyOC44MTI1IiBoZWlnaHQ9IjI0Ij48ZGl2IHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hodG1sIiBzdHlsZT0iZGlzcGxheTogdGFibGUtY2VsbDsgd2hpdGUtc3BhY2U6IG5vd3JhcDsgbGluZS1oZWlnaHQ6IDEuNTsgbWF4LXdpZHRoOiA3OXB4OyB0ZXh0LWFsaWduOiBjZW50ZXI7Ij48c3BhbiBjbGFzcz0ibm9kZUxhYmVsIG1hcmtkb3duLW5vZGUtbGFiZWwiIHN0eWxlPSIiPjxwPkZpc2g8L3A+PC9zcGFuPjwvZGl2PjwvZm9yZWlnbk9iamVjdD48L2c+PC9nPjxnIGNsYXNzPSJtZW1iZXJzLWdyb3VwIHRleHQiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC01OC40NzY1NjI1LCAwKSI+PGcgY2xhc3M9ImxhYmVsIiBzdHlsZT0iIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwLC0xMikiPjxmb3JlaWduT2JqZWN0IHdpZHRoPSIxMDIuNTQ2ODc1IiBoZWlnaHQ9IjI0Ij48ZGl2IHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hodG1sIiBzdHlsZT0iZGlzcGxheTogdGFibGUtY2VsbDsgd2hpdGUtc3BhY2U6IG5vd3JhcDsgbGluZS1oZWlnaHQ6IDEuNTsgbWF4LXdpZHRoOiAxNjBweDsgdGV4dC1hbGlnbjogY2VudGVyOyI+PHNwYW4gY2xhc3M9Im5vZGVMYWJlbCBtYXJrZG93bi1ub2RlLWxhYmVsIiBzdHlsZT0iIj48cD4taW50IHNpemVJbkZlZXQ8L3A+PC9zcGFuPjwvZGl2PjwvZm9yZWlnbk9iamVjdD48L2c+PC9nPjxnIGNsYXNzPSJtZXRob2RzLWdyb3VwIHRleHQiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC01OC40NzY1NjI1LCA0OCkiPjxnIGNsYXNzPSJsYWJlbCIgc3R5bGU9IiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCwtMTIpIj48Zm9yZWlnbk9iamVjdCB3aWR0aD0iNjUuMjM0Mzc1IiBoZWlnaHQ9IjI0Ij48ZGl2IHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hodG1sIiBzdHlsZT0iZGlzcGxheTogdGFibGUtY2VsbDsgd2hpdGUtc3BhY2U6IG5vd3JhcDsgbGluZS1oZWlnaHQ6IDEuNTsgbWF4LXdpZHRoOiAxMjNweDsgdGV4dC1hbGlnbjogY2VudGVyOyI+PHNwYW4gY2xhc3M9Im5vZGVMYWJlbCBtYXJrZG93bi1ub2RlLWxhYmVsIiBzdHlsZT0iIj48cD4tY2FuRWF0KCk8L3A+PC9zcGFuPjwvZGl2PjwvZm9yZWlnbk9iamVjdD48L2c+PC9nPjxnIGNsYXNzPSJkaXZpZGVyIiBzdHlsZT0iIj48cGF0aCBkPSJNLTcwLjQ3NjU2MjUgLTI0IEMtMTUuNjQyNTgzNzU3MjIzODY4IC0yNCwgMzkuMTkxMzk0OTg1NTUyMjY1IC0yNCwgNzAuNDc2NTYyNSAtMjQgTS03MC40NzY1NjI1IC0yNCBDLTQxLjMyOTAxODIyNzA4MTkzIC0yNCwgLTEyLjE4MTQ3Mzk1NDE2Mzg3IC0yNCwgNzAuNDc2NTYyNSAtMjQiIHN0cm9rZT0iIzkzNzBEQiIgc3Ryb2tlLXdpZHRoPSIxLjMiIGZpbGw9Im5vbmUiIHN0cm9rZS1kYXNoYXJyYXk9IjAgMCIgc3R5bGU9IiI+PC9wYXRoPjwvZz48ZyBjbGFzcz0iZGl2aWRlciIgc3R5bGU9IiI+PHBhdGggZD0iTS03MC40NzY1NjI1IDI0IEMtMzIuNzg5OTMyMjcwMzQ5NjcgMjQsIDQuODk2Njk3OTU5MzAwNjY3IDI0LCA3MC40NzY1NjI1IDI0IE0tNzAuNDc2NTYyNSAyNCBDLTE4LjQ2ODgzOTU2ODMyOTAyNCAyNCwgMzMuNTM4ODgzMzYzMzQxOTUgMjQsIDcwLjQ3NjU2MjUgMjQiIHN0cm9rZT0iIzkzNzBEQiIgc3Ryb2tlLXdpZHRoPSIxLjMiIGZpbGw9Im5vbmUiIHN0cm9rZS1kYXNoYXJyYXk9IjAgMCIgc3R5bGU9IiI+PC9wYXRoPjwvZz48L2c+PGcgY2xhc3M9Im5vZGUgZGVmYXVsdCIgaWQ9ImNsYXNzSWQtWmVicmEtMyIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNDg4LjM1NTQ2ODc1LCAzMzQpIj48ZyBjbGFzcz0iYmFzaWMgbGFiZWwtY29udGFpbmVyIj48cGF0aCBkPSJNLTY5LjgxNjQwNjI1IC03MiBMNjkuODE2NDA2MjUgLTcyIEw2OS44MTY0MDYyNSA3MiBMLTY5LjgxNjQwNjI1IDcyIiBzdHJva2U9Im5vbmUiIHN0cm9rZS13aWR0aD0iMCIgZmlsbD0iI0VDRUNGRiIgc3R5bGU9IiI+PC9wYXRoPjxwYXRoIGQ9Ik0tNjkuODE2NDA2MjUgLTcyIEMtMTQuODQ2NDEyMDI3MDc2MDcgLTcyLCA0MC4xMjM1ODIxOTU4NDc4NiAtNzIsIDY5LjgxNjQwNjI1IC03MiBNLTY5LjgxNjQwNjI1IC03MiBDLTMxLjg0MTQzMjc0MjExMzY2IC03MiwgNi4xMzM1NDA3NjU3NzI2NzkgLTcyLCA2OS44MTY0MDYyNSAtNzIgTTY5LjgxNjQwNjI1IC03MiBDNjkuODE2NDA2MjUgLTE3LjkxNDY2NjAyNTY2NTM5NywgNjkuODE2NDA2MjUgMzYuMTcwNjY3OTQ4NjY5MjA1LCA2OS44MTY0MDYyNSA3MiBNNjkuODE2NDA2MjUgLTcyIEM2OS44MTY0MDYyNSAtMzkuODUwOTk4OTk0MzY4MTc2LCA2OS44MTY0MDYyNSAtNy43MDE5OTc5ODg3MzYzNTIsIDY5LjgxNjQwNjI1IDcyIE02OS44MTY0MDYyNSA3MiBDMjkuMzA3MTgxMzkxMzc2NDUzIDcyLCAtMTEuMjAyMDQzNDY3MjQ3MDk0IDcyLCAtNjkuODE2NDA2MjUgNzIgTTY5LjgxNjQwNjI1IDcyIEMzNS42MzUxMTQ1OTg3NTk1OCA3MiwgMS40NTM4MjI5NDc1MTkxNTcgNzIsIC02OS44MTY0MDYyNSA3MiBNLTY5LjgxNjQwNjI1IDcyIEMtNjkuODE2NDA2MjUgMzQuNDMzMDQ2NTM0MTE0MDMsIC02OS44MTY0MDYyNSAtMy4xMzM5MDY5MzE3NzE5NDYzLCAtNjkuODE2NDA2MjUgLTcyIE0tNjkuODE2NDA2MjUgNzIgQy02OS44MTY0MDYyNSAzMC4xNDM2MTY3OTE5NTkzMTcsIC02OS44MTY0MDYyNSAtMTEuNzEyNzY2NDE2MDgxMzY1LCAtNjkuODE2NDA2MjUgLTcyIiBzdHJva2U9IiM5MzcwREIiIHN0cm9rZS13aWR0aD0iMS4zIiBmaWxsPSJub25lIiBzdHJva2UtZGFzaGFycmF5PSIwIDAiIHN0eWxlPSIiPjwvcGF0aD48L2c+PGcgY2xhc3M9ImFubm90YXRpb24tZ3JvdXAgdGV4dCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCwgLTQ4KSI+PC9nPjxnIGNsYXNzPSJsYWJlbC1ncm91cCB0ZXh0IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMjAuNjk1MzEyNSwgLTQ4KSI+PGcgY2xhc3M9ImxhYmVsIiBzdHlsZT0iZm9udC13ZWlnaHQ6IGJvbGRlciIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCwtMTIpIj48Zm9yZWlnbk9iamVjdCB3aWR0aD0iNDEuMzkwNjI1IiBoZWlnaHQ9IjI0Ij48ZGl2IHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hodG1sIiBzdHlsZT0iZGlzcGxheTogdGFibGUtY2VsbDsgd2hpdGUtc3BhY2U6IG5vd3JhcDsgbGluZS1oZWlnaHQ6IDEuNTsgbWF4LXdpZHRoOiA5MXB4OyB0ZXh0LWFsaWduOiBjZW50ZXI7Ij48c3BhbiBjbGFzcz0ibm9kZUxhYmVsIG1hcmtkb3duLW5vZGUtbGFiZWwiIHN0eWxlPSIiPjxwPlplYnJhPC9wPjwvc3Bhbj48L2Rpdj48L2ZvcmVpZ25PYmplY3Q+PC9nPjwvZz48ZyBjbGFzcz0ibWVtYmVycy1ncm91cCB0ZXh0IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtNTcuODE2NDA2MjUsIDApIj48ZyBjbGFzcz0ibGFiZWwiIHN0eWxlPSIiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAsLTEyKSI+PGZvcmVpZ25PYmplY3Qgd2lkdGg9Ijk0LjkzNzUiIGhlaWdodD0iMjQiPjxkaXYgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGh0bWwiIHN0eWxlPSJkaXNwbGF5OiB0YWJsZS1jZWxsOyB3aGl0ZS1zcGFjZTogbm93cmFwOyBsaW5lLWhlaWdodDogMS41OyBtYXgtd2lkdGg6IDE1MnB4OyB0ZXh0LWFsaWduOiBjZW50ZXI7Ij48c3BhbiBjbGFzcz0ibm9kZUxhYmVsIG1hcmtkb3duLW5vZGUtbGFiZWwiIHN0eWxlPSIiPjxwPitib29sIGlzX3dpbGQ8L3A+PC9zcGFuPjwvZGl2PjwvZm9yZWlnbk9iamVjdD48L2c+PC9nPjxnIGNsYXNzPSJtZXRob2RzLWdyb3VwIHRleHQiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC01Ny44MTY0MDYyNSwgNDgpIj48ZyBjbGFzcz0ibGFiZWwiIHN0eWxlPSIiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAsLTEyKSI+PGZvcmVpZ25PYmplY3Qgd2lkdGg9IjQzLjIxODc1IiBoZWlnaHQ9IjI0Ij48ZGl2IHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hodG1sIiBzdHlsZT0iZGlzcGxheTogdGFibGUtY2VsbDsgd2hpdGUtc3BhY2U6IG5vd3JhcDsgbGluZS1oZWlnaHQ6IDEuNTsgbWF4LXdpZHRoOiAxMDFweDsgdGV4dC1hbGlnbjogY2VudGVyOyI+PHNwYW4gY2xhc3M9Im5vZGVMYWJlbCBtYXJrZG93bi1ub2RlLWxhYmVsIiBzdHlsZT0iIj48cD4rcnVuKCk8L3A+PC9zcGFuPjwvZGl2PjwvZm9yZWlnbk9iamVjdD48L2c+PC9nPjxnIGNsYXNzPSJkaXZpZGVyIiBzdHlsZT0iIj48cGF0aCBkPSJNLTY5LjgxNjQwNjI1IC0yNCBDLTIzLjM2Mjc2NzIzNTU4Nzk4OCAtMjQsIDIzLjA5MDg3MTc3ODgyNDAyNCAtMjQsIDY5LjgxNjQwNjI1IC0yNCBNLTY5LjgxNjQwNjI1IC0yNCBDLTE1Ljg3Mjk2NzE4OTk1MTQ2IC0yNCwgMzguMDcwNDcxODcwMDk3MDggLTI0LCA2OS44MTY0MDYyNSAtMjQiIHN0cm9rZT0iIzkzNzBEQiIgc3Ryb2tlLXdpZHRoPSIxLjMiIGZpbGw9Im5vbmUiIHN0cm9rZS1kYXNoYXJyYXk9IjAgMCIgc3R5bGU9IiI+PC9wYXRoPjwvZz48ZyBjbGFzcz0iZGl2aWRlciIgc3R5bGU9IiI+PHBhdGggZD0iTS02OS44MTY0MDYyNSAyNCBDLTQxLjY1MzUwMDY3MTY4ODkyIDI0LCAtMTMuNDkwNTk1MDkzMzc3ODQxIDI0LCA2OS44MTY0MDYyNSAyNCBNLTY5LjgxNjQwNjI1IDI0IEMtMzAuMjEzOTQ4ODUyMDAzNzQ2IDI0LCA5LjM4ODUwODU0NTk5MjUwOCAyNCwgNjkuODE2NDA2MjUgMjQiIHN0cm9rZT0iIzkzNzBEQiIgc3Ryb2tlLXdpZHRoPSIxLjMiIGZpbGw9Im5vbmUiIHN0cm9rZS1kYXNoYXJyYXk9IjAgMCIgc3R5bGU9IiI+PC9wYXRoPjwvZz48L2c+PC9nPjwvZz48L2c+PC9zdmc+"><p>Mermaid 코드 (Class Diagram):</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br></pre></td><td class="code"><pre><span class="line">classDiagram</span><br><span class="line">    Animal &lt;|-- Duck</span><br><span class="line">    Animal &lt;|-- Fish</span><br><span class="line">    Animal &lt;|-- Zebra</span><br><span class="line">    Animal : +int age</span><br><span class="line">    Animal : +String gender</span><br><span class="line">    Animal: +isMammal()</span><br><span class="line">    Animal: +mate()</span><br><span class="line">    class Duck&#123;</span><br><span class="line">      +String beakColor</span><br><span class="line">      +swim()</span><br><span class="line">      +quack()</span><br><span class="line">    &#125;</span><br><span class="line">    class Fish&#123;</span><br><span class="line">      -int sizeInFeet</span><br><span class="line">      -canEat()</span><br><span class="line">    &#125;</span><br><span class="line">    class Zebra&#123;</span><br><span class="line">      +bool is_wild</span><br><span class="line">      +run()</span><br><span class="line">    &#125;</span><br></pre></td></tr></table></figure><h3><span id="xychart">XYChart</span><a href="#xychart" class="header-anchor">#</a></h3><p>XYChart를 사용하여 매출 데이터를 시각화할 수 있습니다.</p><img class="kroki" src="data:image/svg+xml;base64,PHN2ZyBpZD0iY29udGFpbmVyIiB3aWR0aD0iMTAwJSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiBzdHlsZT0ibWF4LXdpZHRoOiA3MDBweDsiIHZpZXdCb3g9IjAgMCA3MDAgNTAwIiByb2xlPSJncmFwaGljcy1kb2N1bWVudCBkb2N1bWVudCIgYXJpYS1yb2xlZGVzY3JpcHRpb249Inh5Y2hhcnQiPjxzdHlsZT4jY29udGFpbmVye2ZvbnQtZmFtaWx5OiJ0cmVidWNoZXQgbXMiLHZlcmRhbmEsYXJpYWwsc2Fucy1zZXJpZjtmb250LXNpemU6MTZweDtmaWxsOiMzMzM7fUBrZXlmcmFtZXMgZWRnZS1hbmltYXRpb24tZnJhbWV7ZnJvbXtzdHJva2UtZGFzaG9mZnNldDowO319QGtleWZyYW1lcyBkYXNoe3Rve3N0cm9rZS1kYXNob2Zmc2V0OjA7fX0jY29udGFpbmVyIC5lZGdlLWFuaW1hdGlvbi1zbG93e3N0cm9rZS1kYXNoYXJyYXk6OSw1IWltcG9ydGFudDtzdHJva2UtZGFzaG9mZnNldDo5MDA7YW5pbWF0aW9uOmRhc2ggNTBzIGxpbmVhciBpbmZpbml0ZTtzdHJva2UtbGluZWNhcDpyb3VuZDt9I2NvbnRhaW5lciAuZWRnZS1hbmltYXRpb24tZmFzdHtzdHJva2UtZGFzaGFycmF5OjksNSFpbXBvcnRhbnQ7c3Ryb2tlLWRhc2hvZmZzZXQ6OTAwO2FuaW1hdGlvbjpkYXNoIDIwcyBsaW5lYXIgaW5maW5pdGU7c3Ryb2tlLWxpbmVjYXA6cm91bmQ7fSNjb250YWluZXIgLmVycm9yLWljb257ZmlsbDojNTUyMjIyO30jY29udGFpbmVyIC5lcnJvci10ZXh0e2ZpbGw6IzU1MjIyMjtzdHJva2U6IzU1MjIyMjt9I2NvbnRhaW5lciAuZWRnZS10aGlja25lc3Mtbm9ybWFse3N0cm9rZS13aWR0aDoxcHg7fSNjb250YWluZXIgLmVkZ2UtdGhpY2tuZXNzLXRoaWNre3N0cm9rZS13aWR0aDozLjVweDt9I2NvbnRhaW5lciAuZWRnZS1wYXR0ZXJuLXNvbGlke3N0cm9rZS1kYXNoYXJyYXk6MDt9I2NvbnRhaW5lciAuZWRnZS10aGlja25lc3MtaW52aXNpYmxle3N0cm9rZS13aWR0aDowO2ZpbGw6bm9uZTt9I2NvbnRhaW5lciAuZWRnZS1wYXR0ZXJuLWRhc2hlZHtzdHJva2UtZGFzaGFycmF5OjM7fSNjb250YWluZXIgLmVkZ2UtcGF0dGVybi1kb3R0ZWR7c3Ryb2tlLWRhc2hhcnJheToyO30jY29udGFpbmVyIC5tYXJrZXJ7ZmlsbDojMzMzMzMzO3N0cm9rZTojMzMzMzMzO30jY29udGFpbmVyIC5tYXJrZXIuY3Jvc3N7c3Ryb2tlOiMzMzMzMzM7fSNjb250YWluZXIgc3Zne2ZvbnQtZmFtaWx5OiJ0cmVidWNoZXQgbXMiLHZlcmRhbmEsYXJpYWwsc2Fucy1zZXJpZjtmb250LXNpemU6MTZweDt9I2NvbnRhaW5lciBwe21hcmdpbjowO30jY29udGFpbmVyIDpyb290ey0tbWVybWFpZC1mb250LWZhbWlseToidHJlYnVjaGV0IG1zIix2ZXJkYW5hLGFyaWFsLHNhbnMtc2VyaWY7fTwvc3R5bGU+PGc+PC9nPjxnIGNsYXNzPSJtYWluIj48cmVjdCB3aWR0aD0iNzAwIiBoZWlnaHQ9IjUwMCIgY2xhc3M9ImJhY2tncm91bmQiIGZpbGw9IndoaXRlIj48L3JlY3Q+PGcgY2xhc3M9ImNoYXJ0LXRpdGxlIj48dGV4dCB4PSIwIiB5PSIwIiBmaWxsPSIjMTMxMzAwIiBmb250LXNpemU9IjIwIiBkb21pbmFudC1iYXNlbGluZT0ibWlkZGxlIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgzNTAsIDIwLjAwMDAwMDk1MzY3NDMxNikgcm90YXRlKDApIj5TYWxlcyBSZXZlbnVlPC90ZXh0PjwvZz48ZyBjbGFzcz0icGxvdCI+PGcgY2xhc3M9ImJhci1wbG90LTAiPjxyZWN0IHg9Ijc0LjkyODEyODgxNDY5NzI2IiB5PSI0MDIuNzE0Mjg4MzAyODMwMyIgd2lkdGg9IjMyLjMiIGhlaWdodD0iNjYuMjg1NzEzNjA0NTE4MzIiIGZpbGw9IiNFQ0VDRkYiIHN0cm9rZT0iI0VDRUNGRiIgc3Ryb2tlLXdpZHRoPSIwIj48L3JlY3Q+PHJlY3QgeD0iMTI4LjczOTIwODAxMzM2MTE1IiB5PSIzNDMuNDI4NTczNzQ0NjM3NiIgd2lkdGg9IjMyLjMiIGhlaWdodD0iMTI1LjU3MTQyODE2MjcxMTAyIiBmaWxsPSIjRUNFQ0ZGIiBzdHJva2U9IiNFQ0VDRkYiIHN0cm9rZS13aWR0aD0iMCI+PC9yZWN0PjxyZWN0IHg9IjE4Mi41NTAyODcyMTIwMjUwNCIgeT0iMjU0LjUwMDAwMTkwNzM0ODYzIiB3aWR0aD0iMzIuMyIgaGVpZ2h0PSIyMTQuNSIgZmlsbD0iI0VDRUNGRiIgc3Ryb2tlPSIjRUNFQ0ZGIiBzdHJva2Utd2lkdGg9IjAiPjwvcmVjdD48cmVjdCB4PSIyMzYuMzYxMzY2NDEwNjg4OSIgeT0iMjEzLjAwMDAwMTcxNjYxMzc4IiB3aWR0aD0iMzIuMyIgaGVpZ2h0PSIyNTYuMDAwMDAwMTkwNzM0ODUiIGZpbGw9IiNFQ0VDRkYiIHN0cm9rZT0iI0VDRUNGRiIgc3Ryb2tlLXdpZHRoPSIwIj48L3JlY3Q+PHJlY3QgeD0iMjkwLjE3MjQ0NTYwOTM1Mjg1IiB5PSIxMzUuOTI4NTcyNzkwOTYzMyIgd2lkdGg9IjMyLjMiIGhlaWdodD0iMzMzLjA3MTQyOTExNjM4NTMzIiBmaWxsPSIjRUNFQ0ZGIiBzdHJva2U9IiNFQ0VDRkYiIHN0cm9rZS13aWR0aD0iMCI+PC9yZWN0PjxyZWN0IHg9IjM0My45ODM1MjQ4MDgwMTY3IiB5PSI3Ni42NDI4NTgyMzI3NzA2NCIgd2lkdGg9IjMyLjMiIGhlaWdodD0iMzkyLjM1NzE0MzY3NDU3Nzk3IiBmaWxsPSIjRUNFQ0ZGIiBzdHJva2U9IiNFQ0VDRkYiIHN0cm9rZS13aWR0aD0iMCI+PC9yZWN0PjxyZWN0IHg9IjM5Ny43OTQ2MDQwMDY2ODA2IiB5PSI0Ny4wMDAwMDA5NTM2NzQzMTYiIHdpZHRoPSIzMi4zIiBoZWlnaHQ9IjQyMi4wMDAwMDA5NTM2NzQzIiBmaWxsPSIjRUNFQ0ZGIiBzdHJva2U9IiNFQ0VDRkYiIHN0cm9rZS13aWR0aD0iMCI+PC9yZWN0PjxyZWN0IHg9IjQ1MS42MDU2ODMyMDUzNDQ0NiIgeT0iOTQuNDI4NTcyNjAwMjI4NDYiIHdpZHRoPSIzMi4zIiBoZWlnaHQ9IjM3NC41NzE0MjkzMDcxMjAyIiBmaWxsPSIjRUNFQ0ZGIiBzdHJva2U9IiNFQ0VDRkYiIHN0cm9rZS13aWR0aD0iMCI+PC9yZWN0PjxyZWN0IHg9IjUwNS40MTY3NjI0MDQwMDg0IiB5PSIxNTMuNzE0Mjg3MTU4NDIxMSIgd2lkdGg9IjMyLjMiIGhlaWdodD0iMzE1LjI4NTcxNDc0ODkyNzU1IiBmaWxsPSIjRUNFQ0ZGIiBzdHJva2U9IiNFQ0VDRkYiIHN0cm9rZS13aWR0aD0iMCI+PC9yZWN0PjxyZWN0IHg9IjU1OS4yMjc4NDE2MDI2NzIyIiB5PSIxOTUuMjE0Mjg3MzQ5MTU1OTQiIHdpZHRoPSIzMi4zIiBoZWlnaHQ9IjI3My43ODU3MTQ1NTgxOTI3IiBmaWxsPSIjRUNFQ0ZGIiBzdHJva2U9IiNFQ0VDRkYiIHN0cm9rZS13aWR0aD0iMCI+PC9yZWN0PjxyZWN0IHg9IjYxMy4wMzg5MjA4MDEzMzYxIiB5PSIyODQuMTQyODU5MTg2NDQ0OSIgd2lkdGg9IjMyLjMiIGhlaWdodD0iMTg0Ljg1NzE0MjcyMDkwMzciIGZpbGw9IiNFQ0VDRkYiIHN0cm9rZT0iI0VDRUNGRiIgc3Ryb2tlLXdpZHRoPSIwIj48L3JlY3Q+PHJlY3QgeD0iNjY2Ljg1IiB5PSIzNDMuNDI4NTczNzQ0NjM3NiIgd2lkdGg9IjMyLjMiIGhlaWdodD0iMTI1LjU3MTQyODE2MjcxMTAyIiBmaWxsPSIjRUNFQ0ZGIiBzdHJva2U9IiNFQ0VDRkYiIHN0cm9rZS13aWR0aD0iMCI+PC9yZWN0PjwvZz48ZyBjbGFzcz0ibGluZS1wbG90LTEiPjxwYXRoIGQ9Ik05MS4wNzgsNDAyLjcxNEwxNDQuODg5LDM0My40MjlMMTk4LjcsMjU0LjVMMjUyLjUxMSwyMTNMMzA2LjMyMiwxMzUuOTI5TDM2MC4xMzQsNzYuNjQzTDQxMy45NDUsNDdMNDY3Ljc1Niw5NC40MjlMNTIxLjU2NywxNTMuNzE0TDU3NS4zNzgsMTk1LjIxNEw2MjkuMTg5LDI4NC4xNDNMNjgzLDM0My40MjkiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzg0OTNBNiIgc3Ryb2tlLXdpZHRoPSIyIj48L3BhdGg+PC9nPjwvZz48ZyBjbGFzcz0iYm90dG9tLWF4aXMiPjxnIGNsYXNzPSJheGlzLWxpbmUiPjxwYXRoIGQ9Ik0gNzQuMDc4MTI4ODE0Njk3MjcsNDcwLjAwMDAwMTkwNzM0ODYzIEwgNzAwLDQ3MC4wMDAwMDE5MDczNDg2MyIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMTMxMzAwIiBzdHJva2Utd2lkdGg9IjIiPjwvcGF0aD48L2c+PGcgY2xhc3M9ImxhYmVsIj48dGV4dCB4PSIwIiB5PSIwIiBmaWxsPSIjMTMxMzAwIiBmb250LXNpemU9IjE0IiBkb21pbmFudC1iYXNlbGluZT0idGV4dC1iZWZvcmUtZWRnZSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoOTEuMDc4MTI4ODE0Njk3MjcsIDQ4MS4wMDAwMDE5MDczNDg2Mykgcm90YXRlKDApIj5qYW48L3RleHQ+PHRleHQgeD0iMCIgeT0iMCIgZmlsbD0iIzEzMTMwMCIgZm9udC1zaXplPSIxNCIgZG9taW5hbnQtYmFzZWxpbmU9InRleHQtYmVmb3JlLWVkZ2UiIHRleHQtYW5jaG9yPSJtaWRkbGUiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDE0NC44ODkyMDgwMTMzNjExNiwgNDgxLjAwMDAwMTkwNzM0ODYzKSByb3RhdGUoMCkiPmZlYjwvdGV4dD48dGV4dCB4PSIwIiB5PSIwIiBmaWxsPSIjMTMxMzAwIiBmb250LXNpemU9IjE0IiBkb21pbmFudC1iYXNlbGluZT0idGV4dC1iZWZvcmUtZWRnZSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMTk4LjcwMDI4NzIxMjAyNTA1LCA0ODEuMDAwMDAxOTA3MzQ4NjMpIHJvdGF0ZSgwKSI+bWFyPC90ZXh0Pjx0ZXh0IHg9IjAiIHk9IjAiIGZpbGw9IiMxMzEzMDAiIGZvbnQtc2l6ZT0iMTQiIGRvbWluYW50LWJhc2VsaW5lPSJ0ZXh0LWJlZm9yZS1lZGdlIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgyNTIuNTExMzY2NDEwNjg4OSwgNDgxLjAwMDAwMTkwNzM0ODYzKSByb3RhdGUoMCkiPmFwcjwvdGV4dD48dGV4dCB4PSIwIiB5PSIwIiBmaWxsPSIjMTMxMzAwIiBmb250LXNpemU9IjE0IiBkb21pbmFudC1iYXNlbGluZT0idGV4dC1iZWZvcmUtZWRnZSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMzA2LjMyMjQ0NTYwOTM1MjgsIDQ4MS4wMDAwMDE5MDczNDg2Mykgcm90YXRlKDApIj5tYXk8L3RleHQ+PHRleHQgeD0iMCIgeT0iMCIgZmlsbD0iIzEzMTMwMCIgZm9udC1zaXplPSIxNCIgZG9taW5hbnQtYmFzZWxpbmU9InRleHQtYmVmb3JlLWVkZ2UiIHRleHQtYW5jaG9yPSJtaWRkbGUiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDM2MC4xMzM1MjQ4MDgwMTY2NiwgNDgxLjAwMDAwMTkwNzM0ODYzKSByb3RhdGUoMCkiPmp1bjwvdGV4dD48dGV4dCB4PSIwIiB5PSIwIiBmaWxsPSIjMTMxMzAwIiBmb250LXNpemU9IjE0IiBkb21pbmFudC1iYXNlbGluZT0idGV4dC1iZWZvcmUtZWRnZSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNDEzLjk0NDYwNDAwNjY4MDU1LCA0ODEuMDAwMDAxOTA3MzQ4NjMpIHJvdGF0ZSgwKSI+anVsPC90ZXh0Pjx0ZXh0IHg9IjAiIHk9IjAiIGZpbGw9IiMxMzEzMDAiIGZvbnQtc2l6ZT0iMTQiIGRvbWluYW50LWJhc2VsaW5lPSJ0ZXh0LWJlZm9yZS1lZGdlIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSg0NjcuNzU1NjgzMjA1MzQ0NDQsIDQ4MS4wMDAwMDE5MDczNDg2Mykgcm90YXRlKDApIj5hdWc8L3RleHQ+PHRleHQgeD0iMCIgeT0iMCIgZmlsbD0iIzEzMTMwMCIgZm9udC1zaXplPSIxNCIgZG9taW5hbnQtYmFzZWxpbmU9InRleHQtYmVmb3JlLWVkZ2UiIHRleHQtYW5jaG9yPSJtaWRkbGUiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDUyMS41NjY3NjI0MDQwMDg0LCA0ODEuMDAwMDAxOTA3MzQ4NjMpIHJvdGF0ZSgwKSI+c2VwPC90ZXh0Pjx0ZXh0IHg9IjAiIHk9IjAiIGZpbGw9IiMxMzEzMDAiIGZvbnQtc2l6ZT0iMTQiIGRvbWluYW50LWJhc2VsaW5lPSJ0ZXh0LWJlZm9yZS1lZGdlIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSg1NzUuMzc3ODQxNjAyNjcyMiwgNDgxLjAwMDAwMTkwNzM0ODYzKSByb3RhdGUoMCkiPm9jdDwvdGV4dD48dGV4dCB4PSIwIiB5PSIwIiBmaWxsPSIjMTMxMzAwIiBmb250LXNpemU9IjE0IiBkb21pbmFudC1iYXNlbGluZT0idGV4dC1iZWZvcmUtZWRnZSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNjI5LjE4ODkyMDgwMTMzNiwgNDgxLjAwMDAwMTkwNzM0ODYzKSByb3RhdGUoMCkiPm5vdjwvdGV4dD48dGV4dCB4PSIwIiB5PSIwIiBmaWxsPSIjMTMxMzAwIiBmb250LXNpemU9IjE0IiBkb21pbmFudC1iYXNlbGluZT0idGV4dC1iZWZvcmUtZWRnZSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNjgzLCA0ODEuMDAwMDAxOTA3MzQ4NjMpIHJvdGF0ZSgwKSI+ZGVjPC90ZXh0PjwvZz48ZyBjbGFzcz0idGlja3MiPjxwYXRoIGQ9Ik0gOTEuMDc4MTI4ODE0Njk3MjcsNDcxLjAwMDAwMTkwNzM0ODYzIEwgOTEuMDc4MTI4ODE0Njk3MjcsNDc2LjAwMDAwMTkwNzM0ODYzIiBmaWxsPSJub25lIiBzdHJva2U9IiMxMzEzMDAiIHN0cm9rZS13aWR0aD0iMiI+PC9wYXRoPjxwYXRoIGQ9Ik0gMTQ0Ljg4OTIwODAxMzM2MTE2LDQ3MS4wMDAwMDE5MDczNDg2MyBMIDE0NC44ODkyMDgwMTMzNjExNiw0NzYuMDAwMDAxOTA3MzQ4NjMiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzEzMTMwMCIgc3Ryb2tlLXdpZHRoPSIyIj48L3BhdGg+PHBhdGggZD0iTSAxOTguNzAwMjg3MjEyMDI1MDUsNDcxLjAwMDAwMTkwNzM0ODYzIEwgMTk4LjcwMDI4NzIxMjAyNTA1LDQ3Ni4wMDAwMDE5MDczNDg2MyIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMTMxMzAwIiBzdHJva2Utd2lkdGg9IjIiPjwvcGF0aD48cGF0aCBkPSJNIDI1Mi41MTEzNjY0MTA2ODg5LDQ3MS4wMDAwMDE5MDczNDg2MyBMIDI1Mi41MTEzNjY0MTA2ODg5LDQ3Ni4wMDAwMDE5MDczNDg2MyIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMTMxMzAwIiBzdHJva2Utd2lkdGg9IjIiPjwvcGF0aD48cGF0aCBkPSJNIDMwNi4zMjI0NDU2MDkzNTI4LDQ3MS4wMDAwMDE5MDczNDg2MyBMIDMwNi4zMjI0NDU2MDkzNTI4LDQ3Ni4wMDAwMDE5MDczNDg2MyIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMTMxMzAwIiBzdHJva2Utd2lkdGg9IjIiPjwvcGF0aD48cGF0aCBkPSJNIDM2MC4xMzM1MjQ4MDgwMTY2Niw0NzEuMDAwMDAxOTA3MzQ4NjMgTCAzNjAuMTMzNTI0ODA4MDE2NjYsNDc2LjAwMDAwMTkwNzM0ODYzIiBmaWxsPSJub25lIiBzdHJva2U9IiMxMzEzMDAiIHN0cm9rZS13aWR0aD0iMiI+PC9wYXRoPjxwYXRoIGQ9Ik0gNDEzLjk0NDYwNDAwNjY4MDU1LDQ3MS4wMDAwMDE5MDczNDg2MyBMIDQxMy45NDQ2MDQwMDY2ODA1NSw0NzYuMDAwMDAxOTA3MzQ4NjMiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzEzMTMwMCIgc3Ryb2tlLXdpZHRoPSIyIj48L3BhdGg+PHBhdGggZD0iTSA0NjcuNzU1NjgzMjA1MzQ0NDQsNDcxLjAwMDAwMTkwNzM0ODYzIEwgNDY3Ljc1NTY4MzIwNTM0NDQ0LDQ3Ni4wMDAwMDE5MDczNDg2MyIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMTMxMzAwIiBzdHJva2Utd2lkdGg9IjIiPjwvcGF0aD48cGF0aCBkPSJNIDUyMS41NjY3NjI0MDQwMDg0LDQ3MS4wMDAwMDE5MDczNDg2MyBMIDUyMS41NjY3NjI0MDQwMDg0LDQ3Ni4wMDAwMDE5MDczNDg2MyIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMTMxMzAwIiBzdHJva2Utd2lkdGg9IjIiPjwvcGF0aD48cGF0aCBkPSJNIDU3NS4zNzc4NDE2MDI2NzIyLDQ3MS4wMDAwMDE5MDczNDg2MyBMIDU3NS4zNzc4NDE2MDI2NzIyLDQ3Ni4wMDAwMDE5MDczNDg2MyIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMTMxMzAwIiBzdHJva2Utd2lkdGg9IjIiPjwvcGF0aD48cGF0aCBkPSJNIDYyOS4xODg5MjA4MDEzMzYsNDcxLjAwMDAwMTkwNzM0ODYzIEwgNjI5LjE4ODkyMDgwMTMzNiw0NzYuMDAwMDAxOTA3MzQ4NjMiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzEzMTMwMCIgc3Ryb2tlLXdpZHRoPSIyIj48L3BhdGg+PHBhdGggZD0iTSA2ODMsNDcxLjAwMDAwMTkwNzM0ODYzIEwgNjgzLDQ3Ni4wMDAwMDE5MDczNDg2MyIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMTMxMzAwIiBzdHJva2Utd2lkdGg9IjIiPjwvcGF0aD48L2c+PC9nPjxnIGNsYXNzPSJsZWZ0LWF4aXMiPjxnIGNsYXNzPSJheGlzbC1saW5lIj48cGF0aCBkPSJNIDczLjA3ODEyODgxNDY5NzI3LDQwLjAwMDAwMTkwNzM0ODYzIEwgNzMuMDc4MTI4ODE0Njk3MjcsNDY5LjAwMDAwMTkwNzM0ODYzIiBmaWxsPSJub25lIiBzdHJva2U9IiMxMzEzMDAiIHN0cm9rZS13aWR0aD0iMiI+PC9wYXRoPjwvZz48ZyBjbGFzcz0ibGFiZWwiPjx0ZXh0IHg9IjAiIHk9IjAiIGZpbGw9IiMxMzEzMDAiIGZvbnQtc2l6ZT0iMTQiIGRvbWluYW50LWJhc2VsaW5lPSJtaWRkbGUiIHRleHQtYW5jaG9yPSJlbmQiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDYyLjA3ODEyODgxNDY5NzI2NiwgNDcuMDAwMDAwOTUzNjc0MzE2KSByb3RhdGUoMCkiPjExMDAwPC90ZXh0Pjx0ZXh0IHg9IjAiIHk9IjAiIGZpbGw9IiMxMzEzMDAiIGZvbnQtc2l6ZT0iMTQiIGRvbWluYW50LWJhc2VsaW5lPSJtaWRkbGUiIHRleHQtYW5jaG9yPSJlbmQiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDYyLjA3ODEyODgxNDY5NzI2NiwgNzYuNjQyODU4MjMyNzcwNjQpIHJvdGF0ZSgwKSI+MTA1MDA8L3RleHQ+PHRleHQgeD0iMCIgeT0iMCIgZmlsbD0iIzEzMTMwMCIgZm9udC1zaXplPSIxNCIgZG9taW5hbnQtYmFzZWxpbmU9Im1pZGRsZSIgdGV4dC1hbmNob3I9ImVuZCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNjIuMDc4MTI4ODE0Njk3MjY2LCAxMDYuMjg1NzE1NTExODY3KSByb3RhdGUoMCkiPjEwMDAwPC90ZXh0Pjx0ZXh0IHg9IjAiIHk9IjAiIGZpbGw9IiMxMzEzMDAiIGZvbnQtc2l6ZT0iMTQiIGRvbWluYW50LWJhc2VsaW5lPSJtaWRkbGUiIHRleHQtYW5jaG9yPSJlbmQiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDYyLjA3ODEyODgxNDY5NzI2NiwgMTM1LjkyODU3Mjc5MDk2MzMpIHJvdGF0ZSgwKSI+OTUwMDwvdGV4dD48dGV4dCB4PSIwIiB5PSIwIiBmaWxsPSIjMTMxMzAwIiBmb250LXNpemU9IjE0IiBkb21pbmFudC1iYXNlbGluZT0ibWlkZGxlIiB0ZXh0LWFuY2hvcj0iZW5kIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSg2Mi4wNzgxMjg4MTQ2OTcyNjYsIDE2NS41NzE0MzAwNzAwNTk2Mikgcm90YXRlKDApIj45MDAwPC90ZXh0Pjx0ZXh0IHg9IjAiIHk9IjAiIGZpbGw9IiMxMzEzMDAiIGZvbnQtc2l6ZT0iMTQiIGRvbWluYW50LWJhc2VsaW5lPSJtaWRkbGUiIHRleHQtYW5jaG9yPSJlbmQiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDYyLjA3ODEyODgxNDY5NzI2NiwgMTk1LjIxNDI4NzM0OTE1NTk0KSByb3RhdGUoMCkiPjg1MDA8L3RleHQ+PHRleHQgeD0iMCIgeT0iMCIgZmlsbD0iIzEzMTMwMCIgZm9udC1zaXplPSIxNCIgZG9taW5hbnQtYmFzZWxpbmU9Im1pZGRsZSIgdGV4dC1hbmNob3I9ImVuZCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNjIuMDc4MTI4ODE0Njk3MjY2LCAyMjQuODU3MTQ0NjI4MjUyMykgcm90YXRlKDApIj44MDAwPC90ZXh0Pjx0ZXh0IHg9IjAiIHk9IjAiIGZpbGw9IiMxMzEzMDAiIGZvbnQtc2l6ZT0iMTQiIGRvbWluYW50LWJhc2VsaW5lPSJtaWRkbGUiIHRleHQtYW5jaG9yPSJlbmQiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDYyLjA3ODEyODgxNDY5NzI2NiwgMjU0LjUwMDAwMTkwNzM0ODYzKSByb3RhdGUoMCkiPjc1MDA8L3RleHQ+PHRleHQgeD0iMCIgeT0iMCIgZmlsbD0iIzEzMTMwMCIgZm9udC1zaXplPSIxNCIgZG9taW5hbnQtYmFzZWxpbmU9Im1pZGRsZSIgdGV4dC1hbmNob3I9ImVuZCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNjIuMDc4MTI4ODE0Njk3MjY2LCAyODQuMTQyODU5MTg2NDQ0OSkgcm90YXRlKDApIj43MDAwPC90ZXh0Pjx0ZXh0IHg9IjAiIHk9IjAiIGZpbGw9IiMxMzEzMDAiIGZvbnQtc2l6ZT0iMTQiIGRvbWluYW50LWJhc2VsaW5lPSJtaWRkbGUiIHRleHQtYW5jaG9yPSJlbmQiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDYyLjA3ODEyODgxNDY5NzI2NiwgMzEzLjc4NTcxNjQ2NTU0MTI3KSByb3RhdGUoMCkiPjY1MDA8L3RleHQ+PHRleHQgeD0iMCIgeT0iMCIgZmlsbD0iIzEzMTMwMCIgZm9udC1zaXplPSIxNCIgZG9taW5hbnQtYmFzZWxpbmU9Im1pZGRsZSIgdGV4dC1hbmNob3I9ImVuZCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNjIuMDc4MTI4ODE0Njk3MjY2LCAzNDMuNDI4NTczNzQ0NjM3Nikgcm90YXRlKDApIj42MDAwPC90ZXh0Pjx0ZXh0IHg9IjAiIHk9IjAiIGZpbGw9IiMxMzEzMDAiIGZvbnQtc2l6ZT0iMTQiIGRvbWluYW50LWJhc2VsaW5lPSJtaWRkbGUiIHRleHQtYW5jaG9yPSJlbmQiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDYyLjA3ODEyODgxNDY5NzI2NiwgMzczLjA3MTQzMTAyMzczMzk2KSByb3RhdGUoMCkiPjU1MDA8L3RleHQ+PHRleHQgeD0iMCIgeT0iMCIgZmlsbD0iIzEzMTMwMCIgZm9udC1zaXplPSIxNCIgZG9taW5hbnQtYmFzZWxpbmU9Im1pZGRsZSIgdGV4dC1hbmNob3I9ImVuZCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNjIuMDc4MTI4ODE0Njk3MjY2LCA0MDIuNzE0Mjg4MzAyODMwMykgcm90YXRlKDApIj41MDAwPC90ZXh0Pjx0ZXh0IHg9IjAiIHk9IjAiIGZpbGw9IiMxMzEzMDAiIGZvbnQtc2l6ZT0iMTQiIGRvbWluYW50LWJhc2VsaW5lPSJtaWRkbGUiIHRleHQtYW5jaG9yPSJlbmQiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDYyLjA3ODEyODgxNDY5NzI2NiwgNDMyLjM1NzE0NTU4MTkyNjY2KSByb3RhdGUoMCkiPjQ1MDA8L3RleHQ+PHRleHQgeD0iMCIgeT0iMCIgZmlsbD0iIzEzMTMwMCIgZm9udC1zaXplPSIxNCIgZG9taW5hbnQtYmFzZWxpbmU9Im1pZGRsZSIgdGV4dC1hbmNob3I9ImVuZCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNjIuMDc4MTI4ODE0Njk3MjY2LCA0NjIuMDAwMDAyODYxMDIyOTUpIHJvdGF0ZSgwKSI+NDAwMDwvdGV4dD48L2c+PGcgY2xhc3M9InRpY2tzIj48cGF0aCBkPSJNIDcyLjA3ODEyODgxNDY5NzI3LDQ3LjAwMDAwMDk1MzY3NDMxNiBMIDY3LjA3ODEyODgxNDY5NzI3LDQ3LjAwMDAwMDk1MzY3NDMxNiIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMTMxMzAwIiBzdHJva2Utd2lkdGg9IjIiPjwvcGF0aD48cGF0aCBkPSJNIDcyLjA3ODEyODgxNDY5NzI3LDc2LjY0Mjg1ODIzMjc3MDY0IEwgNjcuMDc4MTI4ODE0Njk3MjcsNzYuNjQyODU4MjMyNzcwNjQiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzEzMTMwMCIgc3Ryb2tlLXdpZHRoPSIyIj48L3BhdGg+PHBhdGggZD0iTSA3Mi4wNzgxMjg4MTQ2OTcyNywxMDYuMjg1NzE1NTExODY3IEwgNjcuMDc4MTI4ODE0Njk3MjcsMTA2LjI4NTcxNTUxMTg2NyIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMTMxMzAwIiBzdHJva2Utd2lkdGg9IjIiPjwvcGF0aD48cGF0aCBkPSJNIDcyLjA3ODEyODgxNDY5NzI3LDEzNS45Mjg1NzI3OTA5NjMzIEwgNjcuMDc4MTI4ODE0Njk3MjcsMTM1LjkyODU3Mjc5MDk2MzMiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzEzMTMwMCIgc3Ryb2tlLXdpZHRoPSIyIj48L3BhdGg+PHBhdGggZD0iTSA3Mi4wNzgxMjg4MTQ2OTcyNywxNjUuNTcxNDMwMDcwMDU5NjIgTCA2Ny4wNzgxMjg4MTQ2OTcyNywxNjUuNTcxNDMwMDcwMDU5NjIiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzEzMTMwMCIgc3Ryb2tlLXdpZHRoPSIyIj48L3BhdGg+PHBhdGggZD0iTSA3Mi4wNzgxMjg4MTQ2OTcyNywxOTUuMjE0Mjg3MzQ5MTU1OTQgTCA2Ny4wNzgxMjg4MTQ2OTcyNywxOTUuMjE0Mjg3MzQ5MTU1OTQiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzEzMTMwMCIgc3Ryb2tlLXdpZHRoPSIyIj48L3BhdGg+PHBhdGggZD0iTSA3Mi4wNzgxMjg4MTQ2OTcyNywyMjQuODU3MTQ0NjI4MjUyMyBMIDY3LjA3ODEyODgxNDY5NzI3LDIyNC44NTcxNDQ2MjgyNTIzIiBmaWxsPSJub25lIiBzdHJva2U9IiMxMzEzMDAiIHN0cm9rZS13aWR0aD0iMiI+PC9wYXRoPjxwYXRoIGQ9Ik0gNzIuMDc4MTI4ODE0Njk3MjcsMjU0LjUwMDAwMTkwNzM0ODYzIEwgNjcuMDc4MTI4ODE0Njk3MjcsMjU0LjUwMDAwMTkwNzM0ODYzIiBmaWxsPSJub25lIiBzdHJva2U9IiMxMzEzMDAiIHN0cm9rZS13aWR0aD0iMiI+PC9wYXRoPjxwYXRoIGQ9Ik0gNzIuMDc4MTI4ODE0Njk3MjcsMjg0LjE0Mjg1OTE4NjQ0NDkgTCA2Ny4wNzgxMjg4MTQ2OTcyNywyODQuMTQyODU5MTg2NDQ0OSIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMTMxMzAwIiBzdHJva2Utd2lkdGg9IjIiPjwvcGF0aD48cGF0aCBkPSJNIDcyLjA3ODEyODgxNDY5NzI3LDMxMy43ODU3MTY0NjU1NDEyNyBMIDY3LjA3ODEyODgxNDY5NzI3LDMxMy43ODU3MTY0NjU1NDEyNyIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMTMxMzAwIiBzdHJva2Utd2lkdGg9IjIiPjwvcGF0aD48cGF0aCBkPSJNIDcyLjA3ODEyODgxNDY5NzI3LDM0My40Mjg1NzM3NDQ2Mzc2IEwgNjcuMDc4MTI4ODE0Njk3MjcsMzQzLjQyODU3Mzc0NDYzNzYiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzEzMTMwMCIgc3Ryb2tlLXdpZHRoPSIyIj48L3BhdGg+PHBhdGggZD0iTSA3Mi4wNzgxMjg4MTQ2OTcyNywzNzMuMDcxNDMxMDIzNzMzOTYgTCA2Ny4wNzgxMjg4MTQ2OTcyNywzNzMuMDcxNDMxMDIzNzMzOTYiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzEzMTMwMCIgc3Ryb2tlLXdpZHRoPSIyIj48L3BhdGg+PHBhdGggZD0iTSA3Mi4wNzgxMjg4MTQ2OTcyNyw0MDIuNzE0Mjg4MzAyODMwMyBMIDY3LjA3ODEyODgxNDY5NzI3LDQwMi43MTQyODgzMDI4MzAzIiBmaWxsPSJub25lIiBzdHJva2U9IiMxMzEzMDAiIHN0cm9rZS13aWR0aD0iMiI+PC9wYXRoPjxwYXRoIGQ9Ik0gNzIuMDc4MTI4ODE0Njk3MjcsNDMyLjM1NzE0NTU4MTkyNjY2IEwgNjcuMDc4MTI4ODE0Njk3MjcsNDMyLjM1NzE0NTU4MTkyNjY2IiBmaWxsPSJub25lIiBzdHJva2U9IiMxMzEzMDAiIHN0cm9rZS13aWR0aD0iMiI+PC9wYXRoPjxwYXRoIGQ9Ik0gNzIuMDc4MTI4ODE0Njk3MjcsNDYyLjAwMDAwMjg2MTAyMjk1IEwgNjcuMDc4MTI4ODE0Njk3MjcsNDYyLjAwMDAwMjg2MTAyMjk1IiBmaWxsPSJub25lIiBzdHJva2U9IiMxMzEzMDAiIHN0cm9rZS13aWR0aD0iMiI+PC9wYXRoPjwvZz48ZyBjbGFzcz0idGl0bGUiPjx0ZXh0IHg9IjAiIHk9IjAiIGZpbGw9IiMxMzEzMDAiIGZvbnQtc2l6ZT0iMTYiIGRvbWluYW50LWJhc2VsaW5lPSJ0ZXh0LWJlZm9yZS1lZGdlIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSg1LCAyNTQuNTAwMDAxOTA3MzQ4NjMpIHJvdGF0ZSgyNzApIj5SZXZlbnVlIChpbiAkKTwvdGV4dD48L2c+PC9nPjwvZz48ZyBjbGFzcz0ibWVybWFpZC10bXAtZ3JvdXAiPjwvZz48L3N2Zz4="><p>XYChart 코드:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">xychart-beta</span><br><span class="line">title &quot;Sales Revenue&quot;</span><br><span class="line">x-axis [jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec]</span><br><span class="line">y-axis &quot;Revenue (in $)&quot; 4000 --&gt; 11000</span><br><span class="line">bar [5000, 6000, 7500, 8200, 9500, 10500, 11000, 10200, 9200, 8500, 7000, 6000]</span><br><span class="line">line [5000, 6000, 7500, 8200, 9500, 10500, 11000, 10200, 9200, 8500, 7000, 6000]</span><br></pre></td></tr></table></figure><h3><span id="block-diagram">Block Diagram</span><a href="#block-diagram" class="header-anchor">#</a></h3><p>여러 블록을 사용하여 시스템의 구성 요소를 표현할 수 있습니다.</p><img class="kroki" src="data:image/svg+xml;base64,PHN2ZyBpZD0iY29udGFpbmVyIiB3aWR0aD0iMTAwJSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiBzdHlsZT0ibWF4LXdpZHRoOiA2ODcuNTkzNzVweDsiIHZpZXdCb3g9Ii01IC0xNzkuNSA2ODcuNTkzNzUgMzU5IiByb2xlPSJncmFwaGljcy1kb2N1bWVudCBkb2N1bWVudCIgYXJpYS1yb2xlZGVzY3JpcHRpb249ImJsb2NrIj48c3R5bGU+I2NvbnRhaW5lcntmb250LWZhbWlseToidHJlYnVjaGV0IG1zIix2ZXJkYW5hLGFyaWFsLHNhbnMtc2VyaWY7Zm9udC1zaXplOjE2cHg7ZmlsbDojMzMzO31Aa2V5ZnJhbWVzIGVkZ2UtYW5pbWF0aW9uLWZyYW1le2Zyb217c3Ryb2tlLWRhc2hvZmZzZXQ6MDt9fUBrZXlmcmFtZXMgZGFzaHt0b3tzdHJva2UtZGFzaG9mZnNldDowO319I2NvbnRhaW5lciAuZWRnZS1hbmltYXRpb24tc2xvd3tzdHJva2UtZGFzaGFycmF5OjksNSFpbXBvcnRhbnQ7c3Ryb2tlLWRhc2hvZmZzZXQ6OTAwO2FuaW1hdGlvbjpkYXNoIDUwcyBsaW5lYXIgaW5maW5pdGU7c3Ryb2tlLWxpbmVjYXA6cm91bmQ7fSNjb250YWluZXIgLmVkZ2UtYW5pbWF0aW9uLWZhc3R7c3Ryb2tlLWRhc2hhcnJheTo5LDUhaW1wb3J0YW50O3N0cm9rZS1kYXNob2Zmc2V0OjkwMDthbmltYXRpb246ZGFzaCAyMHMgbGluZWFyIGluZmluaXRlO3N0cm9rZS1saW5lY2FwOnJvdW5kO30jY29udGFpbmVyIC5lcnJvci1pY29ue2ZpbGw6IzU1MjIyMjt9I2NvbnRhaW5lciAuZXJyb3ItdGV4dHtmaWxsOiM1NTIyMjI7c3Ryb2tlOiM1NTIyMjI7fSNjb250YWluZXIgLmVkZ2UtdGhpY2tuZXNzLW5vcm1hbHtzdHJva2Utd2lkdGg6MXB4O30jY29udGFpbmVyIC5lZGdlLXRoaWNrbmVzcy10aGlja3tzdHJva2Utd2lkdGg6My41cHg7fSNjb250YWluZXIgLmVkZ2UtcGF0dGVybi1zb2xpZHtzdHJva2UtZGFzaGFycmF5OjA7fSNjb250YWluZXIgLmVkZ2UtdGhpY2tuZXNzLWludmlzaWJsZXtzdHJva2Utd2lkdGg6MDtmaWxsOm5vbmU7fSNjb250YWluZXIgLmVkZ2UtcGF0dGVybi1kYXNoZWR7c3Ryb2tlLWRhc2hhcnJheTozO30jY29udGFpbmVyIC5lZGdlLXBhdHRlcm4tZG90dGVke3N0cm9rZS1kYXNoYXJyYXk6Mjt9I2NvbnRhaW5lciAubWFya2Vye2ZpbGw6IzMzMzMzMztzdHJva2U6IzMzMzMzMzt9I2NvbnRhaW5lciAubWFya2VyLmNyb3Nze3N0cm9rZTojMzMzMzMzO30jY29udGFpbmVyIHN2Z3tmb250LWZhbWlseToidHJlYnVjaGV0IG1zIix2ZXJkYW5hLGFyaWFsLHNhbnMtc2VyaWY7Zm9udC1zaXplOjE2cHg7fSNjb250YWluZXIgcHttYXJnaW46MDt9I2NvbnRhaW5lciAubGFiZWx7Zm9udC1mYW1pbHk6InRyZWJ1Y2hldCBtcyIsdmVyZGFuYSxhcmlhbCxzYW5zLXNlcmlmO2NvbG9yOiMzMzM7fSNjb250YWluZXIgLmNsdXN0ZXItbGFiZWwgdGV4dHtmaWxsOiMzMzM7fSNjb250YWluZXIgLmNsdXN0ZXItbGFiZWwgc3BhbiwjY29udGFpbmVyIHB7Y29sb3I6IzMzMzt9I2NvbnRhaW5lciAubGFiZWwgdGV4dCwjY29udGFpbmVyIHNwYW4sI2NvbnRhaW5lciBwe2ZpbGw6IzMzMztjb2xvcjojMzMzO30jY29udGFpbmVyIC5ub2RlIHJlY3QsI2NvbnRhaW5lciAubm9kZSBjaXJjbGUsI2NvbnRhaW5lciAubm9kZSBlbGxpcHNlLCNjb250YWluZXIgLm5vZGUgcG9seWdvbiwjY29udGFpbmVyIC5ub2RlIHBhdGh7ZmlsbDojRUNFQ0ZGO3N0cm9rZTojOTM3MERCO3N0cm9rZS13aWR0aDoxcHg7fSNjb250YWluZXIgLmZsb3djaGFydC1sYWJlbCB0ZXh0e3RleHQtYW5jaG9yOm1pZGRsZTt9I2NvbnRhaW5lciAubm9kZSAubGFiZWx7dGV4dC1hbGlnbjpjZW50ZXI7fSNjb250YWluZXIgLm5vZGUuY2xpY2thYmxle2N1cnNvcjpwb2ludGVyO30jY29udGFpbmVyIC5hcnJvd2hlYWRQYXRoe2ZpbGw6IzMzMzMzMzt9I2NvbnRhaW5lciAuZWRnZVBhdGggLnBhdGh7c3Ryb2tlOiMzMzMzMzM7c3Ryb2tlLXdpZHRoOjIuMHB4O30jY29udGFpbmVyIC5mbG93Y2hhcnQtbGlua3tzdHJva2U6IzMzMzMzMztmaWxsOm5vbmU7fSNjb250YWluZXIgLmVkZ2VMYWJlbHtiYWNrZ3JvdW5kLWNvbG9yOnJnYmEoMjMyLDIzMiwyMzIsIDAuOCk7dGV4dC1hbGlnbjpjZW50ZXI7fSNjb250YWluZXIgLmVkZ2VMYWJlbCByZWN0e29wYWNpdHk6MC41O2JhY2tncm91bmQtY29sb3I6cmdiYSgyMzIsMjMyLDIzMiwgMC44KTtmaWxsOnJnYmEoMjMyLDIzMiwyMzIsIDAuOCk7fSNjb250YWluZXIgLmxhYmVsQmtne2JhY2tncm91bmQtY29sb3I6cmdiYSgyMzIsIDIzMiwgMjMyLCAwLjUpO30jY29udGFpbmVyIC5ub2RlIC5jbHVzdGVye2ZpbGw6cmdiYSgyNTUsIDI1NSwgMjIyLCAwLjUpO3N0cm9rZTpyZ2JhKDE3MCwgMTcwLCA1MSwgMC4yKTtib3gtc2hhZG93OnJnYmEoNTAsIDUwLCA5MywgMC4yNSkgMHB4IDEzcHggMjdweCAtNXB4LHJnYmEoMCwgMCwgMCwgMC4zKSAwcHggOHB4IDE2cHggLThweDtzdHJva2Utd2lkdGg6MXB4O30jY29udGFpbmVyIC5jbHVzdGVyIHRleHR7ZmlsbDojMzMzO30jY29udGFpbmVyIC5jbHVzdGVyIHNwYW4sI2NvbnRhaW5lciBwe2NvbG9yOiMzMzM7fSNjb250YWluZXIgZGl2Lm1lcm1haWRUb29sdGlwe3Bvc2l0aW9uOmFic29sdXRlO3RleHQtYWxpZ246Y2VudGVyO21heC13aWR0aDoyMDBweDtwYWRkaW5nOjJweDtmb250LWZhbWlseToidHJlYnVjaGV0IG1zIix2ZXJkYW5hLGFyaWFsLHNhbnMtc2VyaWY7Zm9udC1zaXplOjEycHg7YmFja2dyb3VuZDpoc2woODAsIDEwMCUsIDk2LjI3NDUwOTgwMzklKTtib3JkZXI6MXB4IHNvbGlkICNhYWFhMzM7Ym9yZGVyLXJhZGl1czoycHg7cG9pbnRlci1ldmVudHM6bm9uZTt6LWluZGV4OjEwMDt9I2NvbnRhaW5lciAuZmxvd2NoYXJ0VGl0bGVUZXh0e3RleHQtYW5jaG9yOm1pZGRsZTtmb250LXNpemU6MThweDtmaWxsOiMzMzM7fSNjb250YWluZXIgLmxhYmVsLWljb257ZGlzcGxheTppbmxpbmUtYmxvY2s7aGVpZ2h0OjFlbTtvdmVyZmxvdzp2aXNpYmxlO3ZlcnRpY2FsLWFsaWduOi0wLjEyNWVtO30jY29udGFpbmVyIC5ub2RlIC5sYWJlbC1pY29uIHBhdGh7ZmlsbDpjdXJyZW50Q29sb3I7c3Ryb2tlOnJldmVydDtzdHJva2Utd2lkdGg6cmV2ZXJ0O30jY29udGFpbmVyIDpyb290ey0tbWVybWFpZC1mb250LWZhbWlseToidHJlYnVjaGV0IG1zIix2ZXJkYW5hLGFyaWFsLHNhbnMtc2VyaWY7fTwvc3R5bGU+PGc+PC9nPjxtYXJrZXIgaWQ9ImNvbnRhaW5lcl9ibG9jay1wb2ludEVuZCIgY2xhc3M9Im1hcmtlciBibG9jayIgdmlld0JveD0iMCAwIDEwIDEwIiByZWZYPSI2IiByZWZZPSI1IiBtYXJrZXJVbml0cz0idXNlclNwYWNlT25Vc2UiIG1hcmtlcldpZHRoPSIxMiIgbWFya2VySGVpZ2h0PSIxMiIgb3JpZW50PSJhdXRvIj48cGF0aCBkPSJNIDAgMCBMIDEwIDUgTCAwIDEwIHoiIGNsYXNzPSJhcnJvd01hcmtlclBhdGgiIHN0eWxlPSJzdHJva2Utd2lkdGg6IDE7IHN0cm9rZS1kYXNoYXJyYXk6IDEsIDA7Ij48L3BhdGg+PC9tYXJrZXI+PG1hcmtlciBpZD0iY29udGFpbmVyX2Jsb2NrLXBvaW50U3RhcnQiIGNsYXNzPSJtYXJrZXIgYmxvY2siIHZpZXdCb3g9IjAgMCAxMCAxMCIgcmVmWD0iNC41IiByZWZZPSI1IiBtYXJrZXJVbml0cz0idXNlclNwYWNlT25Vc2UiIG1hcmtlcldpZHRoPSIxMiIgbWFya2VySGVpZ2h0PSIxMiIgb3JpZW50PSJhdXRvIj48cGF0aCBkPSJNIDAgNSBMIDEwIDEwIEwgMTAgMCB6IiBjbGFzcz0iYXJyb3dNYXJrZXJQYXRoIiBzdHlsZT0ic3Ryb2tlLXdpZHRoOiAxOyBzdHJva2UtZGFzaGFycmF5OiAxLCAwOyI+PC9wYXRoPjwvbWFya2VyPjxtYXJrZXIgaWQ9ImNvbnRhaW5lcl9ibG9jay1jaXJjbGVFbmQiIGNsYXNzPSJtYXJrZXIgYmxvY2siIHZpZXdCb3g9IjAgMCAxMCAxMCIgcmVmWD0iMTEiIHJlZlk9IjUiIG1hcmtlclVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgbWFya2VyV2lkdGg9IjExIiBtYXJrZXJIZWlnaHQ9IjExIiBvcmllbnQ9ImF1dG8iPjxjaXJjbGUgY3g9IjUiIGN5PSI1IiByPSI1IiBjbGFzcz0iYXJyb3dNYXJrZXJQYXRoIiBzdHlsZT0ic3Ryb2tlLXdpZHRoOiAxOyBzdHJva2UtZGFzaGFycmF5OiAxLCAwOyI+PC9jaXJjbGU+PC9tYXJrZXI+PG1hcmtlciBpZD0iY29udGFpbmVyX2Jsb2NrLWNpcmNsZVN0YXJ0IiBjbGFzcz0ibWFya2VyIGJsb2NrIiB2aWV3Qm94PSIwIDAgMTAgMTAiIHJlZlg9Ii0xIiByZWZZPSI1IiBtYXJrZXJVbml0cz0idXNlclNwYWNlT25Vc2UiIG1hcmtlcldpZHRoPSIxMSIgbWFya2VySGVpZ2h0PSIxMSIgb3JpZW50PSJhdXRvIj48Y2lyY2xlIGN4PSI1IiBjeT0iNSIgcj0iNSIgY2xhc3M9ImFycm93TWFya2VyUGF0aCIgc3R5bGU9InN0cm9rZS13aWR0aDogMTsgc3Ryb2tlLWRhc2hhcnJheTogMSwgMDsiPjwvY2lyY2xlPjwvbWFya2VyPjxtYXJrZXIgaWQ9ImNvbnRhaW5lcl9ibG9jay1jcm9zc0VuZCIgY2xhc3M9Im1hcmtlciBjcm9zcyBibG9jayIgdmlld0JveD0iMCAwIDExIDExIiByZWZYPSIxMiIgcmVmWT0iNS4yIiBtYXJrZXJVbml0cz0idXNlclNwYWNlT25Vc2UiIG1hcmtlcldpZHRoPSIxMSIgbWFya2VySGVpZ2h0PSIxMSIgb3JpZW50PSJhdXRvIj48cGF0aCBkPSJNIDEsMSBsIDksOSBNIDEwLDEgbCAtOSw5IiBjbGFzcz0iYXJyb3dNYXJrZXJQYXRoIiBzdHlsZT0ic3Ryb2tlLXdpZHRoOiAyOyBzdHJva2UtZGFzaGFycmF5OiAxLCAwOyI+PC9wYXRoPjwvbWFya2VyPjxtYXJrZXIgaWQ9ImNvbnRhaW5lcl9ibG9jay1jcm9zc1N0YXJ0IiBjbGFzcz0ibWFya2VyIGNyb3NzIGJsb2NrIiB2aWV3Qm94PSIwIDAgMTEgMTEiIHJlZlg9Ii0xIiByZWZZPSI1LjIiIG1hcmtlclVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgbWFya2VyV2lkdGg9IjExIiBtYXJrZXJIZWlnaHQ9IjExIiBvcmllbnQ9ImF1dG8iPjxwYXRoIGQ9Ik0gMSwxIGwgOSw5IE0gMTAsMSBsIC05LDkiIGNsYXNzPSJhcnJvd01hcmtlclBhdGgiIHN0eWxlPSJzdHJva2Utd2lkdGg6IDI7IHN0cm9rZS1kYXNoYXJyYXk6IDEsIDA7Ij48L3BhdGg+PC9tYXJrZXI+PGcgY2xhc3M9ImJsb2NrIj48ZyBjbGFzcz0ibm9kZSBkZWZhdWx0IGRlZmF1bHQgZmxvd2NoYXJ0LWxhYmVsIiBpZD0iZG9jIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgzMzguNzk2ODc1LCAtMTUzKSI+PHBvbHlnb24gcG9pbnRzPSItMTMuNSwwIDgyLjA0Njg3NSwwIDgyLjA0Njg3NSwtMjcgLTEzLjUsLTI3IDAsLTEzLjUiIGNsYXNzPSJsYWJlbC1jb250YWluZXIiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC00MS4wMjM0Mzc1LDEzLjUpIiBzdHlsZT0iIj48L3BvbHlnb24+PGcgY2xhc3M9ImxhYmVsIiBzdHlsZT0iIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMzcuMDIzNDM3NSwgLTkuNSkiPjxyZWN0PjwvcmVjdD48Zm9yZWlnbk9iamVjdCB3aWR0aD0iNzQuMDQ2ODc1IiBoZWlnaHQ9IjE5Ij48ZGl2IHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hodG1sIiBzdHlsZT0iZGlzcGxheTogaW5saW5lLWJsb2NrOyB3aGl0ZS1zcGFjZTogbm93cmFwOyI+PHNwYW4gY2xhc3M9Im5vZGVMYWJlbCI+RG9jdW1lbnQ8L3NwYW4+PC9kaXY+PC9mb3JlaWduT2JqZWN0PjwvZz48L2c+PGcgY2xhc3M9Im5vZGUgZGVmYXVsdCBkZWZhdWx0IGZsb3djaGFydC1sYWJlbCIgaWQ9ImRvd24xIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgzMzguNzk2ODc1LCAtMTAyKSI+PHBvbHlnb24gcG9pbnRzPSIxMiwwIDAsLTQgOCwtNCA4LC0xMiAxNiwtMTIgMTYsLTQgMjQsLTQiIGNsYXNzPSJsYWJlbC1jb250YWluZXIiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0xMiw4KSIgc3R5bGU9IiI+PC9wb2x5Z29uPjxnIGNsYXNzPSJsYWJlbCIgc3R5bGU9IiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCwgMCkiPjxyZWN0PjwvcmVjdD48Zm9yZWlnbk9iamVjdCB3aWR0aD0iMCIgaGVpZ2h0PSIwIj48ZGl2IHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hodG1sIiBzdHlsZT0iZGlzcGxheTogaW5saW5lLWJsb2NrOyB3aGl0ZS1zcGFjZTogbm93cmFwOyI+PHNwYW4gY2xhc3M9Im5vZGVMYWJlbCI+IDwvc3Bhbj48L2Rpdj48L2ZvcmVpZ25PYmplY3Q+PC9nPjwvZz48ZyBjbGFzcz0ibm9kZSBkZWZhdWx0IGRlZmF1bHQgZmxvd2NoYXJ0LWxhYmVsIiBpZD0iZSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMzM4Ljc5Njg3NSwgLTUxKSI+PHJlY3QgY2xhc3M9ImJhc2ljIGNsdXN0ZXIgY29tcG9zaXRlIGxhYmVsLWNvbnRhaW5lciIgc3R5bGU9IiIgcng9IjAiIHJ5PSIwIiB4PSItMzM4Ljc5Njg3NSIgeT0iLTIxLjUiIHdpZHRoPSI2NzcuNTkzNzUiIGhlaWdodD0iNDMiPjwvcmVjdD48ZyBjbGFzcz0ibGFiZWwiIHN0eWxlPSIiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAsIDApIj48cmVjdD48L3JlY3Q+PGZvcmVpZ25PYmplY3Qgd2lkdGg9IjAiIGhlaWdodD0iMCI+PGRpdiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94aHRtbCIgc3R5bGU9ImRpc3BsYXk6IGlubGluZS1ibG9jazsgd2hpdGUtc3BhY2U6IG5vd3JhcDsiPjxzcGFuIGNsYXNzPSJub2RlTGFiZWwiPjwvc3Bhbj48L2Rpdj48L2ZvcmVpZ25PYmplY3Q+PC9nPjwvZz48ZyBjbGFzcz0ibm9kZSBkZWZhdWx0IGRlZmF1bHQgZmxvd2NoYXJ0LWxhYmVsIiBpZD0ibCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMTE1LjU5ODk1ODMzMzMzMzMzLCAtNTEpIj48cmVjdCBjbGFzcz0iYmFzaWMgbGFiZWwtY29udGFpbmVyIiBzdHlsZT0iIiByeD0iMCIgcnk9IjAiIHg9Ii0xMDcuNTk4OTU4MzMzMzMzMzMiIHk9Ii0xMy41IiB3aWR0aD0iMjE1LjE5NzkxNjY2NjY2NjY2IiBoZWlnaHQ9IjI3Ij48L3JlY3Q+PGcgY2xhc3M9ImxhYmVsIiBzdHlsZT0iIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTIuMjM0Mzc1LCAtOS41KSI+PHJlY3Q+PC9yZWN0Pjxmb3JlaWduT2JqZWN0IHdpZHRoPSIyNC40Njg3NSIgaGVpZ2h0PSIxOSI+PGRpdiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94aHRtbCIgc3R5bGU9ImRpc3BsYXk6IGlubGluZS1ibG9jazsgd2hpdGUtc3BhY2U6IG5vd3JhcDsiPjxzcGFuIGNsYXNzPSJub2RlTGFiZWwiPmxlZnQ8L3NwYW4+PC9kaXY+PC9mb3JlaWduT2JqZWN0PjwvZz48L2c+PGcgY2xhc3M9Im5vZGUgZGVmYXVsdCBkZWZhdWx0IGZsb3djaGFydC1sYWJlbCIgaWQ9Im0iIHRyYW5zZm9ybT0idHJhbnNsYXRlKDMzOC43OTY4NzUsIC01MSkiPjxyZWN0IGNsYXNzPSJiYXNpYyBsYWJlbC1jb250YWluZXIiIHN0eWxlPSJmaWxsOiNkNmQ7c3Ryb2tlOiMzMzM7c3Ryb2tlLXdpZHRoOjRweDsiIHJ4PSI1IiByeT0iNSIgeD0iLTEwNy41OTg5NTgzMzMzMzMzMyIgeT0iLTEzLjUiIHdpZHRoPSIyMTUuMTk3OTE2NjY2NjY2NjYiIGhlaWdodD0iMjciPjwvcmVjdD48ZyBjbGFzcz0ibGFiZWwiIHN0eWxlPSIiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC05MC4yNjU2MjUsIC05LjUpIj48cmVjdD48L3JlY3Q+PGZvcmVpZ25PYmplY3Qgd2lkdGg9IjE4MC41MzEyNSIgaGVpZ2h0PSIxOSI+PGRpdiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94aHRtbCIgc3R5bGU9ImRpc3BsYXk6IGlubGluZS1ibG9jazsgd2hpdGUtc3BhY2U6IG5vd3JhcDsiPjxzcGFuIGNsYXNzPSJub2RlTGFiZWwiPkEgd2lkZSBvbmUgaW4gdGhlIG1pZGRsZTwvc3Bhbj48L2Rpdj48L2ZvcmVpZ25PYmplY3Q+PC9nPjwvZz48ZyBjbGFzcz0ibm9kZSBkZWZhdWx0IGRlZmF1bHQgZmxvd2NoYXJ0LWxhYmVsIiBpZD0iciIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNTYxLjk5NDc5MTY2NjY2NjYsIC01MSkiPjxyZWN0IGNsYXNzPSJiYXNpYyBsYWJlbC1jb250YWluZXIiIHN0eWxlPSIiIHJ4PSIwIiByeT0iMCIgeD0iLTEwNy41OTg5NTgzMzMzMzMzMyIgeT0iLTEzLjUiIHdpZHRoPSIyMTUuMTk3OTE2NjY2NjY2NjYiIGhlaWdodD0iMjciPjwvcmVjdD48ZyBjbGFzcz0ibGFiZWwiIHN0eWxlPSIiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0xNy4wODU5Mzc1LCAtOS41KSI+PHJlY3Q+PC9yZWN0Pjxmb3JlaWduT2JqZWN0IHdpZHRoPSIzNC4xNzE4NzUiIGhlaWdodD0iMTkiPjxkaXYgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGh0bWwiIHN0eWxlPSJkaXNwbGF5OiBpbmxpbmUtYmxvY2s7IHdoaXRlLXNwYWNlOiBub3dyYXA7Ij48c3BhbiBjbGFzcz0ibm9kZUxhYmVsIj5yaWdodDwvc3Bhbj48L2Rpdj48L2ZvcmVpZ25PYmplY3Q+PC9nPjwvZz48ZyBjbGFzcz0ibm9kZSBkZWZhdWx0IGRlZmF1bHQgZmxvd2NoYXJ0LWxhYmVsIiBpZD0iZG93bjIiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDMzOC43OTY4NzUsIDApIj48cG9seWdvbiBwb2ludHM9IjEyLDAgMCwtNCA4LC00IDgsLTEyIDE2LC0xMiAxNiwtNCAyNCwtNCIgY2xhc3M9ImxhYmVsLWNvbnRhaW5lciIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTEyLDgpIiBzdHlsZT0iIj48L3BvbHlnb24+PGcgY2xhc3M9ImxhYmVsIiBzdHlsZT0iIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgwLCAwKSI+PHJlY3Q+PC9yZWN0Pjxmb3JlaWduT2JqZWN0IHdpZHRoPSIwIiBoZWlnaHQ9IjAiPjxkaXYgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGh0bWwiIHN0eWxlPSJkaXNwbGF5OiBpbmxpbmUtYmxvY2s7IHdoaXRlLXNwYWNlOiBub3dyYXA7Ij48c3BhbiBjbGFzcz0ibm9kZUxhYmVsIj4gPC9zcGFuPjwvZGl2PjwvZm9yZWlnbk9iamVjdD48L2c+PC9nPjxnIGNsYXNzPSJub2RlIGRlZmF1bHQgZGVmYXVsdCBmbG93Y2hhcnQtbGFiZWwiIGlkPSJkYiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMzM4Ljc5Njg3NSwgNTEpIj48cGF0aCBzdHlsZT0iIiBkPSJNIDAsNC41ODE0MTkwOTEzNzMxNDkgYSAxNC4wMjM0Mzc1LDQuNTgxNDE5MDkxMzczMTQ5IDAsMCwwIDI4LjA0Njg3NSAwIGEgMTQuMDIzNDM3NSw0LjU4MTQxOTA5MTM3MzE0OSAwLDAsMCAtMjguMDQ2ODc1IDAgbCAwLDMxLjU4MTQxOTA5MTM3MzE1IGEgMTQuMDIzNDM3NSw0LjU4MTQxOTA5MTM3MzE0OSAwLDAsMCAyOC4wNDY4NzUgMCBsIDAsLTMxLjU4MTQxOTA5MTM3MzE1IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMTQuMDIzNDM3NSwtMjAuMzcyMTI4NjM3MDU5NzI0KSI+PC9wYXRoPjxnIGNsYXNzPSJsYWJlbCIgc3R5bGU9IiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTEwLjAyMzQzNzUsIC05LjUpIj48cmVjdD48L3JlY3Q+PGZvcmVpZ25PYmplY3Qgd2lkdGg9IjIwLjA0Njg3NSIgaGVpZ2h0PSIxOSI+PGRpdiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94aHRtbCIgc3R5bGU9ImRpc3BsYXk6IGlubGluZS1ibG9jazsgd2hpdGUtc3BhY2U6IG5vd3JhcDsiPjxzcGFuIGNsYXNzPSJub2RlTGFiZWwiPkRCPC9zcGFuPjwvZGl2PjwvZm9yZWlnbk9iamVjdD48L2c+PC9nPjxnIGNsYXNzPSJub2RlIGRlZmF1bHQgZGVmYXVsdCBmbG93Y2hhcnQtbGFiZWwiIGlkPSJEIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgxMTAuMjY1NjI1LCAxNTMpIj48cmVjdCBjbGFzcz0iYmFzaWMgbGFiZWwtY29udGFpbmVyIiBzdHlsZT0iIiByeD0iMCIgcnk9IjAiIHg9Ii0xMTAuMjY1NjI1IiB5PSItMjEuNSIgd2lkdGg9IjIyMC41MzEyNSIgaGVpZ2h0PSI0MyI+PC9yZWN0PjxnIGNsYXNzPSJsYWJlbCIgc3R5bGU9IiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTUuMTU2MjUsIC05LjUpIj48cmVjdD48L3JlY3Q+PGZvcmVpZ25PYmplY3Qgd2lkdGg9IjEwLjMxMjUiIGhlaWdodD0iMTkiPjxkaXYgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGh0bWwiIHN0eWxlPSJkaXNwbGF5OiBpbmxpbmUtYmxvY2s7IHdoaXRlLXNwYWNlOiBub3dyYXA7Ij48c3BhbiBjbGFzcz0ibm9kZUxhYmVsIj5EPC9zcGFuPjwvZGl2PjwvZm9yZWlnbk9iamVjdD48L2c+PC9nPjxnIGNsYXNzPSJub2RlIGRlZmF1bHQgZGVmYXVsdCBmbG93Y2hhcnQtbGFiZWwiIGlkPSJDIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSg1NjcuMzI4MTI1LCAxNTMpIj48cmVjdCBjbGFzcz0iYmFzaWMgbGFiZWwtY29udGFpbmVyIiBzdHlsZT0iIiByeD0iMCIgcnk9IjAiIHg9Ii0xMTAuMjY1NjI1IiB5PSItMjEuNSIgd2lkdGg9IjIyMC41MzEyNSIgaGVpZ2h0PSI0MyI+PC9yZWN0PjxnIGNsYXNzPSJsYWJlbCIgc3R5bGU9IiIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTQuNDg0Mzc1LCAtOS41KSI+PHJlY3Q+PC9yZWN0Pjxmb3JlaWduT2JqZWN0IHdpZHRoPSI4Ljk2ODc1IiBoZWlnaHQ9IjE5Ij48ZGl2IHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hodG1sIiBzdHlsZT0iZGlzcGxheTogaW5saW5lLWJsb2NrOyB3aGl0ZS1zcGFjZTogbm93cmFwOyI+PHNwYW4gY2xhc3M9Im5vZGVMYWJlbCI+Qzwvc3Bhbj48L2Rpdj48L2ZvcmVpZ25PYmplY3Q+PC9nPjwvZz48cGF0aCBkPSJNMzI0Ljc3Myw1Ny4yNTlMMzA4LjA2Niw2NC43MTZDMjkxLjM1OSw3Mi4xNzMsMjU3Ljk0NSw4Ny4wODYsMjMwLjgzMSw5OS4xODhDMjAzLjcxNywxMTEuMjksMTgyLjkwMywxMjAuNTgsMTcyLjQ5NiwxMjUuMjI1TDE2Mi4wODksMTI5Ljg3IiBpZD0iMS1kYi1EIiBjbGFzcz0iZWRnZS10aGlja25lc3Mtbm9ybWFsIGVkZ2UtcGF0dGVybi1zb2xpZCBmbG93Y2hhcnQtbGluayBMUy1hMSBMRS1iMSIgbWFya2VyLWVuZD0idXJsKCNjb250YWluZXJfYmxvY2stcG9pbnRFbmQpIj48L3BhdGg+PHBhdGggZD0iTTUxOS4xNTcsMTMxLjVMNTA4LjE0MiwxMjYuNTgzQzQ5Ny4xMjYsMTIxLjY2Nyw0NzUuMDk0LDExMS44MzMsNDQ3Ljk4LDk5LjczMkM0MjAuODY2LDg3LjYzLDM4OC42Nyw3My4yNiwzNzIuNTcxLDY2LjA3NEwzNTYuNDczLDU4Ljg4OSIgaWQ9IjEtQy1kYiIgY2xhc3M9ImVkZ2UtdGhpY2tuZXNzLW5vcm1hbCBlZGdlLXBhdHRlcm4tc29saWQgZmxvd2NoYXJ0LWxpbmsgTFMtYTEgTEUtYjEiIG1hcmtlci1lbmQ9InVybCgjY29udGFpbmVyX2Jsb2NrLXBvaW50RW5kKSI+PC9wYXRoPjxwYXRoIGQ9Ik0yMjAuNTMxLDE1M0wyNDAuMjQyLDE1M0MyNTkuOTUzLDE1MywyOTkuMzc1LDE1MywzMzguMTMsMTUzQzM3Ni44ODUsMTUzLDQxNC45NzQsMTUzLDQzNC4wMTgsMTUzTDQ1My4wNjMsMTUzIiBpZD0iMS1ELUMiIGNsYXNzPSJlZGdlLXRoaWNrbmVzcy1ub3JtYWwgZWRnZS1wYXR0ZXJuLXNvbGlkIGZsb3djaGFydC1saW5rIExTLWExIExFLWIxIiBtYXJrZXItZW5kPSJ1cmwoI2NvbnRhaW5lcl9ibG9jay1wb2ludEVuZCkiPjwvcGF0aD48L2c+PC9zdmc+"><p>Block Diagram 코드:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line">block-beta</span><br><span class="line">    columns 3</span><br><span class="line">    doc&gt;&quot;Document&quot;]:3</span><br><span class="line">    space down1&lt;[&quot; &quot;](down) space</span><br><span class="line">    </span><br><span class="line">    block:e:3</span><br><span class="line">          l[&quot;left&quot;]</span><br><span class="line">          m(&quot;A wide one in the middle&quot;)</span><br><span class="line">          r[&quot;right&quot;]</span><br><span class="line">    end</span><br><span class="line">    space down2&lt;[&quot; &quot;](down) space</span><br><span class="line">    db[(&quot;DB&quot;)]:3</span><br><span class="line">    space:3</span><br><span class="line">    D space C</span><br><span class="line">    db --&gt; D</span><br><span class="line">    C --&gt; db</span><br><span class="line">    D --&gt; C</span><br><span class="line">    style m fill:#d6d,stroke:#333,stroke-width:4px</span><br></pre></td></tr></table></figure><p>이 외에도 <strong>시퀀스 다이어그램</strong>, <strong>상태도</strong>, <strong>피에조 차트</strong> 등 다양한 다이어그램을 제공하여 개발뿐 아니라 프로젝트 관리, 사용자 경험 디자인까지 다양한 분야에서 활용할 수 있습니다.</p><h2><span id="mermaid-live-silsigan-daieogeuraem-jagseong">Mermaid Live: 실시간 다이어그램 작성</span><a href="#mermaid-live-silsigan-daieogeuraem-jagseong" class="header-anchor">#</a></h2><p>Mermaid는 <a href="https://mermaid.live/edit"><strong>Mermaid Live Editor</strong></a>라는 웹 기반 도구를 제공하여 실시간으로 다이어그램을 작성하고 미리 볼 수 있습니다. 이 도구를 사용하면 빠르게 다이어그램을 시각화할 수 있으며, 다양한 <strong>템플릿</strong>도 제공되어 누구나 쉽게 시작할 수 있습니다. 복잡한 다이어그램도 빠르게 그릴 수 있기 때문에 협업 시에 유용하게 활용될 수 있습니다.</p><h2><span id="mamuri">마무리</span><a href="#mamuri" class="header-anchor">#</a></h2><p>Mermaid는 코드 기반의 다이어그램 도구로서, 유지보수성, 수정의 용이성, 그리고 프로젝트 문서화의 접근성을 크게 향상시킵니다. Markdown과 통합하여 프로젝트 문서에 쉽게 다이어그램을 포함시킬 수 있으며, 다양한 다이어그램 형식을 통해 개발 과정에서 발생하는 다양한 시각적 요구를 충족시킬 수 있습니다. Mermaid Live를 통해 실시간으로 다이어그램을 작성하고 협업할 수 있는 기능도 매우 유용합니다.</p><p>프로젝트 문서화 작업에서 Mermaid를 활용해 보세요. 코드와 문서의 통합을 통해 더 나은 협업과 유지보수를 경험할 수 있을 것입니다.</p><h2><span id="culceo">출처</span><a href="#culceo" class="header-anchor">#</a></h2><ul><li><a href="https://mermaid.live/">Mermaid Live Editor</a></li></ul>]]></content:encoded>
      
      
      
      <category domain="https://cheese10yun.github.io/tags/Tools/">Tools</category>
      
      
      <comments>https://cheese10yun.github.io/mermaid/#disqus_thread</comments>
      
    </item>
    
    <item>
      <title>Kotlin 객체 생성의 안전성과 유효성 강화하기</title>
      <link>https://cheese10yun.github.io/kotlin-pattern-2/</link>
      <guid>https://cheese10yun.github.io/kotlin-pattern-2/</guid>
      <pubDate>Fri, 01 Nov 2024 15:00:00 GMT</pubDate>
      
      <description>코틀린에서 객체 생성과 값 타입 관리의 안전성을 강화하는 다양한 방법에 대해 알아보겠습니다.</description>
      
      
      
      <content:encoded><![CDATA[<p>코틀린에서 객체 생성과 값 타입 관리의 안전성을 강화하는 다양한 방법에 대해 알아보겠습니다. 이번 포스팅에서는 <code>User</code>라는 엔티티 클래스를 예로 들어 생성자 제약 사항을 안전하게 처리하는 방법과, <code>value class</code>를 활용해 값 타입을 효율적으로 관리하는 방법에 대해 설명하겠습니다.</p><h2><span id="gijon-saengseongja-kodeuyi-munjejeom">기존 생성자 코드의 문제점</span><a href="#gijon-saengseongja-kodeuyi-munjejeom" class="header-anchor">#</a></h2><p>아래는 간단한 <code>User</code> 클래스입니다. 이 클래스는 JPA 엔티티로 정의되어 있으며, 데이터베이스와 매핑되는 사용자 정보를 나타냅니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Entity(name = <span class="string">&quot;user&quot;</span>)</span></span><br><span class="line"><span class="meta">@Table(name = <span class="string">&quot;user&quot;</span>)</span></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">User</span>(</span><br><span class="line">    <span class="meta">@Id</span></span><br><span class="line">    <span class="meta">@GeneratedValue(strategy = GenerationType.IDENTITY)</span></span><br><span class="line">    <span class="keyword">var</span> id: <span class="built_in">Long</span>? = <span class="literal">null</span>,</span><br><span class="line">    <span class="keyword">var</span> name: String,</span><br><span class="line">    <span class="keyword">val</span> email: String,</span><br><span class="line">    <span class="meta">@Enumerated(EnumType.STRING)</span></span><br><span class="line">    <span class="keyword">var</span> stats: UserStats</span><br><span class="line">)</span><br></pre></td></tr></table></figure><p>위 코드를 보면 객체 생성 시 다음과 같은 문제가 발생할 수 있습니다:</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 잘못된 객체 생성 예시</span></span><br><span class="line"><span class="keyword">val</span> user = User(</span><br><span class="line">    id = <span class="number">1L</span>,  <span class="comment">// 외부에서 설정하면 안 됨</span></span><br><span class="line">    name = <span class="string">&quot;  John Doe  &quot;</span>,  <span class="comment">// 공백 제거 불가</span></span><br><span class="line">    email = <span class="string">&quot;  john.doe@example.com  &quot;</span>,  <span class="comment">// 공백 제거 불가</span></span><br><span class="line">    stats = UserStats.ACTIVE  <span class="comment">// 생성 시 초기값은 무조건 NORMAL이어야 함</span></span><br><span class="line">)</span><br></pre></td></tr></table></figure><ol><li><code>id</code>는 데이터베이스에서 자동으로 생성되기 때문에, 외부에서 객체 생성 시 값을 전달하면 안 됩니다.</li><li><code>stats</code> 필드는 초기 생성 시 무조건 <code>UserStats.NORMAL</code>이어야 하므로, 외부에서 제어권을 가지면 안 됩니다.</li><li><code>name</code>과 <code>email</code> 필드는 공백을 제거해야 하는데, 현재 생성자로는 이러한 처리가 어렵습니다.</li><li>그 외에도 객체 생성 시 내부적으로 유효성 검사를 하는 것이 제한적입니다.</li></ol><p>이러한 문제들을 해결하기 위해, 우리는 안전한 객체 생성 방식을 도입할 수 있습니다.</p><h4><span id="companion-objectreul-hwalyonghan-gaegce-saengseong-anjeonseong-ganghwa">Companion Object를 활용한 객체 생성 안전성 강화</span><a href="#companion-objectreul-hwalyonghan-gaegce-saengseong-anjeonseong-ganghwa" class="header-anchor">#</a></h4><p>이 문제를 해결하기 위해, <code>companion object</code>와 <code>operator fun invoke</code>를 활용한 객체 생성 방식을 제안합니다. 아래는 수정된 코드입니다:</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">class</span> <span class="title class_">User</span> <span class="keyword">private</span> <span class="keyword">constructor</span>(</span><br><span class="line">    <span class="keyword">var</span> id: <span class="built_in">Long</span>? = <span class="literal">null</span>,</span><br><span class="line">    <span class="keyword">var</span> name: String,</span><br><span class="line">    <span class="keyword">val</span> email: String,</span><br><span class="line">    <span class="meta">@Enumerated(EnumType.STRING)</span></span><br><span class="line">    <span class="keyword">var</span> stats: UserStats</span><br><span class="line">) &#123;</span><br><span class="line">    <span class="keyword">companion</span> <span class="keyword">object</span> &#123;</span><br><span class="line">        <span class="keyword">operator</span> <span class="function"><span class="keyword">fun</span> <span class="title">invoke</span><span class="params">(</span></span></span><br><span class="line"><span class="params"><span class="function">            name: <span class="type">String</span>,</span></span></span><br><span class="line"><span class="params"><span class="function">            email: <span class="type">String</span></span></span></span><br><span class="line"><span class="params"><span class="function">        )</span></span>: User &#123;</span><br><span class="line">            <span class="comment">// 유효성 검사 로직 추가</span></span><br><span class="line">            require(name.isNotBlank()) &#123; <span class="string">&quot;Name must not be blank&quot;</span> &#125;</span><br><span class="line">            require(email.isNotBlank()) &#123; <span class="string">&quot;Email must not be blank&quot;</span> &#125;</span><br><span class="line"></span><br><span class="line">            <span class="keyword">return</span> User(</span><br><span class="line">                name = name.trim(),</span><br><span class="line">                email = email.trim(),</span><br><span class="line">                id = <span class="literal">null</span>,</span><br><span class="line">                stats = UserStats.NORMAL</span><br><span class="line">            )</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>이제 외부에서 객체를 생성할 때는 다음과 같이 사용할 수 있습니다:</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 안전한 객체 생성 예시</span></span><br><span class="line"><span class="keyword">val</span> user = User(</span><br><span class="line">    name = <span class="string">&quot;  John Doe  &quot;</span>,</span><br><span class="line">    email = <span class="string">&quot;  john.doe@example.com  &quot;</span></span><br><span class="line">)</span><br></pre></td></tr></table></figure><p>위 코드는 기존의 생성자 호출 방식과 동일하게 사용할 수 있지만, 내부적으로는 안전한 로직을 적용하여 객체를 생성하도록 유도합니다. 이를 통해 불필요한 오류를 방지하고 객체 생성 과정을 간소화할 수 있습니다.</p><h3><span id="juyo-gaeseon-sahang">주요 개선 사항</span><a href="#juyo-gaeseon-sahang" class="header-anchor">#</a></h3><ol><li><strong>기본 생성자 막기</strong>: <code>private constructor</code>를 사용해 기본 생성자를 외부에서 호출하지 못하게 막았습니다. 이를 통해 객체 생성을 <code>companion object</code> 내부의 로직으로만 유도할 수 있게 되었습니다.</li><li><strong>invoke 연산자 활용</strong>: <code>companion object</code>에 <code>operator fun invoke</code>를 정의하여 객체 생성 시 사용자가 보다 명확한 API를 사용할 수 있게 했습니다. 이는 기존의 생성자 호출 방식과 동일하게 사용할 수 있으면서도 <code>name</code>과 <code>email</code>의 앞뒤 공백을 자동으로 제거하고, <code>stats</code> 필드는 무조건 <code>UserStats.NORMAL</code>로 설정하게 되었습니다.</li><li><strong>안전한 필드 초기화</strong>: <code>id</code>와 같이 외부에서 설정되면 안 되는 필드는 생성자에서 제외하여, 개발자가 실수로 이를 설정하는 것을 방지했습니다.</li></ol><h2><span id="value-classreul-hwalyonghan-gabs-taib-gwanri">Value Class를 활용한 값 타입 관리</span><a href="#value-classreul-hwalyonghan-gabs-taib-gwanri" class="header-anchor">#</a></h2><p>또한, 이메일과 같은 값 타입을 별도로 관리하기 위해 코틀린의 <code>value class</code>를 활용하는 것도 좋은 방법입니다. 아래는 <code>Email</code>이라는 값 클래스를 정의하고 사용하는 예시입니다:</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@JvmInline</span></span><br><span class="line">value <span class="keyword">class</span> <span class="title class_">Email</span>(<span class="keyword">val</span> value: String) &#123;</span><br><span class="line">    <span class="keyword">init</span> &#123;</span><br><span class="line">        require(value.isNotBlank()) &#123; <span class="string">&quot;Email must not be blank&quot;</span> &#125;</span><br><span class="line">        require(value.contains(<span class="string">&quot;@&quot;</span>)) &#123; <span class="string">&quot;Email must contain &#x27;@&#x27;&quot;</span> &#125;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3><span id="email-value-class-jeogyong">Email Value Class 적용</span><a href="#email-value-class-jeogyong" class="header-anchor">#</a></h3><p>위에서 정의한 <code>Email</code> 클래스를 <code>User</code> 클래스와 <code>Order</code> 클래스에 적용해보겠습니다:</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br><span class="line">45</span><br><span class="line">46</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">class</span> <span class="title class_">User</span> <span class="keyword">private</span> <span class="keyword">constructor</span>(</span><br><span class="line">    <span class="keyword">var</span> id: <span class="built_in">Long</span>? = <span class="literal">null</span>,</span><br><span class="line">    <span class="keyword">var</span> name: String,</span><br><span class="line">    <span class="keyword">val</span> email: Email,</span><br><span class="line">    <span class="keyword">var</span> stats: UserStats</span><br><span class="line">) &#123;</span><br><span class="line">    <span class="keyword">companion</span> <span class="keyword">object</span> &#123;</span><br><span class="line">        <span class="keyword">operator</span> <span class="function"><span class="keyword">fun</span> <span class="title">invoke</span><span class="params">(</span></span></span><br><span class="line"><span class="params"><span class="function">            name: <span class="type">String</span>,</span></span></span><br><span class="line"><span class="params"><span class="function">            email: <span class="type">String</span></span></span></span><br><span class="line"><span class="params"><span class="function">        )</span></span>: User &#123;</span><br><span class="line">            <span class="comment">// 유효성 검사 로직 추가</span></span><br><span class="line">            require(name.isNotBlank()) &#123; <span class="string">&quot;Name must not be blank&quot;</span> &#125;</span><br><span class="line"></span><br><span class="line">            <span class="keyword">return</span> User(</span><br><span class="line">                name = name.trim(),</span><br><span class="line">                email = Email(email),</span><br><span class="line">                id = <span class="literal">null</span>,</span><br><span class="line">                stats = UserStats.NORMAL</span><br><span class="line">            )</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// Order 클래스 정의</span></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">Order</span> <span class="keyword">private</span> <span class="keyword">constructor</span>(</span><br><span class="line">    <span class="keyword">var</span> id: <span class="built_in">Long</span>? = <span class="literal">null</span>,</span><br><span class="line">    <span class="keyword">val</span> email: Email,</span><br><span class="line">    <span class="keyword">var</span> amount: <span class="built_in">Double</span></span><br><span class="line">) &#123;</span><br><span class="line">    <span class="keyword">companion</span> <span class="keyword">object</span> &#123;</span><br><span class="line">        <span class="keyword">operator</span> <span class="function"><span class="keyword">fun</span> <span class="title">invoke</span><span class="params">(</span></span></span><br><span class="line"><span class="params"><span class="function">            email: <span class="type">String</span>,</span></span></span><br><span class="line"><span class="params"><span class="function">            amount: <span class="type">Double</span></span></span></span><br><span class="line"><span class="params"><span class="function">        )</span></span>: Order &#123;</span><br><span class="line">            <span class="comment">// 유효성 검사 로직 추가</span></span><br><span class="line">            require(amount &gt; <span class="number">0</span>) &#123; <span class="string">&quot;Amount must be greater than zero&quot;</span> &#125;</span><br><span class="line"></span><br><span class="line">            <span class="keyword">return</span> Order(</span><br><span class="line">                email = Email(email),</span><br><span class="line">                id = <span class="literal">null</span>,</span><br><span class="line">                amount = amount</span><br><span class="line">            )</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3><span id="value-classreul-tonghan-gaegce-gwanriyi-ijeom">Value Class를 통한 객체 관리의 이점</span><a href="#value-classreul-tonghan-gaegce-gwanriyi-ijeom" class="header-anchor">#</a></h3><ol><li><strong>명확한 타입 정의</strong>: <code>Email</code>이라는 값 클래스를 사용함으로써, 이메일 주소를 단순히 문자열로 다루는 대신 명확한 타입으로 관리할 수 있습니다. 이를 통해 이메일 값이 기대하는 형태로만 사용되도록 강제할 수 있습니다.</li><li><strong>유효성 검사 통합</strong>: 이메일과 관련된 유효성 검사 로직을 <code>Email</code> 클래스 내부에 정의함으로써, 이메일이 생성되는 모든 곳에서 일관된 유효성 검사를 적용할 수 있습니다. 이를 통해 코드 중복을 줄이고 유지보수성을 높일 수 있습니다.</li><li><strong>가독성 향상</strong>: 이메일과 같은 값 타입을 별도로 정의함으로써, 코드의 가독성을 높이고, 객체의 의미를 보다 명확하게 전달할 수 있습니다. 이를 통해 코드가 더 직관적이며 이해하기 쉬워집니다.</li><li><strong>재사용성</strong>: <code>Email</code> 클래스는 <code>User</code> 클래스뿐만 아니라, 예를 들어 <code>Order</code>와 같은 다른 도메인 객체에서도 재사용할 수 있습니다. 이를 통해 일관된 이메일 관리와 유효성 검사를 보장하며, 코드 중복을 최소화할 수 있습니다. 이렇게 공통적으로 사용되는 값 타입을 재사용함으로써 코드의 유지보수성과 확장성을 크게 향상시킬 수 있습니다.</li></ol><h3><span id="value-class-sayong-si-juyi-sahang">Value Class 사용 시 주의 사항</span><a href="#value-class-sayong-si-juyi-sahang" class="header-anchor">#</a></h3><p><code>value class</code>로 감싼 타입은 JPA나 MongoDB와 같은 데이터베이스 접근 라이브러리를 사용할 때 자동으로 매핑되지 않기 때문에, 적절한 컨버터를 정의해줘야 합니다. 예를 들어, JPA에서는 <code>AttributeConverter</code>를 사용하여 <code>Email</code> 클래스를 문자열로 변환하고 다시 복원할 수 있는 컨버터를 구현해야 합니다.</p><h2><span id="gyeolron">결론</span><a href="#gyeolron" class="header-anchor">#</a></h2><p>코틀린에서 객체를 생성하는 다양한 방법이 있지만, 제약 사항이 많은 경우에는 <code>companion object</code>와 <code>invoke</code> 연산자를 활용하는 방식이 매우 유용합니다. 또한, 값 타입을 별도로 관리하기 위해 <code>value class</code>를 사용하는 것도 좋은 접근법입니다. 이를 통해 외부에서 설정하지 말아야 할 필드를 보호하고, 객체 생성 시 발생할 수 있는 오류를 줄일 수 있으며, 값 타입에 대한 유효성 검사와 관리도 일관되게 할 수 있습니다.</p><p>여러분도 코틀린을 사용할 때, 이러한 안전한 객체 생성 방식과 값 타입 관리를 적용해보세요! 필요에 따라 객체의 생성 로직을 유연하고 안전하게 제어할 수 있을 것입니다.</p>]]></content:encoded>
      
      
      
      <category domain="https://cheese10yun.github.io/tags/Kotlin/">Kotlin</category>
      
      
      <comments>https://cheese10yun.github.io/kotlin-pattern-2/#disqus_thread</comments>
      
    </item>
    
    <item>
      <title>HikariCP로 MySQL Connection Pool 최적화하기</title>
      <link>https://cheese10yun.github.io/mysql-connection-pool-timeout-1/</link>
      <guid>https://cheese10yun.github.io/mysql-connection-pool-timeout-1/</guid>
      <pubDate>Fri, 25 Oct 2024 15:00:00 GMT</pubDate>
      
      <description>Spring Boot에서 HikariCP를 활용해 MySQL 커넥션 풀의 성능 한계를 극복하고, TPS 변화에 따라 효율적으로 대응하는 설정 방법을 설명합니다.</description>
      
      
      
      <content:encoded><![CDATA[<p>Spring Boot는 기본적으로 HikariCP를 내장된 커넥션 풀로 지원하며, 이를 통해 데이터베이스 연결을 효율적으로 관리할 수 있습니다. 이번 포스팅에서는 Spring Boot 환경에서 HikariCP의 설정을 최적화하여 TPS 변화에 유연하게 대응하는 방법을 알아보겠습니다.</p><h2><span id="hikaricp-seoljeong-yesi">HikariCP 설정 예시</span><a href="#hikaricp-seoljeong-yesi" class="header-anchor">#</a></h2><p>Spring Boot에서 application.yml 또는 application.properties 파일을 통해 HikariCP 설정을 정의할 수 있습니다. 이번 예시에서는 다음과 같은 설정을 적용하였습니다.</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">spring:</span></span><br><span class="line">    <span class="attr">datasource:</span></span><br><span class="line">        <span class="attr">hikari:</span></span><br><span class="line">            <span class="attr">minimum-idle:</span> <span class="number">10</span></span><br><span class="line">            <span class="attr">maximum-pool-size:</span> <span class="number">10</span></span><br><span class="line">            <span class="attr">idle-timeout:</span> <span class="number">30000</span></span><br><span class="line">            <span class="attr">connection-timeout:</span> <span class="number">20000</span></span><br></pre></td></tr></table></figure><ul><li><strong>minimum-idle</strong>: 최소 유휴 커넥션 수입니다. 초기 설정 시 최소한의 커넥션(여기서는 10개)만 유지하여, TPS가 낮을 때 리소스를 절약할 수 있습니다.</li><li><strong>maximum-pool-size</strong>: 커넥션 풀의 최대 크기입니다. TPS가 높아질 때 최대 10개의 커넥션까지 생성하여 요청을 처리할 수 있게 설정합니다.</li><li><strong>idle-timeout</strong>: 지정된 시간(밀리초) 동안 유휴 상태인 커넥션이 있을 경우 풀에서 제거합니다. 트래픽이 낮아질 때 자동으로 풀 크기를 줄이는 데 기여합니다.</li><li><strong>connection-timeout</strong>: 커넥션을 얻기 위해 대기하는 최대 시간입니다. 이 시간 내에 커넥션을 확보하지 못하면 예외가 발생합니다.</li></ul><h2><span id="tps-byeonhwae-ddareun-keonegsyeon-pulyi-dongjag">TPS 변화에 따른 커넥션 풀의 동작</span><a href="#tps-byeonhwae-ddareun-keonegsyeon-pulyi-dongjag" class="header-anchor">#</a></h2><ol><li><strong>TPS가 낮은 경우</strong>: Spring Boot 애플리케이션이 유휴 상태이거나 트래픽이 적은 경우, HikariCP는 최소 커넥션(minimum-idle)만 유지하여 리소스 사용을 최적화합니다.</li><li><strong>TPS가 높아질 경우</strong>: TPS가 증가하여 커넥션이 필요한 상황이 되면, HikariCP는 최대 커넥션(maximum-pool-size)까지 확장하여 대량의 요청을 처리할 수 있게 합니다. 이를 통해 성능 저하 없이 안정적으로 트래픽을 소화할 수 있습니다.</li><li><strong>TPS가 다시 낮아지는 경우</strong>: TPS가 다시 낮아지면 HikariCP는 <code>idle-timeout</code>에 따라 불필요한 커넥션을 풀에서 제거하고, <code>minimum-idle</code>만 유지하여 리소스를 절약합니다.</li></ol><p>이 케이스는 MySQL Connection Pool에서 <code>minimum-idle</code>과 <code>maximum-pool-size</code>를 동일하게 설정한 상황에서, TPS가 200대에 도달할 때 발생하는 성능 문제를 다루고 있습니다. 그래프와 로그를 바탕으로 아래와 같이 분석할 수 있습니다.</p><h2><span id="tpsga-nopajineun-sanghwangeseoyi-keonegsyeon-pul-dongjag-bunseog">TPS가 높아지는 상황에서의 커넥션 풀 동작 분석</span><a href="#tpsga-nopajineun-sanghwangeseoyi-keonegsyeon-pul-dongjag-bunseog" class="header-anchor">#</a></h2><p>Spring Boot 애플리케이션에서 TPS가 높아질 때, HikariCP의 커넥션 풀이 어떻게 반응하고 성능에 어떤 영향을 미치는지 살펴보겠습니다. 이 테스트는 <code>minimum-idle: 10</code>과 <code>maximum-pool-size: 10</code> 설정을 사용해, 커넥션 풀의 확장성과 한계점을 확인하는 데 중점을 두었습니다.</p><p>애플리케이션은 지속적으로 증가하는 사용자 요청을 처리하며, TPS가 증가함에 따라 커넥션 풀이 최대에 도달하는 시점에서 성능 지연과 요청 실패가 발생하는 과정을 시각적으로 분석했습니다.</p><h3><span id="sanghwang-seolmyeong">상황 설명</span><a href="#sanghwang-seolmyeong" class="header-anchor">#</a></h3><p>다음 코드는 Spring Boot와 Kotlin 환경에서 설정된 컨트롤러와 서비스 로직입니다. 컨트롤러에서는 <code>SampleService</code>의 <code>getMember()</code> 메서드를 호출하며, 이 메서드는 1~100 사이의 랜덤 ID로 <code>Member</code> 엔티티를 PK를 기반으로 조회한 후, 1초의 지연 시간을 둔 뒤 커넥션 풀의 현재 상태를 로깅합니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@RestController</span></span><br><span class="line"><span class="meta">@RequestMapping</span></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">SampleController</span>(</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">val</span> SampleService: SampleService</span><br><span class="line">) &#123;</span><br><span class="line"></span><br><span class="line">    <span class="meta">@GetMapping(<span class="string">&quot;/api/v1/members&quot;</span>)</span></span><br><span class="line">    <span class="function"><span class="keyword">fun</span> <span class="title">sample</span><span class="params">()</span></span>: Member &#123;</span><br><span class="line">        <span class="comment">// 1 ~ 100 사이의 랜덤으로 member 조회</span></span><br><span class="line">        <span class="keyword">return</span> SampleService.getMember()</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Service</span></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">SampleService</span>(</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">val</span> dataSource: DataSource,</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">val</span> memberRepository: MemberRepository</span><br><span class="line">) &#123;</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">val</span> log = LoggerFactory.getLogger(javaClass)!!</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Transactional</span></span><br><span class="line">    <span class="function"><span class="keyword">fun</span> <span class="title">getMember</span><span class="params">()</span></span>: Member &#123;</span><br><span class="line">        <span class="keyword">val</span> findById = memberRepository.findById(Random.nextInt(<span class="number">1</span>, <span class="number">100</span>).toLong()).<span class="keyword">get</span>()</span><br><span class="line">        runBlocking &#123; delay(<span class="number">1000</span>) &#125;</span><br><span class="line">        <span class="keyword">val</span> targetDataSource = dataSource.unwrap(HikariDataSource::<span class="keyword">class</span>.java)</span><br><span class="line">        <span class="keyword">val</span> hikariDataSource = targetDataSource <span class="keyword">as</span> HikariDataSource</span><br><span class="line">        <span class="keyword">val</span> hikariPoolMXBean = hikariDataSource.hikariPoolMXBean</span><br><span class="line">        <span class="keyword">val</span> hikariConfigMXBean = hikariDataSource.hikariConfigMXBean</span><br><span class="line">        <span class="keyword">val</span> log =</span><br><span class="line">            <span class="string">&quot;&quot;&quot;</span></span><br><span class="line"><span class="string">            totalConnections : <span class="subst">$&#123;hikariPoolMXBean.totalConnections&#125;</span></span></span><br><span class="line"><span class="string">            activeConnections : <span class="subst">$&#123;hikariPoolMXBean.activeConnections&#125;</span></span></span><br><span class="line"><span class="string">            idleConnections : <span class="subst">$&#123;hikariPoolMXBean.idleConnections&#125;</span></span></span><br><span class="line"><span class="string">            threadsAwaitingConnection : <span class="subst">$&#123;hikariPoolMXBean.threadsAwaitingConnection&#125;</span></span></span><br><span class="line"><span class="string">            maxLifetime : <span class="subst">$&#123;hikariConfigMXBean.maxLifetime&#125;</span></span></span><br><span class="line"><span class="string">            maximumPoolSize : <span class="subst">$&#123;hikariConfigMXBean.maximumPoolSize&#125;</span></span></span><br><span class="line"><span class="string">            minimumIdle : <span class="subst">$&#123;hikariConfigMXBean.minimumIdle&#125;</span></span></span><br><span class="line"><span class="string">            connectionTimeout : <span class="subst">$&#123;hikariConfigMXBean.connectionTimeout&#125;</span></span></span><br><span class="line"><span class="string">            validationTimeout : <span class="subst">$&#123;hikariConfigMXBean.validationTimeout&#125;</span></span></span><br><span class="line"><span class="string">            idleTimeout : <span class="subst">$&#123;hikariConfigMXBean.idleTimeout&#125;</span></span></span><br><span class="line"><span class="string">            &quot;&quot;&quot;</span>.trimIndent()</span><br><span class="line">        <span class="keyword">this</span>.log.info(log)</span><br><span class="line">        <span class="keyword">return</span> findById</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>이 코드는 지연을 위해 1초 동안 대기한 후, HikariCP 커넥션 풀의 상태를 로깅하여 현재 커넥션 풀 상황을 모니터링할 수 있게 합니다.</p><h3><span id="seongneung-teseuteu-gyeolgwa-wi-imiji-seolmyeong">성능 테스트 결과 (위 이미지 설명)</span><a href="#seongneung-teseuteu-gyeolgwa-wi-imiji-seolmyeong" class="header-anchor">#</a></h3><p><img src="https://raw.githubusercontent.com/cheese10yun/blog-sample/master/kotlin-coroutine/images/mysql-connection-pool-1-1.png" alt></p><p>위 이미지는 커넥션 풀 설정이 <strong>minimum-idle: 10, maximum-pool-size: 10</strong>으로 설정된 상황에서, TPS가 증가함에 따라 성능이 어떻게 변화하는지를 시각화한 결과입니다.</p><ul><li><strong>Total Requests per Second</strong>:<ul><li>이 그래프는 초당 요청 처리량(RPS, 초록색 라인)과 실패한 요청(Failures, 빨간색 라인)을 보여줍니다.</li><li>TPS가 점진적으로 증가하여 초당 12 요청 수준에 도달했을 때, 실패한 요청이 발생하기 시작했습니다. 이는 커넥션 풀이 최대 용량인 10에 도달하여 더 이상 추가 요청을 처리하지 못하는 상황을 나타냅니다.</li><li>이후 TPS는 유지되지만, 실패한 요청이 지속적으로 발생하면서 커넥션 풀의 제한에 따른 성능 저하가 명확히 드러납니다. 이후 TPS는 응답 지연으로 인해 더 이상 올라가지 않습니다.</li></ul></li><li><strong>Response Times</strong>:<ul><li>응답 시간 그래프에서는 <strong>50th 퍼센타일</strong>(주황색 라인)과 <strong>95th 퍼센타일</strong>(보라색 라인)의 응답 시간이 시간이 지남에 따라 증가하는 모습이 보입니다.</li><li>특히 TPS가 증가함에 따라 95th 퍼센타일 응답 시간은 약 20,000ms 이상으로 치솟아, 사용자 요청이 큰 지연을 겪고 있음을 나타냅니다.</li><li>이는 커넥션 풀이 가득 차서 새로운 요청이 대기 상태로 전환되었기 때문이며, 트래픽 증가와 함께 시스템의 성능 한계에 도달했음을 보여줍니다.</li></ul></li><li><strong>Number of Users</strong>:<ul><li>사용자의 수가 점진적으로 증가하며 시스템에 부하를 가하고 있습니다. 사용자가 약 300명 이상일 때부터 시스템은 커넥션 풀이 한계에 도달하여, 그 이후로는 성능 저하가 본격적으로 발생합니다.</li><li>커넥션 풀 크기를 초과하는 사용자 요청은 실패하거나 긴 대기 시간을 초래하게 되며, 이는 응답 시간 증가와 TPS 유지의 원인이 됩니다.</li></ul></li></ul><p>이 이미지에서는 커넥션 풀이 최대 용량에 도달함에 따라, 시스템이 추가적인 요청을 감당하지 못하고 지연 시간과 실패율이 증가하는 과정을 시각적으로 확인할 수 있습니다.</p><h3><span id="rogeu-bunseog">로그 분석</span><a href="#rogeu-bunseog" class="header-anchor">#</a></h3><table><thead><tr><th>필드</th><th>설명</th></tr></thead><tbody><tr><td>activeConnections</td><td>10 - 현재 활성 상태인 모든 커넥션이 사용 중입니다.</td></tr><tr><td>idleConnections</td><td>0 - 유휴 상태의 커넥션은 없습니다.</td></tr><tr><td>threadsAwaitingConnection</td><td>84 - 84개의 스레드가 커넥션을 기다리고 있습니다.</td></tr><tr><td>maxLifetime</td><td>1800000 (밀리초) - 커넥션의 최대 수명입니다.</td></tr><tr><td>maximumPoolSize</td><td>10 - 최대 커넥션 풀 크기가 10으로 설정되어 있습니다.</td></tr><tr><td>minimumIdle</td><td>10 - 최소 유휴 커넥션이 10으로 설정되어 있습니다.</td></tr><tr><td>connectionTimeout</td><td>30000 (밀리초) - 커넥션을 얻기 위해 대기할 수 있는 최대 시간입니다.</td></tr><tr><td>validationTimeout</td><td>5000 (밀리초) - 커넥션 유효성 검사를 위한 시간입니다.</td></tr><tr><td>idleTimeout</td><td>600000 (밀리초) - 유휴 커넥션을 유지하는 최대 시간입니다.</td></tr></tbody></table><p>이 로그는 커넥션 풀이 한계에 도달하여 더 이상 커넥션을 확장할 수 없고, 여러 스레드가 커넥션을 기다리면서 성능 저하가 발생하고 있음을 보여줍니다.</p><h3><span id="munje-weonin">문제 원인</span><a href="#munje-weonin" class="header-anchor">#</a></h3><ol><li><strong>커넥션 풀 크기 제한</strong>: 현재 <code>maximum-pool-size</code>가 10으로 설정되어 있어 초당 12개 이상의 요청(TPS)을 처리하기에는 커넥션 풀 크기가 부족합니다. 모든 커넥션이 이미 사용 중이기 때문에, 추가적인 요청이 들어오면 커넥션을 기다리게 되고, 이로 인해 요청 실패가 발생하거나 응답 시간이 길어집니다.</li><li><strong>스레드 대기</strong>: 커넥션 풀이 최대 용량에 도달하면서 <code>threadsAwaitingConnection</code> 수가 증가하게 됩니다. 이는 커넥션을 얻지 못한 요청이 대기 상태로 전환되는 상황을 나타내며, TPS가 증가할 때 시스템이 추가 요청을 즉각적으로 처리하지 못하고 성능 저하를 초래하는 주요 원인이 됩니다.</li><li><strong>connectionTimeout 설정</strong>: 현재 <code>connectionTimeout</code>이 30000밀리초(30초)로 설정되어 있어, 커넥션을 기다리는 요청은 최대 30초까지 대기할 수 있습니다. 그러나 이 대기 시간이 길어질수록 전체 응답 시간이 증가하게 되며, 대기 중인 요청이 많아지면 TPS가 상승하기 어려워지고 응답 지연으로 인한 성능 저하가 발생할 수 있습니다.</li></ol><p>이러한 문제들은 TPS가 높아질수록 커넥션 풀의 제한으로 인해 전체적인 성능 저하가 발생하게 되는 이유입니다.</p><h2><span id="haegyeol-bangan">해결 방안</span><a href="#haegyeol-bangan" class="header-anchor">#</a></h2><ol><li><strong>maximum-pool-size 증가</strong>: 현재의 TPS 수요를 충족하기 위해 <code>maximum-pool-size</code> 값을 늘려야 합니다. 예를 들어, 10 이상으로 설정하여 커넥션 풀이 더 많은 요청을 처리할 수 있도록 하면, 요청 대기 시간과 실패를 줄일 수 있습니다.</li><li><strong>동적 커넥션 관리</strong>: HikariCP의 특성을 활용해 <code>minimum-idle</code>과 <code>maximum-pool-size</code>를 적절히 조정하여 트래픽 변화에 유연하게 대응할 수 있도록 합니다. TPS가 높아질 때는 커넥션 풀이 자동으로 확장되도록 하고, TPS가 감소할 때는 최소한의 커넥션만 유지해 리소스를 절약하도록 설정하는 것이 좋습니다.</li><li><strong>모니터링 및 지속적인 튜닝</strong>: 커넥션 풀의 상태를 지속적으로 모니터링하여, 트래픽 패턴에 맞게 적절히 튜닝하는 것이 필요합니다. 정기적인 모니터링을 통해 TPS와 응답 시간 변화를 관찰하고, 필요에 따라 <code>maximum-pool-size</code>, <code>connectionTimeout</code> 등의 설정을 조정하여 최적의 성능을 유지할 수 있습니다.</li></ol><p>이러한 방안들은 트래픽 변동에 따라 유연하게 커넥션 풀을 관리하고, 시스템 성능을 최적화하는 데 도움이 됩니다.</p><h3><span id="seongneung-teseuteu-gyeolgwa-bunseog-maximum-pool-sizereul-200euro-jojeonghan-gyeongu">성능 테스트 결과 분석: maximum-pool-size를 200으로 조정한 경우</span><a href="#seongneung-teseuteu-gyeolgwa-bunseog-maximum-pool-sizereul-200euro-jojeonghan-gyeongu" class="header-anchor">#</a></h3><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">spring:</span></span><br><span class="line">    <span class="attr">datasource:</span></span><br><span class="line">        <span class="attr">hikari:</span></span><br><span class="line">            <span class="attr">maximum-pool-size:</span> <span class="number">200</span>           <span class="comment"># 최대 커넥션 수</span></span><br><span class="line">            <span class="attr">minimum-idle:</span> <span class="number">10</span>                 <span class="comment"># 최소 유휴 커넥션 수</span></span><br></pre></td></tr></table></figure><p>위와 같이 <code>maximum-pool-size</code>를 200, <code>minimum-idle</code>을 10으로 설정하여 테스트를 진행한 결과, 커넥션 풀이 충분히 확장 가능해지면서 시스템 성능이 크게 개선되었습니다. 주요 개선 사항은 다음과 같습니다.</p><p><img src="https://raw.githubusercontent.com/cheese10yun/blog-sample/master/kotlin-coroutine/images/mysql-connection-pool-1-2.png" alt></p><ul><li><strong>Total Requests per Second (RPS)</strong>:<ul><li>RPS가 점진적으로 증가하여 높은 TPS를 안정적으로 처리할 수 있게 되었습니다. 초당 요청 처리량이 약 150까지 증가했음에도 불구하고, 실패한 요청(Failures/s)은 발생하지 않았습니다.</li><li>이는 커넥션 풀이 충분히 확장되어, 모든 요청이 처리되는 동안 커넥션 부족으로 인한 대기 시간이 발생하지 않았음을 의미합니다.</li></ul></li><li><strong>Response Times</strong>:<ul><li>응답 시간 그래프에서 50th 및 95th 퍼센타일 응답 시간이 비교적 안정적인 수준을 유지하고 있습니다.</li><li>95th 퍼센타일 응답 시간은 약 3,000ms 이하로, 50th 퍼센타일은 약 1,000ms 내외로 유지되었습니다. 이는 고TPS 상황에서도 일관된 응답 속도를 제공할 수 있음을 보여줍니다.</li><li>이전 설정에서 발생했던 응답 시간의 급격한 증가가 해소되어, 사용자 경험이 크게 개선되었습니다.</li></ul></li><li><strong>Failures/s 비율</strong>:<ul><li>요청 실패율이 0으로 유지되었습니다. <code>maximum-pool-size</code>를 200으로 설정한 덕분에, <code>connectionTimeout</code>으로 인해 대기 상태에서 실패하는 요청이 없었습니다.</li><li>이로써 고TPS 상황에서도 안정적인 서비스가 가능해졌으며, 대량의 동시 요청을 처리하는 데 적합한 환경이 조성되었습니다.</li></ul></li></ul><h3><span id="rogeu-bunseog">로그 분석</span><a href="#rogeu-bunseog" class="header-anchor">#</a></h3><table><thead><tr><th>필드</th><th>설명</th></tr></thead><tbody><tr><td>totalConnections</td><td>152 - 현재 총 152개의 커넥션이 생성되었습니다.</td></tr><tr><td>activeConnections</td><td>152 - 모든 커넥션이 활성 상태로 사용 중입니다.</td></tr><tr><td>idleConnections</td><td>0 - 유휴 상태의 커넥션은 없습니다.</td></tr><tr><td>threadsAwaitingConnection</td><td>48 - 48개의 스레드가 커넥션을 기다리고 있습니다.</td></tr><tr><td>maximumPoolSize</td><td>200 - 최대 커넥션 풀 크기가 200으로 설정되었습니다.</td></tr><tr><td>minimumIdle</td><td>10 - 최소 유휴 커넥션이 10으로 설정되어 있어, 초기에 모든 커넥션이 생성된 상태입니다.</td></tr></tbody></table><p>이 설정을 통해 커넥션 풀은 트래픽이 적을 때에는 최소한의 자원만 사용하고, 트래픽이 증가할 때에는 최대 200개의 커넥션까지 확장하여 요청을 처리할 수 있습니다. 로그에서 볼 수 있듯이 TPS가 높은 상황에서도 커넥션 풀이 충분히 확장되었고, 전체적인 시스템 성능에는 큰 영향을 미치지 않았습니다. 이를 통해 시스템은 높은 TPS 환경에서도 안정적이고 일관된 성능을 제공할 수 있음을 확인할 수 있습니다.</p><h2><span id="seongneung-teseuteu-gyeolgwa-bunseog-tps-gamso-sanghwangeseoyi-keonegsyeon-pul-dongjag">성능 테스트 결과 분석: TPS 감소 상황에서의 커넥션 풀 동작</span><a href="#seongneung-teseuteu-gyeolgwa-bunseog-tps-gamso-sanghwangeseoyi-keonegsyeon-pul-dongjag" class="header-anchor">#</a></h2><p>아래 설정으로 TPS가 낮아진 상황에서 성능 테스트를 진행했습니다.</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">spring:</span></span><br><span class="line">    <span class="attr">datasource:</span></span><br><span class="line">        <span class="attr">hikari:</span></span><br><span class="line">            <span class="attr">maximum-pool-size:</span> <span class="number">200</span>           <span class="comment"># 최대 커넥션 수</span></span><br><span class="line">            <span class="attr">minimum-idle:</span> <span class="number">10</span>                 <span class="comment"># 최소 유휴 커넥션 수</span></span><br><span class="line">            <span class="attr">max-lifetime:</span> <span class="number">300000</span>             <span class="comment"># 커넥션이 유지될 최대 시간 (밀리초)</span></span><br><span class="line">            <span class="attr">idle-timeout:</span> <span class="number">250000</span>             <span class="comment"># 유휴 커넥션이 유지될 최대 시간 (밀리초)</span></span><br></pre></td></tr></table></figure><ul><li><strong>max-lifetime</strong>: 기본값은 1800000ms (30분)입니다. 각 커넥션이 일정 시간 동안만 유지된 후 새 커넥션으로 교체되도록 하여, 오래된 커넥션으로 인해 발생할 수 있는 문제를 방지합니다. 설정된 시간(예: 300,000ms = 5분) 이후에 커넥션은 제거되고 새로운 커넥션으로 대체됩니다.</li><li><strong>idle-timeout</strong>: 기본값은 600000ms (10분)입니다. 유휴 상태의 커넥션이 설정된 시간 동안 사용되지 않으면 커넥션 풀에서 제거됩니다. 트래픽이 감소한 상황에서는 유휴 커넥션이 줄어들어 리소스가 절약됩니다. 위 설정에서는 250,000ms 동안 유휴 상태인 커넥션을 유지하고 이후에 해제하도록 설정했습니다.</li></ul><h3><span id="teseuteu-gyeolgwa-bunseog">테스트 결과 분석</span><a href="#teseuteu-gyeolgwa-bunseog" class="header-anchor">#</a></h3><p><img src="https://raw.githubusercontent.com/cheese10yun/blog-sample/master/kotlin-coroutine/images/mysql-connection-pool-1-3.png" alt></p><ul><li><strong>Total Requests per Second (RPS)</strong>:<ul><li>TPS가 낮아지며, 초당 요청 처리량이 약 5 수준으로 안정화되었습니다.</li><li>요청 실패(Failures/s)가 발생하지 않았으며, 모든 요청이 성공적으로 처리되었습니다.</li><li>이는 트래픽이 줄어들면서 커넥션 풀이 유휴 상태로 돌아가고 있음을 의미합니다.</li></ul></li><li><strong>Response Times</strong>:<ul><li>50th 및 95th 퍼센타일 응답 시간 모두 약 1,000ms 내외로 일정하게 유지되고 있습니다.</li><li>응답 시간의 변동이 크지 않고 안정적인 수준을 보여, TPS가 낮아진 상황에서도 일관된 성능을 제공하고 있습니다.</li></ul></li><li><strong>Number of Users</strong>:<ul><li>테스트에서 사용자 수가 10명으로 일정하게 유지되고 있으며, TPS가 낮은 상태로 안정화되었습니다.</li></ul></li></ul><h3><span id="rogeu-bunseog">로그 분석</span><a href="#rogeu-bunseog" class="header-anchor">#</a></h3><table><thead><tr><th>필드</th><th></th></tr></thead><tbody><tr><td>totalConnections</td><td>15 - 현재 총 15개의 커넥션이 생성되었습니다. 트래픽 감소에 따라 필요 없는 커넥션이 자동으로 해제되며, 풀 크기가 줄어든 상태입니다.</td></tr><tr><td>activeConnections</td><td>6 - 현재 활성 상태로 사용 중인 커넥션이 6개입니다.</td></tr><tr><td>idleConnections</td><td>9 - 최소 유휴 커넥션 설정(<code>minimumIdle</code>)이 10이므로, 트래픽이 줄어든 상황에서 9개의 유휴 커넥션이 유지됩니다.</td></tr><tr><td>threadsAwaitingConnection</td><td>0 - 대기 중인 스레드가 없어 모든 요청이 즉시 처리되고 있습니다.</td></tr><tr><td>maximumPoolSize</td><td>200 - 최대 커넥션 풀 크기가 200으로 설정되어 있지만, 현재 트래픽 수준에서는 전체를 사용할 필요 없이 적정 개수의 커넥션만 유지하고 있습니다.</td></tr><tr><td>minimumIdle</td><td>10 - 최소 유휴 커넥션 수가 10으로 설정되어, 트래픽이 적을 때도 최소한의 커넥션을 유지해 자원을 절약합니다.</td></tr><tr><td>maxLifetime</td><td>300000 (밀리초) - 각 커넥션이 5분(300초) 동안 유지된 후 자동으로 갱신되도록 설정되어 있어, 오래된 커넥션으로 인한 문제를 방지하고 안정적인 연결 상태를 보장합니다.</td></tr><tr><td>idleTimeout</td><td>250000 (밀리초) - 유휴 상태의 커넥션이 4분 10초 동안 사용되지 않으면 풀에서 해제됩니다. 이를 통해 트래픽이 감소한 상황에서는 유휴 커넥션을 줄여 리소스를 절약할 수 있습니다.</td></tr></tbody></table><p>이 테스트 결과는 TPS가 줄어들면 <code>totalConnections</code>도 함께 감소하며 리소스가 효율적으로 관리되는 모습을 보여줍니다. <code>minimum-idle</code> 설정을 통해 커넥션 풀이 최소 10개의 유휴 커넥션을 유지하고, <code>idleTimeout</code>이 설정된 시간 동안 유휴 상태인 커넥션을 자동으로 해제하여 불필요한 자원 낭비를 방지합니다. 또한 <code>maxLifetime</code> 설정으로 각 커넥션의 유지 시간을 제한하여 일정 시간이 지나면 커넥션이 새롭게 갱신되도록 함으로써 오래된 커넥션으로 인한 문제를 예방합니다. 이를 통해 시스템은 트래픽 변화에 유연하게 대응하며 안정적으로 자원을 관리할 수 있는 구조를 갖추게 됩니다.</p><h2><span id="gyeolron">결론</span><a href="#gyeolron" class="header-anchor">#</a></h2><p>HikariCP를 사용한 커넥션 풀 설정은 트래픽 변화에 유연하게 대응할 수 있으며, 성능 최적화를 위해 중요한 도구가 됩니다. 적절한 <code>maximum-pool-size</code>와 <code>minimum-idle</code> 설정을 통해 고TPS 환경에서도 안정적이고 일관된 응답 시간을 제공할 수 있으며, 리소스를 효율적으로 관리하여 비용 절감 효과도 기대할 수 있습니다. 이를 통해 Spring Boot 애플리케이션은 다양한 트래픽 상황에서 성능을 최적화하며, 사용자 경험을 크게 개선할 수 있습니다.</p>]]></content:encoded>
      
      
      
      <category domain="https://cheese10yun.github.io/tags/Performance/">Performance</category>
      
      <category domain="https://cheese10yun.github.io/tags/MySQL/">MySQL</category>
      
      <category domain="https://cheese10yun.github.io/tags/Connection-Pool/">Connection Pool</category>
      
      
      <comments>https://cheese10yun.github.io/mysql-connection-pool-timeout-1/#disqus_thread</comments>
      
    </item>
    
    <item>
      <title>MySQL Connection Pool 관리 - HikariCP 설정 및 타임아웃 처리</title>
      <link>https://cheese10yun.github.io/mysql-connection-pool-timeout/</link>
      <guid>https://cheese10yun.github.io/mysql-connection-pool-timeout/</guid>
      <pubDate>Sun, 20 Oct 2024 16:15:31 GMT</pubDate>
      
      <description>애플리케이션에서 MySQL 데이터베이스와의 효율적인 연결을 위해 커넥션 풀을 사용합니다. 커넥션 풀은 미리 일정한 수의 데이터베이스 연결을 생성해 두고, 애플리케이션이 필요할 때마다 이 연결들을 재사용함으로써 성능을 향상시키는 기법입니다.</description>
      
      
      
      <content:encoded><![CDATA[<h2><span id="connection-pooliran">Connection Pool이란?</span><a href="#connection-pooliran" class="header-anchor">#</a></h2><p>애플리케이션에서 MySQL 데이터베이스와의 효율적인 연결을 위해 <strong>커넥션 풀</strong>을 사용합니다. 커넥션 풀은 미리 일정한 수의 데이터베이스 연결을 생성해 두고, 애플리케이션이 필요할 때마다 이 연결들을 재사용함으로써 성능을 향상시키는 기법입니다. 이 방식은 데이터베이스와의 연결을 매번 새로 생성하는 대신 이미 준비된 연결을 재사용하여 애플리케이션의 응답 시간을 줄이는 데 매우 유용합니다.</p><h2><span id="java-jdbcwa-hikaricp">Java JDBC와 HikariCP</span><a href="#java-jdbcwa-hikaricp" class="header-anchor">#</a></h2><p>Java 애플리케이션에서 가장 널리 사용되는 커넥션 풀 중 하나는 <strong>HikariCP</strong>입니다. HikariCP는 가볍고 빠른 커넥션 풀로, 대규모 트래픽이 발생하는 상황에서도 효율적인 연결 관리를 지원합니다. HikariCP는 <strong>HikariPoolMXBean</strong>과 <strong>HikariConfigMXBean</strong>이라는 JMX(Java Management Extensions)를 통해 커넥션 풀의 상태 및 설정을 관리할 수 있습니다.</p><h3><span id="spring-booteseoyi-hikaricp-seoljeonggwa-gibongabs">Spring Boot에서의 HikariCP 설정과 기본값</span><a href="#spring-booteseoyi-hikaricp-seoljeonggwa-gibongabs" class="header-anchor">#</a></h3><table><thead><tr><th>설정 항목</th><th>설명</th><th>기본값</th></tr></thead><tbody><tr><td><strong>maximum-pool-size</strong></td><td>커넥션 풀에서 유지할 수 있는 최대 커넥션 수입니다. 이 수치를 초과하는 요청은 대기 상태로 들어갑니다.</td><td>10</td></tr><tr><td><strong>minimum-idle</strong></td><td>풀에서 유지할 유휴 커넥션의 최소 개수입니다. 유휴 커넥션이 이 수치 이하로 떨어지면 새로운 커넥션이 생성됩니다.</td><td><code>maximum-pool-size</code> 값과 동일</td></tr><tr><td><strong>connection-timeout</strong></td><td>커넥션을 가져오기 위해 스레드가 대기할 수 있는 최대 시간입니다. 이 시간이 초과되면 예외가 발생합니다.</td><td>30,000ms (30초)</td></tr><tr><td><strong>max-lifetime</strong></td><td>커넥션이 유지될 수 있는 최대 시간입니다. 이 시간이 지나면 커넥션은 폐기되고 새 커넥션으로 교체됩니다.</td><td>1,800,000ms (30분)</td></tr><tr><td><strong>idle-timeout</strong></td><td>유휴 상태의 커넥션이 풀에서 유지될 수 있는 최대 시간입니다. 이 시간이 지나면 유휴 커넥션이 풀에서 제거됩니다.</td><td>600,000ms (10분)</td></tr><tr><td><strong>leak-detection-threshold</strong></td><td>지정된 시간(밀리초) 동안 사용되지 않은 커넥션을 감지하는 데 사용됩니다. 이 시간이 지나면 커넥션 리크(leak)를 의심하고 경고를 남깁니다.</td><td>0 (비활성화)</td></tr><tr><td><strong>pool-name</strong></td><td>커넥션 풀의 이름을 지정합니다. 기본적으로 HikariCP는 자동으로 이름을 생성하지만, 필요에 따라 지정할 수 있습니다.</td><td>자동 생성된 이름</td></tr><tr><td><strong>auto-commit</strong></td><td>새 커넥션이 자동 커밋 모드로 시작할지를 결정합니다. 각 쿼리 후 자동으로 커밋됩니다.</td><td><code>true</code></td></tr><tr><td><strong>validation-timeout</strong></td><td>커넥션이 유효한지 검증할 때 사용할 최대 시간입니다. 이 시간이 초과되면 커넥션은 유효하지 않다고 판단하고 폐기됩니다.</td><td>5,000ms (5초)</td></tr><tr><td><strong>read-only</strong></td><td>커넥션이 읽기 전용 모드에서 작동할지를 결정합니다.</td><td><code>false</code></td></tr><tr><td><strong>isolate-internal-queries</strong></td><td>내부 쿼리(예: 커넥션 풀의 유지 관리 쿼리)가 애플리케이션의 쿼리와 격리되는지를 설정합니다.</td><td><code>false</code></td></tr><tr><td><strong>allow-pool-suspension</strong></td><td>커넥션 풀의 일시 정지 기능을 활성화합니다. 이 설정이 활성화되면 풀을 일시 정지하거나 다시 시작할 수 있습니다.</td><td><code>false</code></td></tr><tr><td><strong>initialization-fail-timeout</strong></td><td>풀을 시작할 때 초기화에 실패하는 경우를 대비한 타임아웃 시간입니다. 이 시간이 지나면 예외가 발생합니다.</td><td>1초 (1,000ms)</td></tr></tbody></table><h2><span id="keonegsyeon-pul-sinario-seolmyeong">커넥션 풀 시나리오 설명</span><a href="#keonegsyeon-pul-sinario-seolmyeong" class="header-anchor">#</a></h2><p><img src="https://raw.githubusercontent.com/cheese10yun/blog-sample/master/kotlin-coroutine/images/mysql-connection-pool-1.png" alt></p><p><code>maximumPoolSize</code>가 10인 커넥션 풀을 가진 애플리케이션에서, 10명의 사용자가 각각 1초에 한 번씩 요청을 보낸다고 가정해보겠습니다. 각 요청은 약 1초가 소요됩니다. 아래 시나리오는 커넥션 풀의 상태를 각 단계별로 설명합니다.</p><h3><span id="keonegsyeon-pulyi-juyo-pildeu-mic-dongjag-sinario-bunseog">커넥션 풀의 주요 필드 및 동작 시나리오 분석</span><a href="#keonegsyeon-pulyi-juyo-pildeu-mic-dongjag-sinario-bunseog" class="header-anchor">#</a></h3><ul><li><strong>maximumPoolSize</strong>:<ul><li><strong>설명</strong>: 커넥션 풀에서 관리할 수 있는 최대 커넥션 수를 나타냅니다. 이 시나리오에서는 10개로 설정되어 있어, 동시에 최대 10개의 요청을 처리할 수 있습니다.</li><li><strong>이미지 설명</strong>: 이미지에서 <code>totalConnections = 10</code>으로 설정되어 있으며, 이는 커넥션 풀에서 관리할 수 있는 총 커넥션이 10개임을 의미합니다.</li></ul></li><li><strong>activeConnections</strong>:<ul><li><strong>설명</strong>: 현재 요청을 처리 중인 커넥션의 수입니다. 동시 요청이 10건 발생하면 <code>activeConnections</code>는 10이 됩니다. 더 이상 여유가 없는 상태에서 추가 요청이 들어오면 대기 상태가 됩니다.</li><li><strong>이미지 설명</strong>: 첫 번째 그림에서 <code>activeConnections = 1</code>로, 한 개의 요청이 활성화된 상태입니다. 두 번째 그림에서는 <code>activeConnections = 5</code>로, 5명이 동시에 요청을 보내고 있습니다. 세 번째 그림에서는 <code>activeConnections = 10</code>으로, 모든 커넥션이 활성화되어 추가 요청을 처리할 수 없는 상태입니다.</li></ul></li><li><strong>idleConnections</strong>:<ul><li><strong>설명</strong>: 유휴 상태로 대기 중인 커넥션의 수를 나타냅니다. 예를 들어, 첫 번째 요청이 처리될 때 <code>idleConnections</code>는 9개이며, 모든 커넥션이 활성화되면 <code>idleConnections</code>는 0이 됩니다.</li><li><strong>이미지 설명</strong>: 첫 번째 그림에서 <code>idleConnections = 9</code>로, 9개의 커넥션이 대기 상태입니다. 두 번째 그림에서는 <code>idleConnections = 5</code>, 세 번째 그림에서는 <code>idleConnections = 0</code>으로, 모든 커넥션이 사용 중인 상태입니다.</li></ul></li><li><strong>totalConnections</strong>:<ul><li><strong>설명</strong>: 커넥션 풀에서 관리하고 있는 총 커넥션 수로, <code>activeConnections</code>와 <code>idleConnections</code>의 합입니다. 이 값은 <code>maximumPoolSize</code> 내에서 유지되며, 동시 요청이 많을수록 <code>idleConnections</code>가 줄어듭니다.</li><li><strong>이미지 설명</strong>: 세 개의 그림 모두 <code>totalConnections = 10</code>으로, 이는 커넥션 풀에서 관리하는 커넥션이 총 10개임을 나타냅니다.</li></ul></li><li><strong>threadsAwaitingConnection</strong>:<ul><li><strong>설명</strong>: 커넥션이 모두 사용 중일 때 대기 중인 요청의 수를 나타냅니다. 예를 들어, 10명의 사용자가 모두 커넥션을 사용 중일 때 추가 요청이 발생하면, 그 요청은 대기 상태로 들어가 <code>threadsAwaitingConnection</code>이 증가합니다.</li><li><strong>이미지 설명</strong>: 마지막 그림에서는 모든 커넥션이 사용 중이기 때문에, 추가 요청이 발생하면 대기 상태로 들어가게 됩니다.</li></ul></li><li><strong>connectionTimeout</strong>:<ul><li><strong>설명</strong>: 대기 중인 요청이 커넥션을 얻기 위해 기다릴 수 있는 최대 시간을 나타냅니다. 예를 들어, <code>connectionTimeout</code>이 2초로 설정된 경우, 대기 중인 요청이 2초 내에 커넥션을 할당받지 못하면 요청은 실패하게 됩니다.</li><li><strong>이미지 설명</strong>: 마지막 그림에서 모든 커넥션이 사용 중인 상태에서 추가 요청이 들어오면, <code>connectionTimeout</code> 내에 커넥션을 할당받지 못할 경우 해당 요청은 실패하게 됩니다.</li></ul></li><li><strong>validationTimeout</strong>:<ul><li><strong>설명</strong>: 풀에서 커넥션을 빌려올 때 해당 커넥션이 유효한지 확인하는 시간입니다. 이 시간이 초과되면 해당 커넥션은 사용되지 않고 새로운 커넥션이 할당됩니다.</li><li><strong>이미지 설명</strong>: 유휴 상태로 오래 있던 커넥션은 유효성 검사에서 실패할 수 있으며, 이 경우 새로운 커넥션이 할당됩니다. 이미지에서는 유휴 상태의 커넥션들이 대기 중인 상태를 보여줍니다.</li></ul></li></ul><h2><span id="hikaricp-seoljeong-mic-sangtae-ceugjeong-saempeul-kodeu">HikariCP 설정 및 상태 측정 샘플 코드</span><a href="#hikaricp-seoljeong-mic-sangtae-ceugjeong-saempeul-kodeu" class="header-anchor">#</a></h2><p>Spring Boot 애플리케이션에서 HikariCP를 사용하는 방법을 설명합니다. HikariCP는 Spring Boot에서 기본적으로 사용하는 커넥션 풀로, 설정을 통해 다양한 커넥션 관리 옵션을 제공합니다. 또한, HikariCP의 상태를 측정할 수 있는 방법을 추가하여 커넥션 풀의 효율적인 관리가 가능합니다.</p><h3><span id="hikaricp-properties-seoljeong">HikariCP Properties 설정</span><a href="#hikaricp-properties-seoljeong" class="header-anchor">#</a></h3><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">spring:</span></span><br><span class="line">    <span class="attr">datasource:</span></span><br><span class="line">        <span class="attr">hikari:</span></span><br><span class="line">            <span class="attr">maximum-pool-size:</span> <span class="number">10</span>           <span class="comment"># 최대 커넥션 수</span></span><br><span class="line">            <span class="attr">minimum-idle:</span> <span class="number">10</span>                <span class="comment"># 최소 유휴 커넥션 수</span></span><br><span class="line">            <span class="attr">connection-timeout:</span> <span class="number">30000</span>       <span class="comment"># 커넥션을 가져올 때 대기할 최대 시간 (밀리초)</span></span><br><span class="line">            <span class="attr">max-lifetime:</span> <span class="number">1800000</span>           <span class="comment"># 커넥션이 유지될 최대 시간 (밀리초)</span></span><br><span class="line">            <span class="attr">idle-timeout:</span> <span class="number">600000</span>            <span class="comment"># 유휴 커넥션이 유지될 최대 시간 (밀리초)</span></span><br><span class="line">            <span class="attr">leak-detection-threshold:</span> <span class="number">2000</span>  <span class="comment"># 커넥션 리크를 감지할 기준 시간 (밀리초)</span></span><br><span class="line">            <span class="attr">pool-name:</span> <span class="string">Sample-HikariPool</span>    <span class="comment"># 커넥션 풀 이름</span></span><br><span class="line">            <span class="attr">auto-commit:</span> <span class="literal">true</span>               <span class="comment"># 자동 커밋 여부</span></span><br><span class="line">            <span class="attr">validation-timeout:</span> <span class="number">5000</span>        <span class="comment"># 커넥션 유효성 검사를 위한 최대 시간 (밀리초)</span></span><br><span class="line">            <span class="attr">read-only:</span> <span class="literal">false</span>                <span class="comment"># 읽기 전용 모드 여부</span></span><br><span class="line">            <span class="attr">isolate-internal-queries:</span> <span class="literal">false</span> <span class="comment"># 내부 쿼리 격리 여부</span></span><br><span class="line">            <span class="attr">allow-pool-suspension:</span> <span class="literal">false</span>    <span class="comment"># 커넥션 풀 일시 정지 허용 여부</span></span><br><span class="line">            <span class="attr">initialization-fail-timeout:</span> <span class="number">1</span>  <span class="comment"># 초기화 실패 시 타임아웃 (밀리초)</span></span><br></pre></td></tr></table></figure><h3><span id="hikaricp-sangtae-ceugjeong-kodeu">HikariCP 상태 측정 코드</span><a href="#hikaricp-sangtae-ceugjeong-kodeu" class="header-anchor">#</a></h3><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line"><span class="meta">@Service</span></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">SampleService</span>(</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">val</span> dataSource: DataSource,</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">val</span> memberRepository: MemberRepository</span><br><span class="line">) &#123;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">val</span> log = LoggerFactory.getLogger(javaClass)!!</span><br><span class="line"></span><br><span class="line">    <span class="meta">@Transactional</span></span><br><span class="line">    <span class="function"><span class="keyword">fun</span> <span class="title">getMember</span><span class="params">()</span></span>: Member &#123;</span><br><span class="line">        <span class="keyword">val</span> member = memberRepository.findById(Random.nextInt(<span class="number">1</span>, <span class="number">101</span>).toLong()).<span class="keyword">get</span>()</span><br><span class="line">        runBlocking &#123; delay(<span class="number">1000</span>) &#125;</span><br><span class="line">        <span class="keyword">val</span> targetDataSource = dataSource.unwrap(HikariDataSource::<span class="keyword">class</span>.java)</span><br><span class="line">        <span class="keyword">val</span> hikariDataSource = targetDataSource <span class="keyword">as</span> HikariDataSource</span><br><span class="line">        <span class="keyword">val</span> hikariPoolMXBean = hikariDataSource.hikariPoolMXBean</span><br><span class="line">        <span class="keyword">val</span> hikariConfigMXBean = hikariDataSource.hikariConfigMXBean</span><br><span class="line">        <span class="keyword">val</span> log =</span><br><span class="line">            <span class="string">&quot;&quot;&quot;</span></span><br><span class="line"><span class="string">            totalConnections : <span class="subst">$&#123;hikariPoolMXBean.totalConnections&#125;</span></span></span><br><span class="line"><span class="string">            activeConnections : <span class="subst">$&#123;hikariPoolMXBean.activeConnections&#125;</span></span></span><br><span class="line"><span class="string">            idleConnections : <span class="subst">$&#123;hikariPoolMXBean.idleConnections&#125;</span></span></span><br><span class="line"><span class="string">            threadsAwaitingConnection : <span class="subst">$&#123;hikariPoolMXBean.threadsAwaitingConnection&#125;</span></span></span><br><span class="line"><span class="string">            &quot;&quot;&quot;</span>.trimIndent()</span><br><span class="line"></span><br><span class="line">        <span class="keyword">this</span>.log.info(log)</span><br><span class="line"></span><br><span class="line">        <span class="keyword">return</span> member</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>이 코드에서는 먼저 <code>DataSource</code>를 <code>HikariDataSource</code>로 변환한 후, <code>HikariPoolMXBean</code>과 <code>HikariConfigMXBean</code>을 사용하여 커넥션 풀의 상태를 확인합니다. 이를 통해 총 커넥션 수, 활성 커넥션 수, 유휴 커넥션 수, 그리고 대기 중인 스레드 수와 같은 정보를 가져옵니다. 또한, <code>runBlocking &#123; delay(1000) &#125;</code>을 사용하여 1초간의 지연을 추가함으로써, 실제 요청이 처리되는 동안 커넥션 풀의 상태를 보다 명확하게 모니터링할 수 있습니다. 마지막으로, 이러한 커넥션 풀의 상태를 로그로 출력하여 애플리케이션의 성능을 모니터링하고, 필요한 경우 성능을 조정할 수 있는 정보를 제공합니다.</p><h2><span id="connection-pool-ceugjeong">Connection Pool 측정</span><a href="#connection-pool-ceugjeong" class="header-anchor">#</a></h2><p><img src="https://raw.githubusercontent.com/cheese10yun/blog-sample/master/kotlin-coroutine/images/mysql-connection-pool-2.png" alt></p><p>애플리케이션에서 커넥션 풀을 사용할 때, 커넥션 풀의 상태를 지속적으로 모니터링하는 것은 매우 중요합니다. 아래와 같은 로그는 **10 TPS (초당 트랜잭션)**를 지속적으로 유지할 때 발생한 로그입니다. 이때 평균 응답 시간은 약 1,000ms로 나타나며, 이는 TPS 수준을 고려했을 때 시스템이 적정 수준에서 작동하고 있음을 보여줍니다.</p><ul><li><p><strong>로그 1</strong>:</p><ul><li><code>totalConnections = 10</code></li><li><code>activeConnections = 7</code></li><li><code>idleConnections = 3</code></li><li><code>threadsAwaitingConnection = 0</code></li></ul><p>이 상태는 <strong>총 10개의 커넥션</strong> 중 <strong>7개가 활성화</strong>되어 요청을 처리하고 있으며, <strong>3개의 유휴 커넥션</strong>이 대기 중인 상황입니다. 모든 요청이 원활하게 처리되고 있기 때문에, 대기 중인 스레드는 없습니다.</p></li><li><p><strong>로그 2</strong>:</p><ul><li><code>totalConnections = 10</code></li><li><code>activeConnections = 10</code></li><li><code>idleConnections = 0</code></li><li><code>threadsAwaitingConnection = 4</code></li></ul><p>이 상태는 <strong>모든 10개의 커넥션이 활성화</strong>되어 요청을 처리 중이며, 더 이상 유휴 커넥션이 남아있지 않습니다. 이때 <strong>4개의 추가 요청이 들어와 대기</strong> 중입니다. <code>threadsAwaitingConnection</code> 값이 4로 증가한 이유는, 요청을 처리할 수 있는 유휴 커넥션이 없기 때문입니다.</p></li></ul><h3><span id="keonegsyeon-pul-dongjag-mic-taimaus-balsaeng">커넥션 풀 동작 및 타임아웃 발생</span><a href="#keonegsyeon-pul-dongjag-mic-taimaus-balsaeng" class="header-anchor">#</a></h3><p>커넥션 풀이 설정된 <code>maximumPoolSize</code>만큼 활성화된 경우, 그 이후에 들어오는 요청은 <strong>대기 상태</strong>에 들어가게 됩니다. 이때 <strong>대기 시간이 길어질 수 있으며</strong>, 이러한 대기 시간이 너무 길어지면 <strong>타임아웃</strong>이 발생할 수 있습니다. 타임아웃이 발생하는 주요 원인은 다음과 같습니다.</p><p><strong>connection-timeout</strong>은 커넥션을 얻기 위해 스레드가 대기할 수 있는 최대 시간을 의미합니다. 예를 들어, <code>connection-timeout</code>이 30초로 설정되어 있다면, 커넥션 풀이 사용 가능한 커넥션을 30초 동안 제공하지 못할 경우 타임아웃이 발생하게 됩니다. 이 설정은 대기 중인 요청이 얼마 동안 기다릴 수 있는지를 제한합니다.</p><p>커넥션 풀은 <strong>한정된 자원</strong>을 효율적으로 관리하여 시스템의 안정성을 유지하는 좋은 방법입니다. 그러나, 만약 요청량이 설정된 <code>maximumPoolSize</code>를 초과하게 되면 대기 상태가 발생할 수 있습니다. 이러한 상황을 방지하기 위해 적절한 <strong>타임아웃</strong> 값을 설정하고, 필요에 따라 풀 크기를 조정하는 것이 중요합니다.</p><h3><span id="connection-timeout-seoljeonggwa-tps-jeunggaro-inhan-oryu-balsaeng">connection-timeout 설정과 TPS 증가로 인한 오류 발생</span><a href="#connection-timeout-seoljeonggwa-tps-jeunggaro-inhan-oryu-balsaeng" class="header-anchor">#</a></h3><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">spring:</span></span><br><span class="line">    <span class="attr">datasource:</span></span><br><span class="line">        <span class="attr">hikari:</span></span><br><span class="line">            <span class="attr">maximum-pool-size:</span> <span class="number">10</span>         <span class="comment"># 최대 커넥션 수</span></span><br><span class="line">            <span class="attr">minimum-idle:</span> <span class="number">10</span>              <span class="comment"># 최소 유휴 커넥션 수</span></span><br><span class="line">            <span class="attr">connection-timeout:</span> <span class="number">250</span>       <span class="comment"># 커넥션을 가져올 때 대기할 최대 시간 (밀리초)</span></span><br></pre></td></tr></table></figure><p>위 설정에서 <code>connection-timeout</code>을 250ms로 지정한 경우, <strong>TPS</strong>가 10을 초과하게 되면 <strong>threadsAwaitingConnection</strong>에 대기하는 시간이 250ms를 넘을 수 있습니다. 이 상황이 발생하면, 커넥션 풀은 설정된 대기 시간보다 오래 걸리기 때문에 타임아웃 오류가 발생하게 됩니다.</p><p><img src="https://raw.githubusercontent.com/cheese10yun/blog-sample/master/kotlin-coroutine/images/mysql-connection-pool-3.png" alt></p><p>이미지에서와 같이, <code>RPS</code>(Request Per Second)가 10 이상일 때 커넥션 풀의 한계로 인해 대기 중인 요청이 발생하고, 그 대기 시간이 <code>250ms</code>를 초과하면 오류가 발생합니다. 이때 <code>Failures/s</code>가 증가하는 것을 확인할 수 있습니다. 이는 타임아웃 설정과 관련이 있으며, 커넥션 풀의 자원 한계와 처리량을 적절히 맞춰야 하는 이유를 보여줍니다.</p><p><strong>오류 메시지 예시</strong>:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">java.sql.SQLTransientConnectionException: Sample-HikariPool - Connection is not available, request timed out after 251ms.</span><br><span class="line">at com.zaxxer.hikari.pool.HikariPool.createTimeoutException(HikariPool.java:696) ~[HikariCP-4.0.3.jar:na]</span><br><span class="line">at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:197) </span><br></pre></td></tr></table></figure><p>이 오류는 대기 시간이 설정된 <code>connection-timeout</code>을 초과했음을 의미하며, 커넥션 풀이 추가 요청을 처리할 수 없다는 것을 나타냅니다. 이 문제를 해결하기 위해 여러 가지 방법을 고려할 수 있습니다.</p><h2><span id="connection-pool-munje-haegyeolbangbeob">Connection Pool 문제 해결방법</span><a href="#connection-pool-munje-haegyeolbangbeob" class="header-anchor">#</a></h2><p>애플리케이션에서 <strong>Connection Pool</strong>은 안정적인 데이터베이스 연결을 관리하는 데 매우 중요한 역할을 합니다. 하지만 시스템 부하가 높거나 설정이 적절하지 않은 경우, 커넥션 풀에서 발생하는 문제로 인해 성능 저하나 타임아웃 오류가 발생할 수 있습니다. 이러한 문제를 해결하기 위해서는 몇 가지 핵심적인 접근 방식을 취할 수 있습니다.</p><ol><li><strong>쿼리 최적화</strong>: 데이터베이스 성능 문제는 종종 비효율적인 쿼리로 인해 발생합니다. 쿼리 최적화는 애플리케이션의 응답 시간을 줄이고, 커넥션이 오랫동안 점유되는 상황을 방지할 수 있는 중요한 방법입니다. 인덱스를 추가하거나 복잡한 쿼리 구조를 단순화하는 것 등이 이에 해당합니다.</li><li><strong>connection-timeout 시간 조정</strong>: 커넥션 타임아웃은 대기 중인 요청이 커넥션을 얻기 위해 얼마나 오랜 시간 기다릴 수 있는지를 결정합니다. 이 시간을 적절히 설정함으로써 대기 중인 요청들이 효율적으로 처리되도록 할 수 있습니다. 너무 짧은 타임아웃은 오류를 유발할 수 있고, 너무 긴 타임아웃은 응답 지연을 초래할 수 있으므로, 상황에 맞게 타임아웃을 조정해야 합니다.</li><li><strong>maximum-pool-size 증가</strong>: 예상되는 트래픽에 맞춰 커넥션 풀의 최대 크기를 늘림으로써 동시 요청 처리 성능을 향상시킬 수 있습니다. 그러나 자원 낭비를 방지하기 위해 시스템의 메모리와 CPU 사용량을 신중하게 고려하여 최적의 크기를 설정하는 것이 중요합니다.</li></ol><p>이와 같은 방법을 적절히 적용하면 Connection Pool 문제를 해결하고, 애플리케이션의 성능과 안정성을 크게 향상시킬 수 있습니다. 아래에서는 이 세 가지 접근 방법을 구체적으로 다루고, 실제 적용 사례를 통해 성능 최적화 방법을 설명합니다.</p><h3><span id="kweori-coejeoghwa">쿼리 최적화</span><a href="#kweori-coejeoghwa" class="header-anchor">#</a></h3><p><strong>쿼리 최적화</strong>는 데이터베이스 성능 향상에 있어 중요한 단계입니다. 특히, 애플리케이션에서 <code>activeConnections</code>가 빠르게 응답하지 못하는 경우, 그 원인이 복잡한 쿼리 처리에 있다면 쿼리 최적화를 통해 성능을 크게 개선할 수 있습니다. 잘못 설계된 쿼리나 불필요하게 긴 실행 시간을 가진 쿼리는 시스템 전반의 성능에 영향을 미치고, 커넥션 풀의 자원을 오래 점유하게 되어 <strong>대기 중인 요청</strong>들이 지연되는 상황을 초래할 수 있습니다.</p><p>다음과 같은 방식으로 쿼리를 최적화함으로써 응답 속도를 개선할 수 있습니다:</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Transactional</span></span><br><span class="line"><span class="function"><span class="keyword">fun</span> <span class="title">getMember</span><span class="params">()</span></span>: Member &#123;</span><br><span class="line">    <span class="keyword">val</span> member = memberRepository.findById(Random.nextInt(<span class="number">1</span>, <span class="number">101</span>).toLong()).<span class="keyword">get</span>()</span><br><span class="line">    <span class="comment">// runBlocking &#123; delay(1000) &#125; 블록킹 코드 제거</span></span><br><span class="line">    ...</span><br><span class="line">    <span class="keyword">return</span> member</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>위 코드에서 불필요한 블로킹 코드인 <code>runBlocking &#123; delay(1000) &#125;</code>를 제거함으로써 쿼리 실행 지연을 없앴습니다. 이와 같이, 쿼리 최적화는 단순히 코드 내의 블로킹 요소를 제거하는 것뿐만 아니라, <strong>인덱스 추가</strong>, <strong>복잡한 조인 구조 단순화</strong>, <strong>캐싱</strong> 등을 통해 데이터베이스에 대한 부하를 줄이는 방법도 포함됩니다. 이러한 최적화 작업을 통해 쿼리 실행 시간이 줄어들면 커넥션이 더 빨리 반환되고, <strong>대기 중인 요청이 빠르게 처리</strong>될 수 있습니다.</p><p><img src="https://raw.githubusercontent.com/cheese10yun/blog-sample/master/kotlin-coroutine/images/mysql-connection-pool-4.png" alt></p><p>쿼리 최적화를 통해 성능이 향상되면, <strong>connection-timeout</strong>이 250ms로 설정된 상황에서도 <strong>RPS가 20</strong>인 경우처럼 높은 요청 처리량에서도 타임아웃 오류가 발생하지 않게 됩니다. 이는 쿼리가 최적화되면 커넥션이 더 빠르게 반환되기 때문에, 커넥션 풀 내에서 더 많은 요청을 동시에 처리할 수 있기 때문입니다.</p><p>최적화된 쿼리는 커넥션 점유 시간을 줄여 <strong>connection-timeout</strong> 설정의 제한을 넘지 않도록 도와주며, 결과적으로 더 많은 트랜잭션을 안정적으로 처리할 수 있게 됩니다. 따라서, 쿼리 최적화는 애플리케이션 성능을 유지하고 향상시키는 중요한 방법입니다.</p><p>결론적으로, <strong>쿼리 최적화</strong>는 데이터베이스 성능 개선뿐만 아니라, 커넥션 풀 자원 관리에도 매우 중요한 역할을 합니다. 불필요한 대기 시간을 줄이고, 시스템이 고부하 상황에서도 원활하게 작동할 수 있도록 돕습니다.</p><h3><span id="connection-timeout-sigan-jojeong">connection-timeout 시간 조정</span><a href="#connection-timeout-sigan-jojeong" class="header-anchor">#</a></h3><p><code>connection-timeout</code> 설정은 대기 중인 요청이 커넥션을 얻기 위해 기다릴 수 있는 시간을 조정하는 중요한 요소입니다. 만약 요청량이 많아 <strong>maximumPoolSize</strong>에 도달했을 때, <code>connection-timeout</code>을 적절히 늘려주면 대기 중인 요청이 커넥션을 할당받기까지 더 오랜 시간을 허용할 수 있습니다. 예를 들어, <code>connection-timeout</code>을 250ms에서 2,500ms로 늘리면, 커넥션 반환 대기 시간을 더 길게 설정함으로써 <strong>타임아웃 오류</strong>를 줄일 수 있습니다. 하지만, 너무 긴 대기 시간을 설정하면 그만큼 <strong>응답 시간이 지연</strong>될 수 있으므로 신중하게 설정해야 합니다.</p><p>아래는 <code>connection-timeout</code>을 2,500ms로 설정하고 테스트한 설정 예시입니다:</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">spring:</span></span><br><span class="line">    <span class="attr">datasource:</span></span><br><span class="line">        <span class="attr">hikari:</span></span><br><span class="line">            <span class="attr">maximum-pool-size:</span> <span class="number">10</span>         <span class="comment"># 최대 커넥션 수</span></span><br><span class="line">            <span class="attr">minimum-idle:</span> <span class="number">10</span>              <span class="comment"># 최소 유휴 커넥션 수</span></span><br><span class="line">            <span class="attr">connection-timeout:</span> <span class="number">2500</span>      <span class="comment"># 커넥션을 가져올 때 대기할 최대 시간 (밀리초)</span></span><br></pre></td></tr></table></figure><p>이 설정에서, <code>delay(1000)</code>는 유지하면서 <code>connection-timeout</code>을 2,500ms로 변경한 후 테스트를 진행하였습니다.</p><p><img src="https://raw.githubusercontent.com/cheese10yun/blog-sample/master/kotlin-coroutine/images/mysql-connection-pool-6.png" alt></p><p>테스트 결과는 아래와 같습니다:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">totalConnections : 10</span><br><span class="line">maximumPoolSize : 10</span><br><span class="line">activeConnections : 10</span><br><span class="line">idleConnections : 0</span><br><span class="line">threadsAwaitingConnection : 20</span><br></pre></td></tr></table></figure><ul><li><strong>totalConnections</strong>: 10개 커넥션이 모두 사용 중입니다.</li><li><strong>maximumPoolSize</strong>: 설정된 최대 커넥션 수는 10개입니다.</li><li><strong>activeConnections</strong>: 현재 10개의 커넥션이 활성화되어 있습니다.</li><li><strong>idleConnections</strong>: 유휴 커넥션이 없으며, 모든 커넥션이 사용 중입니다.</li><li><strong>threadsAwaitingConnection</strong>: 20개의 요청이 대기 중입니다.</li></ul><p><strong>connection-timeout</strong> 설정이 2,500ms로 변경됨에 따라, <strong>threadsAwaitingConnection</strong>에서 대기하는 시간이 길어졌습니다. 결과적으로 <strong>타임아웃 오류는 발생하지 않았지만</strong>, 응답 시간이 더 길어졌습니다. 이는 각 요청에 대해 <code>delay(1000)</code>으로 인한 1,000ms의 블로킹 시간과 <strong>threadsAwaitingConnection</strong>에서 대기한 시간이 합쳐져 응답 시간이 느려진 것입니다.</p><p>이런 경우, 타임아웃을 길게 설정하는 것은 타임아웃 오류를 방지할 수 있지만, 동시에 <strong>응답 속도</strong>가 저하될 수 있습니다. 따라서, <strong>connection-timeout</strong>은 요청의 특성과 트래픽 패턴에 맞추어 적절한 값을 설정하는 것이 매우 중요합니다.</p><h3><span id="maximum-pool-size-jeungga">maximum-pool-size 증가</span><a href="#maximum-pool-size-jeungga" class="header-anchor">#</a></h3><p>애플리케이션에서 처리할 수 있는 동시 요청 수가 많아질 것으로 예상된다면, <code>maximum-pool-size</code>를 늘려 커넥션 풀에서 더 많은 커넥션을 생성하고 유지할 수 있도록 설정할 수 있습니다. 예를 들어, 트래픽이 예상보다 많아지거나, 동시성 처리가 많이 요구되는 상황에서는 커넥션 풀 크기를 늘려주는 것이 유리합니다. 이를 통해 더 많은 요청을 동시에 처리할 수 있게 됩니다. 그러나, 무작정 풀 크기를 크게 설정하는 것은 <strong>자원 낭비</strong>를 초래할 수 있습니다. 커넥션 풀 크기가 커지면 더 많은 메모리와 CPU가 필요하기 때문에, <strong>시스템의 자원 사용량</strong>을 신중히 고려한 후에 조정하는 것이 중요합니다.</p><p>예를 들어, <code>maximum-pool-size</code>를 100으로 설정하고, 1,000ms 대기 코드를 다시 활성화시키며 <code>connection-timeout</code>을 250ms로 유지했음에도 불구하고, 오류가 발생하지 않는 상황을 가정해보겠습니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Transactional</span></span><br><span class="line"><span class="function"><span class="keyword">fun</span> <span class="title">getMember</span><span class="params">()</span></span>: Member &#123;</span><br><span class="line">  <span class="keyword">val</span> member = memberRepository.findById(Random.nextInt(<span class="number">1</span>, <span class="number">101</span>).toLong()).<span class="keyword">get</span>()</span><br><span class="line">  runBlocking &#123; delay(<span class="number">1000</span>) &#125;  <span class="comment">// 1,000ms 대기</span></span><br><span class="line">  ...</span><br><span class="line">  <span class="keyword">return</span> member</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>이 코드에서는 <code>runBlocking &#123; delay(1000) &#125;</code>를 통해 각 요청마다 1초의 지연을 발생시키고 있지만, <code>maximum-pool-size</code>를 충분히 높게 설정함으로써 동시 요청이 몰리는 상황에서도 타임아웃 오류가 발생하지 않습니다. 아래와 같은 로그를 통해 확인할 수 있습니다.</p><p><img src="https://raw.githubusercontent.com/cheese10yun/blog-sample/master/kotlin-coroutine/images/mysql-connection-pool-5.png" alt></p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">totalConnections : 30</span><br><span class="line">maximumPoolSize : 100</span><br><span class="line">activeConnections : 13</span><br><span class="line">idleConnections : 17</span><br></pre></td></tr></table></figure><ul><li><strong>totalConnections</strong>: 현재 커넥션 풀에서 관리되고 있는 커넥션의 총 개수로, 현재 30개가 할당되어 있는 상태입니다.</li><li><strong>maximumPoolSize</strong>: 커넥션 풀에서 설정된 최대 커넥션 수는 100입니다. 하지만, 모든 커넥션이 필요하지 않기 때문에 100까지 차지 않고 있는 상황입니다.</li><li><strong>activeConnections</strong>: 현재 요청을 처리 중인 활성 커넥션 수는 13개입니다.</li><li><strong>idleConnections</strong>: 유휴 상태로 대기 중인 커넥션 수는 17개로, 요청이 추가로 들어오면 이 커넥션들이 활성화될 수 있습니다.</li></ul><p>이 상황에서는 <strong>maximumPoolSize</strong>가 100으로 설정되어 있지만, 모든 커넥션이 사용 중이지 않기 때문에 <strong>자원 낭비를 최소화</strong>할 수 있습니다. 요청이 몰리지 않는 상태에서는 <code>activeConnections</code>가 13개에 머무르고, 나머지 17개는 유휴 상태로 남아있습니다. 이처럼 <strong>최대 커넥션 수</strong>는 설정했지만, 필요할 때만 커넥션이 활성화되고 나머지는 유휴 상태를 유지하는 방식으로 효율적인 자원 관리가 가능합니다.</p><p>따라서, <code>maximum-pool-size</code>는 트래픽이 몰릴 경우를 대비해 충분히 큰 값으로 설정할 수 있지만, 시스템 자원에 부담을 주지 않도록 <strong>실제 트래픽</strong>과 <strong>자원 사용량</strong>을 분석하여 적절한 값으로 설정하는 것이 중요합니다.</p><h2><span id="gyeolron">결론</span><a href="#gyeolron" class="header-anchor">#</a></h2><p>HikariCP를 사용한 <strong>MySQL 커넥션 풀 관리</strong>는 애플리케이션 성능 최적화의 핵심 요소입니다. 적절한 커넥션 풀 설정을 통해 데이터베이스와의 연결을 효율적으로 관리하고, 불필요한 연결 재생성을 피하며, 응답 시간을 단축할 수 있습니다. 그러나, <strong>maximumPoolSize</strong>와 <strong>connection-timeout</strong>과 같은 설정이 적절하지 않으면, 트래픽이 증가할 때 커넥션 풀에서 대기 시간이 늘어나거나 타임아웃 오류가 발생할 수 있습니다.</p><p>이를 방지하고 시스템 성능을 최적화하기 위해 다음과 같은 중요한 포인트를 기억해야 합니다.</p><ol><li><strong>쿼리 최적화</strong>: 쿼리의 성능이 느릴 경우 <code>activeConnections</code>가 불필요하게 오래 점유되며, 전체 시스템의 성능을 저하시킬 수 있습니다. 쿼리를 최적화하여 커넥션을 빠르게 반환하면 대기 중인 요청도 신속하게 처리할 수 있습니다.</li><li><strong>maximum-pool-size 설정</strong>: <strong>maximum-pool-size</strong>는 동시 처리할 수 있는 최대 요청 수를 결정합니다. 트래픽 예측에 맞춰 적절히 풀 크기를 늘리는 것은 중요하지만, 자원 낭비를 방지하기 위해 너무 크게 설정하지 않는 것이 좋습니다. 실제로 필요할 때만 커넥션이 활성화되는 방식으로 자원을 효율적으로 관리해야 합니다.</li><li><strong>connection-timeout 설정</strong>: <strong>connection-timeout</strong> 값은 대기 중인 요청이 커넥션을 얻기 위해 기다릴 수 있는 최대 시간을 결정합니다. 이 값을 너무 낮게 설정하면 트래픽이 몰릴 때 타임아웃 오류가 자주 발생할 수 있으며, 반대로 너무 길게 설정하면 응답 시간이 지연될 수 있습니다. 트래픽 패턴에 맞춰 적절한 값을 설정하는 것이 중요합니다.</li><li><strong>TPS 모니터링</strong>: 초당 처리량(TPS)을 지속적으로 모니터링하고, 트래픽 패턴을 기반으로 HikariCP 설정을 조정해야 합니다. 이를 통해 애플리케이션이 다양한 부하 상황에서도 원활하게 작동할 수 있도록 합니다.</li></ol><p>최종적으로, 커넥션 풀 설정을 조정할 때는 트래픽 패턴과 시스템 자원을 고려하여 <strong>최적화된 값</strong>을 설정하는 것이 필수적입니다. 이를 통해 <strong>응답 속도 지연</strong> 및 <strong>타임아웃 오류</strong>를 최소화할 수 있으며, 안정적인 시스템 운영과 함께 애플리케이션의 성능을 극대화할 수 있습니다.</p>]]></content:encoded>
      
      
      
      <category domain="https://cheese10yun.github.io/tags/Performance/">Performance</category>
      
      <category domain="https://cheese10yun.github.io/tags/MySQL/">MySQL</category>
      
      <category domain="https://cheese10yun.github.io/tags/Connection-Pool/">Connection Pool</category>
      
      
      <comments>https://cheese10yun.github.io/mysql-connection-pool-timeout/#disqus_thread</comments>
      
    </item>
    
    <item>
      <title>코루틴을 이용한 성능 개선 - Flow를 활용한 다중 요청 처리</title>
      <link>https://cheese10yun.github.io/kotlin-flow/</link>
      <guid>https://cheese10yun.github.io/kotlin-flow/</guid>
      <pubDate>Wed, 22 May 2024 15:00:00 GMT</pubDate>
      
      <description>Kotlin의 코루틴을 이용한 비동기 프로그래밍은 성능을 크게 향상시킬 수 있는 강력한 도구입니다. 특히 Flow를 활용하여 여러 요청을 동시에 처리하는 방식은 효율적인 비동기 처리를 가능하게 합니다.</description>
      
      
      
      <content:encoded><![CDATA[<p>Kotlin의 코루틴을 이용한 비동기 프로그래밍은 성능을 크게 향상시킬 수 있는 강력한 도구입니다. 특히 <code>Flow</code>를 활용하여 여러 요청을 동시에 처리하는 방식은 효율적인 비동기 처리를 가능하게 합니다. 이 포스팅에서는는 <code>Flow</code>를 사용하여 다중 요청을 처리하는 방법과 이론적 배경, 그리고 이를 사용할 때 주의할 점에 대해 다루겠습니다.</p><h2><span id="sinario">시나리오</span><a href="#sinario" class="header-anchor">#</a></h2><img class="kroki" src="data:image/svg+xml;base64,PHN2ZyBpZD0iY29udGFpbmVyIiB3aWR0aD0iNTY5IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMzcxIiB2aWV3Qm94PSItNTAgLTEwIDU2OSAzNzEiIHJvbGU9ImdyYXBoaWNzLWRvY3VtZW50IGRvY3VtZW50IiBhcmlhLXJvbGVkZXNjcmlwdGlvbj0ic2VxdWVuY2UiPjxnPjxyZWN0IHg9IjIwMCIgeT0iMjg1IiBmaWxsPSIjZWFlYWVhIiBzdHJva2U9IiM2NjYiIHdpZHRoPSIxNTAiIGhlaWdodD0iNjUiIG5hbWU9IkFQSSIgcng9IjMiIHJ5PSIzIiBjbGFzcz0iYWN0b3IgYWN0b3ItYm90dG9tIj48L3JlY3Q+PHRleHQgeD0iMjc1IiB5PSIzMTcuNSIgZG9taW5hbnQtYmFzZWxpbmU9ImNlbnRyYWwiIGFsaWdubWVudC1iYXNlbGluZT0iY2VudHJhbCIgY2xhc3M9ImFjdG9yIGFjdG9yLWJveCIgc3R5bGU9InRleHQtYW5jaG9yOiBtaWRkbGU7IGZvbnQtc2l6ZTogMTZweDsgZm9udC13ZWlnaHQ6IDQwMDsiPjx0c3BhbiB4PSIyNzUiIGR5PSIwIj5BUEk8L3RzcGFuPjwvdGV4dD48L2c+PGc+PHJlY3QgeD0iMCIgeT0iMjg1IiBmaWxsPSIjZWFlYWVhIiBzdHJva2U9IiM2NjYiIHdpZHRoPSIxNTAiIGhlaWdodD0iNjUiIG5hbWU9IkNsaWVudCIgcng9IjMiIHJ5PSIzIiBjbGFzcz0iYWN0b3IgYWN0b3ItYm90dG9tIj48L3JlY3Q+PHRleHQgeD0iNzUiIHk9IjMxNy41IiBkb21pbmFudC1iYXNlbGluZT0iY2VudHJhbCIgYWxpZ25tZW50LWJhc2VsaW5lPSJjZW50cmFsIiBjbGFzcz0iYWN0b3IgYWN0b3ItYm94IiBzdHlsZT0idGV4dC1hbmNob3I6IG1pZGRsZTsgZm9udC1zaXplOiAxNnB4OyBmb250LXdlaWdodDogNDAwOyI+PHRzcGFuIHg9Ijc1IiBkeT0iMCI+Q2xpZW50PC90c3Bhbj48L3RleHQ+PC9nPjxnPjxsaW5lIGlkPSJhY3RvcjEiIHgxPSIyNzUiIHkxPSI2NSIgeDI9IjI3NSIgeTI9IjI4NSIgY2xhc3M9ImFjdG9yLWxpbmUgMjAwIiBzdHJva2Utd2lkdGg9IjAuNXB4IiBzdHJva2U9IiM5OTkiIG5hbWU9IkFQSSI+PC9saW5lPjxnIGlkPSJyb290LTEiPjxyZWN0IHg9IjIwMCIgeT0iMCIgZmlsbD0iI2VhZWFlYSIgc3Ryb2tlPSIjNjY2IiB3aWR0aD0iMTUwIiBoZWlnaHQ9IjY1IiBuYW1lPSJBUEkiIHJ4PSIzIiByeT0iMyIgY2xhc3M9ImFjdG9yIGFjdG9yLXRvcCI+PC9yZWN0Pjx0ZXh0IHg9IjI3NSIgeT0iMzIuNSIgZG9taW5hbnQtYmFzZWxpbmU9ImNlbnRyYWwiIGFsaWdubWVudC1iYXNlbGluZT0iY2VudHJhbCIgY2xhc3M9ImFjdG9yIGFjdG9yLWJveCIgc3R5bGU9InRleHQtYW5jaG9yOiBtaWRkbGU7IGZvbnQtc2l6ZTogMTZweDsgZm9udC13ZWlnaHQ6IDQwMDsiPjx0c3BhbiB4PSIyNzUiIGR5PSIwIj5BUEk8L3RzcGFuPjwvdGV4dD48L2c+PC9nPjxnPjxsaW5lIGlkPSJhY3RvcjAiIHgxPSI3NSIgeTE9IjY1IiB4Mj0iNzUiIHkyPSIyODUiIGNsYXNzPSJhY3Rvci1saW5lIDIwMCIgc3Ryb2tlLXdpZHRoPSIwLjVweCIgc3Ryb2tlPSIjOTk5IiBuYW1lPSJDbGllbnQiPjwvbGluZT48ZyBpZD0icm9vdC0wIj48cmVjdCB4PSIwIiB5PSIwIiBmaWxsPSIjZWFlYWVhIiBzdHJva2U9IiM2NjYiIHdpZHRoPSIxNTAiIGhlaWdodD0iNjUiIG5hbWU9IkNsaWVudCIgcng9IjMiIHJ5PSIzIiBjbGFzcz0iYWN0b3IgYWN0b3ItdG9wIj48L3JlY3Q+PHRleHQgeD0iNzUiIHk9IjMyLjUiIGRvbWluYW50LWJhc2VsaW5lPSJjZW50cmFsIiBhbGlnbm1lbnQtYmFzZWxpbmU9ImNlbnRyYWwiIGNsYXNzPSJhY3RvciBhY3Rvci1ib3giIHN0eWxlPSJ0ZXh0LWFuY2hvcjogbWlkZGxlOyBmb250LXNpemU6IDE2cHg7IGZvbnQtd2VpZ2h0OiA0MDA7Ij48dHNwYW4geD0iNzUiIGR5PSIwIj5DbGllbnQ8L3RzcGFuPjwvdGV4dD48L2c+PC9nPjxzdHlsZT4jY29udGFpbmVye2ZvbnQtZmFtaWx5OiJ0cmVidWNoZXQgbXMiLHZlcmRhbmEsYXJpYWwsc2Fucy1zZXJpZjtmb250LXNpemU6MTZweDtmaWxsOiMzMzM7fUBrZXlmcmFtZXMgZWRnZS1hbmltYXRpb24tZnJhbWV7ZnJvbXtzdHJva2UtZGFzaG9mZnNldDowO319QGtleWZyYW1lcyBkYXNoe3Rve3N0cm9rZS1kYXNob2Zmc2V0OjA7fX0jY29udGFpbmVyIC5lZGdlLWFuaW1hdGlvbi1zbG93e3N0cm9rZS1kYXNoYXJyYXk6OSw1IWltcG9ydGFudDtzdHJva2UtZGFzaG9mZnNldDo5MDA7YW5pbWF0aW9uOmRhc2ggNTBzIGxpbmVhciBpbmZpbml0ZTtzdHJva2UtbGluZWNhcDpyb3VuZDt9I2NvbnRhaW5lciAuZWRnZS1hbmltYXRpb24tZmFzdHtzdHJva2UtZGFzaGFycmF5OjksNSFpbXBvcnRhbnQ7c3Ryb2tlLWRhc2hvZmZzZXQ6OTAwO2FuaW1hdGlvbjpkYXNoIDIwcyBsaW5lYXIgaW5maW5pdGU7c3Ryb2tlLWxpbmVjYXA6cm91bmQ7fSNjb250YWluZXIgLmVycm9yLWljb257ZmlsbDojNTUyMjIyO30jY29udGFpbmVyIC5lcnJvci10ZXh0e2ZpbGw6IzU1MjIyMjtzdHJva2U6IzU1MjIyMjt9I2NvbnRhaW5lciAuZWRnZS10aGlja25lc3Mtbm9ybWFse3N0cm9rZS13aWR0aDoxcHg7fSNjb250YWluZXIgLmVkZ2UtdGhpY2tuZXNzLXRoaWNre3N0cm9rZS13aWR0aDozLjVweDt9I2NvbnRhaW5lciAuZWRnZS1wYXR0ZXJuLXNvbGlke3N0cm9rZS1kYXNoYXJyYXk6MDt9I2NvbnRhaW5lciAuZWRnZS10aGlja25lc3MtaW52aXNpYmxle3N0cm9rZS13aWR0aDowO2ZpbGw6bm9uZTt9I2NvbnRhaW5lciAuZWRnZS1wYXR0ZXJuLWRhc2hlZHtzdHJva2UtZGFzaGFycmF5OjM7fSNjb250YWluZXIgLmVkZ2UtcGF0dGVybi1kb3R0ZWR7c3Ryb2tlLWRhc2hhcnJheToyO30jY29udGFpbmVyIC5tYXJrZXJ7ZmlsbDojMzMzMzMzO3N0cm9rZTojMzMzMzMzO30jY29udGFpbmVyIC5tYXJrZXIuY3Jvc3N7c3Ryb2tlOiMzMzMzMzM7fSNjb250YWluZXIgc3Zne2ZvbnQtZmFtaWx5OiJ0cmVidWNoZXQgbXMiLHZlcmRhbmEsYXJpYWwsc2Fucy1zZXJpZjtmb250LXNpemU6MTZweDt9I2NvbnRhaW5lciBwe21hcmdpbjowO30jY29udGFpbmVyIC5hY3RvcntzdHJva2U6aHNsKDI1OS42MjYxNjgyMjQzLCA1OS43NzY1MzYzMTI4JSwgODcuOTAxOTYwNzg0MyUpO2ZpbGw6I0VDRUNGRjt9I2NvbnRhaW5lciB0ZXh0LmFjdG9yJmd0O3RzcGFue2ZpbGw6YmxhY2s7c3Ryb2tlOm5vbmU7fSNjb250YWluZXIgLmFjdG9yLWxpbmV7c3Ryb2tlOmhzbCgyNTkuNjI2MTY4MjI0MywgNTkuNzc2NTM2MzEyOCUsIDg3LjkwMTk2MDc4NDMlKTt9I2NvbnRhaW5lciAuaW5uZXJBcmN7c3Ryb2tlLXdpZHRoOjEuNTtzdHJva2UtZGFzaGFycmF5Om5vbmU7fSNjb250YWluZXIgLm1lc3NhZ2VMaW5lMHtzdHJva2Utd2lkdGg6MS41O3N0cm9rZS1kYXNoYXJyYXk6bm9uZTtzdHJva2U6IzMzMzt9I2NvbnRhaW5lciAubWVzc2FnZUxpbmUxe3N0cm9rZS13aWR0aDoxLjU7c3Ryb2tlLWRhc2hhcnJheToyLDI7c3Ryb2tlOiMzMzM7fSNjb250YWluZXIgI2Fycm93aGVhZCBwYXRoe2ZpbGw6IzMzMztzdHJva2U6IzMzMzt9I2NvbnRhaW5lciAuc2VxdWVuY2VOdW1iZXJ7ZmlsbDp3aGl0ZTt9I2NvbnRhaW5lciAjc2VxdWVuY2VudW1iZXJ7ZmlsbDojMzMzO30jY29udGFpbmVyICNjcm9zc2hlYWQgcGF0aHtmaWxsOiMzMzM7c3Ryb2tlOiMzMzM7fSNjb250YWluZXIgLm1lc3NhZ2VUZXh0e2ZpbGw6IzMzMztzdHJva2U6bm9uZTt9I2NvbnRhaW5lciAubGFiZWxCb3h7c3Ryb2tlOmhzbCgyNTkuNjI2MTY4MjI0MywgNTkuNzc2NTM2MzEyOCUsIDg3LjkwMTk2MDc4NDMlKTtmaWxsOiNFQ0VDRkY7fSNjb250YWluZXIgLmxhYmVsVGV4dCwjY29udGFpbmVyIC5sYWJlbFRleHQmZ3Q7dHNwYW57ZmlsbDpibGFjaztzdHJva2U6bm9uZTt9I2NvbnRhaW5lciAubG9vcFRleHQsI2NvbnRhaW5lciAubG9vcFRleHQmZ3Q7dHNwYW57ZmlsbDpibGFjaztzdHJva2U6bm9uZTt9I2NvbnRhaW5lciAubG9vcExpbmV7c3Ryb2tlLXdpZHRoOjJweDtzdHJva2UtZGFzaGFycmF5OjIsMjtzdHJva2U6aHNsKDI1OS42MjYxNjgyMjQzLCA1OS43NzY1MzYzMTI4JSwgODcuOTAxOTYwNzg0MyUpO2ZpbGw6aHNsKDI1OS42MjYxNjgyMjQzLCA1OS43NzY1MzYzMTI4JSwgODcuOTAxOTYwNzg0MyUpO30jY29udGFpbmVyIC5ub3Rle3N0cm9rZTojYWFhYTMzO2ZpbGw6I2ZmZjVhZDt9I2NvbnRhaW5lciAubm90ZVRleHQsI2NvbnRhaW5lciAubm90ZVRleHQmZ3Q7dHNwYW57ZmlsbDpibGFjaztzdHJva2U6bm9uZTt9I2NvbnRhaW5lciAuYWN0aXZhdGlvbjB7ZmlsbDojZjRmNGY0O3N0cm9rZTojNjY2O30jY29udGFpbmVyIC5hY3RpdmF0aW9uMXtmaWxsOiNmNGY0ZjQ7c3Ryb2tlOiM2NjY7fSNjb250YWluZXIgLmFjdGl2YXRpb24ye2ZpbGw6I2Y0ZjRmNDtzdHJva2U6IzY2Njt9I2NvbnRhaW5lciAuYWN0b3JQb3B1cE1lbnV7cG9zaXRpb246YWJzb2x1dGU7fSNjb250YWluZXIgLmFjdG9yUG9wdXBNZW51UGFuZWx7cG9zaXRpb246YWJzb2x1dGU7ZmlsbDojRUNFQ0ZGO2JveC1zaGFkb3c6MHB4IDhweCAxNnB4IDBweCByZ2JhKDAsMCwwLDAuMik7ZmlsdGVyOmRyb3Atc2hhZG93KDNweCA1cHggMnB4IHJnYigwIDAgMCAvIDAuNCkpO30jY29udGFpbmVyIC5hY3Rvci1tYW4gbGluZXtzdHJva2U6aHNsKDI1OS42MjYxNjgyMjQzLCA1OS43NzY1MzYzMTI4JSwgODcuOTAxOTYwNzg0MyUpO2ZpbGw6I0VDRUNGRjt9I2NvbnRhaW5lciAuYWN0b3ItbWFuIGNpcmNsZSwjY29udGFpbmVyIGxpbmV7c3Ryb2tlOmhzbCgyNTkuNjI2MTY4MjI0MywgNTkuNzc2NTM2MzEyOCUsIDg3LjkwMTk2MDc4NDMlKTtmaWxsOiNFQ0VDRkY7c3Ryb2tlLXdpZHRoOjJweDt9I2NvbnRhaW5lciA6cm9vdHstLW1lcm1haWQtZm9udC1mYW1pbHk6InRyZWJ1Y2hldCBtcyIsdmVyZGFuYSxhcmlhbCxzYW5zLXNlcmlmO308L3N0eWxlPjxnPjwvZz48ZGVmcz48c3ltYm9sIGlkPSJjb21wdXRlciIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0Ij48cGF0aCB0cmFuc2Zvcm09InNjYWxlKC41KSIgZD0iTTIgMnYxM2gyMHYtMTNoLTIwem0xOCAxMWgtMTZ2LTloMTZ2OXptLTEwLjIyOCA2bC40NjYtMWgzLjUyNGwuNDY3IDFoLTQuNDU3em0xNC4yMjggM2gtMjRsMi02aDIuMTA0bC0xLjMzIDRoMTguNDVsLTEuMjk3LTRoMi4wNzNsMiA2em0tNS0xMGgtMTR2LTdoMTR2N3oiPjwvcGF0aD48L3N5bWJvbD48L2RlZnM+PGRlZnM+PHN5bWJvbCBpZD0iZGF0YWJhc2UiIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIj48cGF0aCB0cmFuc2Zvcm09InNjYWxlKC41KSIgZD0iTTEyLjI1OC4wMDFsLjI1Ni4wMDQuMjU1LjAwNS4yNTMuMDA4LjI1MS4wMS4yNDkuMDEyLjI0Ny4wMTUuMjQ2LjAxNi4yNDIuMDE5LjI0MS4wMi4yMzkuMDIzLjIzNi4wMjQuMjMzLjAyNy4yMzEuMDI4LjIyOS4wMzEuMjI1LjAzMi4yMjMuMDM0LjIyLjAzNi4yMTcuMDM4LjIxNC4wNC4yMTEuMDQxLjIwOC4wNDMuMjA1LjA0NS4yMDEuMDQ2LjE5OC4wNDguMTk0LjA1LjE5MS4wNTEuMTg3LjA1My4xODMuMDU0LjE4LjA1Ni4xNzUuMDU3LjE3Mi4wNTkuMTY4LjA2LjE2My4wNjEuMTYuMDYzLjE1NS4wNjQuMTUuMDY2LjA3NC4wMzMuMDczLjAzMy4wNzEuMDM0LjA3LjAzNC4wNjkuMDM1LjA2OC4wMzUuMDY3LjAzNS4wNjYuMDM1LjA2NC4wMzYuMDY0LjAzNi4wNjIuMDM2LjA2LjAzNi4wNi4wMzcuMDU4LjAzNy4wNTguMDM3LjA1NS4wMzguMDU1LjAzOC4wNTMuMDM4LjA1Mi4wMzguMDUxLjAzOS4wNS4wMzkuMDQ4LjAzOS4wNDcuMDM5LjA0NS4wNC4wNDQuMDQuMDQzLjA0LjA0MS4wNC4wNC4wNDEuMDM5LjA0MS4wMzcuMDQxLjAzNi4wNDEuMDM0LjA0MS4wMzMuMDQyLjAzMi4wNDIuMDMuMDQyLjAyOS4wNDIuMDI3LjA0Mi4wMjYuMDQzLjAyNC4wNDMuMDIzLjA0My4wMjEuMDQzLjAyLjA0My4wMTguMDQ0LjAxNy4wNDMuMDE1LjA0NC4wMTMuMDQ0LjAxMi4wNDQuMDExLjA0NS4wMDkuMDQ0LjAwNy4wNDUuMDA2LjA0NS4wMDQuMDQ1LjAwMi4wNDUuMDAxLjA0NXYxN2wtLjAwMS4wNDUtLjAwMi4wNDUtLjAwNC4wNDUtLjAwNi4wNDUtLjAwNy4wNDUtLjAwOS4wNDQtLjAxMS4wNDUtLjAxMi4wNDQtLjAxMy4wNDQtLjAxNS4wNDQtLjAxNy4wNDMtLjAxOC4wNDQtLjAyLjA0My0uMDIxLjA0My0uMDIzLjA0My0uMDI0LjA0My0uMDI2LjA0My0uMDI3LjA0Mi0uMDI5LjA0Mi0uMDMuMDQyLS4wMzIuMDQyLS4wMzMuMDQyLS4wMzQuMDQxLS4wMzYuMDQxLS4wMzcuMDQxLS4wMzkuMDQxLS4wNC4wNDEtLjA0MS4wNC0uMDQzLjA0LS4wNDQuMDQtLjA0NS4wNC0uMDQ3LjAzOS0uMDQ4LjAzOS0uMDUuMDM5LS4wNTEuMDM5LS4wNTIuMDM4LS4wNTMuMDM4LS4wNTUuMDM4LS4wNTUuMDM4LS4wNTguMDM3LS4wNTguMDM3LS4wNi4wMzctLjA2LjAzNi0uMDYyLjAzNi0uMDY0LjAzNi0uMDY0LjAzNi0uMDY2LjAzNS0uMDY3LjAzNS0uMDY4LjAzNS0uMDY5LjAzNS0uMDcuMDM0LS4wNzEuMDM0LS4wNzMuMDMzLS4wNzQuMDMzLS4xNS4wNjYtLjE1NS4wNjQtLjE2LjA2My0uMTYzLjA2MS0uMTY4LjA2LS4xNzIuMDU5LS4xNzUuMDU3LS4xOC4wNTYtLjE4My4wNTQtLjE4Ny4wNTMtLjE5MS4wNTEtLjE5NC4wNS0uMTk4LjA0OC0uMjAxLjA0Ni0uMjA1LjA0NS0uMjA4LjA0My0uMjExLjA0MS0uMjE0LjA0LS4yMTcuMDM4LS4yMi4wMzYtLjIyMy4wMzQtLjIyNS4wMzItLjIyOS4wMzEtLjIzMS4wMjgtLjIzMy4wMjctLjIzNi4wMjQtLjIzOS4wMjMtLjI0MS4wMi0uMjQyLjAxOS0uMjQ2LjAxNi0uMjQ3LjAxNS0uMjQ5LjAxMi0uMjUxLjAxLS4yNTMuMDA4LS4yNTUuMDA1LS4yNTYuMDA0LS4yNTguMDAxLS4yNTgtLjAwMS0uMjU2LS4wMDQtLjI1NS0uMDA1LS4yNTMtLjAwOC0uMjUxLS4wMS0uMjQ5LS4wMTItLjI0Ny0uMDE1LS4yNDUtLjAxNi0uMjQzLS4wMTktLjI0MS0uMDItLjIzOC0uMDIzLS4yMzYtLjAyNC0uMjM0LS4wMjctLjIzMS0uMDI4LS4yMjgtLjAzMS0uMjI2LS4wMzItLjIyMy0uMDM0LS4yMi0uMDM2LS4yMTctLjAzOC0uMjE0LS4wNC0uMjExLS4wNDEtLjIwOC0uMDQzLS4yMDQtLjA0NS0uMjAxLS4wNDYtLjE5OC0uMDQ4LS4xOTUtLjA1LS4xOS0uMDUxLS4xODctLjA1My0uMTg0LS4wNTQtLjE3OS0uMDU2LS4xNzYtLjA1Ny0uMTcyLS4wNTktLjE2Ny0uMDYtLjE2NC0uMDYxLS4xNTktLjA2My0uMTU1LS4wNjQtLjE1MS0uMDY2LS4wNzQtLjAzMy0uMDcyLS4wMzMtLjA3Mi0uMDM0LS4wNy0uMDM0LS4wNjktLjAzNS0uMDY4LS4wMzUtLjA2Ny0uMDM1LS4wNjYtLjAzNS0uMDY0LS4wMzYtLjA2My0uMDM2LS4wNjItLjAzNi0uMDYxLS4wMzYtLjA2LS4wMzctLjA1OC0uMDM3LS4wNTctLjAzNy0uMDU2LS4wMzgtLjA1NS0uMDM4LS4wNTMtLjAzOC0uMDUyLS4wMzgtLjA1MS0uMDM5LS4wNDktLjAzOS0uMDQ5LS4wMzktLjA0Ni0uMDM5LS4wNDYtLjA0LS4wNDQtLjA0LS4wNDMtLjA0LS4wNDEtLjA0LS4wNC0uMDQxLS4wMzktLjA0MS0uMDM3LS4wNDEtLjAzNi0uMDQxLS4wMzQtLjA0MS0uMDMzLS4wNDItLjAzMi0uMDQyLS4wMy0uMDQyLS4wMjktLjA0Mi0uMDI3LS4wNDItLjAyNi0uMDQzLS4wMjQtLjA0My0uMDIzLS4wNDMtLjAyMS0uMDQzLS4wMi0uMDQzLS4wMTgtLjA0NC0uMDE3LS4wNDMtLjAxNS0uMDQ0LS4wMTMtLjA0NC0uMDEyLS4wNDQtLjAxMS0uMDQ1LS4wMDktLjA0NC0uMDA3LS4wNDUtLjAwNi0uMDQ1LS4wMDQtLjA0NS0uMDAyLS4wNDUtLjAwMS0uMDQ1di0xN2wuMDAxLS4wNDUuMDAyLS4wNDUuMDA0LS4wNDUuMDA2LS4wNDUuMDA3LS4wNDUuMDA5LS4wNDQuMDExLS4wNDUuMDEyLS4wNDQuMDEzLS4wNDQuMDE1LS4wNDQuMDE3LS4wNDMuMDE4LS4wNDQuMDItLjA0My4wMjEtLjA0My4wMjMtLjA0My4wMjQtLjA0My4wMjYtLjA0My4wMjctLjA0Mi4wMjktLjA0Mi4wMy0uMDQyLjAzMi0uMDQyLjAzMy0uMDQyLjAzNC0uMDQxLjAzNi0uMDQxLjAzNy0uMDQxLjAzOS0uMDQxLjA0LS4wNDEuMDQxLS4wNC4wNDMtLjA0LjA0NC0uMDQuMDQ2LS4wNC4wNDYtLjAzOS4wNDktLjAzOS4wNDktLjAzOS4wNTEtLjAzOS4wNTItLjAzOC4wNTMtLjAzOC4wNTUtLjAzOC4wNTYtLjAzOC4wNTctLjAzNy4wNTgtLjAzNy4wNi0uMDM3LjA2MS0uMDM2LjA2Mi0uMDM2LjA2My0uMDM2LjA2NC0uMDM2LjA2Ni0uMDM1LjA2Ny0uMDM1LjA2OC0uMDM1LjA2OS0uMDM1LjA3LS4wMzQuMDcyLS4wMzQuMDcyLS4wMzMuMDc0LS4wMzMuMTUxLS4wNjYuMTU1LS4wNjQuMTU5LS4wNjMuMTY0LS4wNjEuMTY3LS4wNi4xNzItLjA1OS4xNzYtLjA1Ny4xNzktLjA1Ni4xODQtLjA1NC4xODctLjA1My4xOS0uMDUxLjE5NS0uMDUuMTk4LS4wNDguMjAxLS4wNDYuMjA0LS4wNDUuMjA4LS4wNDMuMjExLS4wNDEuMjE0LS4wNC4yMTctLjAzOC4yMi0uMDM2LjIyMy0uMDM0LjIyNi0uMDMyLjIyOC0uMDMxLjIzMS0uMDI4LjIzNC0uMDI3LjIzNi0uMDI0LjIzOC0uMDIzLjI0MS0uMDIuMjQzLS4wMTkuMjQ1LS4wMTYuMjQ3LS4wMTUuMjQ5LS4wMTIuMjUxLS4wMS4yNTMtLjAwOC4yNTUtLjAwNS4yNTYtLjAwNC4yNTgtLjAwMS4yNTguMDAxem0tOS4yNTggMjAuNDk5di4wMWwuMDAxLjAyMS4wMDMuMDIxLjAwNC4wMjIuMDA1LjAyMS4wMDYuMDIyLjAwNy4wMjIuMDA5LjAyMy4wMS4wMjIuMDExLjAyMy4wMTIuMDIzLjAxMy4wMjMuMDE1LjAyMy4wMTYuMDI0LjAxNy4wMjMuMDE4LjAyNC4wMTkuMDI0LjAyMS4wMjQuMDIyLjAyNS4wMjMuMDI0LjAyNC4wMjUuMDUyLjA0OS4wNTYuMDUuMDYxLjA1MS4wNjYuMDUxLjA3LjA1MS4wNzUuMDUxLjA3OS4wNTIuMDg0LjA1Mi4wODguMDUyLjA5Mi4wNTIuMDk3LjA1Mi4xMDIuMDUxLjEwNS4wNTIuMTEuMDUyLjExNC4wNTEuMTE5LjA1MS4xMjMuMDUxLjEyNy4wNS4xMzEuMDUuMTM1LjA1LjEzOS4wNDguMTQ0LjA0OS4xNDcuMDQ3LjE1Mi4wNDcuMTU1LjA0Ny4xNi4wNDUuMTYzLjA0NS4xNjcuMDQzLjE3MS4wNDMuMTc2LjA0MS4xNzguMDQxLjE4My4wMzkuMTg3LjAzOS4xOS4wMzcuMTk0LjAzNS4xOTcuMDM1LjIwMi4wMzMuMjA0LjAzMS4yMDkuMDMuMjEyLjAyOS4yMTYuMDI3LjIxOS4wMjUuMjIyLjAyNC4yMjYuMDIxLjIzLjAyLjIzMy4wMTguMjM2LjAxNi4yNC4wMTUuMjQzLjAxMi4yNDYuMDEuMjQ5LjAwOC4yNTMuMDA1LjI1Ni4wMDQuMjU5LjAwMS4yNi0uMDAxLjI1Ny0uMDA0LjI1NC0uMDA1LjI1LS4wMDguMjQ3LS4wMTEuMjQ0LS4wMTIuMjQxLS4wMTQuMjM3LS4wMTYuMjMzLS4wMTguMjMxLS4wMjEuMjI2LS4wMjEuMjI0LS4wMjQuMjItLjAyNi4yMTYtLjAyNy4yMTItLjAyOC4yMS0uMDMxLjIwNS0uMDMxLjIwMi0uMDM0LjE5OC0uMDM0LjE5NC0uMDM2LjE5MS0uMDM3LjE4Ny0uMDM5LjE4My0uMDQuMTc5LS4wNC4xNzUtLjA0Mi4xNzItLjA0My4xNjgtLjA0NC4xNjMtLjA0NS4xNi0uMDQ2LjE1NS0uMDQ2LjE1Mi0uMDQ3LjE0OC0uMDQ4LjE0My0uMDQ5LjEzOS0uMDQ5LjEzNi0uMDUuMTMxLS4wNS4xMjYtLjA1LjEyMy0uMDUxLjExOC0uMDUyLjExNC0uMDUxLjExLS4wNTIuMTA2LS4wNTIuMTAxLS4wNTIuMDk2LS4wNTIuMDkyLS4wNTIuMDg4LS4wNTMuMDgzLS4wNTEuMDc5LS4wNTIuMDc0LS4wNTIuMDctLjA1MS4wNjUtLjA1MS4wNi0uMDUxLjA1Ni0uMDUuMDUxLS4wNS4wMjMtLjAyNC4wMjMtLjAyNS4wMjEtLjAyNC4wMi0uMDI0LjAxOS0uMDI0LjAxOC0uMDI0LjAxNy0uMDI0LjAxNS0uMDIzLjAxNC0uMDI0LjAxMy0uMDIzLjAxMi0uMDIzLjAxLS4wMjMuMDEtLjAyMi4wMDgtLjAyMi4wMDYtLjAyMi4wMDYtLjAyMi4wMDQtLjAyMi4wMDQtLjAyMS4wMDEtLjAyMS4wMDEtLjAyMXYtNC4xMjdsLS4wNzcuMDU1LS4wOC4wNTMtLjA4My4wNTQtLjA4NS4wNTMtLjA4Ny4wNTItLjA5LjA1Mi0uMDkzLjA1MS0uMDk1LjA1LS4wOTcuMDUtLjEuMDQ5LS4xMDIuMDQ5LS4xMDUuMDQ4LS4xMDYuMDQ3LS4xMDkuMDQ3LS4xMTEuMDQ2LS4xMTQuMDQ1LS4xMTUuMDQ1LS4xMTguMDQ0LS4xMi4wNDMtLjEyMi4wNDItLjEyNC4wNDItLjEyNi4wNDEtLjEyOC4wNC0uMTMuMDQtLjEzMi4wMzgtLjEzNC4wMzgtLjEzNS4wMzctLjEzOC4wMzctLjEzOS4wMzUtLjE0Mi4wMzUtLjE0My4wMzQtLjE0NC4wMzMtLjE0Ny4wMzItLjE0OC4wMzEtLjE1LjAzLS4xNTEuMDMtLjE1My4wMjktLjE1NC4wMjctLjE1Ni4wMjctLjE1OC4wMjYtLjE1OS4wMjUtLjE2MS4wMjQtLjE2Mi4wMjMtLjE2My4wMjItLjE2NS4wMjEtLjE2Ni4wMi0uMTY3LjAxOS0uMTY5LjAxOC0uMTY5LjAxNy0uMTcxLjAxNi0uMTczLjAxNS0uMTczLjAxNC0uMTc1LjAxMy0uMTc1LjAxMi0uMTc3LjAxMS0uMTc4LjAxLS4xNzkuMDA4LS4xNzkuMDA4LS4xODEuMDA2LS4xODIuMDA1LS4xODIuMDA0LS4xODQuMDAzLS4xODQuMDAyaC0uMzdsLS4xODQtLjAwMi0uMTg0LS4wMDMtLjE4Mi0uMDA0LS4xODItLjAwNS0uMTgxLS4wMDYtLjE3OS0uMDA4LS4xNzktLjAwOC0uMTc4LS4wMS0uMTc2LS4wMTEtLjE3Ni0uMDEyLS4xNzUtLjAxMy0uMTczLS4wMTQtLjE3Mi0uMDE1LS4xNzEtLjAxNi0uMTctLjAxNy0uMTY5LS4wMTgtLjE2Ny0uMDE5LS4xNjYtLjAyLS4xNjUtLjAyMS0uMTYzLS4wMjItLjE2Mi0uMDIzLS4xNjEtLjAyNC0uMTU5LS4wMjUtLjE1Ny0uMDI2LS4xNTYtLjAyNy0uMTU1LS4wMjctLjE1My0uMDI5LS4xNTEtLjAzLS4xNS0uMDMtLjE0OC0uMDMxLS4xNDYtLjAzMi0uMTQ1LS4wMzMtLjE0My0uMDM0LS4xNDEtLjAzNS0uMTQtLjAzNS0uMTM3LS4wMzctLjEzNi0uMDM3LS4xMzQtLjAzOC0uMTMyLS4wMzgtLjEzLS4wNC0uMTI4LS4wNC0uMTI2LS4wNDEtLjEyNC0uMDQyLS4xMjItLjA0Mi0uMTItLjA0NC0uMTE3LS4wNDMtLjExNi0uMDQ1LS4xMTMtLjA0NS0uMTEyLS4wNDYtLjEwOS0uMDQ3LS4xMDYtLjA0Ny0uMTA1LS4wNDgtLjEwMi0uMDQ5LS4xLS4wNDktLjA5Ny0uMDUtLjA5NS0uMDUtLjA5My0uMDUyLS4wOS0uMDUxLS4wODctLjA1Mi0uMDg1LS4wNTMtLjA4My0uMDU0LS4wOC0uMDU0LS4wNzctLjA1NHY0LjEyN3ptMC01LjY1NHYuMDExbC4wMDEuMDIxLjAwMy4wMjEuMDA0LjAyMS4wMDUuMDIyLjAwNi4wMjIuMDA3LjAyMi4wMDkuMDIyLjAxLjAyMi4wMTEuMDIzLjAxMi4wMjMuMDEzLjAyMy4wMTUuMDI0LjAxNi4wMjMuMDE3LjAyNC4wMTguMDI0LjAxOS4wMjQuMDIxLjAyNC4wMjIuMDI0LjAyMy4wMjUuMDI0LjAyNC4wNTIuMDUuMDU2LjA1LjA2MS4wNS4wNjYuMDUxLjA3LjA1MS4wNzUuMDUyLjA3OS4wNTEuMDg0LjA1Mi4wODguMDUyLjA5Mi4wNTIuMDk3LjA1Mi4xMDIuMDUyLjEwNS4wNTIuMTEuMDUxLjExNC4wNTEuMTE5LjA1Mi4xMjMuMDUuMTI3LjA1MS4xMzEuMDUuMTM1LjA0OS4xMzkuMDQ5LjE0NC4wNDguMTQ3LjA0OC4xNTIuMDQ3LjE1NS4wNDYuMTYuMDQ1LjE2My4wNDUuMTY3LjA0NC4xNzEuMDQyLjE3Ni4wNDIuMTc4LjA0LjE4My4wNC4xODcuMDM4LjE5LjAzNy4xOTQuMDM2LjE5Ny4wMzQuMjAyLjAzMy4yMDQuMDMyLjIwOS4wMy4yMTIuMDI4LjIxNi4wMjcuMjE5LjAyNS4yMjIuMDI0LjIyNi4wMjIuMjMuMDIuMjMzLjAxOC4yMzYuMDE2LjI0LjAxNC4yNDMuMDEyLjI0Ni4wMS4yNDkuMDA4LjI1My4wMDYuMjU2LjAwMy4yNTkuMDAxLjI2LS4wMDEuMjU3LS4wMDMuMjU0LS4wMDYuMjUtLjAwOC4yNDctLjAxLjI0NC0uMDEyLjI0MS0uMDE1LjIzNy0uMDE2LjIzMy0uMDE4LjIzMS0uMDIuMjI2LS4wMjIuMjI0LS4wMjQuMjItLjAyNS4yMTYtLjAyNy4yMTItLjAyOS4yMS0uMDMuMjA1LS4wMzIuMjAyLS4wMzMuMTk4LS4wMzUuMTk0LS4wMzYuMTkxLS4wMzcuMTg3LS4wMzkuMTgzLS4wMzkuMTc5LS4wNDEuMTc1LS4wNDIuMTcyLS4wNDMuMTY4LS4wNDQuMTYzLS4wNDUuMTYtLjA0NS4xNTUtLjA0Ny4xNTItLjA0Ny4xNDgtLjA0OC4xNDMtLjA0OC4xMzktLjA1LjEzNi0uMDQ5LjEzMS0uMDUuMTI2LS4wNTEuMTIzLS4wNTEuMTE4LS4wNTEuMTE0LS4wNTIuMTEtLjA1Mi4xMDYtLjA1Mi4xMDEtLjA1Mi4wOTYtLjA1Mi4wOTItLjA1Mi4wODgtLjA1Mi4wODMtLjA1Mi4wNzktLjA1Mi4wNzQtLjA1MS4wNy0uMDUyLjA2NS0uMDUxLjA2LS4wNS4wNTYtLjA1MS4wNTEtLjA0OS4wMjMtLjAyNS4wMjMtLjAyNC4wMjEtLjAyNS4wMi0uMDI0LjAxOS0uMDI0LjAxOC0uMDI0LjAxNy0uMDI0LjAxNS0uMDIzLjAxNC0uMDIzLjAxMy0uMDI0LjAxMi0uMDIyLjAxLS4wMjMuMDEtLjAyMy4wMDgtLjAyMi4wMDYtLjAyMi4wMDYtLjAyMi4wMDQtLjAyMS4wMDQtLjAyMi4wMDEtLjAyMS4wMDEtLjAyMXYtNC4xMzlsLS4wNzcuMDU0LS4wOC4wNTQtLjA4My4wNTQtLjA4NS4wNTItLjA4Ny4wNTMtLjA5LjA1MS0uMDkzLjA1MS0uMDk1LjA1MS0uMDk3LjA1LS4xLjA0OS0uMTAyLjA0OS0uMTA1LjA0OC0uMTA2LjA0Ny0uMTA5LjA0Ny0uMTExLjA0Ni0uMTE0LjA0NS0uMTE1LjA0NC0uMTE4LjA0NC0uMTIuMDQ0LS4xMjIuMDQyLS4xMjQuMDQyLS4xMjYuMDQxLS4xMjguMDQtLjEzLjAzOS0uMTMyLjAzOS0uMTM0LjAzOC0uMTM1LjAzNy0uMTM4LjAzNi0uMTM5LjAzNi0uMTQyLjAzNS0uMTQzLjAzMy0uMTQ0LjAzMy0uMTQ3LjAzMy0uMTQ4LjAzMS0uMTUuMDMtLjE1MS4wMy0uMTUzLjAyOC0uMTU0LjAyOC0uMTU2LjAyNy0uMTU4LjAyNi0uMTU5LjAyNS0uMTYxLjAyNC0uMTYyLjAyMy0uMTYzLjAyMi0uMTY1LjAyMS0uMTY2LjAyLS4xNjcuMDE5LS4xNjkuMDE4LS4xNjkuMDE3LS4xNzEuMDE2LS4xNzMuMDE1LS4xNzMuMDE0LS4xNzUuMDEzLS4xNzUuMDEyLS4xNzcuMDExLS4xNzguMDA5LS4xNzkuMDA5LS4xNzkuMDA3LS4xODEuMDA3LS4xODIuMDA1LS4xODIuMDA0LS4xODQuMDAzLS4xODQuMDAyaC0uMzdsLS4xODQtLjAwMi0uMTg0LS4wMDMtLjE4Mi0uMDA0LS4xODItLjAwNS0uMTgxLS4wMDctLjE3OS0uMDA3LS4xNzktLjAwOS0uMTc4LS4wMDktLjE3Ni0uMDExLS4xNzYtLjAxMi0uMTc1LS4wMTMtLjE3My0uMDE0LS4xNzItLjAxNS0uMTcxLS4wMTYtLjE3LS4wMTctLjE2OS0uMDE4LS4xNjctLjAxOS0uMTY2LS4wMi0uMTY1LS4wMjEtLjE2My0uMDIyLS4xNjItLjAyMy0uMTYxLS4wMjQtLjE1OS0uMDI1LS4xNTctLjAyNi0uMTU2LS4wMjctLjE1NS0uMDI4LS4xNTMtLjAyOC0uMTUxLS4wMy0uMTUtLjAzLS4xNDgtLjAzMS0uMTQ2LS4wMzMtLjE0NS0uMDMzLS4xNDMtLjAzMy0uMTQxLS4wMzUtLjE0LS4wMzYtLjEzNy0uMDM2LS4xMzYtLjAzNy0uMTM0LS4wMzgtLjEzMi0uMDM5LS4xMy0uMDM5LS4xMjgtLjA0LS4xMjYtLjA0MS0uMTI0LS4wNDItLjEyMi0uMDQzLS4xMi0uMDQzLS4xMTctLjA0NC0uMTE2LS4wNDQtLjExMy0uMDQ2LS4xMTItLjA0Ni0uMTA5LS4wNDYtLjEwNi0uMDQ3LS4xMDUtLjA0OC0uMTAyLS4wNDktLjEtLjA0OS0uMDk3LS4wNS0uMDk1LS4wNTEtLjA5My0uMDUxLS4wOS0uMDUxLS4wODctLjA1My0uMDg1LS4wNTItLjA4My0uMDU0LS4wOC0uMDU0LS4wNzctLjA1NHY0LjEzOXptMC01LjY2NnYuMDExbC4wMDEuMDIuMDAzLjAyMi4wMDQuMDIxLjAwNS4wMjIuMDA2LjAyMS4wMDcuMDIyLjAwOS4wMjMuMDEuMDIyLjAxMS4wMjMuMDEyLjAyMy4wMTMuMDIzLjAxNS4wMjMuMDE2LjAyNC4wMTcuMDI0LjAxOC4wMjMuMDE5LjAyNC4wMjEuMDI1LjAyMi4wMjQuMDIzLjAyNC4wMjQuMDI1LjA1Mi4wNS4wNTYuMDUuMDYxLjA1LjA2Ni4wNTEuMDcuMDUxLjA3NS4wNTIuMDc5LjA1MS4wODQuMDUyLjA4OC4wNTIuMDkyLjA1Mi4wOTcuMDUyLjEwMi4wNTIuMTA1LjA1MS4xMS4wNTIuMTE0LjA1MS4xMTkuMDUxLjEyMy4wNTEuMTI3LjA1LjEzMS4wNS4xMzUuMDUuMTM5LjA0OS4xNDQuMDQ4LjE0Ny4wNDguMTUyLjA0Ny4xNTUuMDQ2LjE2LjA0NS4xNjMuMDQ1LjE2Ny4wNDMuMTcxLjA0My4xNzYuMDQyLjE3OC4wNC4xODMuMDQuMTg3LjAzOC4xOS4wMzcuMTk0LjAzNi4xOTcuMDM0LjIwMi4wMzMuMjA0LjAzMi4yMDkuMDMuMjEyLjAyOC4yMTYuMDI3LjIxOS4wMjUuMjIyLjAyNC4yMjYuMDIxLjIzLjAyLjIzMy4wMTguMjM2LjAxNy4yNC4wMTQuMjQzLjAxMi4yNDYuMDEuMjQ5LjAwOC4yNTMuMDA2LjI1Ni4wMDMuMjU5LjAwMS4yNi0uMDAxLjI1Ny0uMDAzLjI1NC0uMDA2LjI1LS4wMDguMjQ3LS4wMS4yNDQtLjAxMy4yNDEtLjAxNC4yMzctLjAxNi4yMzMtLjAxOC4yMzEtLjAyLjIyNi0uMDIyLjIyNC0uMDI0LjIyLS4wMjUuMjE2LS4wMjcuMjEyLS4wMjkuMjEtLjAzLjIwNS0uMDMyLjIwMi0uMDMzLjE5OC0uMDM1LjE5NC0uMDM2LjE5MS0uMDM3LjE4Ny0uMDM5LjE4My0uMDM5LjE3OS0uMDQxLjE3NS0uMDQyLjE3Mi0uMDQzLjE2OC0uMDQ0LjE2My0uMDQ1LjE2LS4wNDUuMTU1LS4wNDcuMTUyLS4wNDcuMTQ4LS4wNDguMTQzLS4wNDkuMTM5LS4wNDkuMTM2LS4wNDkuMTMxLS4wNTEuMTI2LS4wNS4xMjMtLjA1MS4xMTgtLjA1Mi4xMTQtLjA1MS4xMS0uMDUyLjEwNi0uMDUyLjEwMS0uMDUyLjA5Ni0uMDUyLjA5Mi0uMDUyLjA4OC0uMDUyLjA4My0uMDUyLjA3OS0uMDUyLjA3NC0uMDUyLjA3LS4wNTEuMDY1LS4wNTEuMDYtLjA1MS4wNTYtLjA1LjA1MS0uMDQ5LjAyMy0uMDI1LjAyMy0uMDI1LjAyMS0uMDI0LjAyLS4wMjQuMDE5LS4wMjQuMDE4LS4wMjQuMDE3LS4wMjQuMDE1LS4wMjMuMDE0LS4wMjQuMDEzLS4wMjMuMDEyLS4wMjMuMDEtLjAyMi4wMS0uMDIzLjAwOC0uMDIyLjAwNi0uMDIyLjAwNi0uMDIyLjAwNC0uMDIyLjAwNC0uMDIxLjAwMS0uMDIxLjAwMS0uMDIxdi00LjE1M2wtLjA3Ny4wNTQtLjA4LjA1NC0uMDgzLjA1My0uMDg1LjA1My0uMDg3LjA1My0uMDkuMDUxLS4wOTMuMDUxLS4wOTUuMDUxLS4wOTcuMDUtLjEuMDQ5LS4xMDIuMDQ4LS4xMDUuMDQ4LS4xMDYuMDQ4LS4xMDkuMDQ2LS4xMTEuMDQ2LS4xMTQuMDQ2LS4xMTUuMDQ0LS4xMTguMDQ0LS4xMi4wNDMtLjEyMi4wNDMtLjEyNC4wNDItLjEyNi4wNDEtLjEyOC4wNC0uMTMuMDM5LS4xMzIuMDM5LS4xMzQuMDM4LS4xMzUuMDM3LS4xMzguMDM2LS4xMzkuMDM2LS4xNDIuMDM0LS4xNDMuMDM0LS4xNDQuMDMzLS4xNDcuMDMyLS4xNDguMDMyLS4xNS4wMy0uMTUxLjAzLS4xNTMuMDI4LS4xNTQuMDI4LS4xNTYuMDI3LS4xNTguMDI2LS4xNTkuMDI0LS4xNjEuMDI0LS4xNjIuMDIzLS4xNjMuMDIzLS4xNjUuMDIxLS4xNjYuMDItLjE2Ny4wMTktLjE2OS4wMTgtLjE2OS4wMTctLjE3MS4wMTYtLjE3My4wMTUtLjE3My4wMTQtLjE3NS4wMTMtLjE3NS4wMTItLjE3Ny4wMS0uMTc4LjAxLS4xNzkuMDA5LS4xNzkuMDA3LS4xODEuMDA2LS4xODIuMDA2LS4xODIuMDA0LS4xODQuMDAzLS4xODQuMDAxLS4xODUuMDAxLS4xODUtLjAwMS0uMTg0LS4wMDEtLjE4NC0uMDAzLS4xODItLjAwNC0uMTgyLS4wMDYtLjE4MS0uMDA2LS4xNzktLjAwNy0uMTc5LS4wMDktLjE3OC0uMDEtLjE3Ni0uMDEtLjE3Ni0uMDEyLS4xNzUtLjAxMy0uMTczLS4wMTQtLjE3Mi0uMDE1LS4xNzEtLjAxNi0uMTctLjAxNy0uMTY5LS4wMTgtLjE2Ny0uMDE5LS4xNjYtLjAyLS4xNjUtLjAyMS0uMTYzLS4wMjMtLjE2Mi0uMDIzLS4xNjEtLjAyNC0uMTU5LS4wMjQtLjE1Ny0uMDI2LS4xNTYtLjAyNy0uMTU1LS4wMjgtLjE1My0uMDI4LS4xNTEtLjAzLS4xNS0uMDMtLjE0OC0uMDMyLS4xNDYtLjAzMi0uMTQ1LS4wMzMtLjE0My0uMDM0LS4xNDEtLjAzNC0uMTQtLjAzNi0uMTM3LS4wMzYtLjEzNi0uMDM3LS4xMzQtLjAzOC0uMTMyLS4wMzktLjEzLS4wMzktLjEyOC0uMDQxLS4xMjYtLjA0MS0uMTI0LS4wNDEtLjEyMi0uMDQzLS4xMi0uMDQzLS4xMTctLjA0NC0uMTE2LS4wNDQtLjExMy0uMDQ2LS4xMTItLjA0Ni0uMTA5LS4wNDYtLjEwNi0uMDQ4LS4xMDUtLjA0OC0uMTAyLS4wNDgtLjEtLjA1LS4wOTctLjA0OS0uMDk1LS4wNTEtLjA5My0uMDUxLS4wOS0uMDUyLS4wODctLjA1Mi0uMDg1LS4wNTMtLjA4My0uMDUzLS4wOC0uMDU0LS4wNzctLjA1NHY0LjE1M3ptOC43NC04LjE3OWwtLjI1Ny4wMDQtLjI1NC4wMDUtLjI1LjAwOC0uMjQ3LjAxMS0uMjQ0LjAxMi0uMjQxLjAxNC0uMjM3LjAxNi0uMjMzLjAxOC0uMjMxLjAyMS0uMjI2LjAyMi0uMjI0LjAyMy0uMjIuMDI2LS4yMTYuMDI3LS4yMTIuMDI4LS4yMS4wMzEtLjIwNS4wMzItLjIwMi4wMzMtLjE5OC4wMzQtLjE5NC4wMzYtLjE5MS4wMzgtLjE4Ny4wMzgtLjE4My4wNC0uMTc5LjA0MS0uMTc1LjA0Mi0uMTcyLjA0My0uMTY4LjA0My0uMTYzLjA0NS0uMTYuMDQ2LS4xNTUuMDQ2LS4xNTIuMDQ4LS4xNDguMDQ4LS4xNDMuMDQ4LS4xMzkuMDQ5LS4xMzYuMDUtLjEzMS4wNS0uMTI2LjA1MS0uMTIzLjA1MS0uMTE4LjA1MS0uMTE0LjA1Mi0uMTEuMDUyLS4xMDYuMDUyLS4xMDEuMDUyLS4wOTYuMDUyLS4wOTIuMDUyLS4wODguMDUyLS4wODMuMDUyLS4wNzkuMDUyLS4wNzQuMDUxLS4wNy4wNTItLjA2NS4wNTEtLjA2LjA1LS4wNTYuMDUtLjA1MS4wNS0uMDIzLjAyNS0uMDIzLjAyNC0uMDIxLjAyNC0uMDIuMDI1LS4wMTkuMDI0LS4wMTguMDI0LS4wMTcuMDIzLS4wMTUuMDI0LS4wMTQuMDIzLS4wMTMuMDIzLS4wMTIuMDIzLS4wMS4wMjMtLjAxLjAyMi0uMDA4LjAyMi0uMDA2LjAyMy0uMDA2LjAyMS0uMDA0LjAyMi0uMDA0LjAyMS0uMDAxLjAyMS0uMDAxLjAyMS4wMDEuMDIxLjAwMS4wMjEuMDA0LjAyMS4wMDQuMDIyLjAwNi4wMjEuMDA2LjAyMy4wMDguMDIyLjAxLjAyMi4wMS4wMjMuMDEyLjAyMy4wMTMuMDIzLjAxNC4wMjMuMDE1LjAyNC4wMTcuMDIzLjAxOC4wMjQuMDE5LjAyNC4wMi4wMjUuMDIxLjAyNC4wMjMuMDI0LjAyMy4wMjUuMDUxLjA1LjA1Ni4wNS4wNi4wNS4wNjUuMDUxLjA3LjA1Mi4wNzQuMDUxLjA3OS4wNTIuMDgzLjA1Mi4wODguMDUyLjA5Mi4wNTIuMDk2LjA1Mi4xMDEuMDUyLjEwNi4wNTIuMTEuMDUyLjExNC4wNTIuMTE4LjA1MS4xMjMuMDUxLjEyNi4wNTEuMTMxLjA1LjEzNi4wNS4xMzkuMDQ5LjE0My4wNDguMTQ4LjA0OC4xNTIuMDQ4LjE1NS4wNDYuMTYuMDQ2LjE2My4wNDUuMTY4LjA0My4xNzIuMDQzLjE3NS4wNDIuMTc5LjA0MS4xODMuMDQuMTg3LjAzOC4xOTEuMDM4LjE5NC4wMzYuMTk4LjAzNC4yMDIuMDMzLjIwNS4wMzIuMjEuMDMxLjIxMi4wMjguMjE2LjAyNy4yMi4wMjYuMjI0LjAyMy4yMjYuMDIyLjIzMS4wMjEuMjMzLjAxOC4yMzcuMDE2LjI0MS4wMTQuMjQ0LjAxMi4yNDcuMDExLjI1LjAwOC4yNTQuMDA1LjI1Ny4wMDQuMjYuMDAxLjI2LS4wMDEuMjU3LS4wMDQuMjU0LS4wMDUuMjUtLjAwOC4yNDctLjAxMS4yNDQtLjAxMi4yNDEtLjAxNC4yMzctLjAxNi4yMzMtLjAxOC4yMzEtLjAyMS4yMjYtLjAyMi4yMjQtLjAyMy4yMi0uMDI2LjIxNi0uMDI3LjIxMi0uMDI4LjIxLS4wMzEuMjA1LS4wMzIuMjAyLS4wMzMuMTk4LS4wMzQuMTk0LS4wMzYuMTkxLS4wMzguMTg3LS4wMzguMTgzLS4wNC4xNzktLjA0MS4xNzUtLjA0Mi4xNzItLjA0My4xNjgtLjA0My4xNjMtLjA0NS4xNi0uMDQ2LjE1NS0uMDQ2LjE1Mi0uMDQ4LjE0OC0uMDQ4LjE0My0uMDQ4LjEzOS0uMDQ5LjEzNi0uMDUuMTMxLS4wNS4xMjYtLjA1MS4xMjMtLjA1MS4xMTgtLjA1MS4xMTQtLjA1Mi4xMS0uMDUyLjEwNi0uMDUyLjEwMS0uMDUyLjA5Ni0uMDUyLjA5Mi0uMDUyLjA4OC0uMDUyLjA4My0uMDUyLjA3OS0uMDUyLjA3NC0uMDUxLjA3LS4wNTIuMDY1LS4wNTEuMDYtLjA1LjA1Ni0uMDUuMDUxLS4wNS4wMjMtLjAyNS4wMjMtLjAyNC4wMjEtLjAyNC4wMi0uMDI1LjAxOS0uMDI0LjAxOC0uMDI0LjAxNy0uMDIzLjAxNS0uMDI0LjAxNC0uMDIzLjAxMy0uMDIzLjAxMi0uMDIzLjAxLS4wMjMuMDEtLjAyMi4wMDgtLjAyMi4wMDYtLjAyMy4wMDYtLjAyMS4wMDQtLjAyMi4wMDQtLjAyMS4wMDEtLjAyMS4wMDEtLjAyMS0uMDAxLS4wMjEtLjAwMS0uMDIxLS4wMDQtLjAyMS0uMDA0LS4wMjItLjAwNi0uMDIxLS4wMDYtLjAyMy0uMDA4LS4wMjItLjAxLS4wMjItLjAxLS4wMjMtLjAxMi0uMDIzLS4wMTMtLjAyMy0uMDE0LS4wMjMtLjAxNS0uMDI0LS4wMTctLjAyMy0uMDE4LS4wMjQtLjAxOS0uMDI0LS4wMi0uMDI1LS4wMjEtLjAyNC0uMDIzLS4wMjQtLjAyMy0uMDI1LS4wNTEtLjA1LS4wNTYtLjA1LS4wNi0uMDUtLjA2NS0uMDUxLS4wNy0uMDUyLS4wNzQtLjA1MS0uMDc5LS4wNTItLjA4My0uMDUyLS4wODgtLjA1Mi0uMDkyLS4wNTItLjA5Ni0uMDUyLS4xMDEtLjA1Mi0uMTA2LS4wNTItLjExLS4wNTItLjExNC0uMDUyLS4xMTgtLjA1MS0uMTIzLS4wNTEtLjEyNi0uMDUxLS4xMzEtLjA1LS4xMzYtLjA1LS4xMzktLjA0OS0uMTQzLS4wNDgtLjE0OC0uMDQ4LS4xNTItLjA0OC0uMTU1LS4wNDYtLjE2LS4wNDYtLjE2My0uMDQ1LS4xNjgtLjA0My0uMTcyLS4wNDMtLjE3NS0uMDQyLS4xNzktLjA0MS0uMTgzLS4wNC0uMTg3LS4wMzgtLjE5MS0uMDM4LS4xOTQtLjAzNi0uMTk4LS4wMzQtLjIwMi0uMDMzLS4yMDUtLjAzMi0uMjEtLjAzMS0uMjEyLS4wMjgtLjIxNi0uMDI3LS4yMi0uMDI2LS4yMjQtLjAyMy0uMjI2LS4wMjItLjIzMS0uMDIxLS4yMzMtLjAxOC0uMjM3LS4wMTYtLjI0MS0uMDE0LS4yNDQtLjAxMi0uMjQ3LS4wMTEtLjI1LS4wMDgtLjI1NC0uMDA1LS4yNTctLjAwNC0uMjYtLjAwMS0uMjYuMDAxeiI+PC9wYXRoPjwvc3ltYm9sPjwvZGVmcz48ZGVmcz48c3ltYm9sIGlkPSJjbG9jayIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0Ij48cGF0aCB0cmFuc2Zvcm09InNjYWxlKC41KSIgZD0iTTEyIDJjNS41MTQgMCAxMCA0LjQ4NiAxMCAxMHMtNC40ODYgMTAtMTAgMTAtMTAtNC40ODYtMTAtMTAgNC40ODYtMTAgMTAtMTB6bTAtMmMtNi42MjcgMC0xMiA1LjM3My0xMiAxMnM1LjM3MyAxMiAxMiAxMiAxMi01LjM3MyAxMi0xMi01LjM3My0xMi0xMi0xMnptNS44NDggMTIuNDU5Yy4yMDIuMDM4LjIwMi4zMzMuMDAxLjM3Mi0xLjkwNy4zNjEtNi4wNDUgMS4xMTEtNi41NDcgMS4xMTEtLjcxOSAwLTEuMzAxLS41ODItMS4zMDEtMS4zMDEgMC0uNTEyLjc3LTUuNDQ3IDEuMTI1LTcuNDQ1LjAzNC0uMTkyLjMxMi0uMTgxLjM0My4wMTRsLjk4NSA2LjIzOCA1LjM5NCAxLjAxMXoiPjwvcGF0aD48L3N5bWJvbD48L2RlZnM+PGRlZnM+PG1hcmtlciBpZD0iYXJyb3doZWFkIiByZWZYPSI3LjkiIHJlZlk9IjUiIG1hcmtlclVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgbWFya2VyV2lkdGg9IjEyIiBtYXJrZXJIZWlnaHQ9IjEyIiBvcmllbnQ9ImF1dG8tc3RhcnQtcmV2ZXJzZSI+PHBhdGggZD0iTSAtMSAwIEwgMTAgNSBMIDAgMTAgeiI+PC9wYXRoPjwvbWFya2VyPjwvZGVmcz48ZGVmcz48bWFya2VyIGlkPSJjcm9zc2hlYWQiIG1hcmtlcldpZHRoPSIxNSIgbWFya2VySGVpZ2h0PSI4IiBvcmllbnQ9ImF1dG8iIHJlZlg9IjQiIHJlZlk9IjQuNSI+PHBhdGggZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMDAwMDAwIiBzdHJva2Utd2lkdGg9IjFwdCIgZD0iTSAxLDIgTCA2LDcgTSA2LDIgTCAxLDciIHN0eWxlPSJzdHJva2UtZGFzaGFycmF5OiAwLCAwOyI+PC9wYXRoPjwvbWFya2VyPjwvZGVmcz48ZGVmcz48bWFya2VyIGlkPSJmaWxsZWQtaGVhZCIgcmVmWD0iMTUuNSIgcmVmWT0iNyIgbWFya2VyV2lkdGg9IjIwIiBtYXJrZXJIZWlnaHQ9IjI4IiBvcmllbnQ9ImF1dG8iPjxwYXRoIGQ9Ik0gMTgsNyBMOSwxMyBMMTQsNyBMOSwxIFoiPjwvcGF0aD48L21hcmtlcj48L2RlZnM+PGRlZnM+PG1hcmtlciBpZD0ic2VxdWVuY2VudW1iZXIiIHJlZlg9IjE1IiByZWZZPSIxNSIgbWFya2VyV2lkdGg9IjYwIiBtYXJrZXJIZWlnaHQ9IjQwIiBvcmllbnQ9ImF1dG8iPjxjaXJjbGUgY3g9IjE1IiBjeT0iMTUiIHI9IjYiPjwvY2lyY2xlPjwvbWFya2VyPjwvZGVmcz48Zz48cmVjdCB4PSIzMDAiIHk9IjE2OCIgZmlsbD0iI0VERjJBRSIgc3Ryb2tlPSIjNjY2IiB3aWR0aD0iMTU5IiBoZWlnaHQ9IjM5IiBjbGFzcz0ibm90ZSI+PC9yZWN0Pjx0ZXh0IHg9IjM4MCIgeT0iMTczIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBkb21pbmFudC1iYXNlbGluZT0ibWlkZGxlIiBhbGlnbm1lbnQtYmFzZWxpbmU9Im1pZGRsZSIgY2xhc3M9Im5vdGVUZXh0IiBkeT0iMWVtIiBzdHlsZT0iZm9udC1zaXplOiAxNnB4OyBmb250LXdlaWdodDogNDAwOyI+PHRzcGFuIHg9IjM4MCI+UHJvY2Vzc2luZyAoMzAwbXMpPC90c3Bhbj48L3RleHQ+PC9nPjxnPjxsaW5lIHgxPSI2NCIgeTE9Ijc1IiB4Mj0iNDY5IiB5Mj0iNzUiIGNsYXNzPSJsb29wTGluZSI+PC9saW5lPjxsaW5lIHgxPSI0NjkiIHkxPSI3NSIgeDI9IjQ2OSIgeTI9IjI2NSIgY2xhc3M9Imxvb3BMaW5lIj48L2xpbmU+PGxpbmUgeDE9IjY0IiB5MT0iMjY1IiB4Mj0iNDY5IiB5Mj0iMjY1IiBjbGFzcz0ibG9vcExpbmUiPjwvbGluZT48bGluZSB4MT0iNjQiIHkxPSI3NSIgeDI9IjY0IiB5Mj0iMjY1IiBjbGFzcz0ibG9vcExpbmUiPjwvbGluZT48cG9seWdvbiBwb2ludHM9IjY0LDc1IDExNCw3NSAxMTQsODggMTA1LjYsOTUgNjQsOTUiIGNsYXNzPSJsYWJlbEJveCI+PC9wb2x5Z29uPjx0ZXh0IHg9Ijg5IiB5PSI4OCIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZG9taW5hbnQtYmFzZWxpbmU9Im1pZGRsZSIgYWxpZ25tZW50LWJhc2VsaW5lPSJtaWRkbGUiIGNsYXNzPSJsYWJlbFRleHQiIHN0eWxlPSJmb250LXNpemU6IDE2cHg7IGZvbnQtd2VpZ2h0OiA0MDA7Ij5sb29wPC90ZXh0Pjx0ZXh0IHg9IjI5MS41IiB5PSI5MyIgdGV4dC1hbmNob3I9Im1pZGRsZSIgY2xhc3M9Imxvb3BUZXh0IiBzdHlsZT0iZm9udC1zaXplOiAxNnB4OyBmb250LXdlaWdodDogNDAwOyI+PHRzcGFuIHg9IjI5MS41Ij5bMTAwIHRpbWVzXTwvdHNwYW4+PC90ZXh0PjwvZz48dGV4dCB4PSIxNzQiIHk9IjEyNSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZG9taW5hbnQtYmFzZWxpbmU9Im1pZGRsZSIgYWxpZ25tZW50LWJhc2VsaW5lPSJtaWRkbGUiIGNsYXNzPSJtZXNzYWdlVGV4dCIgZHk9IjFlbSIgc3R5bGU9ImZvbnQtc2l6ZTogMTZweDsgZm9udC13ZWlnaHQ6IDQwMDsiPlNlbmQgUmVxdWVzdDwvdGV4dD48bGluZSB4MT0iNzYiIHkxPSIxNTgiIHgyPSIyNzEiIHkyPSIxNTgiIGNsYXNzPSJtZXNzYWdlTGluZTAiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlPSJub25lIiBtYXJrZXItZW5kPSJ1cmwoI2Fycm93aGVhZCkiIHN0eWxlPSJmaWxsOiBub25lOyI+PC9saW5lPjx0ZXh0IHg9IjE3NyIgeT0iMjIyIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBkb21pbmFudC1iYXNlbGluZT0ibWlkZGxlIiBhbGlnbm1lbnQtYmFzZWxpbmU9Im1pZGRsZSIgY2xhc3M9Im1lc3NhZ2VUZXh0IiBkeT0iMWVtIiBzdHlsZT0iZm9udC1zaXplOiAxNnB4OyBmb250LXdlaWdodDogNDAwOyI+UmVzcG9uc2U8L3RleHQ+PGxpbmUgeDE9IjI3NCIgeTE9IjI1NSIgeDI9Ijc5IiB5Mj0iMjU1IiBjbGFzcz0ibWVzc2FnZUxpbmUxIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZT0ibm9uZSIgbWFya2VyLWVuZD0idXJsKCNhcnJvd2hlYWQpIiBzdHlsZT0ic3Ryb2tlLWRhc2hhcnJheTogMywgMzsgZmlsbDogbm9uZTsiPjwvbGluZT48L3N2Zz4="><p>300ms가 발생하는 API 요청을 100번 반복하는 시나리오를 가정해봅시다. 동기적으로 처리하면 100번의 요청을 처리하는 데 30초가 걸립니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">class</span> <span class="title class_">OrderClient</span> &#123;</span><br><span class="line">   <span class="function"><span class="keyword">fun</span> <span class="title">getOrder</span><span class="params">(orderRequest: <span class="type">OrderRequest</span>)</span></span>: ResponseResult&lt;OrderResponse&gt; &#123;</span><br><span class="line">      <span class="keyword">return</span> runBlocking &#123;</span><br><span class="line">         delay(<span class="number">300</span>) <span class="comment">// 300ms 지연, 실제 API를 호출하지 않고 시간만 지연 </span></span><br><span class="line">         ResponseResult.Success(OrderResponse(orderRequest.productId))</span><br><span class="line">      &#125;</span><br><span class="line">   &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">fun</span> <span class="title">getOrderSync</span><span class="params">(orderRequests: <span class="type">List</span>&lt;<span class="type">OrderRequest</span>&gt;)</span></span>: List&lt;OrderResponse&gt; &#123;</span><br><span class="line">   <span class="keyword">return</span> orderRequests</span><br><span class="line">      .map &#123;</span><br><span class="line">         orderClient</span><br><span class="line">            .getOrder(it) <span class="comment">// 300ms 지연</span></span><br><span class="line">            .onFailure &#123; log.error(<span class="string">&quot;Failure: <span class="variable">$it</span>&quot;</span>) &#125;</span><br><span class="line">            .onSuccess &#123; log.info(<span class="string">&quot;Success: <span class="variable">$it</span>&quot;</span>) &#125;</span><br><span class="line">            .getOrThrow()</span><br><span class="line">      &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>위 코드에서 <code>OrderClient</code>의 <code>getOrder</code> 함수는 각 호출마다 300ms가 소요된다고 가정합니다. 일반적인 API 호출의 경우 300ms 응답속도는 빠른 편에 속합니다. 하지만 100번을 호출 한다고 가정하면 총 소요 시간은 100 * 300ms = 30,000ms, 즉 30초가 됩니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line"><span class="meta">@Test</span></span><br><span class="line"><span class="function"><span class="keyword">fun</span> <span class="title">getOrderSync</span><span class="params">()</span></span> &#123;</span><br><span class="line">   <span class="keyword">val</span> stopWatch = StopWatch()</span><br><span class="line">   <span class="keyword">val</span> flatMapMergeStudy = FlatMapMergeStudy()</span><br><span class="line">   <span class="keyword">val</span> orderRequests = (<span class="number">1.</span><span class="number">.100</span>).map &#123; OrderRequest(<span class="string">&quot;<span class="variable">$it</span>&quot;</span>) &#125;</span><br><span class="line"></span><br><span class="line">   stopWatch.start()</span><br><span class="line">   <span class="keyword">val</span> response = flatMapMergeStudy.getOrderSync(orderRequests)</span><br><span class="line">   stopWatch.stop()</span><br><span class="line">   println(stopWatch.totalTimeMillis)</span><br><span class="line"></span><br><span class="line">   <span class="comment">// 30,528ms</span></span><br><span class="line">   println(response)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2><span id="korutin-flowreul-iyonghan-seongneung-gaeseon">코루틴 <code>Flow</code>를 이용한 성능 개선</span><a href="#korutin-flowreul-iyonghan-seongneung-gaeseon" class="header-anchor">#</a></h2><p>Kotlin의 코루틴은 비동기 작업을 손쉽게 처리할 수 있는 강력한 도구입니다. 특히 Flow를 활용하면 여러 비동기 요청을 효율적으로 처리할 수 있습니다. Flow는 데이터 스트림을 처리하는 코루틴 기반 API로, 여러 개의 작업을 동시에 병렬로 수행할 수 있도록 지원합니다. 이번 섹션에서는 Flow의 flatMapMerge를 사용하여 다수의 API 요청을 효율적으로 처리하는 방법과, 이를 통해 얻을 수 있는 성능 향상에 대해 다뤄보겠습니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@OptIn(FlowPreview::class)</span></span><br><span class="line"><span class="keyword">suspend</span> <span class="function"><span class="keyword">fun</span> <span class="title">getOrderFlow</span><span class="params">(orderRequests: <span class="type">List</span>&lt;<span class="type">OrderRequest</span>&gt;)</span></span>: List&lt;OrderResponse&gt; &#123;</span><br><span class="line">   <span class="keyword">return</span> orderRequests</span><br><span class="line">      .asFlow()</span><br><span class="line">      .flatMapMerge &#123; request -&gt;</span><br><span class="line">         flow &#123;</span><br><span class="line">            orderClient</span><br><span class="line">               .getOrder(request)</span><br><span class="line">               .onFailure &#123; log.error(<span class="string">&quot;Failure: <span class="variable">$it</span>&quot;</span>) &#125;</span><br><span class="line">               .onSuccess &#123;</span><br><span class="line">                  log.info(<span class="string">&quot;Success: <span class="variable">$it</span>&quot;</span>)</span><br><span class="line">                  emit(it)</span><br><span class="line">               &#125;</span><br><span class="line">         &#125;</span><br><span class="line">      &#125;</span><br><span class="line">      .toList()</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>위 코드에서 getOrderFlow 함수는 orderRequests 리스트를 플로우로 변환하고, flatMapMerge를 사용하여 각 요청을 병렬로 처리합니다. 각 요청은 코루틴 내에서 300ms 동안 지연된 후 결과를 반환합니다. 이 방식으로 100개의 요청을 동시에 처리하면, 전체 처리 시간은 가장 오래 걸리는 요청 하나의 시간인 300ms로 줄어듭니다.</p><h3><span id="seongneung-teseuteu">성능 테스트</span><a href="#seongneung-teseuteu" class="header-anchor">#</a></h3><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Test</span></span><br><span class="line"><span class="function"><span class="keyword">fun</span> <span class="title">getOrderFlow</span><span class="params">()</span></span>: <span class="built_in">Unit</span> = runBlocking &#123;</span><br><span class="line">      <span class="keyword">val</span> stopWatch = StopWatch()</span><br><span class="line">      <span class="keyword">val</span> flatMapMergeStudy = FlatMapMergeStudy()</span><br><span class="line">      <span class="keyword">val</span> orderRequests = (<span class="number">1.</span><span class="number">.100</span>).map &#123; OrderRequest(<span class="string">&quot;<span class="variable">$it</span>&quot;</span>) &#125;</span><br><span class="line"></span><br><span class="line">      stopWatch.start()</span><br><span class="line">      <span class="keyword">val</span> response = flatMapMergeStudy.getOrderFlow(orderRequests)</span><br><span class="line">      stopWatch.stop()</span><br><span class="line">      <span class="comment">// 2,228ms</span></span><br><span class="line">      println(stopWatch.totalTimeMillis)</span><br><span class="line">   &#125;</span><br></pre></td></tr></table></figure><p>이론상 100개의 요청을 동시에 처리하면 300ms 정도의 시간이 소요되어야 하지만, 실제로는 2,228ms가 소요됩니다. 이는 다음과 같은 요인들로 인한 것입니다.</p><ol><li><strong>코루틴 생성과 컨텍스트 전환 오버헤드</strong><ul><li>코루틴을 생성하고 실행할 때 발생하는 오버헤드는 무시할 수 없는 시간 지연을 초래할 수 있습니다.</li><li>특히, <code>flatMapMerge</code>를 사용하여 다수의 코루틴을 병렬로 실행할 때, 각 코루틴의 생성과 컨텍스트 전환 비용이 누적되어 총 실행 시간이 증가할 수 있습니다.</li></ul></li><li><strong><code>flatMapMerge</code>의 병합 과정</strong><ul><li><code>flatMapMerge</code>는 여러 플로우를 병합하면서 각 플로우의 결과를 수집합니다.</li><li>이 과정에서 발생하는 추가적인 작업들, 예를 들어 플로우의 결과를 수집하고 병합하는 오버헤드가 존재할 수 있습니다.</li><li>이 오버헤드는 특히 플로우의 개수가 많을 때 더 크게 작용합니다.</li></ul></li><li><strong><code>emit</code> 호출과 플로우 수집의 지연</strong><ul><li>각 플로우에서 <code>emit</code>을 호출하고, 최종적으로 <code>toList</code>로 수집하는 과정에서 발생하는 지연도 무시할 수 없습니다.</li><li><code>emit</code>은 비동기적으로 데이터를 내보내는 작업이므로, 여러 번 호출될 때 지연이 누적될 수 있습니다.</li></ul></li><li><strong>기본 Concurrency 설정</strong><ul><li>flatMapMerge의 기본 concurrency 값은 16이며, 이 코드는 기본값으로 동작합니다.</li><li>Concurrency 16으로 동작할 때 100개의 요청을 처리하는 데 소요되는 시간은 100 / 16 * 300 = 1875ms 정도입니다.</li><li>이 시간은 앞서 언급한 1, 2, 3번 항목들과 함께 작업을 수행해야 하므로 추가적인 지연이 발생할 수 있습니다.</li><li><strong>특히, Concurrency 16으로 처리하는 시간이 가장 오래 걸리며, 이는 전체 처리 시간에 크게 영향을 미칩니다.</strong></li></ul></li></ol><p><img src="https://raw.githubusercontent.com/cheese10yun/blog-sample/master/kotlin-coroutine/images/result_001.png" alt></p><table><thead><tr><th>Rows</th><th>getOrderSync (ms)</th><th>getOrderFlow (ms)</th><th>Improvement (%)</th></tr></thead><tbody><tr><td>10</td><td>3,116</td><td>375</td><td>87.97</td></tr><tr><td>50</td><td>15,360</td><td>1,306</td><td>91.50</td></tr><tr><td>100</td><td>30,615</td><td>2,228</td><td>92.72</td></tr><tr><td>500</td><td>152,690</td><td>9,851</td><td>93.55</td></tr><tr><td>1,000</td><td>305,359</td><td>19,321</td><td>93.67</td></tr></tbody></table><p>위 표는 동기 방식(<code>getOrderSync</code>)과 비동기 방식(<code>getOrderFlow</code>)의 성능을 비교한 결과입니다. <code>getOrderFlow</code>를 사용한 비동기 방식은 모든 요청을 동시에 처리함으로써 동기 방식에 비해 큰 성능 개선을 보여주고 있습니다. 특히, 요청 수가 많을수록 비동기 방식의 성능 향상 효과가 더욱 두드러집니다. 이는 동기 방식이 요청 수에 비례하여 선형적으로 시간이 증가하는 반면, 비동기 방식은 병렬 처리를 통해 시간 증가를 억제할 수 있기 때문입니다.</p><h2><span id="seuredeuwa-korutin-gyeongryang-seuredeuyi-gangjeom">스레드와 코루틴: 경량 스레드의 강점</span><a href="#seuredeuwa-korutin-gyeongryang-seuredeuyi-gangjeom" class="header-anchor">#</a></h2><p>Kotlin 문서에서는 코루틴을 경량 스레드라고 합니다. 이는 대부분의 스레드와 마찬가지로 코루틴이 프로세스가 실행할 명령어 집합의 실행을 정의하기 때문입니다. 또한 코루틴은 스레드와 비슷한 라이프 사이클을 갖고 있습니다.</p><p>코루틴은 스레드 안에서 실행됩니다. 하나의 스레드에는 여러 개의 코루틴이 있을 수 있지만, 주어진 시간에 하나의 스레드에서는 하나의 명령만 실행될 수 있습니다. 즉, 같은 스레드에 10개의 코루틴이 있다면 해당 시점에는 하나의 코루틴만 실행됩니다.</p><p>스레드와 코루틴의 가장 큰 차이점은 코루틴이 빠르고 적은 비용으로 생성될 수 있다는 점입니다. 수천 개의 코루틴도 쉽게 생성할 수 있으며, 수천 개의 스레드를 생성하는 것보다 빠르고 자원도 훨씬 적게 사용합니다.</p><h3><span id="korutin-saengseong-teseuteu">코루틴 생성 테스트</span><a href="#korutin-saengseong-teseuteu" class="header-anchor">#</a></h3><p>다음 코드는 코루틴을 생성하는 데 필요한 스레드 수를 테스트하는 예제입니다. 이 테스트를 통해 코루틴이 얼마나 적은 리소스로 동작하는지 확인할 수 있습니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 코드 출처 코틀린 동시성 프로그래밍 </span></span><br><span class="line"><span class="keyword">suspend</span> <span class="function"><span class="keyword">fun</span> <span class="title">createCoroutines</span><span class="params">(amount: <span class="type">Int</span>)</span></span> &#123;</span><br><span class="line">   <span class="keyword">val</span> jobs = ArrayList&lt;Job&gt;()</span><br><span class="line">   <span class="keyword">for</span> (i <span class="keyword">in</span> <span class="number">1.</span>.amount) &#123;</span><br><span class="line">      jobs += GlobalScope.launch &#123;</span><br><span class="line">         delay(<span class="number">1000</span>)</span><br><span class="line">      &#125;</span><br><span class="line">   &#125;</span><br><span class="line">   jobs.forEach &#123; it.join() &#125;</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="meta">@Test</span></span><br><span class="line"><span class="meta">@DelicateCoroutinesApi</span></span><br><span class="line"><span class="function"><span class="keyword">fun</span> `코루틴 생성 테스트`<span class="params">()</span></span> = runBlocking &#123;</span><br><span class="line">   println(<span class="string">&quot;<span class="subst">$&#123;Thread.activeCount()&#125;</span> thread active at the start&quot;</span>)</span><br><span class="line"></span><br><span class="line">   <span class="keyword">val</span> time = measureTimeMillis &#123;</span><br><span class="line">      createCoroutines(<span class="number">100</span>)</span><br><span class="line">   &#125;</span><br><span class="line"></span><br><span class="line">   println(<span class="string">&quot;<span class="subst">$&#123;Thread.activeCount()&#125;</span> thread active at the end&quot;</span>)</span><br><span class="line">   println(<span class="string">&quot;Took <span class="variable">$time</span> ms&quot;</span>)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>다음은 테스트 결과입니다. 다양한 수의 코루틴을 생성했을 때 사용되는 스레드 수를 보여줍니다.</p><p><img src="https://raw.githubusercontent.com/cheese10yun/blog-sample/master/kotlin-coroutine/images/result_003.png" alt></p><table><thead><tr><th>amount</th><th>시작 시 활성 스레드 수</th><th>종료 시 활성 스레드 수</th></tr></thead><tbody><tr><td>1</td><td>4</td><td>7</td></tr><tr><td>100</td><td>4</td><td>15</td></tr><tr><td>500</td><td>4</td><td>15</td></tr><tr><td>1,000</td><td>4</td><td>15</td></tr><tr><td>5,000</td><td>4</td><td>15</td></tr><tr><td>10,000</td><td>4</td><td>15</td></tr><tr><td>100,000</td><td>4</td><td>15</td></tr></tbody></table><p>위 결과에서 알 수 있듯이, 코루틴을 많이 생성해도 사용되는 스레드 수는 크게 증가하지 않습니다. 시작 시에는 4개의 스레드가 활성화되어 있었고, 종료 시에도 최대 15개의 스레드만 활성화되었습니다. 이는 코루틴이 얼마나 적은 리소스를 사용하여 병렬 작업을 수행할 수 있는지를 잘 보여줍니다. 위 Flow 처리에서도 요청 수가 10에서 1,000일 때 4개의 스레드만 필요합니다.</p><p>코루틴은 스레드와 비교했을 때 훨씬 가볍고, 생성 및 전환 비용이 낮습니다. 이러한 특성 덕분에 코루틴은 높은 동시성을 요구하는 애플리케이션에서 매우 유용합니다. 동기 방식에서 수천 개의 스레드를 생성하는 것은 비효율적이고 리소스 낭비가 심하지만, 코루틴을 사용하면 동일한 작업을 더 적은 리소스로 처리할 수 있습니다.</p><h2><span id="flow-concurrency-size-jojeol">Flow Concurrency Size 조절</span><a href="#flow-concurrency-size-jojeol" class="header-anchor">#</a></h2><h3><span id="concurrency-gibon-gaenyeom">Concurrency 기본 개념</span><a href="#concurrency-gibon-gaenyeom" class="header-anchor">#</a></h3><p>Concurrency(동시성)은 여러 작업이 동시에 진행되는 것을 의미합니다. 컴퓨팅에서 이는 여러 작업이 동시에 실행되거나, 하나의 작업이 다른 작업의 실행 중간에 개입하는 것을 의미합니다. 코루틴을 활용한 동시성은, 실제로 동시에 실행되지 않더라도, 작업이 비동기적으로 실행되는 것처럼 보이게 만듭니다. 이는 특히 IO 바운드 작업에서 효과적입니다.</p><p>Kotlin의 <code>flatMapMerge</code>에서 <code>concurrency</code> 파라미터는 동시에 병렬로 실행할 최대 코루틴 수를 설정합니다. 이 파라미터를 통해 한 번에 얼마나 많은 코루틴이 실행될 수 있는지를 제어할 수 있습니다.</p><h3><span id="concurrency-size-seongneung-teseuteu">Concurrency Size 성능 테스트</span><a href="#concurrency-size-seongneung-teseuteu" class="header-anchor">#</a></h3><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@OptIn(FlowPreview::class)</span></span><br><span class="line"><span class="keyword">suspend</span> <span class="function"><span class="keyword">fun</span> <span class="title">getOrderFlow</span><span class="params">(orderRequests: <span class="type">List</span>&lt;<span class="type">OrderRequest</span>&gt;, concurrency: <span class="type">Int</span>)</span></span>: List&lt;OrderResponse&gt; &#123;</span><br><span class="line">   <span class="keyword">return</span> orderRequests</span><br><span class="line">      .asFlow()</span><br><span class="line">      <span class="comment">// concurrency 동시 실행할 코루틴 수 제한, </span></span><br><span class="line">      .flatMapMerge(concurrency) &#123; request -&gt;</span><br><span class="line">         flow &#123;</span><br><span class="line">            orderClient</span><br><span class="line">               .getOrder(request)</span><br><span class="line">               .onFailure &#123; log.error(<span class="string">&quot;Failure: <span class="variable">$it</span>&quot;</span>) &#125;</span><br><span class="line">               .onSuccess &#123;</span><br><span class="line">                  log.info(<span class="string">&quot;Success: <span class="variable">$it</span>&quot;</span>)</span><br><span class="line">                  emit(it)</span><br><span class="line">               &#125;</span><br><span class="line">         &#125;</span><br><span class="line">      &#125;</span><br><span class="line">      .toList()</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>flatMapMerge의 concurrency 파라미터는 동시에 실행되는 코루틴 수를 테스트 코드에서 지정할 수 있도록 코드를 수정합니다. 지정하지 않으면 기본값은 <code>DEFAULT_CONCURRENCY</code>로 설정됩니다. 위 예제에서는 16으로 지정된 상태입니다. 다음은 1,000개의 요청을 처리할 때 다양한 <code>concurrency</code> 값에 따른 성능 테스트 결과입니다</p><p><img src="https://raw.githubusercontent.com/cheese10yun/blog-sample/master/kotlin-coroutine/images/result_004.png" alt></p><table><thead><tr><th>Concurrency</th><th>Time (ms)</th></tr></thead><tbody><tr><td>16</td><td>19,278</td></tr><tr><td>50</td><td>6,174</td></tr><tr><td>100</td><td>3,146</td></tr><tr><td>200</td><td>1,612</td></tr><tr><td>300</td><td>1,312</td></tr><tr><td>400</td><td>1,013</td></tr><tr><td>500</td><td>720</td></tr></tbody></table><p>위 결과에서 알 수 있듯이, <code>concurrency</code> 값을 늘릴수록 전체 처리 시간이 줄어듭니다. 특히, <code>concurrency</code> 값을 16에서 500까지 늘렸을 때, 전체 처리 시간이 19,278ms에서 720ms로 크게 감소한 것을 확인할 수 있습니다. 이는 <code>concurrency</code> 값을 적절히 설정하면 성능을 크게 향상시킬 수 있다는 것을 보여줍니다.</p><p><img src="https://raw.githubusercontent.com/cheese10yun/blog-sample/master/kotlin-coroutine/images/result_007.png" alt></p><table><thead><tr><th>Rows</th><th>getOrderSync (ms)</th><th>getOrderFlow Concurrency 16 (ms)</th><th>getOrderFlow Concurrency 500 (ms)</th></tr></thead><tbody><tr><td>10</td><td>3,116</td><td>375</td><td>345</td></tr><tr><td>50</td><td>15,360</td><td>1,306</td><td>352</td></tr><tr><td>100</td><td>30,615</td><td>2,228</td><td>347</td></tr><tr><td>500</td><td>152,690</td><td>9,851</td><td>388</td></tr><tr><td>1,000</td><td>305,359</td><td>19,321</td><td>728</td></tr></tbody></table><p>1,000개의 요청을 처리하는 데 있어, 동기식 방식은 305,359ms가 소요되었으며, Flow Concurrency 16을 사용하면 처리 시간이 19,321ms로 줄어들어 약 93.67%의 성능 향상을 보였습니다. Concurrency를 500으로 설정하면 처리 시간이 728ms로 더욱 향상됩니다. Concurrency 값을 적절히 설정하면 성능을 크게 향상시킬 수 있음을 확인할 수 있습니다. 또 코루틴은 적은 양의 스레드로도 많은 데이터를 효율적으로 처리할 수 있음을 보여줍니다. 이러한 결과는 적절한 Concurrency 설정을 통해 애플리케이션의 성능을 극대화할 수 있음을 시사합니다.</p><h3><span id="concurrency-size-seongneung-coejeoghwa-si-goryeo-sahang">Concurrency Size 성능 최적화 시 고려 사항</span><a href="#concurrency-size-seongneung-coejeoghwa-si-goryeo-sahang" class="header-anchor">#</a></h3><p>성능은 크게 향상됐지만 무턱대고 concurrency 값을 늘리는 것은 해결책이 아닙니다. concurrency 값을 너무 크게 설정하면 오히려 시스템 자원을 과도하게 사용하게 되어 성능 저하가 발생할 수 있습니다. 따라서 여러 코루틴을 사용하는 것은 자원을 더 많이 사용하게 되므로, 각자의 리소스와 환경에 맞는 concurrency 값을 적절하게 설정하는 것이 중요합니다. 시스템의 CPU, 메모리, 네트워크 대역폭 등을 고려하여 최적의 concurrency 값을 설정해야 합니다.</p><p>또한 배치 애플리케이션처럼 특정 작업만 하고 애플리케이션이 종료되는 환경에서는 concurrency 값을 높여 처리량을 극대화하는 것이 좋습니다. 이런 경우에는 단기간에 최대한 많은 작업을 처리하는 것이 목표이므로, 가능한 한 높은 concurrency 값을 설정하여 성능을 최적화할 수 있습니다.</p><h2><span id="korutineun-deo-jeogeun-seuredeuro-deo-manheun-dongsiseongeul-ceorihanda">코루틴은 더 적은 스레드로 더 많은 동시성을 처리한다</span><a href="#korutineun-deo-jeogeun-seuredeuro-deo-manheun-dongsiseongeul-ceorihanda" class="header-anchor">#</a></h2><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">private</span> <span class="function"><span class="keyword">fun</span> <span class="title">rxAndBulkWriter</span><span class="params">()</span></span>: ItemWriter&lt;StoreProjection&gt; &#123;</span><br><span class="line">    <span class="keyword">return</span> ItemWriter &#123; stores -&gt;</span><br><span class="line">        stores</span><br><span class="line">            .toFlowable()</span><br><span class="line">            .parallel()</span><br><span class="line">            .runOn(Schedulers.io())</span><br><span class="line">            .map &#123; store -&gt;</span><br><span class="line">                ...</span><br><span class="line">            &#125;</span><br><span class="line">            .sequential()</span><br><span class="line">            .blockingSubscribe(</span><br><span class="line">                &#123; store -&gt; <span class="comment">// 속도 특정 시에는 주석 ... &#125;,</span></span><br><span class="line">                &#123; log.error(it.message, it) &#125;,</span><br><span class="line">                &#123; ... &#125;</span><br><span class="line">            )</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>RxJava에서 flow 방식으로 처리하는 코드는 코루틴과 비교해볼 수 있습니다. RxJava와 코루틴의 flow를 이용한 병렬 처리 및 병합 과정은 기본적으로 유사한 개념을 공유합니다. 두 방식 모두 데이터를 비동기적으로 처리하고 결과를 수집하며, 병렬로 처리한 작업들을 하나의 흐름으로 다시 병합합니다. 그렇다면, 이 두 방식의 차이점은 무엇일까요?</p><p><img src="https://raw.github.com/wiki/ReactiveX/RxJava/images/rx-operators/parallelflowable.sequential.png" alt></p><p>RxJava와 같은 라이브러리에서는 동시성 처리를 위해 CPU 코어 수에 맞춰 스레드를 생성하는 것이 일반적입니다. 예를 들어, CPU가 4코어라면 4개의 스레드를 생성해 병렬로 작업을 처리합니다. 이 방식은 각 스레드에 작업을 분배해 동시에 여러 작업을 처리할 수 있지만, 스레드의 생성과 컨텍스트 전환에서 발생하는 비용이 성능에 영향을 미칠 수 있습니다. 스레드 수가 많아질수록 시스템 자원을 더 많이 소비하게 됩니다.</p><p>코틀린의 코루틴은 동시성 프로그래밍에 있어서 더 효율적인 대안을 제공합니다. 코루틴은 스레드보다 훨씬 가벼우며, 하나의 스레드에서 여러 개의 코루틴을 실행할 수 있습니다. 코루틴은 스레드처럼 독립적인 작업 단위이지만, 스레드보다 적은 자원을 사용하고 빠르게 컨텍스트 전환을 할 수 있습니다.</p><p>코루틴은 I/O 작업이나 비동기 처리가 필요한 경우 특히 효과적입니다. 여러 코루틴이 동시에 실행되더라도, 이는 스레드 수와는 무관하게 적은 스레드로도 많은 작업을 처리할 수 있습니다. 즉, CPU 코어 수보다 훨씬 더 많은 동시 작업을 수행할 수 있으며, 스레드의 생성 및 컨텍스트 전환 비용도 줄어듭니다.</p><p>결론적으로, RxJava와 같은 방식은 CPU 코어 수만큼 스레드를 생성해 동시성을 처리하는 반면, 코루틴은 하나의 스레드에서 여러 작업을 동시에 처리할 수 있어, 적은 스레드로도 더 많은 동시성을 처리할 수 있다는 장점이 있습니다. 코루틴의 이러한 특성은 자원을 절약하고, 더 높은 성능을 제공할 수 있는 강력한 도구가 됩니다.</p><h2><span id="culceo">출처</span><a href="#culceo" class="header-anchor">#</a></h2><ul><li><a href="https://m.yes24.com/Goods/Detail/90338766">코틀린 동시성 프로그래밍</a></li><li><a href="https://github.com/ReactiveX/RxJava">RxJava</a></li></ul>]]></content:encoded>
      
      
      
      <category domain="https://cheese10yun.github.io/tags/Performance/">Performance</category>
      
      <category domain="https://cheese10yun.github.io/tags/Kotlin/">Kotlin</category>
      
      <category domain="https://cheese10yun.github.io/tags/Coroutines/">Coroutines</category>
      
      
      <comments>https://cheese10yun.github.io/kotlin-flow/#disqus_thread</comments>
      
    </item>
    
    <item>
      <title>Kotlin 자주 사용하는 패턴 정리</title>
      <link>https://cheese10yun.github.io/kotlin-pattern/</link>
      <guid>https://cheese10yun.github.io/kotlin-pattern/</guid>
      <pubDate>Sun, 05 May 2024 15:00:00 GMT</pubDate>
      
      <description>불변 객체는 데이터의 안정성과 예측 가능성을 제공하여 소프트웨어 개발에서 권장되는 패턴입니다. 특히, 암호화와 같은 데이터의 보안적 처리에 있어서 불변 객체를 사용하면 변경될 필요가 없는 정보의 무결성을 유지할 수 있습니다.</description>
      
      
      
      <content:encoded><![CDATA[<h2><span id="bulbyeon-gaegceyi-hyoyuljeog-gwanri-copy-mesodeu-hwalyong">불변 객체의 효율적 관리: <code>copy()</code> 메소드 활용</span><a href="#bulbyeon-gaegceyi-hyoyuljeog-gwanri-copy-mesodeu-hwalyong" class="header-anchor">#</a></h2><p>불변 객체는 데이터의 안정성과 예측 가능성을 제공하여 소프트웨어 개발에서 권장되는 패턴입니다. 특히, 암호화와 같은 데이터의 보안적 처리에 있어서 불변 객체를 사용하면 변경될 필요가 없는 정보의 무결성을 유지할 수 있습니다. Kotlin의 <code>data class</code>는 이러한 불변 객체를 다루기 위한 유용한 기능 중 하나로 <code>copy()</code> 메소드를 제공합니다. 이 메소드를 사용하면 객체의 일부만을 변경한 새로운 객체를 생성할 수 있어, 기존 객체의 불변성을 해치지 않으면서 필요한 부분만 업데이트가 가능합니다.</p><h3><span id="kodeu-yesi-mic-seolmyeong">코드 예시 및 설명</span><a href="#kodeu-yesi-mic-seolmyeong" class="header-anchor">#</a></h3><p>아래의 테스트 코드는 <code>User</code>라는 데이터 클래스의 인스턴스를 생성한 후, <code>copy()</code> 메소드를 사용하여 이메일 주소만을 암호화된 형태로 변경하는 예를 보여줍니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@Test</span></span><br><span class="line"><span class="function"><span class="keyword">fun</span> `불변 객체의 유지보수를 위한 copy 활용 예시`<span class="params">()</span></span> &#123;</span><br><span class="line">    <span class="keyword">val</span> user = User(</span><br><span class="line">        name = <span class="string">&quot;name&quot;</span>,</span><br><span class="line">        email = <span class="string">&quot;email@asd.com&quot;</span></span><br><span class="line">    )</span><br><span class="line"></span><br><span class="line">    <span class="keyword">val</span> userCopy = user.copy(</span><br><span class="line">        email = <span class="string">&quot;email@asd.com 암호화&quot;</span></span><br><span class="line">    )</span><br><span class="line"></span><br><span class="line">    <span class="comment">// User(name=name, email=email@asd.com)</span></span><br><span class="line">    println(<span class="string">&quot;user: <span class="variable">$user</span>&quot;</span>)</span><br><span class="line">    <span class="comment">// User(name=name, email=email@asd.com 암호화)</span></span><br><span class="line">    println(<span class="string">&quot;userCopy: <span class="variable">$userCopy</span>&quot;</span>)</span><br><span class="line"></span><br><span class="line">    <span class="comment">// 428039780</span></span><br><span class="line">    println(<span class="string">&quot;user: <span class="subst">$&#123;System.identityHashCode(user)&#125;</span>&quot;</span>)</span><br><span class="line">    <span class="comment">// 48361312</span></span><br><span class="line">    println(<span class="string">&quot;userCopy: <span class="subst">$&#123;System.identityHashCode(userCopy)&#125;</span>&quot;</span>)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><ul><li><strong>원본 객체 출력</strong>: <code>user: User(name=name, email=email@asd.com)</code></li><li><strong>복사 후 업데이트된 객체 출력</strong>: <code>userCopy: User(name=name, email=email@asd.com 암호화)</code></li><li><strong>객체 식별자 비교</strong>: 두 객체의 <code>System.identityHashCode</code> 값을 출력하여 각각 다른 객체임을 확인할 수 있습니다.</li></ul><h3><span id="pointeu-jeongri">포인트 정리</span><a href="#pointeu-jeongri" class="header-anchor">#</a></h3><ul><li><code>copy()</code> 메소드는 원본 객체의 일부 속성을 변경하여 새로운 객체를 생성합니다. 이 방식은 기존 객체의 불변성을 유지하면서 필요한 데이터만 갱신할 수 있는 효율적인 방법을 제공합니다.</li><li><code>val</code> 키워드를 사용하여 불변성을 명시하는 것은 데이터 보호 및 버그 방지에 중요합니다. 특히 암호화와 같이 데이터 보안이 중요한 작업에서는 불변 객체의 사용이 더욱 중요합니다.</li></ul><p>이 방법은 데이터의 무결성을 유지하면서도 효율적인 데이터 관리를 가능하게 하여, 유지보수성을 높이고 시스템의 안정성을 강화합니다. 불변 객체와 <code>copy()</code> 메소드의 적절한 사용은 모던 소프트웨어 개발의 중요한 측면 중 하나입니다.</p><h2><span id="pairwa-triple-gaegceyi-yuyongseonggwa-hyoyuljeog-sayong">Pair와 Triple 객체의 유용성과 효율적 사용</span><a href="#pairwa-triple-gaegceyi-yuyongseonggwa-hyoyuljeog-sayong" class="header-anchor">#</a></h2><p>코틀린에서는 간단한 객체를 빠르게 생성하고 사용할 수 있도록 <code>Pair</code>와 <code>Triple</code>이라는 두 가지 유틸리티 클래스를 제공합니다. 이러한 클래스는 특히 서비스 내부 로직에서만 사용되는 임시 데이터를 다룰 때, 매번 새로운 DTO(Data Transfer Object)를 만드는 것보다 더 효율적일 수 있습니다. 또한, 멀티 모듈 프로젝트에서 여러 모듈 간에 DTO 클래스를 공유해야 할 때 이러한 객체의 사용이 유용합니다.</p><h3><span id="kodeu-yesi-mic-seolmyeong">코드 예시 및 설명</span><a href="#kodeu-yesi-mic-seolmyeong" class="header-anchor">#</a></h3><p>다음은 <code>UserPointCalculator</code> 클래스의 구현 예시입니다. 이 클래스는 사용자 정보를 MySQL 데이터베이스에서 가져오고, 사용자의 포인트 정보는 Redis에서 가져와 계산을 진행합니다. 이 과정에서 Triple 객체를 활용하여 각 사용자의 이름, 이메일, 포인트 정보를 효과적으로 관리합니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">class</span> <span class="title class_">UserPointCalculator</span>(</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">val</span> userRepository: UserRepository,</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">val</span> userPointRepository: UserPointRepository</span><br><span class="line">) &#123;</span><br><span class="line">    <span class="function"><span class="keyword">fun</span> <span class="title">calculate</span><span class="params">()</span></span> &#123;</span><br><span class="line">        <span class="keyword">val</span> users = userRepository.findUserByIds(listOf(<span class="number">1</span>, <span class="number">2</span>, <span class="number">3</span>))</span><br><span class="line">        <span class="keyword">val</span> points = userPointRepository.findUserPoint(listOf(<span class="number">1</span>, <span class="number">2</span>, <span class="number">3</span>))</span><br><span class="line">            .associateBy &#123; it.id &#125;</span><br><span class="line"></span><br><span class="line">        <span class="keyword">val</span> userPoints = users.map &#123;</span><br><span class="line">            Triple(</span><br><span class="line">                first = it.name,</span><br><span class="line">                second = it.email,</span><br><span class="line">                third = points[it.id]!!.point</span><br><span class="line">            )</span><br><span class="line">        &#125;</span><br><span class="line"></span><br><span class="line">        <span class="keyword">for</span> (userPoint <span class="keyword">in</span> userPoints) &#123;</span><br><span class="line">            println(<span class="string">&quot;user name: <span class="subst">$&#123;userPoint.first&#125;</span>, user email  <span class="subst">$&#123;userPoint.second&#125;</span>, user point  <span class="subst">$&#123;userPoint.third&#125;</span>&quot;</span>)</span><br><span class="line">        &#125;</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>분석</strong>: 이 예에서 <code>Triple</code> 객체는 각 사용자의 이름, 이메일, 포인트를 저장하는데 사용됩니다. 이는 데이터베이스와 다른 저장소에서 정보를 읽어와 조합할 때 유용하게 사용됩니다.</p><h3><span id="pairwa-triple-sayong-si-gadogseong-hyangsang">Pair와 Triple 사용 시 가독성 향상</span><a href="#pairwa-triple-sayong-si-gadogseong-hyangsang" class="header-anchor">#</a></h3><p>Pair와 Triple은 기본적으로 <code>first</code>, <code>second</code>, <code>third</code>라는 속성명을 사용합니다. 이 속성명은 코드의 가독성을 저하시킬 수 있으므로, 구조 분해 할당(destructuring declaration)을 사용하여 보다 의미 있는 변수명을 사용하는 것이 좋습니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">fun</span> <span class="title">calculate</span><span class="params">()</span></span> &#123;</span><br><span class="line">    <span class="comment">// 반복 처리</span></span><br><span class="line">    <span class="keyword">for</span> ((userName, userEmail, userPoint) <span class="keyword">in</span> userPoints) &#123;</span><br><span class="line">        println(<span class="string">&quot;user name: <span class="subst">$&#123;userName&#125;</span>, user email <span class="subst">$&#123;userEmail&#125;</span>, user point <span class="variable">$userPoint</span>&quot;</span>)</span><br><span class="line">    &#125;</span><br><span class="line">    <span class="comment">// 개별 처리</span></span><br><span class="line">    <span class="keyword">val</span> (userName, userEmail, userPoint) = userPoints.first()</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>이와 같은 처리 방식은 <code>Pair</code>와 <code>Triple</code>을 사용할 때 코드의 명확성을 향상시키고, 데이터를 보다 효율적으로 다루는 데 도움이 됩니다. 데이터를 직관적으로 알아볼 수 있도록 이름을 명확하게 지정함으로써, 코드의 가독성과 유지보수성을 크게 개선할 수 있습니다.</p><p>이 방법은 간단한 데이터 구조를 사용하면서도 프로그램의 복잡성을 줄이고, 클린 코드를 유지하는 데 기여합니다.</p><h3><span id="pointeu-jeongri">포인트 정리</span><a href="#pointeu-jeongri" class="header-anchor">#</a></h3><p><code>Pair</code>와 <code>Triple</code> 객체는 임시 데이터 또는 내부 로직에서만 사용되는 데이터를 간편하게 다루기 위한 우수한 도구입니다. 이들은 DTO를 정의하는 복잡성을 피할 수 있으며, 특히 간단한 데이터 그룹을 빠르게 다루어야 할 때 효율적입니다. 하지만, 이들을 사용할 때는 변수명을 명확하게 지정하여 코드의 가독성을 유지하는 것이 중요합니다.</p><p>이 글은 Pair와 Triple 객체의 적절한 사용 사례를 통해 개발자들이 코드의 간결성과 유지보수성을 향상시키는 방법을 제시합니다.</p><h2><span id="typealiasreul-hwalyonghan-kodeu-gaeseon"><code>typealias</code>를 활용한 코드 개선</span><a href="#typealiasreul-hwalyonghan-kodeu-gaeseon" class="header-anchor">#</a></h2><p>코틀린의 <code>typealias</code> 기능은 복잡한 타입 선언에 대한 간결하고 의미 있는 이름을 제공함으로써 코드의 가독성과 유지보수성을 크게 향상시킵니다. 특히, 프로젝트 내에서 자주 사용되는 타입 조합에 별칭을 부여함으로써, 코드의 일관성을 유지하고 타입 변경 시의 유연성을 높일 수 있습니다.</p><h3><span id="kodeu-yesi-mic-seolmyeong">코드 예시 및 설명</span><a href="#kodeu-yesi-mic-seolmyeong" class="header-anchor">#</a></h3><p>다음 예제에서는 사용자 정보(<code>User</code>)와 사용자의 포인트(<code>UserPoint</code>)를 연결하는 <code>Pair</code>에 <code>UserPointAssociation</code>이라는 <code>typealias</code>를 사용합니다. 이는 <code>Pair&lt;User, UserPoint&gt;</code>의 사용을 간소화하고, 의미를 명확히 합니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">typealias</span> UserPointAssociation = Pair&lt;User, UserPoint&gt;</span><br><span class="line"></span><br><span class="line"><span class="keyword">val</span> userPointAssociations = users.map &#123;</span><br><span class="line">    UserPointAssociation(</span><br><span class="line">        first = it,</span><br><span class="line">        second = points[it.id]!!</span><br><span class="line">    )</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">val</span> (user, userPoint) = userPointAssociations.first()</span><br></pre></td></tr></table></figure><ul><li><code>UserPointAssociation</code> 타입 별칭은 <code>Pair&lt;User, UserPoint&gt;</code>를 대체하여 코드의 목적을 더 명확하게 표현합니다.</li><li><code>map</code> 함수 내에서 <code>UserPointAssociation</code> 생성자를 사용하여 각 사용자와 해당 포인트 객체를 쌍으로 묶습니다. 이는 데이터의 논리적 연관성을 직관적으로 보여줍니다.</li><li>구조 분해 할당을 통해 <code>user</code>와 <code>userPoint</code> 변수에 각각 사용자 정보와 포인트 정보를 할당함으로써, 코드의 가독성을 더욱 향상시킵니다.</li></ul><h3><span id="pointeu-jeongri">포인트 정리</span><a href="#pointeu-jeongri" class="header-anchor">#</a></h3><ul><li><strong>명확성</strong>: <code>UserPointAssociation</code>과 같은 타입 별칭을 사용하면, 복잡한 타입 조합도 의미 있는 이름으로 대체되어 코드의 목적이 분명해집니다.</li><li><strong>유연성</strong>: 타입 별칭을 사용하면, 기본 타입 구조에 변경이 필요할 때 별칭 정의만 수정하면 되므로 코드 전체에 걸쳐 유연하게 변경을 적용할 수 있습니다.</li><li><strong>재사용성</strong>: 일단 정의된 타입 별칭은 프로젝트 전반에 걸쳐 재사용될 수 있으며, 코드의 일관성을 유지하는 데 도움이 됩니다.</li></ul><p><code>typealias</code>는 복잡한 타입을 간소화하고, 코드의 의도를 명확히 전달하는 데 큰 도움을 줍니다. 이는 특히 크고 복잡한 프로젝트에서 타입 관리를 효과적으로 수행하는 데 중요한 역할을 합니다.</p><h2><span id="runcatching-hamsuwa-result-gaegcereul-hwalyonghan-anjeonhan-ceori"><code>runCatching</code> 함수와 <code>Result</code> 객체를 활용한 안전한 처리</span><a href="#runcatching-hamsuwa-result-gaegcereul-hwalyonghan-anjeonhan-ceori" class="header-anchor">#</a></h2><p>코틀린의 <code>runCatching</code> 함수는 예외 발생 가능성이 있는 코드 블록을 실행하고 그 결과를 <code>Result</code> 타입으로 캡처합니다. 이 기능은 HTTP 통신과 같은 네트워크 요청에서 매우 유용하게 사용될 수 있습니다. <code>Result</code> 객체는 성공적인 결과 또는 발생한 예외를 안전하게 처리할 수 있는 API를 제공합니다.</p><h3><span id="kodeu-yesi-mic-seolmyeong">코드 예시 및 설명</span><a href="#kodeu-yesi-mic-seolmyeong" class="header-anchor">#</a></h3><p>다음 예제는 HTTP API를 통해 사용자 데이터를 요청하고 결과를 처리하는 과정을 보여줍니다:</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">fun</span> <span class="title">getUser</span><span class="params">(userId: <span class="type">Long</span>)</span></span>: User &#123;</span><br><span class="line">    <span class="keyword">return</span> runCatching &#123; userClient.getUser(userId) &#125;</span><br><span class="line">        .onFailure &#123; <span class="keyword">throw</span> IllegalArgumentException(<span class="string">&quot;Failed to fetch user data for user ID <span class="variable">$userId</span>&quot;</span>) &#125;</span><br><span class="line">        .getOrThrow()</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><ul><li><code>userClient.getUser(userId)</code> 함수는 HTTP 요청을 통해 사용자 정보를 가져옵니다. 이 함수는 네트워크 에러나 데이터 문제로 예외를 발생시킬 수 있습니다.</li><li><code>runCatching</code>은 이 요청을 감싸 실행하며, 요청 중 발생하는 예외를 <code>Result</code> 객체로 캡처합니다.</li><li><code>onFailure</code> 블록은 <code>Result</code> 객체가 예외를 캡처했을 경우 실행됩니다. 여기서는 사용자 정의 예외를 던져, 오류 발생을 명확히 알립니다.</li><li><code>getOrThrow</code>는 <code>Result</code> 객체에서 값을 추출합니다. 만약 <code>Result</code>가 실패를 나타내는 경우, <code>onFailure</code>에서 설정한 예외가 발생됩니다.</li></ul><h3><span id="pointeu-jeongri">포인트 정리</span><a href="#pointeu-jeongri" class="header-anchor">#</a></h3><ul><li><strong>안전한 실행과 예외 처리</strong>: <code>runCatching</code>과 <code>Result</code> 객체를 사용하면 예외 처리를 안전하고 효율적으로 수행할 수 있습니다. 이를 통해 프로그램의 견고성이 증가합니다.</li><li><strong>결과 처리의 유연성</strong>: <code>Result</code> 타입은 <code>getOrThrow</code>, <code>getOrElse</code>, <code>getOrNull</code> 등 다양한 방법으로 결과를 처리할 수 있는 확장 함수를 제공합니다. 이 함수들은 각 상황에 맞게 결과를 유연하게 처리할 수 있도록 도와줍니다.</li><li><strong>코드의 간결성과 명확성</strong>: <code>runCatching</code>을 사용함으로써 전통적인 <code>try-catch</code> 블록보다 코드를 더 간결하고 읽기 쉽게 만들 수 있습니다.</li><li><strong>에러 핸들링의 명확성</strong>: <code>onFailure</code>를 통해 에러 발생 시 명확한 처리 로직을 구현할 수 있으며, 에러 메시지를 통해 오류의 원인을 더욱 분명히 할 수 있습니다.</li></ul><p>코틀린의 <code>Result</code> 타입은 개발자가 더 유연하고 견고한 코드를 작성할 수 있도록 지원합니다. 특히, HTTP 통신을 많이 다루는 서비스에서는 이러한 패턴을 적극적으로 활용하여 애플리케이션의 안정성과 유연성을 동시에 향상시킬 수 있습니다.  더 자세한 내용과 실용적인 설계 전략은 카카오페이 기술 블로그의 <a href="https://tech.kakaopay.com/post/make-http-client-design-flexible/">MSA 환경에서의 유연한 HTTP 클라이언트 설계 전략</a> 글에서 확인하실 수 있습니다. 이 글에서는 <code>Result</code> 타입을 활용하여 MSA 환경에서 HTTP 클라이언트를 유연하게 설계하는 방법을 소개하고 있습니다.</p><p>안정적인 서비스 운영을 위해 <code>runCatching</code>과 같은 코틀린의 기능을 적극 활용해보시길 권장드립니다. 이러한 패턴들은 예외가 발생할 가능성이 있는 네트워크 요청을 처리할 때 특히 유용하며, 시스템의 전체적인 에러 관리 능력을 개선할 수 있습니다.</p><h2><span id="koteulrineseo-by-kiweodeureul-hwalyonghan-roging-seoljeong">코틀린에서 <code>by</code> 키워드를 활용한 로깅 설정</span><a href="#koteulrineseo-by-kiweodeureul-hwalyonghan-roging-seoljeong" class="header-anchor">#</a></h2><p>코틀린에서 <code>by</code> 키워드는 위임 패턴(delegation)을 간편하게 구현할 수 있도록 도와줍니다. 특히, 로깅과 같은 반복적으로 사용되는 기능을 클래스에 쉽게 통합할 수 있게 하는 강력한 도구입니다. <code>by</code> 키워드를 사용하면 인스턴스 생성을 위임함으로써 코드의 중복을 줄이고 유지보수를 용이하게 만들 수 있습니다.</p><h3><span id="kodeu-yesi-mic-seolmyeong">코드 예시 및 설명</span><a href="#kodeu-yesi-mic-seolmyeong" class="header-anchor">#</a></h3><p>아래 예제는 <code>by</code> 키워드를 사용하여 <code>Logger</code> 인스턴스를 생성하고 이를 클래스에서 쉽게 사용할 수 있도록 보여줍니다:</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">import</span> org.slf4j.Logger</span><br><span class="line"><span class="keyword">import</span> org.slf4j.LoggerFactory</span><br><span class="line"></span><br><span class="line"><span class="comment">// 로거 인스턴스 생성을 위한 제네릭 확장 함수</span></span><br><span class="line"><span class="function"><span class="keyword">fun</span> <span class="type">&lt;A : Any&gt;</span> A.<span class="title">logger</span><span class="params">()</span></span>: Lazy&lt;Logger&gt; = lazy &#123; LoggerFactory.getLogger(<span class="keyword">this</span>.javaClass) &#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// 클래스 내부에서 위임을 통해 로그 인스턴스 사용</span></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">MyClass</span> &#123;</span><br><span class="line">    <span class="keyword">private</span> <span class="keyword">val</span> log <span class="keyword">by</span> logger()</span><br><span class="line"></span><br><span class="line">    <span class="function"><span class="keyword">fun</span> <span class="title">performAction</span><span class="params">()</span></span> &#123;</span><br><span class="line">        log.info(<span class="string">&quot;Action performed&quot;</span>)</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><ul><li><code>logger()</code> 함수는 <code>Lazy&lt;Logger&gt;</code>를 반환합니다. 이 함수는 호출하는 객체의 클래스 이름을 사용하여 <code>Logger</code> 인스턴스를 생성합니다.</li><li><code>private val log by logger()</code> 표현은 실제 로그 인스턴스가 필요할 때까지 로그 객체의 생성을 지연시킵니다(<code>lazy</code>를 사용).</li><li>이 방식을 통해 클래스 내부에서 <code>log</code>를 직접 사용할 수 있으며, 로그 호출 시점에만 로거 인스턴스가 초기화됩니다.</li></ul><h3><span id="pointeu-jeongri">포인트 정리</span><a href="#pointeu-jeongri" class="header-anchor">#</a></h3><ul><li><strong>효율적인 자원 사용</strong>: <code>lazy</code>를 사용함으로써 로거의 초기화를 실제 로깅이 필요한 시점까지 지연시킬 수 있습니다. 이는 자원을 효율적으로 사용하게 합니다.</li><li><strong>코드 중복 감소</strong>: <code>logger()</code> 확장 함수를 사용하면 모든 클래스에서 동일한 로깅 구성을 쉽게 재사용할 수 있습니다. 이는 코드 중복을 크게 줄여줍니다.</li><li><strong>유지보수의 용이성</strong>: 로그 인스턴스 생성 코드를 한 곳에 집중시키므로, 로거 설정을 변경할 때 다수의 클래스를 수정할 필요가 없습니다. 이는 전체적인 유지보수를 간단하게 만듭니다.</li></ul><p><code>by</code> 키워드의 사용은 코틀린의 강력한 기능 중 하나로, 개발자가 코드를 보다 효과적이고 깔끔하게 관리할 수 있도록 돕습니다. 위의 예제처럼 <code>by</code> 키워드를 사용하는 것은 반복되는 코드 패턴을 단순화하고 프로젝트의 전반적인 품질을 향상시키는 데 큰 도움이 됩니다.</p><h2><span id="koteulrineseo-cogihwa-jiyeoneul-anjeonhage-gwanrihagi">코틀린에서 초기화 지연을 안전하게 관리하기</span><a href="#koteulrineseo-cogihwa-jiyeoneul-anjeonhage-gwanrihagi" class="header-anchor">#</a></h2><p><code>Delegates.notNull()</code>은 코틀린에서 프로퍼티가 사용되기 전에 초기화되어야 함을 보장하는 위임 메커니즘입니다. 이 방법은 특히 프로퍼티의 초기화 시점이 명확하지 않을 때 유용하며, 초기화되지 않은 상태에서의 접근을 방지하여 안전성을 높입니다.</p><h3><span id="kodeu-yesi-mic-seolmyeong">코드 예시 및 설명</span><a href="#kodeu-yesi-mic-seolmyeong" class="header-anchor">#</a></h3><p>다음 예제는 <code>QuerydslCustomRepositorySupport</code> 클래스에서 <code>Delegates.notNull()</code>을 사용하는 방법을 보여줍니다. 스프링 프레임워크의 의존성 주입 기능을 사용하여 <code>EntityManager</code>가 주입된 후 <code>JPAQueryFactory</code>를 초기화합니다. 이런 경우, 의존성 주입의 시점이 런타임에 결정되므로 <code>Delegates.notNull()</code>을 활용하여 안전하게 초기화를 보장할 수 있습니다. 이는 <code>EntityManager</code>가 설정되기 전에 <code>queryFactory</code>가 사용되는 것을 방지하며, 초기화되지 않은 상태에서의 접근을 효과적으로 차단합니다.</p><figure class="highlight kotlin"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">abstract</span> <span class="keyword">class</span> <span class="title class_">QuerydslCustomRepositorySupport</span>(domainClass: Class&lt;*&gt;) : QuerydslRepositorySupport(domainClass) &#123;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">protected</span> <span class="keyword">var</span> queryFactory: JPAQueryFactory <span class="keyword">by</span> Delegates.notNull()</span><br><span class="line"></span><br><span class="line">    <span class="meta">@PersistenceContext</span></span><br><span class="line">    <span class="keyword">override</span> <span class="function"><span class="keyword">fun</span> <span class="title">setEntityManager</span><span class="params">(entityManager: <span class="type">EntityManager</span>)</span></span> &#123;</span><br><span class="line">        <span class="keyword">super</span>.setEntityManager(entityManager)</span><br><span class="line">        <span class="keyword">this</span>.queryFactory = JPAQueryFactory(entityManager)</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><ul><li><code>queryFactory</code> 프로퍼티는 <code>JPAQueryFactory</code> 타입으로 선언되어 있으며, <code>Delegates.notNull()</code>을 통해 위임되고 있습니다. 이는 <code>queryFactory</code>가 사용되기 전에 반드시 초기화되어야 함을 보장합니다.</li><li><code>setEntityManager</code> 메소드는 <code>EntityManager</code>를 받아 <code>super</code> 클래스의 같은 메소드를 호출한 후, <code>queryFactory</code>를 초기화합니다. 이 메소드는 <code>@PersistenceContext</code> 애노테이션을 통해 JPA의 영속성 컨텍스트에서 <code>EntityManager</code>가 주입될 때 자동으로 호출됩니다.</li><li>만약 <code>queryFactory</code>가 <code>setEntityManager</code> 메소드 호출 전에 사용되려고 하면, <code>IllegalStateException</code>이 발생하여 개발자에게 초기화 문제를 즉시 알려줍니다.</li></ul><h3><span id="pointeu-jeongri">포인트 정리</span><a href="#pointeu-jeongri" class="header-anchor">#</a></h3><ul><li><strong>안전성 보장</strong>: 초기화되지 않은 프로퍼티의 사용을 방지하여 애플리케이션의 안정성을 향상시킵니다.</li><li><strong>명시적인 오류 처리</strong>: 초기화되지 않은 프로퍼티에 접근하려고 할 때 즉각적으로 예외가 발생함으로써, 초기화 로직의 오류를 빠르게 파악하고 수정할 수 있습니다.</li><li><strong>초기화 유연성</strong>: 특정 메소드나 조건 하에서만 초기화가 가능한 경우에 <code>Delegates.notNull()</code>을 사용하여 유연하게 초기화를 관리할 수 있습니다.</li></ul><p>이러한 특성 덕분에 <code>Delegates.notNull()</code>은 코틀린에서 프로퍼티의 초기화를 안전하고 효과적으로 관리할 수 있는 강력한 방법을 제공하며, 특히 늦은 초기화가 필요한 상황에서 그 가치가 더욱 빛납니다.</p>]]></content:encoded>
      
      
      
      <category domain="https://cheese10yun.github.io/tags/Kotlin/">Kotlin</category>
      
      
      <comments>https://cheese10yun.github.io/kotlin-pattern/#disqus_thread</comments>
      
    </item>
    
    <item>
      <title>Locust 성능 테스트 도구 소개 Part 2</title>
      <link>https://cheese10yun.github.io/locust-part-2/</link>
      <guid>https://cheese10yun.github.io/locust-part-2/</guid>
      <pubDate>Sat, 06 Apr 2024 15:00:00 GMT</pubDate>
      
      <description>이 글을 읽기 전에, Locust 성능 테스트 도구 소개를 먼저 확인해 보시는 것이 좋습니다. 이를 통해 Locust의 기본적인 사용법과 개념을 이해하신 후, 본 글에서 다루는 보다 심화된 사용 방법과 전략에 쉽게 접근하실 수 있습니다.</description>
      
      
      
      <content:encoded><![CDATA[<p>이 글을 읽기 전에, <a href="https://cheese10yun.github.io/locust-part-1/">Locust 성능 테스트 도구 소개</a>를 먼저 확인해 보시는 것이 좋습니다. 이를 통해 Locust의 기본적인 사용법과 개념을 이해하신 후, 본 글에서 다루는 보다 심화된 사용 방법과 전략에 쉽게 접근하실 수 있습니다.</p><h2><span id="on-start-mic-on-stop-meseodeu">on_start 및 on_stop 메서드</span><a href="#on-start-mic-on-stop-meseodeu" class="header-anchor">#</a></h2><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">class</span> <span class="title class_">OrderApiTest</span>(<span class="title class_ inherited__">HttpUser</span>):</span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">on_start</span>(<span class="params">self</span>):</span><br><span class="line">        <span class="variable language_">self</span>.client.post(<span class="string">&quot;/login&quot;</span>)</span><br><span class="line">    </span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">on_stop</span>(<span class="params">self</span>):</span><br><span class="line">        <span class="variable language_">self</span>.client.post(<span class="string">&quot;/logout&quot;</span>)</span><br></pre></td></tr></table></figure><p><code>on_start</code>와 <code>on_stop</code> 메서드는 사용자의 세션 시작과 종료 시 특정 작업을 실행하는 데 사용됩니다. 로그인과 로그아웃 외에도, 사용자가 시나리오를 시작하기 전에 필요한 데이터를 세팅하거나, 시나리오 종료 후 사용한 리소스를 정리하는 데 사용할 수 있습니다. 예를 들어, 시나리오 시작 시 특정 API를 호출하여 필요한 설정을 하거나, 시나리오가 끝난 후 생성된 데이터를 삭제하는 등의 작업이 있을 수 있습니다. 이러한 메서드를 통해 테스트의 사전 준비와 후처리를 자동화할 수 있습니다. <code>on_start</code>는 사용자가 시작될 때 호출되며, <code>on_stop</code>은 사용자가 종료될 때 호출됩니다. 강제로 loucst를 종료하면 <code>on_stop</code> 메서드가 호출되지 않습니다.</p><h2><span id="taskreul-iyonghan-api-yoceong-biyul-jojeong">@task를 이용한 API 요청 비율 조정</span><a href="#taskreul-iyonghan-api-yoceong-biyul-jojeong" class="header-anchor">#</a></h2><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">Advance</span>(<span class="title class_ inherited__">HttpUser</span>):</span><br><span class="line">    ...</span><br><span class="line"><span class="meta">    @task(<span class="params"><span class="number">3</span></span>)</span></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">getOrder</span>(<span class="params">self</span>):</span><br><span class="line">        ...</span><br><span class="line">        <span class="variable language_">self</span>.client.get(</span><br><span class="line">            <span class="string">&quot;/api/v1/orders&quot;</span>,</span><br><span class="line">            headers = &#123; <span class="string">&quot;Content-Type&quot;</span>: <span class="string">&quot;application/json&quot;</span> &#125;,</span><br><span class="line">            params = params,</span><br><span class="line">            name = <span class="string">&quot;/api/v1/orders&quot;</span></span><br><span class="line">        )</span><br><span class="line"></span><br><span class="line"><span class="meta">    @task(<span class="params"><span class="number">1</span></span>)</span></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">getShop</span>(<span class="params">self</span>):</span><br><span class="line">        ...</span><br><span class="line">        <span class="variable language_">self</span>.client.get(</span><br><span class="line">            <span class="string">&quot;/api/v1/shops&quot;</span>,</span><br><span class="line">            headers = &#123; <span class="string">&quot;Content-Type&quot;</span>: <span class="string">&quot;application/json&quot;</span> &#125;,</span><br><span class="line">            params = params,</span><br><span class="line">            name = <span class="string">&quot;/api/v1/shops&quot;</span></span><br><span class="line">        )</span><br><span class="line"></span><br></pre></td></tr></table></figure><p><img src="https://raw.githubusercontent.com/cheese10yun/blog-sample/master/locust/imag/locust-1-1.png" alt></p><p><code>@task</code> 데코레이터는 Locust에서 작업의 실행 빈도나 우선순위를 지정하는 데 사용됩니다. 숫자를 인자로 제공함으로써, 특정 작업이 다른 작업들에 비해 상대적으로 얼마나 자주 실행될지 결정할 수 있습니다. 예를 들어, <code>@task(3)</code>은 해당 작업이 같은 TaskSet 내 다른 <code>@task(1)</code> 작업보다 세 배 더 많이 실행됨을 의미합니다. 이를 통해 실제 사용자 행동을 더 잘 모방한 부하 테스트 시나리오를 구성할 수 있습니다.</p><h2><span id="suncajeog-tasksetsro-weokeupeulrou-simyulreisyeon">순차적 TaskSets로 워크플로우 시뮬레이션</span><a href="#suncajeog-tasksetsro-weokeupeulrou-simyulreisyeon" class="header-anchor">#</a></h2><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">class</span> <span class="title class_">OrderTaskSet</span>(<span class="title class_ inherited__">TaskSet</span>):</span><br><span class="line"></span><br><span class="line"><span class="meta">    @task</span></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">getOrder</span>(<span class="params">self</span>):</span><br><span class="line">        ...</span><br><span class="line">        <span class="variable language_">self</span>.client.get(</span><br><span class="line">            <span class="string">&quot;/api/v1/orders&quot;</span>,</span><br><span class="line">            headers = &#123; <span class="string">&quot;Content-Type&quot;</span>: <span class="string">&quot;application/json&quot;</span> &#125;,</span><br><span class="line">            params = params,</span><br><span class="line">            name = <span class="string">&quot;/api/v1/orders&quot;</span></span><br><span class="line">        )</span><br><span class="line"></span><br><span class="line"><span class="meta">    @task</span></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">getShop</span>(<span class="params">self</span>):</span><br><span class="line">        ...</span><br><span class="line">        <span class="variable language_">self</span>.client.get(</span><br><span class="line">            <span class="string">&quot;/api/v1/shops&quot;</span>,</span><br><span class="line">            headers = &#123; <span class="string">&quot;Content-Type&quot;</span>: <span class="string">&quot;application/json&quot;</span> &#125;,</span><br><span class="line">            params = params,</span><br><span class="line">            name = <span class="string">&quot;/api/v1/shops&quot;</span></span><br><span class="line">        )</span><br><span class="line"></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">OrderTask</span>(<span class="title class_ inherited__">HttpUser</span>):</span><br><span class="line">    wait_time = constant_pacing(<span class="number">2</span>)  <span class="comment"># 최소 10초 간격으로 작업 실행이 보장되도록 대기 시간 설정</span></span><br><span class="line">    host = <span class="string">&quot;http://localhost:8080&quot;</span>  <span class="comment"># 테스트 대상 호스트 주소 지정</span></span><br><span class="line"></span><br><span class="line">    tasks = [OrderTaskSet]</span><br></pre></td></tr></table></figure><p>순차적 TaskSets를 사용하는 워크플로우 시뮬레이션은 사용자가 실제 애플리케이션을 사용할 때의 행동 순서를 모방하는 데 사용됩니다. 이 방식에서는 TaskSet 클래스 내에서 각각의 <code>@task</code> 함수가 사용자의 다음 동작을 시뮬레이션합니다. 이 예제에서는 <code>OrderTaskSet</code> 내의 <code>getOrder</code>와 <code>getShop</code>이 동일한 비율로 실행되며, 사용자는 이 두 작업 사이를 순차적으로, 또는 랜덤으로 전환하면서 진행할 수 있습니다. <code>constant_pacing</code> 설정을 통해 각 작업 사이의 실행 간격을 조절함으로써, 실제 사용자 경험에 더 가까운 테스트 환경을 구성할 수 있습니다. 자세한 내용은 <a href="https://docs.locust.io/en/stable/tasksets.html#tasksets">Locust 공식 문서</a>를 참조하세요.</p><p>공식 문서는 정확한 비율의 작업 호출을 달성하기 위해 루프와 제어문 사용을 권장합니다. <code>@task</code>를 이용한 간단한 호출 비율 조정은 대략적인 작업 순서에 적합하지만, 정확한 작업 순서가 필요한 경우, 공식 문서의 권장 사항을 따르는 것이 더 바람직합니다.</p><h2><span id="majcumhyeong-buha-hyeongtae-simyulreisyeon">맞춤형 부하 형태 시뮬레이션</span><a href="#majcumhyeong-buha-hyeongtae-simyulreisyeon" class="header-anchor">#</a></h2><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">class</span> <span class="title class_">Advance</span>(<span class="title class_ inherited__">HttpUser</span>):</span><br><span class="line">    wait_time = constant(<span class="number">1</span>)  <span class="comment"># 모든 요청 사이에 3초의 고정된 대기 시간 설정</span></span><br><span class="line">    host = <span class="string">&quot;http://localhost:8080&quot;</span>  <span class="comment"># 테스트 대상 호스트 주소 지정</span></span><br><span class="line"></span><br><span class="line"><span class="meta">    @task</span></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">getOrder</span>(<span class="params">self</span>):</span><br><span class="line">        ...</span><br><span class="line">        <span class="variable language_">self</span>.client.get(</span><br><span class="line">            <span class="string">&quot;/api/v1/orders&quot;</span>,</span><br><span class="line">            headers = &#123; <span class="string">&quot;Content-Type&quot;</span>: <span class="string">&quot;application/json&quot;</span> &#125;,</span><br><span class="line">            params = params,</span><br><span class="line">            name = <span class="string">&quot;/api/v1/orders&quot;</span></span><br><span class="line">        )</span><br><span class="line"></span><br><span class="line"><span class="meta">    @task</span></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">getShop</span>(<span class="params">self</span>):</span><br><span class="line">        ...</span><br><span class="line">        <span class="variable language_">self</span>.client.get(</span><br><span class="line">            <span class="string">&quot;/api/v1/shops&quot;</span>,</span><br><span class="line">            headers = &#123; <span class="string">&quot;Content-Type&quot;</span>: <span class="string">&quot;application/json&quot;</span> &#125;,</span><br><span class="line">            params = params,</span><br><span class="line">            name = <span class="string">&quot;/api/v1/shops&quot;</span></span><br><span class="line">        )</span><br><span class="line"></span><br><span class="line"></span><br><span class="line"></span><br><span class="line"><span class="comment"># 사용자 정의 부하 모양을 정의하는 LoadTestShape 클래스</span></span><br><span class="line"><span class="keyword">class</span> <span class="title class_">CustomShape</span>(<span class="title class_ inherited__">LoadTestShape</span>):</span><br><span class="line">    time_limit = <span class="number">600</span>  <span class="comment"># 부하 테스트의 총 시간 한계 설정</span></span><br><span class="line">    spawn_rate = <span class="number">20</span>  <span class="comment"># 초당 새로운 사용자를 생성하는 속도 설정</span></span><br><span class="line"></span><br><span class="line">    <span class="keyword">def</span> <span class="title function_">tick</span>(<span class="params">self</span>):</span><br><span class="line">        run_time = <span class="variable language_">self</span>.get_run_time()  <span class="comment"># 현재 실행 시간 가져오기</span></span><br><span class="line"></span><br><span class="line">        <span class="keyword">if</span> run_time &lt; <span class="variable language_">self</span>.time_limit:</span><br><span class="line">            <span class="comment"># 실행 시간에 따라 사용자 수 증가</span></span><br><span class="line">            user_count = run_time // <span class="number">10</span></span><br><span class="line">            <span class="keyword">return</span> (user_count, <span class="variable language_">self</span>.spawn_rate)</span><br><span class="line"></span><br><span class="line">        <span class="keyword">return</span> <span class="literal">None</span>  <span class="comment"># 시간 한계를 넘으면 테스트 종료</span></span><br></pre></td></tr></table></figure><p>위 코드는 Locust를 사용한 사용자 정의 부하 테스트 시나리오를 설정하는 예시입니다. 테스트 시작부터 시간이 600초(10분)에 이르기까지 실행 시간에 따라 사용자 수를 점진적으로 증가시킵니다. <code>tick</code> 함수는 현재 실행 시간을 기반으로 사용자 수를 결정하고, 실행 시간이 10초마다 사용자 수를 1명씩 증가시키는 로직을 포함하고 있습니다. 시간 한계에 도달하면, 즉 실행 시간이 600초를 초과하면, 테스트는 자동으로 종료됩니다. 이를 통해 초기 단계에서는 부하가 점점 증가하다가 설정된 시간이 지나면 테스트가 종료되는 시나리오를 구현할 수 있습니다.</p><h2><span id="jeongri">정리</span><a href="#jeongri" class="header-anchor">#</a></h2><p>사용자 세션 시작과 종료에 필요한 동작을 자동화하는 <code>on_start</code>와 <code>on_stop</code> 메서드, 다양한 API 요청의 실행 비율을 조절하는 <code>@task</code>, 실제 사용자 워크플로우 시뮬레이션에 유용한 순차적 <code>TaskSets</code>, 그리고 테스트 동안 사용자 부하를 동적으로 조절할 수 있는 맞춤형 부하 형태 <code>CustomShape</code>에 대해 설명합니다. 이 방법들은 Locust를 활용하여 보다 실제적이고 유연한 성능 테스트를 구현하는 데 도움을 줍니다.</p>]]></content:encoded>
      
      
      
      <category domain="https://cheese10yun.github.io/tags/Performance/">Performance</category>
      
      <category domain="https://cheese10yun.github.io/tags/Monitoring/">Monitoring</category>
      
      
      <comments>https://cheese10yun.github.io/locust-part-2/#disqus_thread</comments>
      
    </item>
    
    <item>
      <title>Locust 성능 테스트 도구 소개 Part 1</title>
      <link>https://cheese10yun.github.io/locust-part-1/</link>
      <guid>https://cheese10yun.github.io/locust-part-1/</guid>
      <pubDate>Sat, 30 Mar 2024 15:00:00 GMT</pubDate>
      
      <description>Locust는 오픈 소스 부하 테스트 도구로, 사용자가 Python으로 시나리오를 작성하여 웹 애플리케이션의 성능을 측정할 수 있게 해줍니다.</description>
      
      
      
      <content:encoded><![CDATA[<h2><span id="locust-ran">Locust 란?</span><a href="#locust-ran" class="header-anchor">#</a></h2><p>Locust는 오픈 소스 부하 테스트 도구로, 사용자가 Python으로 시나리오를 작성하여 웹 애플리케이션의 성능을 측정할 수 있게 해줍니다. 이 도구는 이벤트 기반 모델을 사용하여 수천 명의 사용자를 시뮬레이션하고, 웹사이트나 API 서버에 대한 부하 테스트를 실시간으로 실행할 수 있습니다. Locust는 사용자 친화적인 웹 인터페이스를 제공하여 테스트의 진행 상황을 모니터링하고, 결과를 분석할 수 있게 합니다.</p><h2><span id="locust-seolci">Locust 설치</span><a href="#locust-seolci" class="header-anchor">#</a></h2><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">$ pip install locust</span><br></pre></td></tr></table></figure><p>pip를 통해서 <code>locust</code>를 설치합니다.</p><figure class="highlight pycon"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line">from locust import HttpUser, task, constant</span><br><span class="line">import random</span><br><span class="line"></span><br><span class="line">class HelloWorldUser(HttpUser):</span><br><span class="line">    wait_time = constant(1)  # 모든 요청 사이에 1초의 고정된 대기 시간 설정</span><br><span class="line">    host = &quot;http://localhost:8080&quot;  # 테스트 대상 호스트 주소 지정</span><br><span class="line"></span><br><span class="line">    @task</span><br><span class="line">    def hello_world(self):</span><br><span class="line">        self.client.get(</span><br><span class="line">            &quot;/api/v1/orders&quot;,</span><br><span class="line">            headers = &#123; &quot;Content-Type&quot;: &quot;application/json&quot; &#125;,</span><br><span class="line">            params = params,</span><br><span class="line">            name = &quot;/api/v1/orders&quot;</span><br><span class="line">        )</span><br></pre></td></tr></table></figure><p>간단하게 HTTP GET 요청을 보내는 Locust 스크립트를 작성해보았습니다. <code>HttpUser</code> 클래스를 상속받아 사용자 클래스를 정의하고, <code>@task</code> 데코레이터를 사용하여 테스트 함수를 정의합니다. <code>self.client.get</code> 메서드를 사용하여 GET 요청을 보내고, <code>params</code>와 <code>name</code> 매개변수를 사용하여 요청 파라미터와 요청 이름을 설정합니다.</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">$ locust -f &lt;file_name.py&gt;</span><br></pre></td></tr></table></figure><p><code>locust</code> 명령어를 사용하여 Locust 스크립트를 실행합니다. <code>-f</code> 옵션을 사용하여 실행할 스크립트 파일을 지정합니다. 실행 후 웹 브라우저에서 <code>http://localhost:8089</code>로 접속하여 Locust 웹 인터페이스를 확인할 수 있습니다. 만약 파일명을 <code>locustfile.py</code> 으로 지정했다면 <code>locust</code> 명령어만 수행하면 됩니다.</p><h2><span id="locustyi-teugjing">Locust의 특징</span><a href="#locustyi-teugjing" class="header-anchor">#</a></h2><p>API 서버 성능 테스트 도구로 많이 알려진 도구들 중 JMeter와 nGrinder는 강력한 기능과 세밀한 설정 옵션으로 널리 사용되고 있습니다. 이러한 도구들은 복잡한 시나리오를 구현하고 대규모의 부하 테스트를 수행할 수 있는 뛰어난 능력을 가지고 있습니다. 그러나 이러한 기능성과 다양성이 더 간편하고 신속한 테스트 실행을 선호하는 사용자들에게는 설정과 실행 과정에서의 복잡성으로 인해 접근성이 떨어질 수 있습니다.</p><p>이에 비해 Locust는 사용자 친화적인 API 서버 성능 테스트 도구로, 그 사용의 용이성과 편리함에서 큰 장점을 가지고 있습니다. 특히, Locust는 Python으로 테스트 스크립트를 작성하기 때문에, 기존에 Python을 사용해본 경험이 있는 개발자라면 누구나 쉽게 접근할 수 있습니다. 이는 테스트 스크립트의 작성과 수정을 매우 간단하게 만들어 줍니다.</p><p>Locust의 설치 및 운용의 용이성은 테스트 프로세스를 대폭 단순화시킵니다. 몇 가지 간단한 명령어로 Locust를 설치할 수 있으며, 별도의 복잡한 설정 없이도 로컬 환경에서 바로 부하 테스트를 시작할 수 있습니다. 이러한 점은 개발 초기 단계에서 빠르게 API 성능을 평가하고자 할 때 특히 유용합니다.</p><p>또한, Locust로 작성된 테스트 스크립트는 Python 코드로 구성되어 있기 때문에, GitHub과 같은 원격 저장소에 코드를 올려두면 팀원이나 다른 개발자들이 언제든지 손쉽게 해당 스크립트를 클론하고, 필요한 부하 테스트를 즉시 실행할 수 있습니다. 이는 협업 환경에서의 테스트 과정을 매우 효율적으로 만들어 줍니다. 팀원들은 최신의 테스트 스크립트를 공유받아, 실시간으로 테스트 결과를 확인하고 성능 개선 작업을 진행할 수 있습니다.</p><p>이와 더불어, Locust는 실시간으로 테스트 결과를 웹 인터페이스를 통해 제공합니다. 사용자는 웹 브라우저를 통해 테스트의 진행 상황을 모니터링하고, 성능 지표를 실시간으로 확인할 수 있습니다. 이는 테스트 과정에서의 직관적인 데이터 분석과 신속한 의사 결정을 가능하게 합니다.</p><p>요약하자면, Locust는 설치와 사용이 쉬우며, 로컬 환경에서의 빠른 구동 능력으로 인해 개발자가 신속하게 성능 테스트를 수행할 수 있게 해줍니다. Python 기반의 스크립트 작성 방식은 깃헙과 같은 원격 저장소를 통한 협업에 매우 유리하며, 이로 인해 개발 프로세스의 효율성과 속도를 크게 향상시킬 수 있습니다.</p><h2><span id="locust-dashboard">Locust Dashboard</span><a href="#locust-dashboard" class="header-anchor">#</a></h2><h3><span id="start-new-load-test">Start new load test</span><a href="#start-new-load-test" class="header-anchor">#</a></h3><p><img src="https://raw.githubusercontent.com/cheese10yun/blog-sample/master/locust/imag/locust_005.png" alt></p><p>Start new load test 버튼을 클릭하여 새로운 부하 테스트를 시작할 수 있습니다. 이 버튼을 클릭하면 다음과 같은 옵션을 설정할 수 있는 팝업 창이 나타납니다.</p><ul><li>Number of Users<ul><li>정의: 테스트에서 동시에 시뮬레이션할 가상 사용자의 총 수입니다.</li><li>목적: 애플리케이션이 동시에 처리할 수 있는 사용자 수를 설정하여, 애플리케이션의 동시 사용자 처리 능력을 테스트합니다.</li></ul></li><li>Ramp Up (일반적으로 Ramp Up 시간을 의미하며, Locust에서는 Spawn Rate으로 표현될 수 있음)<ul><li>정의: 테스트 시작부터 설정된 전체 사용자 수에 도달하기까지의 시간 또는 사용자가 점진적으로 증가하는 속도입니다.</li><li>목적: 사용자 수가 점진적으로 증가하는 상황을 모델링하여, 애플리케이션이 사용자 증가 속도에 어떻게 대응하는지 평가합니다.</li></ul></li></ul><h3><span id="statistics">Statistics</span><a href="#statistics" class="header-anchor">#</a></h3><p><img src="https://raw.githubusercontent.com/cheese10yun/blog-sample/master/locust/imag/locust_001.png" alt></p><p>Statistics 섹션은 현재 진행 중인 테스트의 실시간 통계를 제공합니다. 이 테이블에는 각 요청 유형별로 세분화된 데이터가 포함되어 있으며, 다음과 같은 정보를 확인할 수 있습니다:</p><ul><li>Name: 요청의 이름이나 경로를 나타냅니다.</li><li>requests: 해당 요청이 몇 번 실행되었는지 보여줍니다.</li><li>failures: 요청 실패 횟수를 나타냅니다.</li><li>Median response time: 응답 시간의 중앙값(밀리초 단위)을 보여줍니다. 이는 모든 요청 중간에 위치하는 응답 시간을 의미합니다.</li><li>Average response time: 평균 응답 시간을 나타냅니다.</li><li>Min/Max response time: 관찰된 최소 및 최대 응답 시간입니다.</li><li>Request per second: 초당 요청 수를 보여줍니다.</li></ul><h3><span id="charts">Charts</span><a href="#charts" class="header-anchor">#</a></h3><p><img src="https://raw.githubusercontent.com/cheese10yun/blog-sample/master/locust/imag/locust_002.png" alt></p><p>Charts 섹션은 테스트 동안 수집된 데이터를 그래프 형태로 시각화합니다. 이 차트는 테스트의 진행에 따라 동적으로 업데이트되며, 주로 다음과 같은 정보를 제공합니다.</p><ul><li>Requests per second (RPS): 시간에 따른 초당 요청 수의 변화를 나타냅니다.</li><li>Response times: 다양한 응답 시간(평균, 최소, 최대)을 시간 경과에 따라 보여줍니다.</li><li>Number of users: 시간에 따른 사용자 수의 변화를 보여줍니다.</li></ul><h3><span id="download-data">Download Data</span><a href="#download-data" class="header-anchor">#</a></h3><p><img src="https://raw.githubusercontent.com/cheese10yun/blog-sample/master/locust/imag/locust_003.png" alt></p><p>Download Data 메뉴는 테스트 결과를 다운로드할 수 있는 옵션을 제공합니다. 테스트의 통계 및 차트 데이터를 CSV 파일 형식으로 내보낼 수 있으며, 이는 보다 심층적인 분석이나 문서화, 또는 다른 팀 구성원과의 공유를 위해 사용될 수 있습니다.</p><p><img src="https://raw.githubusercontent.com/cheese10yun/blog-sample/master/locust/imag/locust_004.png" alt></p><p>특히 Download the Report 메뉴를 통해 테스트 결과를 HTML 형식으로 다운로드할 수 있습니다. 이 HTML 리포트는 테스트의 요약 정보와 세부 통계, 그리고 차트 데이터를 포함하고 있으며, 테스트 결과를 보다 시각적으로 표현할 수 있습니다. 이 리포트는 테스트 결과를 문서화하거나, 다른 팀원과 공유할 때 유용하게 사용될 수 있습니다.</p><h3><span id="local-performance-test">Local Performance Test</span><a href="#local-performance-test" class="header-anchor">#</a></h3><p><img src="https://raw.githubusercontent.com/cheese10yun/blog-sample/master/locust/imag/locust_006.png" alt></p><p>로컬 환경에서의 성능 테스트 결과, 루프백 네트워크를 통해 2,500 TPS를 달성했습니다. 이는 로컬 환경의 특성을 활용한 결과이며, 실제 운영 환경에서는 성능이 다를 수 있지만, 로컬에서 쉽게 높은 TPS를 달성할 수 있다는 점을 시사합니다.</p><h2><span id="jeongri">정리</span><a href="#jeongri" class="header-anchor">#</a></h2><p>Locust는 사용의 용이성과 빠른 테스트 실행 능력으로 개발자에게 탁월한 부하 테스트 도구를 제공합니다. Python 기반으로 간단한 설치와 함께, 누구나 쉽게 테스트를 시작할 수 있으며, 이는 빠른 성능 평가와 적시의 개선으로 이어집니다.</p><p>원격 저장소를 통한 테스트 스크립트 공유는 팀 내 협업을 강화하며, 모든 팀원이 필요한 테스트를 쉽게 실행할 수 있게 합니다. 이러한 접근성은 테스트의 재사용성을 높이고, 개발 프로세스의 효율성을 개선합니다.</p><p>Locust는 단순한 테스트 도구를 넘어, 성능 모니터링과 개선을 위한 협업의 핵심이 됩니다. 이를 통해, 사용자에게 최적의 경험을 제공하는 애플리케이션을 구축할 수 있습니다.</p>]]></content:encoded>
      
      
      
      <category domain="https://cheese10yun.github.io/tags/Performance/">Performance</category>
      
      <category domain="https://cheese10yun.github.io/tags/Monitoring/">Monitoring</category>
      
      
      <comments>https://cheese10yun.github.io/locust-part-1/#disqus_thread</comments>
      
    </item>
    
  </channel>
</rss>
