<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://hyeon9mak.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://hyeon9mak.github.io/" rel="alternate" type="text/html" /><updated>2026-04-29T15:20:19+09:00</updated><id>https://hyeon9mak.github.io/feed.xml</id><title type="html">현구막 기술 블로그</title><subtitle>부지런히 살자</subtitle><author><name>현구막</name><email>jinha3507@gmail.com</email></author><entry><title type="html">Metric URI 미관리로 인한 Netty 메모리 누수 해결</title><link href="https://hyeon9mak.github.io/fixing-netty-memory-leaks-unmanaged-metric-uri/" rel="alternate" type="text/html" title="Metric URI 미관리로 인한 Netty 메모리 누수 해결" /><published>2026-04-29T00:00:00+09:00</published><updated>2026-04-29T00:00:00+09:00</updated><id>https://hyeon9mak.github.io/fixing-netty-memory-leaks-unmanaged-metric-uri</id><content type="html" xml:base="https://hyeon9mak.github.io/fixing-netty-memory-leaks-unmanaged-metric-uri/"><![CDATA[<h2 id="-메모리-누수-발생">🚰 메모리 누수 발생</h2>

<p>지난 수 개월간 알 수 없는 이유로 동작중인 Netty application 의 메모리 사용량이 선형적으로 증가하는 문제가 있었다.
다행스럽게도 정기적으로 기능 추가 및 수정으로 배포를 반복하고 있었기 때문에, application 이 다시 배포 되면서
메모리가 초기화 된 덕분에 OOM(OutOfMemory) 문제를 회피할 수 있었지만, 조금이라도 서비스 운영 시간이 늘어난다면 
무조건 OOM 을 마주할 수 밖에 없는 상황이었다.</p>

<p><img width="1420" height="312" alt="Image" src="https://github.com/user-attachments/assets/a74ac634-b5a1-4cae-a01b-2f30f3c79a36" /></p>

<p>여러 분석 및 해결 시도를 반복하다가, 동작중인 application 의 힙 덤프를 시간차를 두어 뽑아낸 후 비교하는 과정에서 실마리를 잡아냈는데,
Micrometer <code class="language-plaintext highlighter-rouge">StatsdMeterRegistry</code> 클래스가 그 핵심이었다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">preFilterIdToMeterMap</code>: 799,129개 엔트리 (~40MB)</li>
  <li><code class="language-plaintext highlighter-rouge">meterMap</code>: 2M capacity 테이블 (64MB)</li>
  <li><code class="language-plaintext highlighter-rouge">MicrometerHttpClientMetricsRecorder</code> 캐시: 84,884개 엔트리 (~37MB)</li>
</ul>

<p><br /></p>

<h2 id="-원인---uri-cardinality-관리">🚰 원인 - URI Cardinality 관리</h2>

<p>Reactor Netty 의 메트릭 수집을 위해 
외부 Client 에서 Reactor Netty 쪽으로 향하는 트래픽을 관리하는 <code class="language-plaintext highlighter-rouge">HttpServer</code> 와,
Server 에서 외부 Client 로 향하는 트래픽을 관리하는 <code class="language-plaintext highlighter-rouge">HttpClient</code> 2개의 구현체를 다루고 있었다.</p>

<p>이 구현체들 각각에서 공통으로 <code class="language-plaintext highlighter-rouge">metrics(true, uriTagValueFunction)</code> 메서드를 호출하고 있는데,
다음과 같은 방식으로 전달되는 URI 를 그대로 메트릭 태그로 수집하고 있었다.</p>

<div class="language-kotlin highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">httpServer</span><span class="p">.</span><span class="nf">metrics</span><span class="p">(</span><span class="k">true</span><span class="p">)</span> <span class="p">{</span> <span class="n">uri</span> <span class="p">-&gt;</span> <span class="n">uri</span> <span class="p">}</span> <span class="c1">// URI 정규화 없음</span>
<span class="n">httpClient</span><span class="p">.</span><span class="nf">metrics</span><span class="p">(</span><span class="k">true</span><span class="p">)</span> <span class="p">{</span> <span class="n">uri</span> <span class="p">-&gt;</span> <span class="n">uri</span> <span class="p">}</span> <span class="c1">// URI 정규화 없음</span>
</code></pre></div></div>

<p>보통은 문제가 없겠지만, 아래와 같이 API path 에 UUID 와 같은 가변 변수가 포함될 경우
각기 요청마다 새로운 메트릭 태그로 수집되게 된다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/threads/550e8400-e29b-41d4-a716-446655440000/runs/stream
/threads/6ba7b810-9dad-11d1-80b4-00c04fd430c8/runs/stream
/threads/f47ac10b-58cc-4372-a567-0e02b2c3d479/runs/stream
...
</code></pre></div></div>

<p>즉, application 구동 시간이 늘어날수록, 유저 요청이 누적될수록 Meter 수가 무한히 증가하고,
각 Meter 마다 새로운 instance 를 생성해서 관리하기 때문에 힙 메모리 사용량이 선형적으로 증가했던 것이다.</p>

<p><br /></p>

<h2 id="-해결---uuid-정규화">🚰 해결 - UUID 정규화</h2>

<p><code class="language-plaintext highlighter-rouge">HttpClient.metrics(true, uriTagValueFunction)</code>과 <code class="language-plaintext highlighter-rouge">HttpServer.metrics(true, uriTagValueFunction)</code>의 두 번째 파라미터는 
Reactor Netty 가 공식 제공하는 URI 태그 정규화 확장 포인트다. 이 함수는 확장된 URI path 를 받아서 릭 태그로 사용할 문자열을 반환한다.
즉, UUID 가 전달될 경우 이를 정규화하여 공통 문자로 변경하면 요청 별로 태그가 무한히 생성되는 문제를 방어할 수 있게 된다.</p>

<p>UUID 를 <code class="language-plaintext highlighter-rouge">{id}</code>로 치환하는 정규화 함수를 아래와 같이 적용해볼 수 있다.</p>

<div class="language-kotlin highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// UUID 정규식</span>
<span class="k">private</span> <span class="kd">val</span> <span class="py">UUID_PATTERN</span> <span class="p">=</span> <span class="nc">Regex</span><span class="p">(</span><span class="s">"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"</span><span class="p">)</span>

<span class="n">httpServer</span><span class="p">.</span><span class="nf">metrics</span><span class="p">(</span><span class="k">true</span><span class="p">)</span> <span class="p">{</span> <span class="n">uri</span> <span class="p">-&gt;</span> <span class="n">uri</span><span class="p">.</span><span class="nf">replace</span><span class="p">(</span><span class="nc">UUID_PATTERN</span><span class="p">,</span> <span class="s">"{id}"</span><span class="p">)</span> <span class="p">}</span>
<span class="n">httpClient</span><span class="p">.</span><span class="nf">metrics</span><span class="p">(</span><span class="k">true</span><span class="p">)</span> <span class="p">{</span> <span class="n">uri</span> <span class="p">-&gt;</span> <span class="n">uri</span><span class="p">.</span><span class="nf">replace</span><span class="p">(</span><span class="nc">UUID_PATTERN</span><span class="p">,</span> <span class="s">"{id}"</span><span class="p">)</span> <span class="p">}</span>
</code></pre></div></div>

<p>정규화 결과:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>// AS-IS
/threads/550e8400-e29b-41d4-a716-446655440000/runs/stream
/threads/550e8400-.../runs/f47ac10b-.../cancel

// TO-BE
/threads/{id}/runs/stream
/threads/{id}/runs/{id}/cancel
</code></pre></div></div>

<p>새로운 path 를 가진 API 가 추가되더라도 UUID 형식이면 자동으로 정규화되므로 Config 수정이 필요 없다.
반대로 다른 형식이라면, 그에 걸맞는 정규 표현식을 더 추가해주어야한다.
이 부분에서는 주의가 필요하겠다.</p>

<p><br /></p>

<h2 id="-결과">🚰 결과</h2>

<p><img width="461" height="201" alt="Image" src="https://github.com/user-attachments/assets/d6c24bbc-6d93-42b6-8095-1a67eaacd252" /></p>

<p>여전히 누수 지점이 남아 메모리 사용량이 점진적으로 늘고 있지만, 급격하게 치솓던 양상은 사라진 모습.</p>

<p><img width="1872" height="301" alt="Image" src="https://github.com/user-attachments/assets/d8b6c932-7a93-4fda-97f8-5072a2c41c7a" /></p>

<p>중복해서 수집되던 메트릭들이 한 순간에 제거되어 사라지는 모습.</p>

<p><br /></p>

<h2 id="-새롭게-알게된-것들">🚰 새롭게 알게된 것들</h2>

<h3 id="1-spring-boot-webflux-와-reactor-netty-메트릭은-별개-레이어다">1. Spring Boot WebFlux 와 Reactor Netty 메트릭은 별개 레이어다.</h3>

<p>Spring Boot WebFlux 자동설정은 <code class="language-plaintext highlighter-rouge">http.server.requests</code> 메트릭을 <code class="language-plaintext highlighter-rouge">@RequestMapping</code> 패턴 기반으로 자동 정규화한다. 
<code class="language-plaintext highlighter-rouge">ServerConfig</code>에서 <code class="language-plaintext highlighter-rouge">httpServer.metrics(true)</code>를 호출하면 이와 별개로 <code class="language-plaintext highlighter-rouge">reactor.netty.http.server.*</code> 메트릭이 추가된다.</p>

<p>즉, 이 두 레이어는 독립적으로 동작하며, 각각 별도로 카디널리티를 관리해야 한다.</p>

<table>
  <thead>
    <tr>
      <th>레이어</th>
      <th>메트릭 접두사</th>
      <th>URI 정규화 방식</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Spring Observation</td>
      <td><code class="language-plaintext highlighter-rouge">http.server.requests</code> / <code class="language-plaintext highlighter-rouge">http.client.requests</code></td>
      <td><code class="language-plaintext highlighter-rouge">@RequestMapping</code> 패턴 / <code class="language-plaintext highlighter-rouge">URI_TEMPLATE_ATTRIBUTE</code></td>
    </tr>
    <tr>
      <td>Reactor Netty</td>
      <td><code class="language-plaintext highlighter-rouge">reactor.netty.http.server.*</code> / <code class="language-plaintext highlighter-rouge">reactor.netty.http.client.*</code></td>
      <td><code class="language-plaintext highlighter-rouge">uriTagValueFunction</code> 파라미터</td>
    </tr>
  </tbody>
</table>

<h3 id="2-webclient-uritemplate-vars-방식은-reactor-netty-메트릭을-해결하지-못한다">2. WebClient <code class="language-plaintext highlighter-rouge">.uri(template, vars)</code> 방식은 Reactor Netty 메트릭을 해결하지 못한다</h3>

<p>Spring WebClient 의 URI 템플릿 방식(<code class="language-plaintext highlighter-rouge">.uri("/threads/{threadId}/runs/stream", threadId)</code>)을 사용하면, Spring 이 <code class="language-plaintext highlighter-rouge">URI_TEMPLATE_ATTRIBUTE</code>에 템플릿을 저장한다. 
그러나 이 값은 <strong>Spring Observation 레이어(<code class="language-plaintext highlighter-rouge">http.client.requests</code> 메트릭)에서만 사용</strong>되며, Reactor Netty 에는 확장된 URI 만 전달된다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>WebClient.uri("/threads/{threadId}/runs/stream", threadId)
  1. URI_TEMPLATE_ATTRIBUTE = "/threads/{threadId}/runs/stream"  (Spring Observation용)
  2. 변수를 확장해서 concrete URI 생성
  3. ReactorClientHttpConnector 가 Reactor Netty 에 다시 확장된 URI 전달
  4. Reactor Netty metrics: uriTagValue.apply("/threads/abc-123/runs/stream")  (결국 다시 확장된 URI)
</code></pre></div></div>

<p>따라서 <code class="language-plaintext highlighter-rouge">reactor.netty.http.client.*</code> 메트릭의 카디널리티를 제어하려면 반드시 <code class="language-plaintext highlighter-rouge">uriTagValueFunction</code>을 사용해야 한다.</p>

<h3 id="3-max-uri-tags는-reactor-netty-레벨-메트릭에-적용되지-않는다">3. <code class="language-plaintext highlighter-rouge">max-uri-tags</code>는 Reactor Netty 레벨 메트릭에 적용되지 않는다</h3>

<p><code class="language-plaintext highlighter-rouge">management.metrics.web.server.max-uri-tags</code> 속성은 Spring Observation 기반 <code class="language-plaintext highlighter-rouge">http.server.requests</code> 메트릭에만 적용된다.
<code class="language-plaintext highlighter-rouge">reactor.netty.http.client.*</code>이나 <code class="language-plaintext highlighter-rouge">reactor.netty.http.server.*</code> 메트릭에는 효과가 없다.</p>

<h3 id="4-새로운-webclient-빈을-추가할-때">4. 새로운 WebClient 빈을 추가할 때</h3>

<p>새로운 <code class="language-plaintext highlighter-rouge">HttpClient.create().metrics(true, ...)</code> 설정을 만들 때 <code class="language-plaintext highlighter-rouge">Function.identity()</code>를 사용하지 말 것. 
반드시 URI 정규화 함수를 적용해야 한다. 
동적 path variable(UUID, 숫자 ID 등)이 포함된 URL 을 호출하면 동일한 메모리 릭이 재발한다.</p>

<p><br /></p>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://github.com/spring-projects/spring-framework/issues/30027">DefaultWebClient ignores baseUrl when setting URI_TEMPLATE_ATTRIBUTE #30027</a></li>
  <li><a href="https://github.com/spring-projects/spring-framework/issues/29885">Client request observation contributes full URI template to uri meter tag values #29885</a></li>
</ul>]]></content><author><name>현구막</name><email>jinha3507@gmail.com</email></author><summary type="html"><![CDATA[🚰 메모리 누수 발생]]></summary></entry><entry><title type="html">파티셔닝 전략 이해하기</title><link href="https://hyeon9mak.github.io/about-partitioning-strategies/" rel="alternate" type="text/html" title="파티셔닝 전략 이해하기" /><published>2026-04-01T00:00:00+09:00</published><updated>2026-04-01T00:00:00+09:00</updated><id>https://hyeon9mak.github.io/about-partitioning-strategies</id><content type="html" xml:base="https://hyeon9mak.github.io/about-partitioning-strategies/"><![CDATA[<h2 id="-들어가며">🗂 들어가며</h2>

<p>저장소에 데이터가 수천만, 수억 건 쌓이기 시작하면 쿼리 성능은 급격히 떨어진다.
인덱스를 아무리 잘 설계해도 스캔 비용 자체가 커지고, 백업이나 유지보수 작업도 점점 부담스러워진다.</p>

<p>이럴 때 가장 먼저 고려하는 해결책이 <strong>파티셔닝(Partitioning)</strong> 이다.
큰 데이터 테이블을 작은 단위로 쪼개서 관리하면, 쿼리는 필요한 조각만 읽고, 운영 부담도 줄어든다.
하지만 막상 파티셔닝을 도입하려고 하면 수많은 전략 앞에서 혼란스럽다.</p>

<p>이 글에서는 파티셔닝의 핵심 개념과 각 전략이 어떤 상황에 적합한지,
그리고 실전에서 어떻게 조합해 사용할 수 있는지 간략히 정리해본다.</p>

<p><br /></p>

<h2 id="-용어에-대한-이해">🗂 용어에 대한 이해</h2>

<h3 id="파티셔닝partitioning">파티셔닝(Partitioning)</h3>
<p>대용량 테이블이나 인덱스를 더 작고 관리하기 쉬운 단위로 분할하는 프로세스.
그 단위를 <strong>파티션(Partition)</strong> 이라고 하며, 파티션을 만드는 행위를 <strong>파티셔닝(Partitioning)</strong> 이라고 부른다.
데이터를 조회하는 쿼리가 전체 테이블 혹은 인덱스를 스캔하는 대신 특정 파티션만 대상으로 처리할 수 있다.</p>

<h3 id="파티션-프루닝partition-pruning">파티션 프루닝(Partition Pruning)</h3>
<p>파티셔닝의 핵심은 결국 “불필요한 파티션은 생략, 필요한 파티션만” 다루는 것이다.
이를 <strong>파티션 프루닝(Partition Pruning)</strong> 이라고 부르며, 파티션 전략을 선택하는 기준점이 바로 파티션 프루닝이다.
데이터를 얼마나 예쁘게 보관 하는지가 중심이 아니다. 데이터를 삽입하거나 조회할 때, 즉 사용할 때가 중심이 되어야 한다.</p>

<p><br /></p>

<h2 id="전략에-대한-이해">🗂전략에 대한 이해</h2>

<p>파티셔닝 전략은 크게 3가지 축으로 나누어 생각해볼 수 있다.</p>

<p><img width="922" height="678" alt="Image" src="https://github.com/user-attachments/assets/90021e81-7521-4f0c-ab32-b6c297b95097" /></p>

<h3 id="수평-파티셔닝-horizontal-partitioning">수평 파티셔닝 (Horizontal Partitioning)</h3>

<p>모든 파티션이 동일한 스키마를 갖도록 유지한 체, 행(row)을 기준으로 데이터를 나눈다.
규모의 단위를 테이블에서 데이터베이스로 키우면 샤딩(Sharding)으로 볼 수도 있다.</p>

<blockquote>
  <p>샤딩은 여러 DB(혹은 서버)에 분산된 데이터를 관리하므로 트랜잭션 관리나 JOIN 이 특히나 복잡하다.
가능하면 테이블 파티셔닝을 우선적으로 채택 후 샤딩을 고려하는 것이 좋다.</p>
</blockquote>

<h3 id="수평-파티셔닝---범위range">수평 파티셔닝 - 범위(Range)</h3>

<p>범위 파티셔닝은 날짜나 숫자처럼 구간을 특정지을 수 있는 컬럼 값의 범위를 기준으로 데이터를 분할한다.
연도별, 월별, 일별 같은 시계열 데이터가 가장 적합하다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">orders</span> <span class="p">(</span>
    <span class="n">order_id</span> <span class="nb">BIGINT</span><span class="p">,</span>
    <span class="n">order_date</span> <span class="nb">TIMESTAMP</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
    <span class="p">...</span>
<span class="p">)</span> <span class="k">PARTITION</span> <span class="k">BY</span> <span class="k">RANGE</span> <span class="p">(</span><span class="n">order_date</span><span class="p">);</span>

<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">orders_2025_01</span> <span class="k">PARTITION</span> <span class="k">OF</span> <span class="n">orders</span>
    <span class="k">FOR</span> <span class="k">VALUES</span> <span class="k">FROM</span> <span class="p">(</span><span class="s1">'2025-01-01'</span><span class="p">)</span> <span class="k">TO</span> <span class="p">(</span><span class="s1">'2025-02-01'</span><span class="p">);</span>
<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">orders_2025_02</span> <span class="k">PARTITION</span> <span class="k">OF</span> <span class="n">orders</span>
    <span class="k">FOR</span> <span class="k">VALUES</span> <span class="k">FROM</span> <span class="p">(</span><span class="s1">'2025-02-01'</span><span class="p">)</span> <span class="k">TO</span> <span class="p">(</span><span class="s1">'2025-03-01'</span><span class="p">);</span>
</code></pre></div></div>

<p>쿼리의 <code class="language-plaintext highlighter-rouge">WHERE</code> 절을 기반으로 데이터베이스 쿼리 플래너가 자동으로 어떤 파티션을 스캔할지 결정(파티션 프루닝)한다.
이 기능 없이는 데이터베이스가 모든 파티션을 스캔하게 되어 파티셔닝의 의미가 사라진다.</p>

<h3 id="수평-파티셔닝---해시hash">수평 파티셔닝 - 해시(Hash)</h3>

<p>파티션 키에 해시 함수를 적용해 파티션 전체에 균일하게 데이터를 분산시킨다.
시계열 데이터가 없거나, 파티션마다 데이터가 균등하게 분배(핫 파티션 문제 방지) 되는 것이 가장 중요한 경우 선택하기 좋다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">users</span> <span class="p">(</span>
    <span class="n">user_id</span> <span class="nb">BIGINT</span><span class="p">,</span>
    <span class="n">username</span> <span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">100</span><span class="p">),</span>
    <span class="p">...</span>
<span class="p">)</span> <span class="k">PARTITION</span> <span class="k">BY</span> <span class="n">HASH</span> <span class="p">(</span><span class="n">user_id</span><span class="p">);</span>

<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">users_p0</span> <span class="k">PARTITION</span> <span class="k">OF</span> <span class="n">users</span>
    <span class="k">FOR</span> <span class="k">VALUES</span> <span class="k">WITH</span> <span class="p">(</span><span class="n">MODULUS</span> <span class="mi">4</span><span class="p">,</span> <span class="n">REMAINDER</span> <span class="mi">0</span><span class="p">);</span>
<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">users_p1</span> <span class="k">PARTITION</span> <span class="k">OF</span> <span class="n">users</span>
    <span class="k">FOR</span> <span class="k">VALUES</span> <span class="k">WITH</span> <span class="p">(</span><span class="n">MODULUS</span> <span class="mi">4</span><span class="p">,</span> <span class="n">REMAINDER</span> <span class="mi">1</span><span class="p">);</span>
</code></pre></div></div>

<h3 id="수평-파티셔닝---리스트list">수평 파티셔닝 - 리스트(List)</h3>

<p>미리 정의된 값 리스트를 기반으로 데이터를 그룹화한다.
지역이나 부서 등으로 제한적이고 명확한, 잘 변화되지 않는 진리 같은 값을 이용하는 것이 좋다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">events</span> <span class="p">(</span>
    <span class="n">id</span> <span class="nb">BIGINT</span><span class="p">,</span>
    <span class="n">region</span> <span class="nb">TEXT</span><span class="p">,</span>
    <span class="p">...</span>
<span class="p">)</span> <span class="k">PARTITION</span> <span class="k">BY</span> <span class="n">LIST</span> <span class="p">(</span><span class="n">region</span><span class="p">);</span>

<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">events_us</span> <span class="k">PARTITION</span> <span class="k">OF</span> <span class="n">events</span>
    <span class="k">FOR</span> <span class="k">VALUES</span> <span class="k">IN</span> <span class="p">(</span><span class="s1">'us-east'</span><span class="p">,</span> <span class="s1">'us-west'</span><span class="p">);</span>
<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">events_eu</span> <span class="k">PARTITION</span> <span class="k">OF</span> <span class="n">events</span>
    <span class="k">FOR</span> <span class="k">VALUES</span> <span class="k">IN</span> <span class="p">(</span><span class="s1">'eu-west'</span><span class="p">,</span> <span class="s1">'eu-central'</span><span class="p">);</span>
</code></pre></div></div>

<h3 id="수평-파티셔닝---복합composite">수평 파티셔닝 - 복합(Composite)</h3>

<p>파티셔닝 구조에 추가적인 세분화 단계를 더한다. 
예를 들어, 날짜로 범위 파티셔닝 후 각 파티션 내부를 지역으로 다시 리스트 파티셔닝하는 방식이다.
초대형/이력 데이터 등에 활용하게 된다.
당연히 <code class="language-plaintext highlighter-rouge">리스트/리스트</code>, <code class="language-plaintext highlighter-rouge">리스트/범위</code>, <code class="language-plaintext highlighter-rouge">범위/해시</code> 등등 다양한 조합이 가능하다.</p>

<p>가장 널리 사용되는 3가지만 예시를 살펴보자.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- 루트: 날짜 기준 Range</span>
<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">event_logs</span> <span class="p">(</span>
    <span class="n">log_id</span>     <span class="n">BIGSERIAL</span>    <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
    <span class="n">event_date</span> <span class="nb">DATE</span>         <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
    <span class="n">region</span>     <span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">10</span><span class="p">)</span>  <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>   <span class="c1">-- 'KR', 'US', 'EU'</span>
    <span class="n">severity</span>   <span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">10</span><span class="p">)</span>  <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
    <span class="n">message</span>    <span class="nb">TEXT</span>
<span class="p">)</span> <span class="k">PARTITION</span> <span class="k">BY</span> <span class="k">RANGE</span> <span class="p">(</span><span class="n">event_date</span><span class="p">);</span>

<span class="c1">-- 2024년 파티션 → 내부를 region(List)으로 재분할</span>
<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">event_logs_2024</span>
    <span class="k">PARTITION</span> <span class="k">OF</span> <span class="n">event_logs</span>
    <span class="k">FOR</span> <span class="k">VALUES</span> <span class="k">FROM</span> <span class="p">(</span><span class="s1">'2024-01-01'</span><span class="p">)</span> <span class="k">TO</span> <span class="p">(</span><span class="s1">'2025-01-01'</span><span class="p">)</span>
    <span class="k">PARTITION</span> <span class="k">BY</span> <span class="n">LIST</span> <span class="p">(</span><span class="n">region</span><span class="p">);</span>

<span class="c1">-- 2024년 안의 List 서브파티션</span>
<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">event_logs_2024_kr</span>
    <span class="k">PARTITION</span> <span class="k">OF</span> <span class="n">event_logs_2024</span>
    <span class="k">FOR</span> <span class="k">VALUES</span> <span class="k">IN</span> <span class="p">(</span><span class="s1">'KR'</span><span class="p">);</span>

<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">event_logs_2024_us</span>
    <span class="k">PARTITION</span> <span class="k">OF</span> <span class="n">event_logs_2024</span>
    <span class="k">FOR</span> <span class="k">VALUES</span> <span class="k">IN</span> <span class="p">(</span><span class="s1">'US'</span><span class="p">);</span>

<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">event_logs_2024_eu</span>
    <span class="k">PARTITION</span> <span class="k">OF</span> <span class="n">event_logs_2024</span>
    <span class="k">FOR</span> <span class="k">VALUES</span> <span class="k">IN</span> <span class="p">(</span><span class="s1">'EU'</span><span class="p">);</span>

<span class="c1">-- 기타 지역을 위한 default 파티션</span>
<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">event_logs_2024_etc</span>
    <span class="k">PARTITION</span> <span class="k">OF</span> <span class="n">event_logs_2024</span>
    <span class="k">DEFAULT</span><span class="p">;</span>
</code></pre></div></div>

<p>프루닝은 날짜 범위로 먼저 탐색 후, region 기준으로 서브 파티션을 찾아내어 해당 서브 파티션 하나만 스캔을 진행하게 된다.</p>

<p>2개의 시간 축을 사용하는 경우 범위/범위 조합으로도 파티션을 나눠볼 수 있다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- 루트: 주문일 기준 Range</span>
<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">orders</span> <span class="p">(</span>
    <span class="n">order_id</span>    <span class="nb">BIGINT</span>    <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
    <span class="n">order_date</span>  <span class="nb">DATE</span>      <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
    <span class="n">ship_date</span>   <span class="nb">DATE</span><span class="p">,</span>
    <span class="n">customer_id</span> <span class="nb">BIGINT</span>    <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
    <span class="n">amount</span>      <span class="nb">NUMERIC</span><span class="p">(</span><span class="mi">12</span><span class="p">,</span><span class="mi">2</span><span class="p">)</span>
<span class="p">)</span> <span class="k">PARTITION</span> <span class="k">BY</span> <span class="k">RANGE</span> <span class="p">(</span><span class="n">order_date</span><span class="p">);</span>

<span class="c1">-- 2024년 주문 파티션 → 내부를 ship_date(Range)로 재분할</span>
<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">orders_2024</span>
    <span class="k">PARTITION</span> <span class="k">OF</span> <span class="n">orders</span>
    <span class="k">FOR</span> <span class="k">VALUES</span> <span class="k">FROM</span> <span class="p">(</span><span class="s1">'2024-01-01'</span><span class="p">)</span> <span class="k">TO</span> <span class="p">(</span><span class="s1">'2025-01-01'</span><span class="p">)</span>
    <span class="k">PARTITION</span> <span class="k">BY</span> <span class="k">RANGE</span> <span class="p">(</span><span class="n">ship_date</span><span class="p">);</span>

<span class="c1">-- 분기별 배송 서브파티션</span>
<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">orders_2024_ship_q1</span>
    <span class="k">PARTITION</span> <span class="k">OF</span> <span class="n">orders_2024</span>
    <span class="k">FOR</span> <span class="k">VALUES</span> <span class="k">FROM</span> <span class="p">(</span><span class="s1">'2024-01-01'</span><span class="p">)</span> <span class="k">TO</span> <span class="p">(</span><span class="s1">'2024-04-01'</span><span class="p">);</span>

<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">orders_2024_ship_q2</span>
    <span class="k">PARTITION</span> <span class="k">OF</span> <span class="n">orders_2024</span>
    <span class="k">FOR</span> <span class="k">VALUES</span> <span class="k">FROM</span> <span class="p">(</span><span class="s1">'2024-04-01'</span><span class="p">)</span> <span class="k">TO</span> <span class="p">(</span><span class="s1">'2024-07-01'</span><span class="p">);</span>

<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">orders_2024_ship_q3</span>
    <span class="k">PARTITION</span> <span class="k">OF</span> <span class="n">orders_2024</span>
    <span class="k">FOR</span> <span class="k">VALUES</span> <span class="k">FROM</span> <span class="p">(</span><span class="s1">'2024-07-01'</span><span class="p">)</span> <span class="k">TO</span> <span class="p">(</span><span class="s1">'2024-10-01'</span><span class="p">);</span>

<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">orders_2024_ship_q4</span>
    <span class="k">PARTITION</span> <span class="k">OF</span> <span class="n">orders_2024</span>
    <span class="k">FOR</span> <span class="k">VALUES</span> <span class="k">FROM</span> <span class="p">(</span><span class="s1">'2024-10-01'</span><span class="p">)</span> <span class="k">TO</span> <span class="p">(</span><span class="s1">'2025-01-01'</span><span class="p">);</span>
</code></pre></div></div>

<p>범위/해시 조합으로 대형 거래나 대규모 이력 테이블에도 적합한 파티션을 구성할 수 있다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- 루트: RANGE 기준</span>
<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">transactions</span> <span class="p">(</span>
    <span class="n">tx_id</span>       <span class="nb">BIGINT</span>       <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
    <span class="n">tx_date</span>     <span class="nb">DATE</span>         <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
    <span class="n">user_id</span>     <span class="nb">BIGINT</span>       <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
    <span class="n">amount</span>      <span class="nb">NUMERIC</span><span class="p">(</span><span class="mi">12</span><span class="p">,</span><span class="mi">2</span><span class="p">),</span>
    <span class="n">status</span>      <span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">20</span><span class="p">)</span>
<span class="p">)</span> <span class="k">PARTITION</span> <span class="k">BY</span> <span class="k">RANGE</span> <span class="p">(</span><span class="n">tx_date</span><span class="p">);</span>

<span class="c1">-- 연도별 Range 파티션 생성 + 각각 HASH 서브파티션 선언</span>
<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">transactions_2024</span>
    <span class="k">PARTITION</span> <span class="k">OF</span> <span class="n">transactions</span>
    <span class="k">FOR</span> <span class="k">VALUES</span> <span class="k">FROM</span> <span class="p">(</span><span class="s1">'2024-01-01'</span><span class="p">)</span> <span class="k">TO</span> <span class="p">(</span><span class="s1">'2025-01-01'</span><span class="p">)</span>
    <span class="k">PARTITION</span> <span class="k">BY</span> <span class="n">HASH</span> <span class="p">(</span><span class="n">user_id</span><span class="p">);</span>

<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">transactions_2025</span>
    <span class="k">PARTITION</span> <span class="k">OF</span> <span class="n">transactions</span>
    <span class="k">FOR</span> <span class="k">VALUES</span> <span class="k">FROM</span> <span class="p">(</span><span class="s1">'2025-01-01'</span><span class="p">)</span> <span class="k">TO</span> <span class="p">(</span><span class="s1">'2026-01-01'</span><span class="p">)</span>
    <span class="k">PARTITION</span> <span class="k">BY</span> <span class="n">HASH</span> <span class="p">(</span><span class="n">user_id</span><span class="p">);</span>

<span class="c1">-- 각 Range 파티션 안에 Hash 서브파티션 생성 (4개 버킷)</span>
<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">transactions_2024_h0</span>
    <span class="k">PARTITION</span> <span class="k">OF</span> <span class="n">transactions_2024</span>
    <span class="k">FOR</span> <span class="k">VALUES</span> <span class="k">WITH</span> <span class="p">(</span><span class="n">MODULUS</span> <span class="mi">4</span><span class="p">,</span> <span class="n">REMAINDER</span> <span class="mi">0</span><span class="p">);</span>

<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">transactions_2024_h1</span>
    <span class="k">PARTITION</span> <span class="k">OF</span> <span class="n">transactions_2024</span>
    <span class="k">FOR</span> <span class="k">VALUES</span> <span class="k">WITH</span> <span class="p">(</span><span class="n">MODULUS</span> <span class="mi">4</span><span class="p">,</span> <span class="n">REMAINDER</span> <span class="mi">1</span><span class="p">);</span>

<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">transactions_2024_h2</span>
    <span class="k">PARTITION</span> <span class="k">OF</span> <span class="n">transactions_2024</span>
    <span class="k">FOR</span> <span class="k">VALUES</span> <span class="k">WITH</span> <span class="p">(</span><span class="n">MODULUS</span> <span class="mi">4</span><span class="p">,</span> <span class="n">REMAINDER</span> <span class="mi">2</span><span class="p">);</span>

<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">transactions_2024_h3</span>
    <span class="k">PARTITION</span> <span class="k">OF</span> <span class="n">transactions_2024</span>
    <span class="k">FOR</span> <span class="k">VALUES</span> <span class="k">WITH</span> <span class="p">(</span><span class="n">MODULUS</span> <span class="mi">4</span><span class="p">,</span> <span class="n">REMAINDER</span> <span class="mi">3</span><span class="p">);</span>

<span class="c1">-- 2025년 반복...</span>

<span class="c1">-- 인덱스는 서브파티션 단위로 생성</span>
<span class="k">CREATE</span> <span class="k">INDEX</span> <span class="k">ON</span> <span class="n">transactions_2024_h0</span> <span class="p">(</span><span class="n">user_id</span><span class="p">,</span> <span class="n">tx_date</span><span class="p">);</span>
</code></pre></div></div>

<h3 id="수직-파티셔닝-vertical-partitioning">수직 파티셔닝 (Vertical Partitioning)</h3>

<p>수직 파티셔닝은 단일 테이블을 컬럼 기준으로 여러 개의 작은 테이블로 분할하는 개념이다.
각기 원본 테이블 일부의 컬럼을 가지며, 동일한 PK 를 공유한다. 동일한 PK 는 필요시 JOIN 에 활용된다.</p>

<p><img width="1295" height="321" alt="Image" src="https://github.com/user-attachments/assets/ad8f2b0f-6cad-4d37-a8e2-a93bd8f35f91" /></p>

<p>자주 사용되는 hot 컬럼과 드물게 사용되는 cold 컬럼을 나누어 별도로 관리함으로서, Disk I/O 오버헤드와 메모리 공간 효율성을 높이기 위해 사용된다.
잘 생각해보면 수직 파티셔닝은 사실 테이블(스키마)을 완전히 재설계하는 것과 동일하다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">users</span> <span class="p">(</span>
    <span class="n">user_id</span>      <span class="nb">BIGINT</span> <span class="k">PRIMARY</span> <span class="k">KEY</span><span class="p">,</span>
    <span class="n">email</span>        <span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">255</span><span class="p">)</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>  <span class="c1">-- 로그인 시</span>
    <span class="n">password</span>     <span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">255</span><span class="p">)</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>  <span class="c1">-- 로그인 시</span>
    <span class="n">name</span>         <span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">100</span><span class="p">),</span>           <span class="c1">-- 프로필 조회 시</span>
    <span class="n">image_url</span>   <span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">512</span><span class="p">),</span>            <span class="c1">-- 프로필 조회 시</span>
    <span class="n">bio</span>          <span class="nb">TEXT</span><span class="p">,</span>                   <span class="c1">-- 마이페이지 진입 시</span>
    <span class="n">preferences</span>  <span class="n">JSONB</span><span class="p">,</span>                  <span class="c1">-- 마이페이지 진입 시</span>
    <span class="n">last_login</span>   <span class="nb">TIMESTAMP</span><span class="p">,</span>              <span class="c1">-- 로그인 시</span>
    <span class="n">created_at</span>   <span class="nb">TIMESTAMP</span>               <span class="c1">-- 로그인 시</span>
<span class="p">);</span>
</code></pre></div></div>

<p>하나의 테이블에 지나치게 많은 컬럼이 존재하는 경우, 유스케이스별로 hot/cold 컬럼이 나뉘게 된다.
이 때 cold 컬럼이 많아지면 많아질수록 비용 비효율이 발생하기 마련이다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- 로그인 시</span>
<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">users_auth</span> <span class="p">(</span>
    <span class="n">user_id</span>       <span class="nb">BIGINT</span>       <span class="k">PRIMARY</span> <span class="k">KEY</span><span class="p">,</span>
    <span class="n">email</span>         <span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">255</span><span class="p">)</span> <span class="k">NOT</span> <span class="k">NULL</span> <span class="k">UNIQUE</span><span class="p">,</span>
    <span class="n">password</span> <span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">255</span><span class="p">)</span> <span class="k">NOT</span> <span class="k">NULL</span><span class="p">,</span>
    <span class="n">last_login</span>    <span class="nb">TIMESTAMP</span><span class="p">,</span>
    <span class="n">created_at</span>    <span class="nb">TIMESTAMP</span>
<span class="p">);</span>

<span class="c1">-- 프로필 조회 시</span>
<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">users_profile</span> <span class="p">(</span>
    <span class="n">user_id</span>    <span class="nb">BIGINT</span>       <span class="k">PRIMARY</span> <span class="k">KEY</span> <span class="k">REFERENCES</span> <span class="n">users_auth</span><span class="p">(</span><span class="n">user_id</span><span class="p">),</span>
    <span class="n">name</span>       <span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">100</span><span class="p">),</span>
    <span class="n">image_url</span> <span class="nb">VARCHAR</span><span class="p">(</span><span class="mi">512</span><span class="p">)</span>
<span class="p">);</span>

<span class="c1">-- 마이페이지 진입 시</span>
<span class="k">CREATE</span> <span class="k">TABLE</span> <span class="n">users_detail</span> <span class="p">(</span>
    <span class="n">user_id</span>     <span class="nb">BIGINT</span>  <span class="k">PRIMARY</span> <span class="k">KEY</span> <span class="k">REFERENCES</span> <span class="n">users_auth</span><span class="p">(</span><span class="n">user_id</span><span class="p">),</span>
    <span class="n">bio</span>         <span class="nb">TEXT</span><span class="p">,</span>
    <span class="n">preferences</span> <span class="n">JSONB</span>
<span class="p">);</span>
</code></pre></div></div>

<p>조회 쿼리 역시도 훨씬 간결하고 깔끔하게 관리가 가능하다.</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- 로그인 시</span>
<span class="k">SELECT</span> <span class="n">user_id</span><span class="p">,</span> <span class="n">password</span>
<span class="k">FROM</span> <span class="n">users_auth</span>
<span class="k">WHERE</span> <span class="n">email</span> <span class="o">=</span> <span class="s1">'alice@example.com'</span><span class="p">;</span>

<span class="c1">-- 프로필 조회 시</span>
<span class="k">SELECT</span> <span class="n">a</span><span class="p">.</span><span class="n">user_id</span><span class="p">,</span> <span class="n">a</span><span class="p">.</span><span class="n">email</span><span class="p">,</span> <span class="n">p</span><span class="p">.</span><span class="n">name</span><span class="p">,</span> <span class="n">p</span><span class="p">.</span><span class="n">image_url</span>
<span class="k">FROM</span> <span class="n">users_auth</span> <span class="n">a</span>
<span class="k">JOIN</span> <span class="n">users_profile</span> <span class="n">p</span> <span class="k">USING</span> <span class="p">(</span><span class="n">user_id</span><span class="p">)</span>
<span class="k">WHERE</span> <span class="n">a</span><span class="p">.</span><span class="n">user_id</span> <span class="o">=</span> <span class="mi">1</span><span class="p">;</span>

<span class="c1">-- 마이페이지 진입 시</span>
<span class="k">SELECT</span> <span class="n">a</span><span class="p">.</span><span class="n">email</span><span class="p">,</span> <span class="n">p</span><span class="p">.</span><span class="n">name</span><span class="p">,</span> <span class="n">d</span><span class="p">.</span><span class="n">bio</span><span class="p">,</span> <span class="n">d</span><span class="p">.</span><span class="n">preferences</span>
<span class="k">FROM</span> <span class="n">users_auth</span> <span class="n">a</span>
<span class="k">JOIN</span> <span class="n">users_profile</span> <span class="n">p</span> <span class="k">USING</span> <span class="p">(</span><span class="n">user_id</span><span class="p">)</span>
<span class="k">JOIN</span> <span class="n">users_detail</span>  <span class="n">d</span> <span class="k">USING</span> <span class="p">(</span><span class="n">user_id</span><span class="p">)</span>
<span class="k">WHERE</span> <span class="n">a</span><span class="p">.</span><span class="n">user_id</span> <span class="o">=</span> <span class="mi">1</span><span class="p">;</span>
</code></pre></div></div>

<p>사실 유스케이스별 도메인 로직, 추상화 계층 관리에 대해 익숙한 개발자라면 이미 이와 같은 분리 경험이 많을 것이다.</p>

<p><br /></p>

<h2 id="정리하며">🗂정리하며</h2>

<p>파티셔닝 전략에 정답은 없다. Range 가 맞는 테이블에 Hash 를 쓰면 프루닝이 사라지고, 수직 분리가 필요한 곳에 수평 파티셔닝만 고집하면 캐시 효율은 여전히 나쁘다. 
결국은 경험 차이다. 대규모 데이터를 자주, 많이 다뤄볼수록 쿼리 패턴을 보는 눈이 생기고, 적합한 전략을 선택하는 속도도 빨라진다.
더 나아가 아키텍처(테이블 분리를 넘어 서비스 분리) 수준의 고민까지 할 수 있게 된다.</p>

<p>각각의 전략들은 ‘이런 것들이 있구나’ 하고 개안하는 정도로 넣어두고, 천천히 내 것으로 만들어 가면 될 것 같다.
아는 만큼 보이고, 해본 만큼 빨라진다.</p>

<p><br /></p>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://wikidocs.net/306571">MariaDB와 함께하는 데이터 이야기 - 06. 파티셔닝 전략과 구현</a></li>
</ul>]]></content><author><name>현구막</name><email>jinha3507@gmail.com</email></author><summary type="html"><![CDATA[🗂 들어가며]]></summary></entry><entry><title type="html">홈 서버 구축기 - 모니터링 환경 구축하기</title><link href="https://hyeon9mak.github.io/home-server-monitoring/" rel="alternate" type="text/html" title="홈 서버 구축기 - 모니터링 환경 구축하기" /><published>2026-03-28T00:00:00+09:00</published><updated>2026-03-28T00:00:00+09:00</updated><id>https://hyeon9mak.github.io/home-server-monitoring</id><content type="html" xml:base="https://hyeon9mak.github.io/home-server-monitoring/"><![CDATA[<h2 id="-홈-서버-구축-목표">💾 홈 서버 구축 목표</h2>

<ul>
  <li>서비스보다는 서버환경 구축 자체에 집중한다.</li>
  <li>만들고 싶은 서비스가 있으면 바로 파이프라인 구성 후 배포해서 확인해볼 수 있는 환경을 만든다.</li>
  <li>첫 스텝은 단일 애플리케이션 CI-CD 파이프라이닝</li>
  <li>두 번째 스탭은 컨테이너화(도커라이징)</li>
  <li>세 번째 스탭은 <strong>데스크탑 리소스 모니터링 환경 구축</strong></li>
  <li>마지막 스탭으로 컨테이너 오케스트레이션(k8s, argo 등) 도입 (저번 글에 완료)</li>
</ul>

<p>지난 시간에는 Docker + Nginx 기반의 구조를 MicroK8s + ArgoCD(GitOps) 기반으로 전환했다.
그리고 드디어, 계속 예고만 해왔던 리소스 모니터링 환경을 구축할 차례가 됐다.</p>

<p><br /></p>

<h2 id="-모니터링-스택-선정">💾 모니터링 스택 선정</h2>

<p>모니터링 환경을 구축하려면 크게 세 가지 역할이 필요하다.</p>

<ul>
  <li><strong>메트릭 수집 &amp; 저장</strong>: CPU, 메모리, 디스크 등 수치 데이터를 모은다.</li>
  <li><strong>로그 수집 &amp; 저장</strong>: 애플리케이션과 시스템 로그를 모은다.</li>
  <li><strong>시각화</strong>: 수집한 데이터를 대시보드로 보여준다.</li>
</ul>

<p>오픈소스를 활용한 가장 널리 쓰이는 조합은 <strong>Prometheus + Loki + Grafana</strong> 다.</p>

<table>
  <thead>
    <tr>
      <th>역할</th>
      <th>선택</th>
      <th>이유</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>메트릭 수집/저장</td>
      <td>Prometheus</td>
      <td>풍부한 exporter 생태계, Grafana 와의 궁합</td>
    </tr>
    <tr>
      <td>로그 수집</td>
      <td>Grafana Alloy</td>
      <td>DaemonSet으로 전 Pod 로그 자동 수집</td>
    </tr>
    <tr>
      <td>로그 저장</td>
      <td>Loki</td>
      <td>Prometheus 와 유사한 레이블 기반, Grafana 와의 궁합</td>
    </tr>
    <tr>
      <td>시각화</td>
      <td>Grafana</td>
      <td>Prometheus, Loki 모두 데이터 소스로 지원</td>
    </tr>
  </tbody>
</table>

<p>세 가지 모두 Grafana Labs 에서 관리하는 프로젝트라 연동이 자연스럽고, Helm chart 도 잘 관리된다.</p>

<p><br /></p>

<h2 id="-전체-모니터링-아키텍처">💾 전체 모니터링 아키텍처</h2>

<p>설치 전에 전체 구조를 먼저 파악해두는 게 좋다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[홈 서버 Host]
  ├── node_exporter     :9100  ← CPU, 메모리, 디스크 등 호스트 메트릭
  └── postgres_exporter :9187  ← PostgreSQL 메트릭

[MicroK8s - monitoring namespace]
  ├── Prometheus        :9090  ← 메트릭 수집 &amp; 저장 (node_exporter, postgres_exporter, 앱 스크랩)
  ├── Loki              :3100  ← 로그 저장
  ├── Alloy (DaemonSet)        ← 전 Pod 로그 수집 → Loki
  └── Grafana           :3000  ← 시각화 (Prometheus + Loki 데이터 소스)
</code></pre></div></div>

<p>이전 글에서 정리했던 이미지에서 모니터링 스택 영역을 다시 참고해보자.</p>

<p><img width="1603" height="1100" alt="Image" src="https://github.com/user-attachments/assets/07ac2f16-de3e-4a4c-b3c8-bfb70725135c" /></p>

<p>4화와 마찬가지로, 모니터링 스택도 두 레이어로 나뉜다.</p>

<table>
  <thead>
    <tr>
      <th>레이어</th>
      <th>도구</th>
      <th>관리 대상</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>호스트 레이어</td>
      <td>Ansible</td>
      <td>node_exporter, postgres_exporter, Helm 배포</td>
    </tr>
    <tr>
      <td>k8s 워크로드 레이어</td>
      <td>k8s-configs/ArgoCD</td>
      <td>prom-label-proxy, Loki datasource ConfigMap</td>
    </tr>
  </tbody>
</table>

<p>node_exporter와 postgres_exporter는 k8s 안에 두지 않고 호스트에 직접 설치했다.
k8s가 중단되더라도 호스트 메트릭은 계속 수집되어야 하기 때문이다.</p>

<p><br /></p>

<h2 id="-호스트-메트릭-수집-node_exporter-postgres_exporter">💾 호스트 메트릭 수집 (node_exporter, postgres_exporter)</h2>

<p>두 exporter 모두 Ansible playbook 이 설치를 담당한다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># setup-monitoring.yml</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Install node_exporter</span>
      <span class="na">apt</span><span class="pi">:</span>
        <span class="na">name</span><span class="pi">:</span> <span class="s">prometheus-node-exporter</span>
        <span class="na">state</span><span class="pi">:</span> <span class="s">present</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Download postgres_exporter</span>
      <span class="na">get_url</span><span class="pi">:</span>
        <span class="na">url</span><span class="pi">:</span> <span class="s">https://github.com/prometheus-community/postgres_exporter/releases/download/v0.15.0/postgres_exporter-0.15.0.linux-amd64.tar.gz</span>
        <span class="na">dest</span><span class="pi">:</span> <span class="s">/tmp/postgres_exporter.tar.gz</span>

    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Install postgres_exporter binary</span>
      <span class="na">copy</span><span class="pi">:</span>
        <span class="na">src</span><span class="pi">:</span> <span class="s">/tmp/postgres_exporter-0.15.0.linux-amd64/postgres_exporter</span>
        <span class="na">dest</span><span class="pi">:</span> <span class="s">/usr/local/bin/postgres_exporter</span>
        <span class="na">mode</span><span class="pi">:</span> <span class="s1">'</span><span class="s">0755'</span>
        <span class="na">remote_src</span><span class="pi">:</span> <span class="s">yes</span>

    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Create postgres_exporter config</span>
      <span class="na">copy</span><span class="pi">:</span>
        <span class="na">dest</span><span class="pi">:</span> <span class="s">/etc/postgres_exporter.env</span>
        <span class="na">content</span><span class="pi">:</span> <span class="pi">|</span>
          <span class="s">DATA_SOURCE_NAME=postgresql://postgres:@localhost:5432/postgres?sslmode=disable</span>
        <span class="na">mode</span><span class="pi">:</span> <span class="s1">'</span><span class="s">0600'</span>
      <span class="na">no_log</span><span class="pi">:</span> <span class="no">true</span>

    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Create postgres_exporter systemd service</span>
      <span class="na">copy</span><span class="pi">:</span>
        <span class="na">dest</span><span class="pi">:</span> <span class="s">/etc/systemd/system/postgres_exporter.service</span>
        <span class="na">content</span><span class="pi">:</span> <span class="pi">|</span>
          <span class="s">[Unit]</span>
          <span class="s">Description=Prometheus PostgreSQL Exporter</span>
          <span class="s">After=postgresql.service</span>

          <span class="s">[Service]</span>
          <span class="s">User=postgres</span>
          <span class="s">EnvironmentFile=/etc/postgres_exporter.env</span>
          <span class="s">ExecStart=/usr/local/bin/postgres_exporter</span>
          <span class="s">Restart=always</span>

          <span class="s">[Install]</span>
          <span class="s">WantedBy=multi-user.target</span>

    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Enable and start postgres_exporter</span>
      <span class="na">systemd</span><span class="pi">:</span>
        <span class="na">name</span><span class="pi">:</span> <span class="s">postgres_exporter</span>
        <span class="na">enabled</span><span class="pi">:</span> <span class="s">yes</span>
        <span class="na">state</span><span class="pi">:</span> <span class="s">started</span>
        <span class="na">daemon_reload</span><span class="pi">:</span> <span class="s">yes</span>
</code></pre></div></div>

<p>당연히 비밀번호 같은 민감한 값은 Ansible Vault 로 암호화해서 관리중이다.</p>

<p><br /></p>

<h2 id="-prometheus-설치-및-스크랩-설정">💾 Prometheus 설치 및 스크랩 설정</h2>

<p>Prometheus 역시도 Ansible playbook 을 통해 <code class="language-plaintext highlighter-rouge">kube-prometheus-stack</code> Helm 차트로 배포한다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Install or upgrade kube-prometheus-stack</span>
      <span class="na">command</span><span class="pi">:</span> <span class="pi">&gt;</span>
        <span class="s">microk8s helm3 upgrade --install monitoring prometheus-community/kube-prometheus-stack</span>
        <span class="s">--namespace monitoring</span>
        <span class="s">--values /tmp/prometheus-values.yaml</span>
        <span class="s">--create-namespace</span>
</code></pre></div></div>

<p>Helm values 파일도 playbook 이 동적으로 생성한다. 핵심 설정은 스크랩 대상과 내장 node_exporter 비활성화다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">prometheus</span><span class="pi">:</span>
  <span class="na">prometheusSpec</span><span class="pi">:</span>
    <span class="na">retention</span><span class="pi">:</span> <span class="s2">"</span><span class="s">15d"</span>
    <span class="na">storageSpec</span><span class="pi">:</span>
      <span class="na">volumeClaimTemplate</span><span class="pi">:</span>
        <span class="na">spec</span><span class="pi">:</span>
          <span class="na">accessModes</span><span class="pi">:</span> <span class="pi">[</span><span class="s2">"</span><span class="s">ReadWriteOnce"</span><span class="pi">]</span>
          <span class="na">resources</span><span class="pi">:</span>
            <span class="na">requests</span><span class="pi">:</span>
              <span class="na">storage</span><span class="pi">:</span> <span class="s">10Gi</span>
    <span class="na">additionalScrapeConfigs</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">job_name</span><span class="pi">:</span> <span class="s1">'</span><span class="s">postgres-host'</span>
      <span class="na">static_configs</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">targets</span><span class="pi">:</span> <span class="pi">[</span><span class="s1">'</span><span class="s">:9187'</span><span class="pi">]</span>   <span class="c1"># 호스트 IP</span>
    <span class="pi">-</span> <span class="na">job_name</span><span class="pi">:</span> <span class="s1">'</span><span class="s">node-exporter'</span>
      <span class="na">static_configs</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">targets</span><span class="pi">:</span> <span class="pi">[</span><span class="s1">'</span><span class="s">:9100'</span><span class="pi">]</span>   <span class="c1"># 호스트 IP</span>
    <span class="pi">-</span> <span class="na">job_name</span><span class="pi">:</span> <span class="s1">'</span><span class="s">my-service-application'</span>
      <span class="na">static_configs</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">targets</span><span class="pi">:</span> <span class="pi">[</span><span class="s1">'</span><span class="s">my-service-application-service.my-service-application.svc.cluster.local:8080'</span><span class="pi">]</span>

<span class="na">prometheus-node-exporter</span><span class="pi">:</span>
  <span class="na">enabled</span><span class="pi">:</span> <span class="no">false</span>   <span class="c1"># 호스트에 직접 설치했으므로 차트 내장 exporter 비활성화</span>
</code></pre></div></div>

<p><br /></p>

<h2 id="-loki--alloy-로그-수집-설정">💾 Loki + Alloy 로그 수집 설정</h2>

<p>Loki 와 Alloy 역시 Ansible playbook 을 활용해서 배포한다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># setup-logging.yml</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Install or upgrade Loki</span>
      <span class="na">command</span><span class="pi">:</span> <span class="pi">&gt;</span>
        <span class="s">microk8s helm3 upgrade --install loki grafana/loki</span>
        <span class="s">--namespace monitoring</span>
        <span class="s">--values /tmp/loki-values.yaml</span>

    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Install or upgrade Alloy</span>
      <span class="na">command</span><span class="pi">:</span> <span class="pi">&gt;</span>
        <span class="s">microk8s helm3 upgrade --install alloy grafana/alloy</span>
        <span class="s">--namespace monitoring</span>
        <span class="s">--values /tmp/alloy-values.yaml</span>
</code></pre></div></div>

<h3 id="loki-설치">Loki 설치</h3>

<p>Loki 는 Prometheus 가 메트릭을 저장하는 것처럼 로그를 저장하는 역할을 수행한다.
Loki 는 레이블 기반으로 로그를 저장하고 LogQL 로 쿼리할 수 있다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># setup-logging.yml</span>
<span class="na">loki</span><span class="pi">:</span>
  <span class="na">auth_enabled</span><span class="pi">:</span> <span class="no">true</span>   <span class="c1"># 멀티 테넌트 활성화</span>
  <span class="na">commonConfig</span><span class="pi">:</span>
    <span class="na">replication_factor</span><span class="pi">:</span> <span class="m">1</span>
  <span class="na">storage</span><span class="pi">:</span>
    <span class="na">type</span><span class="pi">:</span> <span class="s">filesystem</span>
  <span class="na">limits_config</span><span class="pi">:</span>
    <span class="na">retention_period</span><span class="pi">:</span> <span class="s">744h</span>  <span class="c1"># 31일</span>

<span class="na">singleBinary</span><span class="pi">:</span>
  <span class="na">replicas</span><span class="pi">:</span> <span class="m">1</span>
  <span class="na">persistence</span><span class="pi">:</span>
    <span class="na">enabled</span><span class="pi">:</span> <span class="no">true</span>
    <span class="na">size</span><span class="pi">:</span> <span class="s">20Gi</span>
</code></pre></div></div>

<p>기본값(<code class="language-plaintext highlighter-rouge">auth_enabled: false</code>)으로 두면 Loki 는 모든 로그를 하나의 공간에 섞어서 저장한다.
즉, 서비스 로그든 ArgoCD 로그든 Grafana 로그든 전부 한 바구니에 쌓인다.
이 경우 Grafana 에서 “서비스 로그만 보여주는 데이터 소스”를 만들 수가 없다.</p>

<p><code class="language-plaintext highlighter-rouge">auth_enabled: true</code>를 켜면 Loki 가 요청 헤더의 <code class="language-plaintext highlighter-rouge">X-Scope-OrgID</code> 값을 보고
로그를 <strong>테넌트(tenant)</strong> 단위로 분리해서 저장한다.
마치 같은 건물 안에 회사별로 사무실이 나뉘는 것처럼, 로그를 격리된 공간에 각각 쌓는다.</p>

<p>이후 Grafana Alloy 가 로그를 보낼 때 헤더에 테넌트 ID를 실어서 보내면, Loki 는 해당 테넌트 공간에만 로그를 저장한다.
Grafana 에서도 테넌트별로 다른 Loki 데이터 소스를 만들어 연결할 수 있어서, “서비스 담당자에게는 서비스 로그만” 보여주는 구성이 가능해진다.</p>

<h3 id="alloy-로-pod-로그-수집">Alloy 로 Pod 로그 수집</h3>

<p>Alloy 는 Grafana Labs 에서 만든 범용 수집기(collector)다.
이전에는 Promtail 이 같은 역할을 했는데, Alloy 가 그 후속 프로젝트라고 한다.
(사실 정확한 차이점은 잘 모르지만, Alloy 가 후속 프로젝트여서 채택했다.)</p>

<p>DaemonSet 으로 배포하면 모든 노드의 Pod 로그를 자동으로 수집해서 Loki 로 보낸다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># setup-logging.yml</span>
<span class="na">alloy</span><span class="pi">:</span>
  <span class="na">configMap</span><span class="pi">:</span>
    <span class="na">content</span><span class="pi">:</span> <span class="pi">|</span>
      <span class="s">discovery.kubernetes "pods" {</span>
        <span class="s">role = "pod"</span>
      <span class="s">}</span>

      <span class="s">discovery.relabel "pod_logs" {</span>
        <span class="s">targets = discovery.kubernetes.pods.targets</span>
        <span class="s">rule {</span>
          <span class="s">source_labels = ["__meta_kubernetes_namespace"]</span>
          <span class="s">target_label  = "namespace"</span>
        <span class="s">}</span>
        <span class="s">rule {</span>
          <span class="s">source_labels = ["__meta_kubernetes_pod_name"]</span>
          <span class="s">target_label  = "pod"</span>
        <span class="s">}</span>
        <span class="s">rule {</span>
          <span class="s">source_labels = ["__meta_kubernetes_pod_container_name"]</span>
          <span class="s">target_label  = "container"</span>
        <span class="s">}</span>
      <span class="s">}</span>

      <span class="s">loki.source.kubernetes "pod_logs" {</span>
        <span class="s">targets    = discovery.relabel.pod_logs.output</span>
        <span class="s">forward_to = [loki.process.my-application-service.receiver, loki.process.default_tenant.receiver]</span>
      <span class="s">}</span>

      <span class="s">// my-application-service 네임스페이스만 통과 → 로그 레벨 레이블 추출</span>
      <span class="s">loki.process "my-application-service" {</span>
        <span class="s">stage.match {</span>
          <span class="s">selector = "{namespace=\"my-application-service\"}"</span>
          <span class="s">action   = "keep"</span>
          <span class="s">stage.regex {</span>
            <span class="s">expression = "^.+\\s+(?P&lt;level&gt;TRACE|DEBUG|INFO|WARN|ERROR)\\s+.*"</span>
          <span class="s">}</span>
          <span class="s">stage.labels {</span>
            <span class="s">values = { level = "" }</span>
          <span class="s">}</span>
        <span class="s">}</span>
        <span class="s">forward_to = [loki.write.my-application-service.receiver]</span>
      <span class="s">}</span>

      <span class="s">// my-application-service 제외한 나머지</span>
      <span class="s">loki.process "default_tenant" {</span>
        <span class="s">stage.match {</span>
          <span class="s">selector = "{namespace=\"my-application-service\"}"</span>
          <span class="s">action   = "drop"</span>
        <span class="s">}</span>
        <span class="s">forward_to = [loki.write.default_tenant.receiver]</span>
      <span class="s">}</span>

      <span class="s">loki.write "my-application-service" {</span>
        <span class="s">endpoint {</span>
          <span class="s">url     = "http://loki.monitoring.svc.cluster.local:3100/loki/api/v1/push"</span>
          <span class="s">headers = { "X-Scope-OrgID" = "my-application-service" }</span>
        <span class="s">}</span>
      <span class="s">}</span>

      <span class="s">loki.write "default_tenant" {</span>
        <span class="s">endpoint {</span>
          <span class="s">url     = "http://loki.monitoring.svc.cluster.local:3100/loki/api/v1/push"</span>
          <span class="s">headers = { "X-Scope-OrgID" = "default" }</span>
        <span class="s">}</span>
      <span class="s">}</span>
</code></pre></div></div>

<p>흐름을 정리하면 이렇다.</p>

<ol>
  <li>전체 Pod 로그를 두 파이프라인으로 분기한다.</li>
  <li><code class="language-plaintext highlighter-rouge">my-application-service</code> 네임스페이스 로그: 로그 레벨(<code class="language-plaintext highlighter-rouge">TRACE/DEBUG/INFO/WARN/ERROR</code>) 레이블을 추출 후 <code class="language-plaintext highlighter-rouge">my-application-service</code> 테넌트로 전송한다.</li>
  <li>나머지 네임스페이스 로그: <code class="language-plaintext highlighter-rouge">default</code> 테넌트로 전송한다.</li>
</ol>

<p>이렇게 분리하면 Grafana 에서 서비스별로 독립된 Loki 데이터 소스를 연결해줄 수 있다.</p>

<p><br /></p>

<h2 id="-grafana-설치-및-데이터-소스-구성">💾 Grafana 설치 및 데이터 소스 구성</h2>

<p>Grafana는 <code class="language-plaintext highlighter-rouge">kube-prometheus-stack</code>에 번들로 포함되어 있어서 별도 Helm 배포가 필요 없다.
앞서 Prometheus 를 배포한 <code class="language-plaintext highlighter-rouge">setup-monitoring.yml</code> playbook의 values 파일에 Grafana 설정도 함께 들어있다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># setup-monitoring.yml</span>
<span class="na">grafana</span><span class="pi">:</span>
  <span class="na">grafana.ini</span><span class="pi">:</span>
    <span class="na">users</span><span class="pi">:</span>
      <span class="na">viewers_can_edit</span><span class="pi">:</span> <span class="no">true</span>   <span class="c1"># 뷰어도 대시보드 탐색 가능</span>
  <span class="na">adminPassword</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
  <span class="na">persistence</span><span class="pi">:</span>
    <span class="na">enabled</span><span class="pi">:</span> <span class="no">true</span>
    <span class="na">size</span><span class="pi">:</span> <span class="s">5Gi</span>
  <span class="na">dashboards</span><span class="pi">:</span>
    <span class="na">default</span><span class="pi">:</span>
      <span class="na">postgres</span><span class="pi">:</span>
        <span class="na">gnetId</span><span class="pi">:</span> <span class="m">9628</span>    <span class="c1"># PostgreSQL Database</span>
        <span class="na">revision</span><span class="pi">:</span> <span class="m">7</span>
        <span class="na">datasource</span><span class="pi">:</span> <span class="s">Prometheus</span>
      <span class="na">kubernetes</span><span class="pi">:</span>
        <span class="na">gnetId</span><span class="pi">:</span> <span class="m">315</span>     <span class="c1"># Kubernetes cluster monitoring</span>
        <span class="na">revision</span><span class="pi">:</span> <span class="m">3</span>
        <span class="na">datasource</span><span class="pi">:</span> <span class="s">Prometheus</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">gnetId</code> 는 <a href="https://grafana.com/grafana/dashboards/">Grafana Dashboards</a> 에서 공유되는 커뮤니티 대시보드 ID다.
값 하나 적어두면 Grafana가 시작할 때 자동으로 대시보드를 불러온다!
Grafana 를 채택한 가장 강력한 이유.</p>

<h3 id="grafana-멀티-org-구성">Grafana 멀티 org 구성</h3>

<p>prom-label-proxy 와 Loki 멀티 테넌트를 도입하면서, Grafana 도 멀티 org 로 구성했다.</p>

<ul>
  <li><strong>Main org(기본)</strong>: 관리자용. Prometheus 전체 메트릭 + 모든 로그에 접근 가능.</li>
  <li><strong>MyServiceApplication org</strong>: 특정 서비스 담당자용. prom-label-proxy를 통해 해당 네임스페이스 메트릭만 접근 가능.</li>
</ul>

<p>이 org 생성과 데이터 소스 연결, 대시보드 가져오기까지 모두 Ansible playbook 이 Grafana HTTP API 를 직접 호출해서 처리한다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># setup-monitoring.yml</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Create MyServiceApplication org (idempotent)</span>
      <span class="na">uri</span><span class="pi">:</span>
        <span class="na">url</span><span class="pi">:</span> <span class="s2">"</span><span class="s">https://grafana.yourdomain.com/api/orgs"</span>
        <span class="na">method</span><span class="pi">:</span> <span class="s">POST</span>
        <span class="na">user</span><span class="pi">:</span> <span class="s">admin</span>
        <span class="na">password</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
        <span class="na">force_basic_auth</span><span class="pi">:</span> <span class="s">yes</span>
        <span class="na">body_format</span><span class="pi">:</span> <span class="s">json</span>
        <span class="na">body</span><span class="pi">:</span>
          <span class="na">name</span><span class="pi">:</span> <span class="s2">"</span><span class="s">MyServiceApplication"</span>
        <span class="na">status_code</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">200</span><span class="pi">,</span> <span class="nv">409</span><span class="pi">]</span>   <span class="c1"># 이미 있으면 409 → 무시</span>

    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Add Prometheus (MyServiceApplication) datasource to MyServiceApplication org</span>
      <span class="na">uri</span><span class="pi">:</span>
        <span class="na">url</span><span class="pi">:</span> <span class="s2">"</span><span class="s">https://grafana.yourdomain.com/api/datasources"</span>
        <span class="na">method</span><span class="pi">:</span> <span class="s">POST</span>
        <span class="na">headers</span><span class="pi">:</span>
          <span class="na">X-Grafana-Org-Id</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
        <span class="na">body_format</span><span class="pi">:</span> <span class="s">json</span>
        <span class="na">body</span><span class="pi">:</span>
          <span class="na">name</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Prometheus</span><span class="nv"> </span><span class="s">(MyServiceApplication)"</span>
          <span class="na">type</span><span class="pi">:</span> <span class="s">prometheus</span>
          <span class="na">url</span><span class="pi">:</span> <span class="s2">"</span><span class="s">http://prom-label-proxy.monitoring.svc.cluster.local:8082"</span>
          <span class="na">isDefault</span><span class="pi">:</span> <span class="no">true</span>

    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Import Spring Boot dashboard to MyServiceApplication org</span>
      <span class="na">uri</span><span class="pi">:</span>
        <span class="na">url</span><span class="pi">:</span> <span class="s2">"</span><span class="s">https://grafana.yourdomain.com/api/dashboards/import"</span>
        <span class="na">method</span><span class="pi">:</span> <span class="s">POST</span>
        <span class="na">headers</span><span class="pi">:</span>
          <span class="na">X-Grafana-Org-Id</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
        <span class="na">body_format</span><span class="pi">:</span> <span class="s">json</span>
        <span class="na">body</span><span class="pi">:</span>
          <span class="na">dashboard</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>   <span class="c1"># grafana.com에서 다운로드</span>
          <span class="na">overwrite</span><span class="pi">:</span> <span class="no">true</span>
          <span class="na">inputs</span><span class="pi">:</span>
            <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s2">"</span><span class="s">DS_PROMETHEUS"</span>
              <span class="na">type</span><span class="pi">:</span> <span class="s">datasource</span>
              <span class="na">pluginId</span><span class="pi">:</span> <span class="s">prometheus</span>
              <span class="na">value</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Prometheus</span><span class="nv"> </span><span class="s">(MyServiceApplication)"</span>
</code></pre></div></div>

<p>API를 통해 org ID를 받아서 헤더에 <code class="language-plaintext highlighter-rouge">X-Grafana-Org-Id</code>로 넘기는 방식이다.
playbook이 idempotent하게 설계되어 있어서 재실행해도 409(이미 존재)는 성공으로 처리된다.</p>

<p>MyServiceApplication org에는 Spring Boot(4701), Spring Boot Statistics(6756), Spring Boot 3.x Observability(17175), Kubernetes Pods(13659), PostgreSQL(9628), Kubernetes cluster(315) 대시보드를 임포트했다.</p>

<h3 id="grafana-접근-설정">Grafana 접근 설정</h3>

<p>이전에 Tailscale VPN 으로 ArgoCD를 보호한 것처럼, Grafana 도 보호하는게 안전하다.
다만 Grafana 는 팀원이나 외부 관계자에게 대시보드를 공유하고 싶을 때가 있다.
때문에 public HTTPS 와 Tailscale VPN 두 경로를 모두 열어두었다.</p>

<p>이 Ingress와 Tailscale LoadBalancer Service 생성도 Ansible playbook 이 담당한다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># setup-monitoring.yml </span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Expose Grafana via public Ingress</span>
      <span class="na">kubernetes.core.k8s</span><span class="pi">:</span>
        <span class="na">state</span><span class="pi">:</span> <span class="s">present</span>
        <span class="na">definition</span><span class="pi">:</span>
          <span class="na">apiVersion</span><span class="pi">:</span> <span class="s">networking.k8s.io/v1</span>
          <span class="na">kind</span><span class="pi">:</span> <span class="s">Ingress</span>
          <span class="na">metadata</span><span class="pi">:</span>
            <span class="na">name</span><span class="pi">:</span> <span class="s">grafana-public-ingress</span>
            <span class="na">namespace</span><span class="pi">:</span> <span class="s">monitoring</span>
            <span class="na">annotations</span><span class="pi">:</span>
              <span class="na">cert-manager.io/cluster-issuer</span><span class="pi">:</span> <span class="s2">"</span><span class="s">letsencrypt-prod"</span>
              <span class="na">nginx.ingress.kubernetes.io/force-ssl-redirect</span><span class="pi">:</span> <span class="s2">"</span><span class="s">true"</span>
          <span class="na">spec</span><span class="pi">:</span>
            <span class="na">ingressClassName</span><span class="pi">:</span> <span class="s">public</span>
            <span class="na">tls</span><span class="pi">:</span>
            <span class="pi">-</span> <span class="na">hosts</span><span class="pi">:</span>
              <span class="pi">-</span> <span class="s">grafana.yourdomain.com</span>
              <span class="na">secretName</span><span class="pi">:</span> <span class="s">grafana-tls-public</span>
            <span class="na">rules</span><span class="pi">:</span>
            <span class="pi">-</span> <span class="na">host</span><span class="pi">:</span> <span class="s">grafana.yourdomain.com</span>

    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Expose Grafana via Tailscale LoadBalancer</span>
      <span class="na">kubernetes.core.k8s</span><span class="pi">:</span>
        <span class="na">state</span><span class="pi">:</span> <span class="s">present</span>
        <span class="na">definition</span><span class="pi">:</span>
          <span class="na">apiVersion</span><span class="pi">:</span> <span class="s">v1</span>
          <span class="na">kind</span><span class="pi">:</span> <span class="s">Service</span>
          <span class="na">metadata</span><span class="pi">:</span>
            <span class="na">name</span><span class="pi">:</span> <span class="s">monitoring-grafana-tailscale</span>
            <span class="na">namespace</span><span class="pi">:</span> <span class="s">monitoring</span>
            <span class="na">annotations</span><span class="pi">:</span>
              <span class="na">tailscale.com/hostname</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
          <span class="na">spec</span><span class="pi">:</span>
            <span class="na">type</span><span class="pi">:</span> <span class="s">LoadBalancer</span>
            <span class="na">loadBalancerClass</span><span class="pi">:</span> <span class="s">tailscale</span>
            <span class="na">selector</span><span class="pi">:</span>
              <span class="na">app.kubernetes.io/name</span><span class="pi">:</span> <span class="s">grafana</span>
</code></pre></div></div>

<p><br /></p>

<h2 id="-prom-label-proxy로-메트릭-접근-제어하기">💾 prom-label-proxy로 메트릭 접근 제어하기</h2>

<p>운영하다 보면 특정 서비스 담당자에게 “자기 서비스의 메트릭만” 보여주고 싶은 상황이 생긴다.
Prometheus 전체를 노출하면 다른 서비스의 메트릭까지 다 보이기 때문이다.</p>

<p>이 때 <strong>prom-label-proxy</strong> 를 활용하면 Prometheus 앞단에서 특정 레이블로 필터링된 메트릭만 노출할 수 있다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># prom-label-proxy deployment 예시</span>
<span class="na">args</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="s">--proxy-path-prefix=/</span>
  <span class="pi">-</span> <span class="s">--upstream=http://prometheus-server:80</span>
  <span class="pi">-</span> <span class="s">--label=namespace</span>
  <span class="pi">-</span> <span class="s">--label-value=my-service-application</span>
  <span class="pi">-</span> <span class="s">--enable-label-apis=true</span>
</code></pre></div></div>

<p>이렇게 하면 <code class="language-plaintext highlighter-rouge">namespace=my-service-application</code> 인 메트릭만 반환하는 Prometheus 엔드포인트가 생긴다.
Grafana에서 이 proxy URL을 별도 데이터 소스로 추가하면, my-service-application 서비스 담당자에게
해당 네임스페이스의 메트릭만 접근하는 대시보드를 제공할 수 있다!</p>

<p>큰 규모의 서버에서는 큰 의미가 없을 수도 있지만,
여러 팀이 같은 클러스터(단일 노드)를 사용하는 상황에서는 꽤 유용한 패턴이다.</p>

<p><br /></p>

<h2 id="-마치며">💾 마치며</h2>

<p>모니터링 스택을 구축하고 나면 홈 서버 운영이 확연히 달라진다.</p>

<p><img width="2268" height="1121" alt="Image" src="https://github.com/user-attachments/assets/7105eca4-c72b-46b8-87c5-6666f593e0dd" /></p>

<p>무언가 이상하다는 느낌이 들기 전에, 그래프에서 먼저 보인다.
특정 시간대에 메모리 사용량이 급격히 오른다거나,
불특정 IP에서 이상한 요청이 반복된다거나 하는 것들이 대시보드에 고스란히 드러난다.</p>

<p><img width="2313" height="998" alt="Image" src="https://github.com/user-attachments/assets/967e90c9-27ae-4eca-a048-45dc60ea650e" /></p>

<p>잘 돌아가고 있는 서비스들을 보며 뿌듯함도 느끼고, 더 나은 환경으로 개선해나갈 아이디어도 떠오른다.</p>

<p>시리즈를 처음 시작했을 때 목표로 잡았던 항목들을 다시 보면 이렇다.</p>

<ul>
  <li>✅ 서비스보다는 서버환경 구축 자체에 집중한다.</li>
  <li>✅ 만들고 싶은 서비스가 있으면 바로 파이프라인 구성 후 배포해서 확인해볼 수 있는 환경을 만든다.</li>
  <li>✅ 첫 스텝은 단일 애플리케이션 CI-CD 파이프라이닝</li>
  <li>✅ 두 번째 스탭은 컨테이너화(도커라이징)</li>
  <li>✅ 세 번째 스탭은 데스크탑 리소스 모니터링 환경 구축</li>
  <li>✅ 마지막 스탭으로 컨테이너 오케스트레이션(k8s, argo 등) 도입</li>
</ul>

<p>반년의 인고의 시간을 거쳐, 드디어 목표를 모두 달성했다.</p>

<p>물론 아직 할 게 많다. 단일 노드라 고가용성(HA) 따윈 지킬 수 없고, 데이터 백업도 단순 크론잡에 의존하고 있고,
네트워크 보안도 허술해서 정교하게 다듬어야 할 부분이 많다.</p>

<p>그래도 처음 “집에 서버 한 대는 굴려야지”라는 막연한 생각에서 시작해서,
지금은 GitOps 로 배포하고, 모니터링 대시보드로 상태를 확인할 수 있는 환경이 갖춰졌다.</p>

<p>PC 를 추가 구매해서 멀티 노드 클러스터로 확장하고, 인터넷 회선 대역폭과 전기세에 대해 고민하고
소음과 발열을 줄이는 방법을 찾아보고, 데이터 백업과 보안 강화 방안을 연구하는 등등
하나씩 금칠을 해나갈 일들이 남았다.</p>

<p>그러다 어느 순간 “이럴거면 클라우드 쓰지!” 하고 정리하게 될지도 모르겠다.
그래도, 그게 재미 아닐까.</p>

<p>5화 끝.</p>]]></content><author><name>현구막</name><email>jinha3507@gmail.com</email></author><summary type="html"><![CDATA[💾 홈 서버 구축 목표]]></summary></entry><entry><title type="html">홈 서버 구축기 - K8s 와 GitOps 기반으로 전환하기</title><link href="https://hyeon9mak.github.io/home-server-k8s-and-gitops/" rel="alternate" type="text/html" title="홈 서버 구축기 - K8s 와 GitOps 기반으로 전환하기" /><published>2026-03-24T00:00:00+09:00</published><updated>2026-03-24T00:00:00+09:00</updated><id>https://hyeon9mak.github.io/home-server-k8s-and-gitops</id><content type="html" xml:base="https://hyeon9mak.github.io/home-server-k8s-and-gitops/"><![CDATA[<h2 id="-홈-서버-구축-목표">💾 홈 서버 구축 목표</h2>

<ul>
  <li>서비스보다는 서버환경 구축 자체에 집중한다.</li>
  <li>만들고 싶은 서비스가 있으면 바로 파이프라인 구성 후 배포해서 확인해볼 수 있는 환경을 만든다.</li>
  <li>첫 스텝은 단일 애플리케이션 CI-CD 파이프라이닝</li>
  <li>두 번째 스탭은 컨테이너화(도커라이징)</li>
  <li>세 번째 스탭은 데스크탑 리소스 모니터링 환경 구축</li>
  <li>마지막 스탭으로 컨테이너 오케스트레이션(k8s, argo 등) 도입</li>
</ul>

<p>지난 시간에는 Nginx 리버스 프록시를 호스트에 설치하고, 서브 도메인과 DNS 설정을 진행했다.
이번에는 계획에 없던(?) 대규모 전환 작업을 먼저 이야기해야 한다.</p>

<p>3화에서 “리소스 모니터링 환경 구축을 서둘러 진행하겠다”고 예고했는데,
막상 모니터링 환경을 구축하려고 보니 “지금 구조에서 모니터링만 붙이는 게 맞나?”라는 의구심이 들기 시작했다.</p>

<p><br /></p>

<h2 id="-기존-구조의-한계">💾 기존 구조의 한계</h2>

<p>3화까지 구축된 홈 서버의 구조를 정리해보면 아래와 같다.</p>

<ul>
  <li>GitHub Actions self-hosted runner가 홈 서버에서 직접 빌드 &amp; 배포</li>
  <li>컨테이너는 <code class="language-plaintext highlighter-rouge">docker run</code> 혹은 <code class="language-plaintext highlighter-rouge">docker-compose</code> 로 관리</li>
  <li>Nginx를 호스트에 설치해서 서브 도메인별 리버스 프록시 구성</li>
  <li>SSL 인증서는 certbot으로 수동(반자동) 발급</li>
</ul>

<p>처음 하나 둘 서비스를 올릴 때는 이 구조가 나쁘지 않았다.
그런데 운영하는 서비스가 세 개, 네 개를 넘어가기 시작하면서 슬슬 문제가 보이기 시작했다.</p>

<h3 id="선형으로-늘어나는-설정-파일">선형으로 늘어나는 설정 파일</h3>

<p>서비스 하나를 추가할 때마다 아래 작업이 반복됐다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">/etc/nginx/conf.d/{서비스명}.conf</code> 작성</li>
  <li><code class="language-plaintext highlighter-rouge">sudo certbot --nginx -d {서브도메인}</code> 으로 인증서 발급</li>
  <li>서비스 GitHub repository 에 <code class="language-plaintext highlighter-rouge">.github/workflows/deploy.yml</code> 작성
    <ul>
      <li>이건 서비스마다 특정 옵션을 넣기 위해 내가 결정한 구조이므로 그나마 괜찮았다.</li>
    </ul>
  </li>
</ul>

<p>처음에는 별 게 아닌 일이었는데, 서비스가 늘어나면서 어디서 뭘 고쳐야 할지 한 눈에 파악이 되지 않았다.
그리고 반복되는 작업이 너무 귀찮았고, 실수로 Nginx conf 파일을 잘못 작성해서 nginx -t 테스트에 실패하는 일도 종종 생겼다.
(테스트 실패로 Nginx가 reload 되지 않아서 서비스가 잠시 다운되는 장애도 생겼다.)</p>

<h3 id="불편한-롤백">불편한 롤백</h3>

<p>배포 후 문제가 생기면 롤백을 해야 하는데, 방법이 마땅치 않았다.
이전 jar 파일을 보관해두지 않았다면 PR을 revert하고 다시 빌드 &amp; 배포를 기다려야 했다.
컨테이너 이미지 태그를 남기는 방식도 시도해봤지만, 이미지를 어디에 저장할지부터 정해야 하는 문제가 생겼다.</p>

<h3 id="모니터링-구성-고민">모니터링 구성 고민</h3>

<p>Prometheus + Grafana + Loki 조합으로 모니터링을 구축하고 싶었는데,
서비스마다 docker-compose 파일을 다르게 관리하면서 각 서비스의 로그와 메트릭 수집을 위한 컨테이너 간 네트워크 연결 설정이 복잡해졌다.
컨테이너 오케스트레이션 없이 모니터링 스택을 붙이는 건 어딘가 억지스럽다는 느낌이 들었다.</p>

<p>결국 “리소스 모니터링”으로 가기 전에, “오케스트레이션 레이어를 먼저 도입”하는 게 순서에 맞겠다고 판단했다.</p>

<p><br /></p>

<h2 id="-왜-kubernetes-인가">💾 왜 Kubernetes 인가</h2>

<p>옛날 같으면 “단일 홈 서버에 k8s 는 오버엔지니어링 아닌가?” 라고 생각했겠지만,
container orchestration 이 너무나 자연스러운 요즘에는 단일 홈 서버여도 도입하지 않을 이유가 없다고 생각한다.</p>

<p>거기에 홀로 k8s 환경을 운영해보면서 여러 트러블 상황을 겪으면 실제 업무에도 큰 도움이 되지 않을까?
결국 홈 서버 구축의 애초 목표가 “나중에 써볼 기술을 미리 경험해보는 것”이었기 때문에,
단일 노드라도 k8s 환경을 직접 운영해보는 게 더 큰 공부가 된다고 판단했다.</p>

<p>당연히 학습 목적 외 실용적인 이유도 많았다.</p>

<ul>
  <li><strong>선언형 관리</strong>: YAML 파일 하나로 서비스의 전체 상태를 표현할 수 있다.</li>
  <li><strong>자가 치유</strong>: 컨테이너가 죽으면 자동으로 재시작된다. 물론 노드(컴퓨터) 자체가 죽으면…</li>
  <li><strong>Ingress 리소스</strong>: Nginx conf 파일 대신 k8s Ingress 리소스로 라우팅을 선언적으로 관리한다.</li>
  <li><strong>cert-manager</strong>: Let’s Encrypt 인증서 발급과 갱신을 완전 자동화할 수 있다.</li>
</ul>

<p>물론 아무리 장점이 많다고 해도 단일 노드 홈 서버를 운영하는데 실제 K8s 전체 환경을 구성하는 것은 큰 사치다.
최근에는 경량 K8s 솔루션들이 많이 나와서, 입맛에 따라 적절한 솔루션을 선택할 수 있다.</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th><strong>k3s</strong></th>
      <th><strong>MicroK8s</strong></th>
      <th><strong>kind / minikube</strong></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>목적</td>
      <td>경량 프로덕션</td>
      <td>Ubuntu 환경 최적화</td>
      <td>로컬 개발/테스트</td>
    </tr>
    <tr>
      <td>플러그인</td>
      <td>수동 설치</td>
      <td><code class="language-plaintext highlighter-rouge">microk8s enable</code></td>
      <td>별도 애드온</td>
    </tr>
    <tr>
      <td>Ubuntu 친화성</td>
      <td>보통</td>
      <td>높음</td>
      <td>낮음</td>
    </tr>
  </tbody>
</table>

<p>이미 Ubuntu Server OS 를 운영하고 있고, <code class="language-plaintext highlighter-rouge">microk8s enable cert-manager</code> 한 줄로 플러그인을 켤 수 있다는 게 큰 매력이었다.
또한 실제 K8s 와 가장 유사한 사용 경험을 제공하기 때문에, <strong>MicroK8s</strong> 로 결정했다.</p>

<p><br /></p>

<h2 id="-왜-gitops-pattern-인가">💾 왜 GitOps Pattern 인가</h2>

<p>k8s를 도입했다고 해서 기존 self-hosted runner 방식을 그대로 쓸 수도 있다. GitHub Actions workflow 에서 <code class="language-plaintext highlighter-rouge">kubectl apply</code> 를 호출하는 방식이다.
하지만 이 방식에는 분명한 문제점이 있다.</p>

<ul>
  <li>클러스터 상태가 별도로 기록되지 않는다. 언젠가 내가 혹은 claude 가 <code class="language-plaintext highlighter-rouge">kubectl</code> 명령어로 변경을 가했을 때 추적이 안된다.</li>
  <li>배포 이력이 workflow 실행 로그에만 남는다.</li>
  <li>롤백을 하려면 이전 매니페스트를 찾아서 다시 apply 해야 한다.</li>
</ul>

<p><strong>GitOps</strong> 는 이 문제를 다른 방향으로 해결한다.
클러스터의 “원하는 상태(desired state)”를 Git 레포지토리에 선언해두고,
클러스터가 주기적으로 레포를 확인해서 실제 상태를 원하는 상태로 맞춰나가는 방식이다.</p>

<p>비유하자면 Push 방식에서 Pull 방식으로 변경되는 것이다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>코드 변경
  → GHCR 이미지 빌드 &amp; 푸시 (GitHub Actions)
  → k8s-configs 레포 매니페스트 업데이트 (이미지 태그 변경)
  → ArgoCD 자동 감지 &amp; 클러스터 반영
</code></pre></div></div>

<p>덕분에 롤백도 간단해진다. k8s-configs 레포에서 이전 커밋으로 revert 하면 그만이다.</p>

<h3 id="argocd-vs-flux">ArgoCD vs Flux</h3>

<p>GitOps 를 수행하기 위한 CD 툴의 양대 산맥은 ArgoCD와 Flux 다.</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th><strong>ArgoCD</strong></th>
      <th><strong>Flux</strong></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>UI</td>
      <td>웹 대시보드 제공</td>
      <td>별도 UI 없음 (CLI 중심)</td>
    </tr>
    <tr>
      <td>패턴</td>
      <td>App-of-Apps, ApplicationSet</td>
      <td>Kustomization, HelmRelease</td>
    </tr>
    <tr>
      <td>학습 곡선</td>
      <td>상대적으로 완만</td>
      <td>상대적으로 가파름</td>
    </tr>
  </tbody>
</table>

<p>개인 홈 서버에서 배포 상태를 시각적으로 확인하고 싶었기 때문에 <strong>ArgoCD</strong> 를 선택했다.
특히 App-of-Apps 패턴을 쓰면 “애플리케이션 목록 자체”도 Git으로 관리할 수 있어서 깔끔하다.
제일 중요한 건 지금까지 근무한 회사에서 모두 ArgoCD 를 사용했다 ㅎㅎ</p>

<p>ArgoCD 를 활용한 GitOps 패턴 일련의 과정을 그림으로 표현하면 아래와 같다.</p>

<p><img width="637" height="933" alt="Image" src="https://github.com/user-attachments/assets/2b6f02ec-f819-444a-8746-2d02c3cd21b8" /></p>

<p><br /></p>

<h2 id="-ansible로-호스트-환경-코드화하기">💾 Ansible로 호스트 환경 코드화하기</h2>

<p>GitOps Pattern 덕분에 k8s 워크로드는 ArgoCD가 Git 레포를 바라보며 선언형으로 관리하게 됐다.
그런데 곧 한 가지 불편함이 생겼다.</p>

<p>“그럼 MicroK8s 설치나 플러그인 활성화, 호스트 레벨 패키지 설치는?”</p>

<p>이런 작업들은 아직 수동이고, 어딘가에 기록해두지 않으면 서버를 재구성할 때 처음부터 기억을 더듬어야 한다.
가령 새로운 PC 를 구입해서 멀티 노드 클러스터를 구성하고 싶다면? 그 PC 의 세팅은?
(사실 이 시리즈를 포스팅하는 이유 중 하나이기도 하다…)</p>

<p>이 문제를 해결하는 도구가 <strong>Ansible</strong> 이다.</p>

<p>Ansible 은 SSH 기반의 구성 관리 도구로, YAML 로 작성된 playbook(동작 스크립트)을 실행하면
원격 서버에 필요한 패키지 설치, 서비스 설정, 파일 배포 등을 자동으로 처리한다.
k8s에서 YAML 매니페스트로 워크로드를 선언하듯, Ansible playbook 으로 호스트 환경을 선언하는 것이다.</p>

<p>결과적으로 인프라 전체가 두 레이어로 코드화된다.</p>

<table>
  <thead>
    <tr>
      <th>레이어</th>
      <th>도구</th>
      <th>관리 대상</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>호스트 레이어</td>
      <td>Ansible</td>
      <td>OS 패키지, MicroK8s 설치/플러그인, 시스템 서비스</td>
    </tr>
    <tr>
      <td>k8s 워크로드 레이어</td>
      <td>ArgoCD (GitOps)</td>
      <td>Deployment, Service, Ingress, ConfigMap 등</td>
    </tr>
  </tbody>
</table>

<p>이 구조의 가장 큰 장점은 재현성이다.
서버가 날아가거나 새 머신으로 이전해야 할 때, playbook 실행 하나로 호스트 환경을 복구할 수 있다.
이후 ArgoCD가 k8s-configs 레포를 감지해서 워크로드도 자동으로 복구해준다.</p>

<p>실제 사용 중인 playbook의 일부를 보면 아래와 같다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># setup-home-server.yaml</span>
<span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Setup Home Server</span>
  <span class="na">hosts</span><span class="pi">:</span> <span class="s">homeserver</span>
  <span class="na">become</span><span class="pi">:</span> <span class="no">true</span>

  <span class="na">tasks</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Install required packages</span>
      <span class="na">apt</span><span class="pi">:</span>
        <span class="na">name</span><span class="pi">:</span>
          <span class="pi">-</span> <span class="s">curl</span>
          <span class="pi">-</span> <span class="s">git</span>
          <span class="pi">-</span> <span class="s">jq</span>
          <span class="pi">-</span> <span class="s">snapd</span>
        <span class="na">state</span><span class="pi">:</span> <span class="s">present</span>
        <span class="na">update_cache</span><span class="pi">:</span> <span class="s">yes</span>

    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Install MicroK8s</span>
      <span class="na">snap</span><span class="pi">:</span>
        <span class="na">name</span><span class="pi">:</span> <span class="s">microk8s</span>
        <span class="na">classic</span><span class="pi">:</span> <span class="no">true</span>
        <span class="na">channel</span><span class="pi">:</span> <span class="s2">"</span><span class="s">1.33/stable"</span>

    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Add user to microk8s group</span>
      <span class="na">user</span><span class="pi">:</span>
        <span class="na">name</span><span class="pi">:</span> <span class="s2">"</span><span class="s">"</span>
        <span class="na">groups</span><span class="pi">:</span> <span class="s">microk8s</span>
        <span class="na">append</span><span class="pi">:</span> <span class="s">yes</span>

    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Enable MicroK8s addons</span>
      <span class="na">command</span><span class="pi">:</span> <span class="s">microk8s enable</span> 
      <span class="na">loop</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="s">dns</span>
        <span class="pi">-</span> <span class="s">ingress</span>
        <span class="pi">-</span> <span class="s">cert-manager</span>
        <span class="pi">-</span> <span class="s">hostpath-storage</span>
      <span class="na">changed_when</span><span class="pi">:</span> <span class="no">false</span>

    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Set kubectl alias</span>
      <span class="na">lineinfile</span><span class="pi">:</span>
        <span class="na">path</span><span class="pi">:</span> <span class="s2">"</span><span class="s">/home//.bashrc"</span>
        <span class="na">line</span><span class="pi">:</span> <span class="s1">'</span><span class="s">alias</span><span class="nv"> </span><span class="s">kubectl="microk8s</span><span class="nv"> </span><span class="s">kubectl"'</span>
        <span class="na">create</span><span class="pi">:</span> <span class="s">yes</span>
</code></pre></div></div>

<p>무엇보다 현재와 같은 식으로 작업 내용을 글로 표현할 때 아주 용이하다 ㅎㅎ</p>

<p><br /></p>

<h2 id="-microk8s-설치-및-기본-플러그인-구성">💾 MicroK8s 설치 및 기본 플러그인 구성</h2>

<p>앞서 보여준 Ansible playbook이 하는 일을 순서대로 정리하면 아래와 같다.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># MicroK8s 설치 (snap)</span>
<span class="nb">sudo </span>snap <span class="nb">install </span>microk8s <span class="nt">--classic</span> <span class="nt">--channel</span><span class="o">=</span>1.33/stable

<span class="c"># 현재 사용자를 microk8s 그룹에 추가 (재로그인 필요)</span>
<span class="nb">sudo </span>usermod <span class="nt">-aG</span> microk8s <span class="nv">$USER</span>

<span class="c"># 기본 플러그인 활성화</span>
microk8s <span class="nb">enable </span>dns ingress cert-manager hostpath-storage

<span class="c"># kubectl alias 설정</span>
<span class="nb">echo</span> <span class="s1">'alias kubectl="microk8s kubectl"'</span> <span class="o">&gt;&gt;</span> ~/.bashrc

<span class="c"># 클러스터 상태 확인</span>
kubectl get nodes
</code></pre></div></div>

<p>이 명령들을 수동으로 한 번 실행해서 동작을 확인한 후, Ansible playbook 으로 옮겨두는 방식으로 작업했다.</p>

<p>MicroK8s 진가가 여기서 나타난다.
<code class="language-plaintext highlighter-rouge">microk8s enable</code> 명령 하나로 플러그인이 설치되는 게 편리하고, 특히 <code class="language-plaintext highlighter-rouge">cert-manager</code> 를 따로 설치할 필요 없이 한 줄로 해결된다.</p>

<p><br /></p>

<h2 id="-nginx-ingress--cert-manager-lets-encrypt-tls-자동화">💾 NGINX Ingress + cert-manager (Let’s Encrypt TLS 자동화)</h2>

<h3 id="clusterissuer-설정">ClusterIssuer 설정</h3>

<p>cert-manager가 Let’s Encrypt에서 인증서를 자동으로 발급받으려면 ClusterIssuer 리소스가 필요하다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># cluster-issuer.yaml</span>
<span class="na">apiVersion</span><span class="pi">:</span> <span class="s">cert-manager.io/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">ClusterIssuer</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">letsencrypt-prod</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">acme</span><span class="pi">:</span>
    <span class="na">server</span><span class="pi">:</span> <span class="s">https://acme-v02.api.letsencrypt.org/directory</span>
    <span class="na">email</span><span class="pi">:</span> <span class="s">your@email.com</span>
    <span class="na">privateKeySecretRef</span><span class="pi">:</span>
      <span class="na">name</span><span class="pi">:</span> <span class="s">letsencrypt-prod</span>
    <span class="na">solvers</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">http01</span><span class="pi">:</span>
          <span class="na">ingress</span><span class="pi">:</span>
            <span class="na">class</span><span class="pi">:</span> <span class="s">public</span>
</code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>kubectl apply <span class="nt">-f</span> cluster-issuer.yaml
</code></pre></div></div>

<h3 id="ingress-리소스-예시">Ingress 리소스 예시</h3>

<p>기존에는 Nginx conf 파일에 <code class="language-plaintext highlighter-rouge">server_name</code>, <code class="language-plaintext highlighter-rouge">proxy_pass</code> 를 직접 작성했다면,
이제는 k8s Ingress 리소스 YAML로 선언한다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># ingress.yaml</span>
<span class="na">apiVersion</span><span class="pi">:</span> <span class="s">networking.k8s.io/v1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Ingress</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">app-ingress</span>
  <span class="na">annotations</span><span class="pi">:</span>
    <span class="na">cert-manager.io/cluster-issuer</span><span class="pi">:</span> <span class="s">letsencrypt-prod</span>
    <span class="na">nginx.ingress.kubernetes.io/ssl-redirect</span><span class="pi">:</span> <span class="s2">"</span><span class="s">true"</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">ingressClassName</span><span class="pi">:</span> <span class="s">public</span>
  <span class="na">tls</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">hosts</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="s">app.yourdomain.com</span>
      <span class="na">secretName</span><span class="pi">:</span> <span class="s">app-tls</span>
  <span class="na">rules</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">host</span><span class="pi">:</span> <span class="s">app.yourdomain.com</span>
      <span class="na">http</span><span class="pi">:</span>
        <span class="na">paths</span><span class="pi">:</span>
          <span class="pi">-</span> <span class="na">path</span><span class="pi">:</span> <span class="s">/</span>
            <span class="na">pathType</span><span class="pi">:</span> <span class="s">Prefix</span>
            <span class="na">backend</span><span class="pi">:</span>
              <span class="na">service</span><span class="pi">:</span>
                <span class="na">name</span><span class="pi">:</span> <span class="s">app-service</span>
                <span class="na">port</span><span class="pi">:</span>
                  <span class="na">number</span><span class="pi">:</span> <span class="m">8080</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">cert-manager.io/cluster-issuer: letsencrypt-prod</code> 어노테이션 하나로
인증서 발급과 자동 갱신이 모두 처리된다.
certbot을 수동으로 실행하거나 cron으로 갱신 스크립트를 관리할 필요가 없어진 것이다.</p>

<p><br /></p>

<h2 id="-argocd-설치-및-app-of-apps-패턴">💾 ArgoCD 설치 및 App-of-Apps 패턴</h2>

<h3 id="argocd-설치">ArgoCD 설치</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>kubectl create namespace argocd
kubectl apply <span class="nt">-n</span> argocd <span class="nt">-f</span> https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
</code></pre></div></div>

<h3 id="app-of-apps-패턴">App-of-Apps 패턴</h3>

<p>ArgoCD의 App-of-Apps 패턴은 “ArgoCD Application 목록 자체를 ArgoCD가 관리하도록 하는 것”이다.
k8s-configs 레포의 구조를 보면 아래와 같다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>k8s-configs/
└── argocd/
    └── applications/
        ├── root-app.yaml        # App-of-Apps 진입점
        ├── second-app.yaml
        ├── third-app.yaml
        └── monitoring.yaml
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">root-app.yaml</code> 하나만 ArgoCD에 등록해두면,
<code class="language-plaintext highlighter-rouge">applications/</code> 디렉토리 하위의 Application YAML들을 ArgoCD가 자동으로 감지하고 등록한다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># root-app.yaml</span>
<span class="na">apiVersion</span><span class="pi">:</span> <span class="s">argoproj.io/v1alpha1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">Application</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">root-app</span>
  <span class="na">namespace</span><span class="pi">:</span> <span class="s">argocd</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">project</span><span class="pi">:</span> <span class="s">default</span>
  <span class="na">source</span><span class="pi">:</span>
    <span class="na">repoURL</span><span class="pi">:</span> <span class="s">https://github.com/yourname/k8s-configs</span>
    <span class="na">targetRevision</span><span class="pi">:</span> <span class="s">HEAD</span>
    <span class="na">path</span><span class="pi">:</span> <span class="s">argocd/applications</span>
  <span class="na">destination</span><span class="pi">:</span>
    <span class="na">server</span><span class="pi">:</span> <span class="s">https://kubernetes.default.svc</span>
    <span class="na">namespace</span><span class="pi">:</span> <span class="s">argocd</span>
  <span class="na">syncPolicy</span><span class="pi">:</span>
    <span class="na">automated</span><span class="pi">:</span>
      <span class="na">prune</span><span class="pi">:</span> <span class="no">true</span>
      <span class="na">selfHeal</span><span class="pi">:</span> <span class="no">true</span>
</code></pre></div></div>

<p>새로운 서비스를 추가할 때는 <code class="language-plaintext highlighter-rouge">applications/</code> 에 YAML 파일 하나를 커밋하면 끝이다.
기존처럼 서버에 SSH 접속해서 docker-compose 파일을 배치하거나,
Nginx conf를 추가하고 nginx -t 테스트 후 reload 하는 과정이 사라졌다.</p>

<p><br /></p>

<h2 id="-tailscale-vpn으로-관리-서비스-보호하기">💾 Tailscale VPN으로 관리 서비스 보호하기</h2>

<p>ArgoCD 와 Grafana(다음 포스트에서 설치할 예정)는 외부에 공개할 필요가 없다.
오히려 public 으로 열어두면 공격 대상이 될 수 있다.</p>

<p>기존에 SSH 접속을 비대칭 키 방식으로만 허용했던 것처럼,
관리 서비스도 신뢰할 수 있는 디바이스에서만 접근할 수 있으면 충분하다.</p>

<p><strong>Tailscale</strong> 은 WireGuard 기반의 VPN 서비스로, 설정이 매우 간단하다.
Tailscale 에 기기를 등록하면 <code class="language-plaintext highlighter-rouge">100.x.x.x</code> 대역의 private IP가 생성되고,
같은 Tailscale 네트워크에 속한 기기끼리 직접 통신이 가능해진다.</p>

<p>호스트에 Tailscale 클라이언트를 설치하는 것도 Ansible playbook 에 포함시켰다.</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># setup-home-server.yml</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Add Tailscale apt key</span>
      <span class="na">apt_key</span><span class="pi">:</span>
        <span class="na">url</span><span class="pi">:</span> <span class="s">https://pkgs.tailscale.com/stable/ubuntu/noble.gpg</span>
        <span class="na">state</span><span class="pi">:</span> <span class="s">present</span>

    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Add Tailscale repository</span>
      <span class="na">apt_repository</span><span class="pi">:</span>
        <span class="na">repo</span><span class="pi">:</span> <span class="s2">"</span><span class="s">deb</span><span class="nv"> </span><span class="s">https://pkgs.tailscale.com/stable/ubuntu</span><span class="nv"> </span><span class="s">noble</span><span class="nv"> </span><span class="s">main"</span>
        <span class="na">state</span><span class="pi">:</span> <span class="s">present</span>

    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Install Tailscale</span>
      <span class="na">apt</span><span class="pi">:</span>
        <span class="na">name</span><span class="pi">:</span> <span class="s">tailscale</span>
        <span class="na">state</span><span class="pi">:</span> <span class="s">present</span>
        <span class="na">update_cache</span><span class="pi">:</span> <span class="s">yes</span>
</code></pre></div></div>

<p>설치 후 <code class="language-plaintext highlighter-rouge">sudo tailscale up</code> 으로 계정에 연결하는 작업은 브라우저 인증이 필요해서 수동으로 진행하면 등록이 완료된다.
Tailscale에 등록된 기기에서만 접근 가능하기 때문에 public ingress에 노출할 필요가 없다.</p>

<p>여기까지 모든 구조를 그림으로 표현하면 아래와 같다.</p>

<p><img width="1603" height="1100" alt="Image" src="https://github.com/user-attachments/assets/07ac2f16-de3e-4a4c-b3c8-bfb70725135c" /></p>

<p><br /></p>

<h2 id="-마치며">💾 마치며</h2>

<p>전환 이후 서비스를 추가하는 흐름이 아래처럼 바뀌었다.</p>

<p><strong>이전:</strong></p>
<ol>
  <li>홈 서버에 SSH 접속</li>
  <li><code class="language-plaintext highlighter-rouge">docker-compose.yml</code> 배치</li>
  <li><code class="language-plaintext highlighter-rouge">/etc/nginx/conf.d/{서비스}.conf</code> 작성</li>
  <li><code class="language-plaintext highlighter-rouge">sudo certbot --nginx -d {도메인}</code></li>
  <li><code class="language-plaintext highlighter-rouge">sudo systemctl reload nginx</code></li>
</ol>

<p><strong>이후:</strong></p>
<ol>
  <li>애플리케이션 레포에 <code class="language-plaintext highlighter-rouge">Dockerfile</code> 및 GitHub Actions workflow 추가 (이미지 빌드 &amp; GHCR 푸시)</li>
  <li>k8s-configs 레포에 Deployment, Service, Ingress YAML 파일 추가 커밋</li>
  <li>ArgoCD가 자동으로 감지해서 클러스터에 반영</li>
</ol>

<p>홈 서버에 직접 SSH 접속해서 설정 파일을 수동으로 만지는 일이 거의 없어졌다.
모든 인프라 상태가 ansible, k8s-configs Git 레포에 선언되어 있고, 변경 이력도 남는다.
롤백도 k8s-configs 레포에서 revert 커밋 하나면 끝이다.</p>

<p>처음 설계했던 목표의 마지막 스탭 “컨테이너 오케스트레이션” 이 드디어 완성됐다.
이제 진짜 남은 건 이전부터 계속 예고해왔던 <strong>리소스 모니터링</strong> 이다.
Prometheus + Grafana + Loki로 구성된 모니터링 스택 구축은 다음 포스트에서 이어가겠다.</p>

<p>4화 끝.</p>]]></content><author><name>현구막</name><email>jinha3507@gmail.com</email></author><summary type="html"><![CDATA[💾 홈 서버 구축 목표]]></summary></entry><entry><title type="html">뇌 기능성 유지하기</title><link href="https://hyeon9mak.github.io/maintain-brain-function/" rel="alternate" type="text/html" title="뇌 기능성 유지하기" /><published>2026-03-06T00:00:00+09:00</published><updated>2026-03-06T00:00:00+09:00</updated><id>https://hyeon9mak.github.io/maintain-brain-function</id><content type="html" xml:base="https://hyeon9mak.github.io/maintain-brain-function/"><![CDATA[<h2 id="-대나무-숲에-올라온-글">🧠 대나무 숲에 올라온 글</h2>

<p>어느 날 글또 대나무숲 채널에 올라온 글 하나가 눈에 띄었다.</p>

<p><img width="610" height="113" alt="Image" src="https://github.com/user-attachments/assets/a6819d1e-60db-4975-a77c-67d12c8f370e" /></p>

<blockquote>
  <p>나이가 먹어가면서 두뇌 회전력이 느려지는 것 같아서 걱정이에요.
어떻게 하면 계속 좋아지거나 유지할까요?</p>
</blockquote>

<p>그러게. 요즘 같이 간단한 사고 하나도 AI 에게 맡기는 세상에서, 어떻게 하면 건강한 뇌 기능을 유지할 수 있을까?
불현듯 피어오르는 긴장감에 한참 생각을 해보게 된다.</p>

<p><br /></p>

<h2 id="-뇌-돌아보기">🧠 뇌 돌아보기</h2>

<p>돌이켜보면 어릴 때는 정말 반짝이는 집중력과 상상력을 갖고 있었던 것 같다.
집중력은 지금도 환경을 조금씩 바꿔보면서 어느정도 컨트롤이 가능한 영역이라 생각하지만,
상상력은 그만큼 되돌리기 힘든 것 같다. 그 근원이 대체 무엇이었을까?</p>

<p>정답이라고 생각할 수 없지만, 적어도 어릴 땐 지금처럼 금전, 건강, 인간관계 등등 신경 써야할 것들이 많지 않았던 것 같다.
문방구에 팔던 3,000원 짜리 플라스틱 탱크모양 필통이 내 온 관심사였던 걸로 기억한다.</p>

<p>알고 있는 정보가 많지 않기 때문에 모든 상황을 판단할 떄 기반 지식 없이 모든 걸 직접 사고해야했고, 자연스레 상상으로 이어졌겠다.
나이를 먹고 비슷한 상황들에 대한 경험이 쌓이면서 기반 지식을 갖추게 되고, 그래서 빠르게 판단할 수 있게 되면서 사고가 줄어들고, 
자연스레 상상할 기회도 줄어들게 된 것 같다.</p>

<p>우리의 뇌는 영악해서 편한 방향을 찾아 계속 최적화를 진행할 거라고 생각한다.
상상하는 횟수와 규모가 줄어들수록 뇌는 사고 영역을 줄이고 저장 영역을 키우는 쪽으로 발달하지 않을까?
점점 기반 지식에 빗대어 빠르게 판단하는, 선입견을 가진 어른이 되는 것 같다.</p>

<p>아~ 그래서 어릴 때 많은 경험을 하고, 식견을 넓혀야 한다고 어른들이 그렇게 강조하는구나.</p>

<p>아~ 그래서 공부하는 습관을 놓지 말라고, 새로운 걸 배우라고, 항상 도전하라고 어른들이 그렇게 강조하는구나.</p>

<p>식견을 넓힐 기회를 놓쳤다고 생각이 든다면, 적어도 새로운 걸 배우고 도전하는 습관을 놓치지 않도록 노력해야겠다.</p>

<p>돌이켜보면 항상 새로운 도전과 지식 습득을 멈추지 않는 어른들은 눈빛이 항상 반짝였다.
뇌가 계속해서 새로운 자극을 받으면서 사고하고 상상하면서 빛을 발산하는 듯한 느낌.</p>

<p>사실 요즘 새롭게 시작하는 일들에 대해서 막연함과 스트레스가 조금씩 느껴졌었는데,
자칫 잠들 수 있는 뇌를 일깨우는 시간이라고 생각하니 오히려 설렘이 더해진다.</p>

<p>인간 뇌에는 한계가 존재하기 때문에, 모든 내용을 저장할 수 없다는 걸 모두가 알고 있다.
게다가 언젠가 찾아올 기능적 저하를 막을 방법도 존재하지 않는다.
이 때문에 영상이 됐건 음성이 됐건 문자가 됐건. 항상 기록하고 들여다보는 습관을 잘 다듬어놔야 한다는 것도 모두가 알고 있다.
이 조차 자주 까먹어서 그렇지. 다시 한 번 다짐을 해본다.</p>

<p>쏟아지는 생각들을 정리해서 아래와 같은 답글을 남겼다.</p>

<p><br /></p>

<h2 id="-나-돌아보기">🧠 나 돌아보기</h2>

<blockquote>
  <p>어릴 때를 떠올려보면, 정말 반짝이는 집중력과 몰입하는 능력을 갖고 있었잖아요.<br />
지금도 가끔은, 정말 아주 가끔은 한 번씩 그 때의 집중력이 발휘되는 느낌이 오실거구요.<br />
‘그 땐 그게 왜 가능했을까? 지금은 왜 잘 안될까?’ 에 대해 한참 생각해보고 제가 내린 결론은 “우리 뇌 기능은 사실 어릴 때와 크게 차이 나지 않는데, 인생을 편하게 해줄 지식을 너무 많이 알아버렸고 신경 써야할 거리들이 너무 많아졌다.” 였어요.</p>

  <p>동물의 본성은 이기적이고 편한 걸 찾으니까, 뇌도 그 일부로 편한 방향을 찾아 계속 최적화를 진행할거라 생각해요. 
어릴 땐 기반 지식이 없으니 모든 걸 직접 사고해야하고, 상상해야하고, 그 과정에서 창의적인 사고가 쏟아지고, 그래서 빛이 나고. 
나이를 먹고 경험이 쌓이면서 대부분을 기반 지식에 빗대어 빠르게 판단하고, 그래서 사고가 점점 줄어들고. 대신 실수가 줄어들고. 
인생을 편하게 해줄 지식을 많이 습득해버렸으니 자연스러운 현상 같아요.</p>

  <p>그래서 어른들이 “공부하는 습관 놓지 마라.”, “새로운 걸 배워라.”, “항상 도전하라.” 라고 말씀하시는거 같아요.<br />
이미 아는 것들만 반복 해나가면 어느 순간 우리의 뇌는 “더 이상 사고는 필요 없으니 그 쪽 뇌는 줄일게~” 최적화를 끝마칠거고, 
계속해서 새로운 자극을 얻어내면 우리의 뇌는 “와씨 이건 또 뭐야? 어떻게 해야 살아남지? 상상해!” 하고 열일을 할테니까요.</p>
</blockquote>

<blockquote>
  <p>정말로 신체적 뇌 노화가 오는 50, 60대 이상이 아니라면, 기능적 저하보다는 부하가 온다고 생각하는데요,<br />
안그래도 세상살이 알아야할 것들이 천지삐까린데, 정보 습득은 너무나 쉬워졌고, 정보가 쏟아지는 속도는 특히나 더 빨라졌죠.<br />
신경 써야할 거리들이 너무 많아진 탓에 ‘기반 지식에 빗대어 빠르게 판단’ 하는 행위가 되려 어려워지는거 같아요.</p>

  <p>그래서 저는 떠오르는 생각과 알게된 지식들을 모두 기록하고, 그것들의 우선 순위를 매기고, 
나머지는 일단 나중으로 미룬 뒤 우선 순위가 높은 일에 집중하는 습관을 들이는 게 중요하다고 믿고 연습하고 있어요.</p>

  <p>이 습관을 반복하다보면 기반 지식없이 모든 걸 직접 사고하고 상상하던 어린 시절로 돌아갈 수 있지 않을까 막연히 기대하면서…</p>
</blockquote>

<p><br /></p>]]></content><author><name>현구막</name><email>jinha3507@gmail.com</email></author><summary type="html"><![CDATA[🧠 대나무 숲에 올라온 글]]></summary></entry><entry><title type="html">Spring Batch 병렬 처리 전략</title><link href="https://hyeon9mak.github.io/spring-batch-parallel-processing-strategies/" rel="alternate" type="text/html" title="Spring Batch 병렬 처리 전략" /><published>2026-02-13T00:00:00+09:00</published><updated>2026-02-13T00:00:00+09:00</updated><id>https://hyeon9mak.github.io/spring-batch-parallel-processing-strategies</id><content type="html" xml:base="https://hyeon9mak.github.io/spring-batch-parallel-processing-strategies/"><![CDATA[<h2 id="-overview">💿 Overview</h2>
<p>Spring Batch 는 주로 대용량 데이터를 효율적으로 자동 처리하기 위해 사용한다.<br />
batch 작업 특성상 실시간 성이 떨어지는 경우가 많아 성능이 크게 중요하지 않지만, 데이터 양이 너무 많아 처리 시간이 지나치게 오래 걸린다던지, 다른 작업과 연결되어 최소한의 처리 시간이 요구되는 경우도 분명히 존재한다.
이 때 Spring Batch 가 제공하는 병렬 처리 전략을 활용하면 작업 처리 시간을 크게 단축시킬 수 있다.</p>

<p>이번 글에서 Spring Batch 병렬 처리 전략을 알아보기 위한 목차는 아래와 같다.</p>

<ul>
  <li>Spring Batch 병렬 처리 전략 필요성</li>
  <li>Spring Batch Step 에서 데이터를 처리하는 단위</li>
  <li>Spring Batch 병렬 처리 전략들의 특징
    <ul>
      <li>AsyncItemProcessor</li>
      <li>Multi-threaded Step</li>
      <li>Partitioning</li>
    </ul>
  </li>
  <li>시나리오별 권장 전략</li>
</ul>

<p><br /></p>

<h2 id="-spring-batch-병렬-처리-전략-필요성">💿 Spring Batch 병렬 처리 전략 필요성</h2>

<p>batch 작업을 병렬로 처리하고 싶을 때 Spring Batch 에서 제공하는 병렬 처리 전략 외 직접 구현하는 것도 가능하다.
대표적으로 CompletableFuture, Kotlin Coroutines 등을 활용해 비동기적으로 작업을 처리하는 방법이 있다.</p>

<div class="language-kotlin highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// 기본적인 처리 로직</span>
<span class="k">fun</span> <span class="nf">processItem</span><span class="p">(</span><span class="n">item</span><span class="p">:</span> <span class="nc">Item</span><span class="p">):</span> <span class="nc">ProcessedItem</span> <span class="p">{</span>
    <span class="k">return</span> <span class="nc">ProcessedItem</span><span class="p">(</span><span class="n">item</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>기본적인 item 처리 로직을 병렬로 처리하고 싶다면, 아래와 같이 Collection(List) 와 함께 CompletableFuture 를 활용할 수도 있다.</p>

<div class="language-kotlin highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// CompletableFuture 를 이용한 병렬 처리 로직</span>
<span class="k">fun</span> <span class="nf">processItems</span><span class="p">(</span><span class="n">items</span><span class="p">:</span> <span class="nc">List</span><span class="p">&lt;</span><span class="nc">Item</span><span class="p">&gt;):</span> <span class="nc">List</span><span class="p">&lt;</span><span class="nc">ProcessedItem</span><span class="p">&gt;</span> <span class="p">{</span>
  <span class="kd">val</span> <span class="py">executor</span> <span class="p">=</span> <span class="nc">Executors</span><span class="p">.</span><span class="nf">newFixedThreadPool</span><span class="p">(</span><span class="mi">10</span><span class="p">)</span>
  <span class="k">return</span> <span class="k">try</span> <span class="p">{</span>
    <span class="n">items</span><span class="p">.</span><span class="nf">map</span> <span class="p">{</span> <span class="n">item</span> <span class="p">-&gt;</span>
      <span class="nc">CompletableFuture</span><span class="p">.</span><span class="nf">supplyAsync</span><span class="p">({</span> <span class="nc">ProcessedItem</span><span class="p">(</span><span class="n">item</span><span class="p">)</span> <span class="p">},</span> <span class="n">executor</span><span class="p">)</span>
    <span class="p">}.</span><span class="nf">map</span> <span class="p">{</span> <span class="n">it</span><span class="p">.</span><span class="nf">join</span><span class="p">()</span> <span class="p">}</span>
  <span class="p">}</span> <span class="k">finally</span> <span class="p">{</span>
    <span class="n">executor</span><span class="p">.</span><span class="nf">shutdown</span><span class="p">()</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>그러나 Spring Batch 에서 제공하는 병렬 처리가 아닌 경우 여러가지 문제가 생기기 쉽다.</p>

<h3 id="collection-단위로-인해-발생하는-비효율">Collection 단위로 인해 발생하는 비효율</h3>

<p><code class="language-plaintext highlighter-rouge">processItems</code> 메서드 파라미터로 넘어온 1,000,000 건의 데이터들 중, 1건의 데이터에 문제가 발생하여 Retry 가 발생했다고 가정해보자.
단 1건의 데이터에 문제가 발생했음에도 불구하고, <code class="language-plaintext highlighter-rouge">processItems</code> 메서드 내부에서는 1,000,000 건의 데이터를 모두 처리해야 한다.</p>

<p>Skip 은 어떨까?  1,000,000 건의 데이터들 중, 1건의 데이터에 문제가 발생하여 Retry 가 발생했다고 가정해보자.
1건을 제외하고 정상 처리가 가능한 999,999 건의 데이터가 모두 Skip 된다.</p>

<h3 id="transaction-관리-부담">Transaction 관리 부담</h3>

<p>Spring 은 기본적으로 ThreadLocal 을 이용해 Thread 단위의 Transaction 을 관리한다.
따라서 Spring Batch Step 내부에서 별도의 병렬 처리 코드를 작성하는 경우, 병렬 처리에 사용된 Thread 들은 Step 의 Transaction Context 를 공유하지 못한다.</p>

<p><img width="777" height="470" alt="Image" src="https://github.com/user-attachments/assets/d5487839-9e29-48f3-9aa3-2291c21e4c91" /></p>

<p>새롭게 생성된 Thread 들은 Step 의 Transaction Context 를 알지 못하기 때문에 Transaction 을 보장 받지 못한다.</p>

<ul>
  <li>Step Chunk Thread 가 rollback 될 때, 병렬 처리에 사용된 Thread 들의 작업은 rollback 되지 않는다.</li>
  <li>JPA 사용 환경에서 lazy loading, persistence context 등을 활용할 수 없다.</li>
</ul>

<h3 id="예외-처리-복잡도-증가">예외 처리 복잡도 증가</h3>

<p>Spring Batch 에서는 Step 을 생성하는 과정에서 Chunk 단위 데이터 처리에 Skip, Retry 와 같은 강력한 예외 처리 기능을 설정할 수 있다.
Spring Batch 에서 제공하는 병렬 처리 전략을 이용하는 경우 이를 자연스럽게 활용할 수 있으나,
직접 병렬 처리를 구현하는 경우 Skip, Retry 와 같은 예외 처리 기능을 활용하기 위해 별도 코드 작성이 필요하다.
특히 CompletableFuture 를 사용한 위 예시 같은 경우, 예외가 CompletionException 으로 감싸져 전파되기 때문에 아래와 같은 추가 예외 처리가 필요하다.</p>

<div class="language-kotlin highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">fun</span> <span class="nf">processItems</span><span class="p">(</span><span class="n">items</span><span class="p">:</span> <span class="nc">List</span><span class="p">&lt;</span><span class="nc">Item</span><span class="p">&gt;):</span> <span class="nc">List</span><span class="p">&lt;</span><span class="nc">ProcessedItem</span><span class="p">&gt;</span> <span class="p">{</span>
  <span class="kd">val</span> <span class="py">executor</span> <span class="p">=</span> <span class="nc">Executors</span><span class="p">.</span><span class="nf">newFixedThreadPool</span><span class="p">(</span><span class="mi">10</span><span class="p">)</span>
  <span class="k">return</span> <span class="k">try</span> <span class="p">{</span>
    <span class="n">items</span><span class="p">.</span><span class="nf">map</span> <span class="p">{</span> <span class="n">item</span> <span class="p">-&gt;</span>
      <span class="k">try</span> <span class="p">{</span>
        <span class="nc">CompletableFuture</span><span class="p">.</span><span class="nf">supplyAsync</span><span class="p">({</span> <span class="nc">ProcessedItem</span><span class="p">(</span><span class="n">item</span><span class="p">)</span> <span class="p">},</span> <span class="n">executor</span><span class="p">)</span>
      <span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="n">exception</span><span class="p">:</span> <span class="nc">Exception</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">throw</span> <span class="n">exception</span><span class="p">.</span><span class="n">cause</span> <span class="o">?:</span> <span class="k">throw</span> <span class="n">exception</span>
      <span class="p">}</span>
    <span class="p">}.</span><span class="nf">map</span> <span class="p">{</span> <span class="n">it</span><span class="p">.</span><span class="nf">join</span><span class="p">()</span> <span class="p">}</span>
  <span class="p">}</span> <span class="k">finally</span> <span class="p">{</span>
    <span class="n">executor</span><span class="p">.</span><span class="nf">shutdown</span><span class="p">()</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="spring-batch-병렬-처리-전략은-만능인가">Spring Batch 병렬 처리 전략은 만능인가?</h3>

<p>Spring Batch 가 제공하는 병렬 처리 전략들은 앞서 언급한 문제점들을 효과적으로 해결할 수 있다.
그러나 Spring Batch 병렬 처리 전략들 또한 완벽하지는 않다.
명백한 제약사항과 단점 또한 존재하기 때문에, 각 전략들의 특징을 명확히 이해해둔 후
각 상황별로 적합한 전략을 채택해서 활용하는 지혜가 필요하겠다.</p>

<p><br /></p>

<h2 id="-spring-batch-step-에서-데이터를-처리하는-단위">💿 Spring Batch Step 에서 데이터를 처리하는 단위</h2>

<blockquote>
  <p><a href="https://docs.spring.io/spring-batch/reference/step/chunk-oriented-processing.html">Spring Batch Documentation - Chunk-oriented Processing</a></p>
</blockquote>

<p><img src="https://docs.spring.io/spring-batch/reference/_images/chunk-oriented-processing-with-item-processor.png" alt="image" /></p>

<p>대부분의 대량 데이터를 처리할 땐 Chunk 기반의 처리 방식(Chunk-oriented Processing)을 사용한다.<br />
Chunk 기반 처리 방식은 Step 이 데이터를 일정 단위로 읽고(Read), 처리하고(Process), 기록(Write)하는 방식을 의미한다.<br />
그림을 자세히 살펴보면 <code class="language-plaintext highlighter-rouge">read() - read() - process() - process() - write()</code> 와 같은 흐름을 갖고 있다.<br />
즉 Chunk 기반 처리 방식에서 각 작업들은 아래와 같은 특징을 갖는다.</p>

<ul>
  <li>Reader: Item 단위로 하나씩 데이터를 읽어온다. 이를 Chunk 수 만큼 반복한다.</li>
  <li>Processor: Reader 가 읽어온 Item 단위 데이터를 가공한다. Processor 가 없는 경우 Reader 가 읽어온 Item 을 그대로 Write 로 전달한다.</li>
  <li>Writer: Item 들을 모아 Chunk 단위로 한꺼번에 기록한다.</li>
</ul>

<h3 id="itemreader-paging-처리">ItemReader Paging 처리</h3>
<p>Reader 에서 실제 Item(데이터) 단위로 RDB 에 query 를 수행하는 것은 지나치게 비효율적이이다.<br />
그렇다고 모든 데이터를 한꺼번에 읽어오는 것 또한 메모리에 부담이다.<br />
때문에 Reader 내부적으로는 Paging 처리를 이용해서 적정한 크기의 데이터를 읽어오도록 한다.<br />
이를 <code class="language-plaintext highlighter-rouge">List&lt;Item&gt;</code> 단위로 Processor 에게 넘길 수도 있고, Iterator 를 이용해 Item 단위로 하나씩 넘길 수도 있다.</p>

<p>아래와 같은 예시를 들어보자.</p>

<ul>
  <li>RDB 에 10,000 건의 데이터가 존재한다.</li>
  <li>Chunk 크기를 1,000 으로 설정한다.</li>
  <li>Reader 는 Paging 처리를 이용해 한 번에 100 건의 데이터를 읽어온다.</li>
</ul>

<p>이 때 Iterator 를 이용해 하나씩 넘기는 과정을 살펴보면 다음과 같다.</p>

<ol>
  <li>Reader 가 100 건의 데이터를 읽어온다.</li>
  <li>Reader 가 읽어온 100 건의 데이터를 Item 단위로 하나씩 Step 에 전달한다.</li>
  <li>Step 은 Item 단위로 Processor 를 호출해 데이터를 가공한다.</li>
  <li>Step 은 가공된 Item 들을 모아 Chunk 크기(1,000) 가 될 때까지 대기한다.</li>
  <li>Step 은 Chunk 크기(1,000) 가 될 때마다 Writer 를 호출해 데이터를 기록한다.</li>
  <li>위 과정을 모든 데이터(10,000 건) 가 처리될 때까지 반복한다.</li>
</ol>

<p><br /></p>

<h2 id="-asyncitemprocessor">💿 AsyncItemProcessor</h2>

<p><img width="1059" height="563" alt="Image" src="https://github.com/user-attachments/assets/8ad1d3a7-9f18-4a64-88d9-2704880931e6" /></p>

<p>AsyncItemProcessor 는 Chunk 동작 단위 중 process 단계에서 새로운 thread 를 할당해 비동기적으로 Item 을 처리한다.
<strong>process 단계가 완료되면 writer 로 Feature 를 넘기고, writer 단계에서 Future 를 종합하여 기록</strong>한다.</p>

<div class="language-kotlin highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Bean</span>
<span class="k">fun</span> <span class="nf">eatStep</span><span class="p">(</span>
  <span class="n">jobRepository</span><span class="p">:</span> <span class="nc">JobRepository</span><span class="p">,</span>
  <span class="n">transactionManager</span><span class="p">:</span> <span class="nc">PlatformTransactionManager</span><span class="p">,</span>
  <span class="n">eatableCookLogReader</span><span class="p">:</span> <span class="nc">ItemReader</span><span class="p">&lt;</span><span class="nc">EatableCookingLog</span><span class="p">&gt;,</span>
  <span class="n">asyncItemProcessor</span><span class="p">:</span> <span class="nc">AsyncItemProcessor</span><span class="p">&lt;</span><span class="nc">EatableCookingLog</span><span class="p">,</span> <span class="nc">AteCookingLog</span><span class="p">&gt;,</span>
  <span class="n">ateCookingLogAsyncWriter</span><span class="p">:</span> <span class="nc">AsyncItemWriter</span><span class="p">&lt;</span><span class="nc">AteCookingLog</span><span class="p">&gt;,</span>
<span class="p">):</span> <span class="nc">Step</span> <span class="p">{</span>
  <span class="k">return</span> <span class="nc">StepBuilder</span><span class="p">(</span><span class="nc">STEP_NAME</span><span class="p">,</span> <span class="n">jobRepository</span><span class="p">)</span>
    <span class="p">.</span><span class="n">chunk</span><span class="p">&lt;</span><span class="nc">EatableCookingLog</span><span class="p">,</span> <span class="nc">Future</span><span class="p">&lt;</span><span class="nc">AteCookingLog</span><span class="p">&gt;&gt;(</span><span class="nc">CHUNK_SIZE</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">transactionManager</span><span class="p">(</span><span class="n">transactionManager</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">reader</span><span class="p">(</span><span class="n">eatableCookLogReader</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">processor</span><span class="p">(</span><span class="n">asyncItemProcessor</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">writer</span><span class="p">(</span><span class="n">ateCookingLogAsyncWriter</span><span class="p">)</span>
    <span class="p">.</span><span class="nf">build</span><span class="p">()</span>
<span class="p">}</span>

<span class="nd">@Bean</span>
<span class="k">fun</span> <span class="nf">eatableCookLogReader</span><span class="p">(</span>
  <span class="n">jdbcTemplate</span><span class="p">:</span> <span class="nc">JdbcTemplate</span><span class="p">,</span>
<span class="p">):</span> <span class="nc">ItemReader</span><span class="p">&lt;</span><span class="nc">EatableCookingLog</span><span class="p">&gt;</span> <span class="p">{</span>
  <span class="k">return</span> <span class="nc">NoOffsetPagingItemReader</span><span class="p">(</span>
    <span class="n">jdbcTemplate</span> <span class="p">=</span> <span class="n">jdbcTemplate</span><span class="p">,</span>
    <span class="n">chunkSize</span> <span class="p">=</span> <span class="nc">CHUNK_SIZE</span><span class="p">,</span>
  <span class="p">)</span>
<span class="p">}</span>

<span class="nd">@Bean</span>
<span class="k">fun</span> <span class="nf">asyncItemProcessor</span><span class="p">():</span> <span class="nc">AsyncItemProcessor</span><span class="p">&lt;</span><span class="nc">EatableCookingLog</span><span class="p">,</span> <span class="nc">AteCookingLog</span><span class="p">&gt;</span> <span class="p">{</span>
  <span class="kd">val</span> <span class="py">processor</span> <span class="p">=</span> <span class="nc">ItemProcessor</span><span class="p">&lt;</span><span class="nc">EatableCookingLog</span><span class="p">,</span> <span class="nc">AteCookingLog</span><span class="p">&gt;</span> <span class="p">{</span> <span class="n">it</span><span class="p">.</span><span class="nf">eat</span><span class="p">()</span> <span class="p">}</span>
  <span class="kd">val</span> <span class="py">asyncItemProcessor</span> <span class="p">=</span> <span class="nc">AsyncItemProcessor</span><span class="p">(</span><span class="n">processor</span><span class="p">)</span>
  <span class="n">asyncItemProcessor</span><span class="p">.</span><span class="nf">setTaskExecutor</span><span class="p">(</span><span class="nf">cookingLogUpdateExecutor</span><span class="p">())</span>
  <span class="k">return</span> <span class="n">asyncItemProcessor</span>
<span class="p">}</span>

<span class="nd">@Bean</span>
<span class="k">fun</span> <span class="nf">ateCookingLogAsyncWriter</span><span class="p">(</span>
  <span class="n">ateCookingLogWriter</span><span class="p">:</span> <span class="nc">ItemWriter</span><span class="p">&lt;</span><span class="nc">AteCookingLog</span><span class="p">&gt;,</span>
<span class="p">):</span> <span class="nc">AsyncItemWriter</span><span class="p">&lt;</span><span class="nc">AteCookingLog</span><span class="p">&gt;</span> <span class="p">{</span>
  <span class="k">return</span> <span class="nc">AsyncItemWriter</span><span class="p">(</span><span class="n">ateCookingLogWriter</span><span class="p">)</span>
<span class="p">}</span>

<span class="nd">@Bean</span>
<span class="k">fun</span> <span class="nf">cookingLogUpdateExecutor</span><span class="p">():</span> <span class="nc">TaskExecutor</span> <span class="p">{</span>
  <span class="kd">val</span> <span class="py">executor</span> <span class="p">=</span> <span class="nc">ThreadPoolTaskExecutor</span><span class="p">()</span>
  <span class="n">executor</span><span class="p">.</span><span class="n">corePoolSize</span> <span class="p">=</span> <span class="nc">POOL_SIZE</span>
  <span class="n">executor</span><span class="p">.</span><span class="n">maxPoolSize</span> <span class="p">=</span> <span class="nc">POOL_SIZE</span>
  <span class="n">executor</span><span class="p">.</span><span class="nf">setThreadNamePrefix</span><span class="p">(</span><span class="s">"async-processor-"</span><span class="p">)</span>
  <span class="n">executor</span><span class="p">.</span><span class="nf">setWaitForTasksToCompleteOnShutdown</span><span class="p">(</span><span class="k">true</span><span class="p">)</span>
  <span class="n">executor</span><span class="p">.</span><span class="nf">initialize</span><span class="p">()</span>
  <span class="k">return</span> <span class="n">executor</span>
<span class="p">}</span>
</code></pre></div></div>

<blockquote>
  <p>전체 코드는 <a href="https://github.com/Hyeon9mak/lab/tree/master/spring-batch-async-item-processor">https://github.com/Hyeon9mak/lab/tree/master/spring-batch-async-item-processor</a> 에서 확인할 수 있다.</p>
</blockquote>

<p>기본적으로 병렬 처리에 사용되는 thread 의 수는 <code class="language-plaintext highlighter-rouge">SimpleAsyncTaskExecutor</code> 에 의해 결정된다.
<code class="language-plaintext highlighter-rouge">SimpleAsyncTaskExecutor</code> 는 가용 가능한 자원만큼 무제한으로 thread 를 생성하기 때문에, 자원 고갈에 따른 OOM 이슈가 발생할 수 있다.
따라서 대량의 데이터가 발생하는 운영 환경에서는 직접 <code class="language-plaintext highlighter-rouge">TaskExecutor</code> 를 구현해 적정한 thread 수를 제어하는 것이 좋다.</p>

<h3 id="순서-보장">순서 보장</h3>

<p>Chunk 내부의 process 에서 병렬 처리가 수행되므로, Chunk 단위에서는 순서를 보장받을 수 있다.
때문에 <code class="language-plaintext highlighter-rouge">ItemStreamReader</code>, <code class="language-plaintext highlighter-rouge">ItemStreamWriter</code> 를 사용하는 경우에도 순서가 보장된다.</p>

<h3 id="이름-그대로-process-만-병렬-처리">이름 그대로 process 만 병렬 처리</h3>

<p>그림을 통해 이해할 수 있듯, <code class="language-plaintext highlighter-rouge">AsyncItemProcessor</code> 는 process 단계에서만 병렬 처리를 수행한다.
때문에 reader, writer 에서 병목이 발생하는 경우 큰 효과를 기대하기 어렵다.</p>

<h3 id="예외-처리">예외 처리</h3>

<p>앞서 강조했듯, <code class="language-plaintext highlighter-rouge">AsyncItemProcessor</code> 는 process 단계에서 Feature 를 이용한 비동기 처리를 수행한 후 writer 단계에서 Future 를 정리하고 적재한다.
따라서 process 단계에서 예외가 발생한 경우 예외가 Feature 로 wrapping 되어있기 때문에, Feature 를 unwrapping 하는 writer 단계에서 예외가 처리된다.</p>

<p>가령 process 단계에서 <code class="language-plaintext highlighter-rouge">BusinessException</code> 에 대한 skip 설정을 해두었어도,
process 단계에서 발생한 <code class="language-plaintext highlighter-rouge">BusinessException</code> 은 writer 단계에서 <code class="language-plaintext highlighter-rouge">ExecutionException</code> 으로 wrapping 되어 전파된다.
<code class="language-plaintext highlighter-rouge">ExecutionException</code> 에 대한 별도 설정을 하지 않았다면 Batch 가 그대로 중단 되는 것이다.</p>

<h3 id="transaction-관리">Transaction 관리</h3>

<p><code class="language-plaintext highlighter-rouge">AsyncItemProcessor</code> 는 process 단계에서 새로운 thread 를 생성해 비동기적으로 Item 을 처리한다.
따라서 process 단계에서 생성된 thread 들은 Step 의 Transaction Context 를 공유하지 못한다.
이는 앞서 언급한 직접 병렬 처리 구현 시 발생하는 문제점과 동일하다.</p>

<p><br /></p>

<h2 id="-multi-threaded-step">💿 Multi-threaded Step</h2>

<p><img width="1134" height="611" alt="Image" src="https://github.com/user-attachments/assets/af732d71-0934-44f7-b3bd-15f3645ace8d" /></p>

<p>Multi-threaded Step 은 Chunk 단위 동작 전체를 하나의 thread 로 처리한다.
즉 reader, processor, writer 단계가 모두 하나의 thread 에서 처리되는 것이다.</p>

<div class="language-kotlin highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    <span class="nd">@Bean</span>
    <span class="k">fun</span> <span class="nf">cookingLogUpdateExecutor</span><span class="p">():</span> <span class="nc">TaskExecutor</span> <span class="p">{</span>
        <span class="kd">val</span> <span class="py">executor</span> <span class="p">=</span> <span class="nc">ThreadPoolTaskExecutor</span><span class="p">()</span>
        <span class="n">executor</span><span class="p">.</span><span class="n">corePoolSize</span> <span class="p">=</span> <span class="nc">POOL_SIZE</span>
        <span class="n">executor</span><span class="p">.</span><span class="n">maxPoolSize</span> <span class="p">=</span> <span class="nc">POOL_SIZE</span>
        <span class="n">executor</span><span class="p">.</span><span class="nf">setThreadNamePrefix</span><span class="p">(</span><span class="s">"multi-threaded-step-"</span><span class="p">)</span>
        <span class="n">executor</span><span class="p">.</span><span class="nf">setWaitForTasksToCompleteOnShutdown</span><span class="p">(</span><span class="k">true</span><span class="p">)</span>
        <span class="n">executor</span><span class="p">.</span><span class="nf">initialize</span><span class="p">()</span>
        <span class="k">return</span> <span class="n">executor</span>
    <span class="p">}</span>

    <span class="nd">@Bean</span>
    <span class="k">fun</span> <span class="nf">eatStep</span><span class="p">(</span>
        <span class="n">jobRepository</span><span class="p">:</span> <span class="nc">JobRepository</span><span class="p">,</span>
        <span class="n">transactionManager</span><span class="p">:</span> <span class="nc">PlatformTransactionManager</span><span class="p">,</span>
        <span class="n">cookingLogUpdateExecutor</span><span class="p">:</span> <span class="nc">TaskExecutor</span><span class="p">,</span>
        <span class="n">eatableCookLogReader</span><span class="p">:</span> <span class="nc">ItemReader</span><span class="p">&lt;</span><span class="nc">EatableCookingLog</span><span class="p">&gt;,</span>
        <span class="n">eatCookLogProcessor</span><span class="p">:</span> <span class="nc">ItemProcessor</span><span class="p">&lt;</span><span class="nc">EatableCookingLog</span><span class="p">,</span> <span class="nc">AteCookingLog</span><span class="p">&gt;,</span>
        <span class="n">ateCookingLogWriter</span><span class="p">:</span> <span class="nc">ItemWriter</span><span class="p">&lt;</span><span class="nc">AteCookingLog</span><span class="p">&gt;,</span>
    <span class="p">):</span> <span class="nc">Step</span> <span class="p">{</span>
        <span class="k">return</span> <span class="nc">StepBuilder</span><span class="p">(</span><span class="nc">STEP_NAME</span><span class="p">,</span> <span class="n">jobRepository</span><span class="p">)</span>
        <span class="p">.</span><span class="n">chunk</span><span class="p">&lt;</span><span class="nc">EatableCookingLog</span><span class="p">,</span> <span class="nc">AteCookingLog</span><span class="p">&gt;(</span><span class="nc">CHUNK_SIZE</span><span class="p">,</span> <span class="n">transactionManager</span><span class="p">)</span>
        <span class="p">.</span><span class="nf">reader</span><span class="p">(</span><span class="n">eatableCookLogReader</span><span class="p">)</span>
        <span class="p">.</span><span class="nf">processor</span><span class="p">(</span><span class="n">eatCookLogProcessor</span><span class="p">)</span>
        <span class="p">.</span><span class="nf">writer</span><span class="p">(</span><span class="n">ateCookingLogWriter</span><span class="p">)</span>
        <span class="p">.</span><span class="nf">taskExecutor</span><span class="p">(</span><span class="n">cookingLogUpdateExecutor</span><span class="p">)</span>
        <span class="p">.</span><span class="nf">build</span><span class="p">()</span>
    <span class="p">}</span>
</code></pre></div></div>

<blockquote>
  <p>전체 코드는 <a href="https://github.com/Hyeon9mak/lab/tree/master/spring-batch-multi-threaded-step">https://github.com/Hyeon9mak/lab/tree/master/spring-batch-multi-threaded-step</a> 에서 확인할 수 있다.</p>
</blockquote>

<p>Spring Batch 6.0 이전 버전에서는 <code class="language-plaintext highlighter-rouge">.chunk&lt;EatableCookingLog, AteCookingLog&gt;(CHUNK_SIZE, transactionManager)</code> 형태로 내부에서 <code class="language-plaintext highlighter-rouge">ChunkOrientedTasklet</code> 를 생성하여
각각의 Chunk 단위로 thread 를 할당해서 read-process-write 를 수행할 수 있었다.</p>

<p><a href="https://github.com/spring-projects/spring-batch/wiki/Spring-Batch-6.0-Migration-Guide#new-chunk-oriented-model-implementation">그러나 Spring Batch 6.0 부터는 <code class="language-plaintext highlighter-rouge">StepBuilder</code> 에서 아래와 같은 형태로 호출 방식이 변경되었다.</a></p>

<div class="language-kotlin highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    <span class="nd">@Bean</span>
    <span class="k">fun</span> <span class="nf">eatStep</span><span class="p">(</span>
        <span class="n">jobRepository</span><span class="p">:</span> <span class="nc">JobRepository</span><span class="p">,</span>
        <span class="n">transactionManager</span><span class="p">:</span> <span class="nc">PlatformTransactionManager</span><span class="p">,</span>
        <span class="n">cookingLogUpdateExecutor</span><span class="p">:</span> <span class="nc">AsyncTaskExecutor</span><span class="p">,</span> <span class="c1">// AsyncTaskExecutor 로 변경</span>
        <span class="n">eatableCookLogReader</span><span class="p">:</span> <span class="nc">ItemReader</span><span class="p">&lt;</span><span class="nc">EatableCookingLog</span><span class="p">&gt;,</span>
        <span class="n">eatCookLogProcessor</span><span class="p">:</span> <span class="nc">ItemProcessor</span><span class="p">&lt;</span><span class="nc">EatableCookingLog</span><span class="p">,</span> <span class="nc">AteCookingLog</span><span class="p">&gt;,</span>
        <span class="n">ateCookingLogWriter</span><span class="p">:</span> <span class="nc">ItemWriter</span><span class="p">&lt;</span><span class="nc">AteCookingLog</span><span class="p">&gt;,</span>
    <span class="p">):</span> <span class="nc">Step</span> <span class="p">{</span>
    <span class="k">return</span> <span class="nc">StepBuilder</span><span class="p">(</span><span class="nc">STEP_NAME</span><span class="p">,</span> <span class="n">jobRepository</span><span class="p">)</span>
        <span class="p">.</span><span class="n">chunk</span><span class="p">&lt;</span><span class="nc">EatableCookingLog</span><span class="p">,</span> <span class="nc">AteCookingLog</span><span class="p">&gt;(</span><span class="nc">CHUNK_SIZE</span><span class="p">)</span>  <span class="c1">// transactionManager 제거 후 별도로 설정</span>
        <span class="p">.</span><span class="nf">transactionManager</span><span class="p">(</span><span class="n">transactionManager</span><span class="p">)</span>
        <span class="p">.</span><span class="nf">reader</span><span class="p">(</span><span class="n">eatableCookLogReader</span><span class="p">)</span>
        <span class="p">.</span><span class="nf">processor</span><span class="p">(</span><span class="n">eatCookLogProcessor</span><span class="p">)</span>
        <span class="p">.</span><span class="nf">writer</span><span class="p">(</span><span class="n">ateCookingLogWriter</span><span class="p">)</span>
        <span class="p">.</span><span class="nf">taskExecutor</span><span class="p">(</span><span class="n">cookingLogUpdateExecutor</span><span class="p">)</span>
        <span class="p">.</span><span class="nf">build</span><span class="p">()</span>
    <span class="p">}</span>
</code></pre></div></div>

<p>기존 <code class="language-plaintext highlighter-rouge">ChunkOrientedTasklet</code> 를 생성하는 대신 <code class="language-plaintext highlighter-rouge">ChunkOrientedStep</code> 를 생성하도록 변경되었다.
<code class="language-plaintext highlighter-rouge">ChunkOrientedStep</code> 는 read, write 작업은 단일 thread 에서 처리하고, process 작업만 별도의 worker-thread 로 분화하여 처리하는 구조로 변경되었다.
결국 AsyncItemProcessor 와 유사한 구조가 된 것이다.</p>

<p>기존 <code class="language-plaintext highlighter-rouge">ChunkOrientedTasklet</code> 을 이용하여 정통적으로 수행되던 Multi-threaded Step 의 구조를 다시 생각해보자.
read-process-write 작업이 모두 하나의 thread 에서 처리되는 특징을 갖고 있다.
그러나 우리는 이미 read/write 를 수행하는 I/O bound 작업이 병렬처리 효율성이 그다지 높지 않다는 것을 예측할 수 있다.
Spring Batch 팀에서도 병렬처리 효율성과 트랜잭션 관리, 재시도 일관성 보장 복잡성 등을 고려하여 <code class="language-plaintext highlighter-rouge">ChunkOrientedStep</code> 구조로 변경한 것으로 추측할 수 있다.</p>

<p><strong>Spring Batch 7.0 이후 버전부터는 <code class="language-plaintext highlighter-rouge">ChunkOrientedTasklet</code> 가 제거될 예정이므로, Multi-threaded Step 은 사실상 앞으로 사용할 수 없는 전략이다.</strong></p>

<p><br /></p>

<h2 id="-partitioning">💿 Partitioning</h2>

<p><img width="1383" height="655" alt="Image" src="https://github.com/user-attachments/assets/8aa7e512-404a-480a-b06d-41397718afe0" /></p>

<p>Partitioning 은 Step 자체를 여러 개로 나누어 병렬로 처리하는 전략이다.
얼핏 Multi-threaded Step 과 비슷해 보이지만, Multi-threaded Step 는 하나의 Step 내부에서 Chunk 단위로 thread 를 할당하는 반면,
Partitioning 은 Step 자체를 여러 개로 나누어 각각의 Step 을 별도의 thread 에서 처리한다는 차이가 있다.
Partitioning 은 각각의 Step 들이 Context(<code class="language-plaintext highlighter-rouge">StepExecution</code>, <code class="language-plaintext highlighter-rouge">StepExecutionContext</code>) 를 독립적으로 갖기 때문에, 개별적인 상태 관리가 가능하다.</p>

<div class="language-kotlin highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Configuration</span>
<span class="kd">class</span> <span class="nc">CookingLogUpdateJob</span> <span class="p">{</span>

  <span class="nd">@Bean</span><span class="p">(</span><span class="nc">JOB_NAME</span><span class="p">)</span>
  <span class="k">fun</span> <span class="nf">job</span><span class="p">(</span>
    <span class="n">jobRepository</span><span class="p">:</span> <span class="nc">JobRepository</span><span class="p">,</span>
    <span class="n">eatStepManager</span><span class="p">:</span> <span class="nc">Step</span><span class="p">,</span>
  <span class="p">):</span> <span class="nc">Job</span> <span class="p">{</span>
    <span class="k">return</span> <span class="nc">JobBuilder</span><span class="p">(</span><span class="nc">JOB_NAME</span><span class="p">,</span> <span class="n">jobRepository</span><span class="p">)</span>
      <span class="p">.</span><span class="nf">start</span><span class="p">(</span><span class="n">eatStepManager</span><span class="p">)</span>
      <span class="p">.</span><span class="nf">preventRestart</span><span class="p">()</span>
      <span class="p">.</span><span class="nf">build</span><span class="p">()</span>
  <span class="p">}</span>

  <span class="nd">@Bean</span>
  <span class="k">fun</span> <span class="nf">cookingLogUpdateExecutor</span><span class="p">():</span> <span class="nc">TaskExecutor</span> <span class="p">{</span>
    <span class="kd">val</span> <span class="py">executor</span> <span class="p">=</span> <span class="nc">ThreadPoolTaskExecutor</span><span class="p">()</span>
    <span class="n">executor</span><span class="p">.</span><span class="n">corePoolSize</span> <span class="p">=</span> <span class="nc">POOL_SIZE</span>
    <span class="n">executor</span><span class="p">.</span><span class="n">maxPoolSize</span> <span class="p">=</span> <span class="nc">POOL_SIZE</span>
    <span class="n">executor</span><span class="p">.</span><span class="nf">setThreadNamePrefix</span><span class="p">(</span><span class="s">"partition-thread"</span><span class="p">)</span>
    <span class="n">executor</span><span class="p">.</span><span class="nf">setWaitForTasksToCompleteOnShutdown</span><span class="p">(</span><span class="k">true</span><span class="p">)</span>
    <span class="n">executor</span><span class="p">.</span><span class="nf">initialize</span><span class="p">()</span>
    <span class="k">return</span> <span class="n">executor</span>
  <span class="p">}</span>

  <span class="nd">@Bean</span>
  <span class="k">fun</span> <span class="nf">eatStepPartitionHandler</span><span class="p">(</span>
    <span class="n">eatStep</span><span class="p">:</span> <span class="nc">Step</span><span class="p">,</span>
    <span class="n">cookingLogUpdateExecutor</span><span class="p">:</span> <span class="nc">TaskExecutor</span><span class="p">,</span>
  <span class="p">):</span> <span class="nc">TaskExecutorPartitionHandler</span> <span class="p">{</span>
    <span class="kd">val</span> <span class="py">partitionHandler</span> <span class="p">=</span> <span class="nc">TaskExecutorPartitionHandler</span><span class="p">()</span>
    <span class="n">partitionHandler</span><span class="p">.</span><span class="n">step</span> <span class="p">=</span> <span class="n">eatStep</span>
    <span class="n">partitionHandler</span><span class="p">.</span><span class="nf">setTaskExecutor</span><span class="p">(</span><span class="n">cookingLogUpdateExecutor</span><span class="p">)</span>
    <span class="n">partitionHandler</span><span class="p">.</span><span class="n">gridSize</span> <span class="p">=</span> <span class="nc">POOL_SIZE</span>
    <span class="k">return</span> <span class="n">partitionHandler</span>
  <span class="p">}</span>

  <span class="nd">@StepScope</span>
  <span class="nd">@Bean</span>
  <span class="k">fun</span> <span class="nf">eatStepPartitioner</span><span class="p">(</span>
    <span class="nd">@Value</span><span class="p">(</span><span class="s">"#{jobParameters['startDate']}"</span><span class="p">)</span> <span class="n">startDate</span><span class="p">:</span> <span class="nc">String</span><span class="p">?,</span>
    <span class="nd">@Value</span><span class="p">(</span><span class="s">"#{jobParameters['endDate']}"</span><span class="p">)</span> <span class="n">endDate</span><span class="p">:</span> <span class="nc">String</span><span class="p">?,</span>
    <span class="n">jdbcTemplate</span><span class="p">:</span> <span class="nc">JdbcTemplate</span><span class="p">,</span>
  <span class="p">):</span> <span class="nc">CookingLogIdRangePartitioner</span> <span class="p">{</span>
    <span class="nf">requireNotNull</span><span class="p">(</span><span class="n">startDate</span><span class="p">)</span> <span class="p">{</span> <span class="s">"startDate job parameter is required"</span> <span class="p">}</span>
    <span class="nf">requireNotNull</span><span class="p">(</span><span class="n">endDate</span><span class="p">)</span> <span class="p">{</span> <span class="s">"endDate job parameter is required"</span> <span class="p">}</span>

    <span class="kd">val</span> <span class="py">startDateInstant</span> <span class="p">=</span> <span class="nc">LocalDate</span><span class="p">.</span><span class="nf">parse</span><span class="p">(</span><span class="n">startDate</span><span class="p">,</span> <span class="nc">DateTimeFormatter</span><span class="p">.</span><span class="nc">ISO_LOCAL_DATE</span><span class="p">)</span>
      <span class="p">.</span><span class="nf">atStartOfDay</span><span class="p">(</span><span class="nc">KST_ZONE_ID</span><span class="p">)</span>
      <span class="p">.</span><span class="nf">toInstant</span><span class="p">()</span>
    <span class="kd">val</span> <span class="py">endDateInstant</span> <span class="p">=</span> <span class="nc">LocalDate</span><span class="p">.</span><span class="nf">parse</span><span class="p">(</span><span class="n">endDate</span><span class="p">,</span> <span class="nc">DateTimeFormatter</span><span class="p">.</span><span class="nc">ISO_LOCAL_DATE</span><span class="p">)</span>
      <span class="p">.</span><span class="nf">atStartOfDay</span><span class="p">(</span><span class="nc">KST_ZONE_ID</span><span class="p">)</span>
      <span class="p">.</span><span class="nf">toInstant</span><span class="p">()</span>
    <span class="k">return</span> <span class="nc">CookingLogIdRangePartitioner</span><span class="p">(</span>
      <span class="n">jdbcTemplate</span> <span class="p">=</span> <span class="n">jdbcTemplate</span><span class="p">,</span>
      <span class="n">startDate</span> <span class="p">=</span> <span class="n">startDateInstant</span><span class="p">,</span>
      <span class="n">endDate</span> <span class="p">=</span> <span class="n">endDateInstant</span><span class="p">,</span>
      <span class="n">dataCountPerPartition</span> <span class="p">=</span> <span class="nc">CHUNK_SIZE</span><span class="p">,</span>
    <span class="p">)</span>
  <span class="p">}</span>

  <span class="nd">@Bean</span>
  <span class="k">fun</span> <span class="nf">eatStepManager</span><span class="p">(</span>
    <span class="n">jobRepository</span><span class="p">:</span> <span class="nc">JobRepository</span><span class="p">,</span>
    <span class="n">eatStepPartitioner</span><span class="p">:</span> <span class="nc">CookingLogIdRangePartitioner</span><span class="p">,</span>
    <span class="n">partitionHandler</span><span class="p">:</span> <span class="nc">TaskExecutorPartitionHandler</span><span class="p">,</span>
  <span class="p">):</span> <span class="nc">Step</span> <span class="p">{</span>
    <span class="k">return</span> <span class="nc">StepBuilder</span><span class="p">(</span><span class="s">"eat-step-manager"</span><span class="p">,</span> <span class="n">jobRepository</span><span class="p">)</span>
      <span class="p">.</span><span class="nf">partitioner</span><span class="p">(</span><span class="nc">STEP_NAME</span><span class="p">,</span> <span class="n">eatStepPartitioner</span><span class="p">)</span>
      <span class="p">.</span><span class="nf">partitionHandler</span><span class="p">(</span><span class="n">partitionHandler</span><span class="p">)</span>
      <span class="p">.</span><span class="nf">build</span><span class="p">()</span>
  <span class="p">}</span>

  <span class="nd">@Bean</span>
  <span class="k">fun</span> <span class="nf">eatStep</span><span class="p">(</span>
    <span class="n">jobRepository</span><span class="p">:</span> <span class="nc">JobRepository</span><span class="p">,</span>
    <span class="n">transactionManager</span><span class="p">:</span> <span class="nc">PlatformTransactionManager</span><span class="p">,</span>
    <span class="n">eatableCookLogReader</span><span class="p">:</span> <span class="nc">ItemReader</span><span class="p">&lt;</span><span class="nc">EatableCookingLog</span><span class="p">&gt;,</span>
    <span class="n">ateCookingLogWriter</span><span class="p">:</span> <span class="nc">ItemWriter</span><span class="p">&lt;</span><span class="nc">AteCookingLog</span><span class="p">&gt;,</span>
  <span class="p">):</span> <span class="nc">Step</span> <span class="p">{</span>
    <span class="k">return</span> <span class="nc">StepBuilder</span><span class="p">(</span><span class="nc">STEP_NAME</span><span class="p">,</span> <span class="n">jobRepository</span><span class="p">)</span>
      <span class="p">.</span><span class="n">chunk</span><span class="p">&lt;</span><span class="nc">EatableCookingLog</span><span class="p">,</span> <span class="nc">AteCookingLog</span><span class="p">&gt;(</span><span class="nc">CHUNK_SIZE</span><span class="p">)</span>
      <span class="p">.</span><span class="nf">transactionManager</span><span class="p">(</span><span class="n">transactionManager</span><span class="p">)</span>
      <span class="p">.</span><span class="nf">reader</span><span class="p">(</span><span class="n">eatableCookLogReader</span><span class="p">)</span>
      <span class="p">.</span><span class="nf">processor</span><span class="p">(</span><span class="nf">processor</span><span class="p">())</span>
      <span class="p">.</span><span class="nf">writer</span><span class="p">(</span><span class="n">ateCookingLogWriter</span><span class="p">)</span>
      <span class="p">.</span><span class="nf">build</span><span class="p">()</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<blockquote>
  <p>전체 코드는 <a href="https://github.com/Hyeon9mak/lab/tree/master/spring-batch-partitioning">https://github.com/Hyeon9mak/lab/tree/master/spring-batch-partitioning</a> 에서 확인할 수 있다.</p>
</blockquote>

<p>Partitioning 에는 크게 2가지 개념의 Component 가 추가된다.</p>

<ul>
  <li>Partitioner: 조건에 따라 Partition(Step) 을 나누는 역할을 수행한다. 각 Step 들이 처리할 Chunk 범위를 결정한다.</li>
  <li>StepManager: Partitioner 와 나눠진 Partition(Step) 들을 관리한다.</li>
</ul>

<p>구현이 복잡하다고 느낄 수 있지만, 개념을 이해하고 코드를 따라간다면 크게 어렵지 않다.</p>

<p>Partitioning 도 역시나 별도 설정이 없다면 병렬 처리에 사용되는 thread 의 수는 <code class="language-plaintext highlighter-rouge">SimpleAsyncTaskExecutor</code> 에 의해 결정된다.
대량의 데이터가 발생하는 운영 환경에서는 직접 <code class="language-plaintext highlighter-rouge">TaskExecutor</code> 를 구현해 적정한 thread 수를 제어하는 것이 좋다.</p>

<h3 id="transaction-관리-1">Transaction 관리</h3>

<p>Partitioning 은 Step 자체를 여러 개로 나누어 병렬로 처리하는 전략이다.
Chunk 내부 동작은 모두 단일 Thread 에서 처리되기 때문에, Transaction 관리가 아주 쉽다.</p>

<h3 id="순서-보장-1">순서 보장</h3>

<p>Partitioning 은 Step 내부 read - process - write 가 하나의 thread 에서 처리되기 때문에, 각 partition 간 순서를 보장할 수 없다.
다만 Step 내부 에서는 단일 Thread 로, 하나의 <code class="language-plaintext highlighter-rouge">StepExecution</code> 과 <code class="language-plaintext highlighter-rouge">StepExecutionContext</code> 를 공유하기 때문에 Chunk 단위에서는 순서를 보장받을 수 있다.
때문에 <code class="language-plaintext highlighter-rouge">ItemStreamReader</code>, <code class="language-plaintext highlighter-rouge">ItemStreamWriter</code> 를 사용하는 경우에도 순서가 보장된다.</p>

<h3 id="step-간-실패로부터-격리">Step 간 실패로부터 격리</h3>

<p>각 Partition(Step) 들이 독립적인 Context 를 갖기 때문에, 서로 다른 Step 들 간의 실패로부터 격리된다.
가령 Partition(Step) A 가 실패하더라도 Partition(Step) B 는 영향을 받지 않고 정상적으로 완료될 수 있다.
이 모든 결과는 StepManager 가 집계하여 최종 Job 결과로 반영한다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Partition(Step) A: FAILED
Partition(Step) B: COMPLETED
Partition(Step) C: COMPLETED
------------------
Job: FAILED
</code></pre></div></div>

<p>이 경우 Batch 를 재시작하여 실패한 Partition(Step) A 만 동작하도록 할 수 있다.</p>

<p>주의할 점은 위와 같은 특성으로 인해 Step 내부에서 Skip 을 잘못 사용할 경우 실패지점을 되찾아 재실행 하기 어려워진다는 것이다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Partition(Step) A: COMPLETED (1,000 건 중 1건 Skip)
Partition(Step) B: COMPLETED
Partition(Step) C: COMPLETED
------------------
Job: COMPLETED
</code></pre></div></div>

<p>Partitioning 과 같은 병렬 처리를 고민하는 시점이라면 이미 대량의 데이터를 다루고 있을 가능성이 높다.
때문에 전체 데이터를 다시 처리하는 비효율을 피하기 위해 성공/실패 시나리오를 명확히 구분하고,
설계를 진행하는 것이 중요하겠다.</p>

<p><br /></p>

<h2 id="-시나리오별-권장-전략">💿 시나리오별 권장 전략</h2>

<p>당연하게 Multi-threaded Step 는 모든 시나리오에서 배제된다.</p>

<h3 id="외부-api-호출이-병목-지점인-경우">외부 API 호출이 병목 지점인 경우</h3>

<ul>
  <li>AsyncItemProcessor 권장</li>
  <li>read/write 단계에서 DB I/O bound 가 적은 대신, process 단계에서 API I/O bound 가 큰 경우 유리하다.</li>
  <li>Partitioning 은 구현 복잡도 + Chunk 처리 순서 보장이 어렵다.</li>
</ul>

<h3 id="cpu-연산이-병목-지점인-경우">CPU 연산이 병목 지점인 경우</h3>

<ul>
  <li>AsyncItemProcessor 권장</li>
  <li>read/write 단계에서 DB I/O bound 가 적은 대신, process 단계에서 CPU bound 가 큰 경우 유리하다.</li>
  <li>Partitioning 은 구현 복잡도 + Chunk 처리 순서 보장이 어렵다.</li>
</ul>

<h3 id="대량-데이터-통계-집계">대량 데이터 통계 집계</h3>

<ul>
  <li>Partitioning 권장</li>
  <li>read/write 단계에서 DB I/O bound 가 큰 경우 유리하다.</li>
  <li>ID, 날짜 등으로 범위를 나눠서 Partition 을 나누어 독립 수행 시키기 좋다.</li>
  <li>실패시 재실행 지점이 명확하다.</li>
</ul>

<h3 id="실패-격리가-필요한-경우">실패 격리가 필요한 경우</h3>

<ul>
  <li>Partitioning 권장</li>
  <li>Partition(Step) 간 실패 격리가 필요한 경우 유리하다.</li>
  <li>실패한 Partition(Step) 만 재실행 시키기 좋다.</li>
</ul>

<p>각각의 전략들은 모두 병렬처리를 이용한 성능 향상을 목적으로 하기 때문에, 병렬로 쏟아지는 요청을 받아낼 데이터베이스의 성능도 함께 고려해야 한다.
데이터베이스가 감당할 수 있도록 Connection Pool 크기 등도 함께 꼭 신경써주어야겠다.</p>

<p><br /></p>

<h2 id="references">References</h2>
<ul>
  <li><a href="https://docs.spring.io/spring-batch/reference/step/chunk-oriented-processing.html">Spring Batch Documentation - Chunk-oriented Processing</a></li>
  <li><a href="https://github.com/spring-projects/spring-batch/wiki/Spring-Batch-6.0-Migration-Guide#new-chunk-oriented-model-implementation">https://github.com/spring-projects/spring-batch/wiki/Spring-Batch-6.0-Migration-Guide#new-chunk-oriented-model-implementation</a></li>
  <li><a href="https://github.com/Hyeon9mak/lab/tree/master/spring-batch-partitioning">https://github.com/Hyeon9mak/lab/tree/master/spring-batch-partitioning</a></li>
  <li><a href="https://github.com/Hyeon9mak/lab/tree/master/spring-batch-async-item-processor">https://github.com/Hyeon9mak/lab/tree/master/spring-batch-async-item-processor</a></li>
  <li><a href="https://github.com/Hyeon9mak/lab/tree/master/spring-batch-multi-threaded-step">https://github.com/Hyeon9mak/lab/tree/master/spring-batch-multi-threaded-step</a></li>
</ul>]]></content><author><name>현구막</name><email>jinha3507@gmail.com</email></author><summary type="html"><![CDATA[💿 Overview Spring Batch 는 주로 대용량 데이터를 효율적으로 자동 처리하기 위해 사용한다. batch 작업 특성상 실시간 성이 떨어지는 경우가 많아 성능이 크게 중요하지 않지만, 데이터 양이 너무 많아 처리 시간이 지나치게 오래 걸린다던지, 다른 작업과 연결되어 최소한의 처리 시간이 요구되는 경우도 분명히 존재한다. 이 때 Spring Batch 가 제공하는 병렬 처리 전략을 활용하면 작업 처리 시간을 크게 단축시킬 수 있다.]]></summary></entry><entry><title type="html">S3 Storage Class 와 Lifecycle rule 을 활용한 저비용 장기 보관 전략</title><link href="https://hyeon9mak.github.io/aws-s3-glacier-with-lifecycle-rule/" rel="alternate" type="text/html" title="S3 Storage Class 와 Lifecycle rule 을 활용한 저비용 장기 보관 전략" /><published>2026-01-18T00:00:00+09:00</published><updated>2026-01-18T00:00:00+09:00</updated><id>https://hyeon9mak.github.io/aws-s3-glacier-with-lifecycle-rule</id><content type="html" xml:base="https://hyeon9mak.github.io/aws-s3-glacier-with-lifecycle-rule/"><![CDATA[<h2 id="-s3-storage-class">🪣 S3 Storage Class</h2>

<p>S3 Storage Class 는 AWS S3 에 저장된 객체(파일) 들의 저장 유형을 정의하는 설정이다.
모든 Class 는 기본적으로 내구성, 가용성, 가용성 SLA 들을 기본적으로 99% 이상 제공한다.
모든 Storage Class 들은 내구성과 가용성은 높게 보장되니, 사용자는 <strong>데이터 종류</strong>, <strong>접근 빈도</strong> 2가지 요소에 따라 적절한 Storage Class 를 선택하면 된다.
즉, 사용 패턴을 명확히 진단하면 쓴 만큼만 비용을 지불하는 효율적인 Storage 운영이 가능하다.</p>

<p>더 자세한 비교는 아래 표를 참고하자.</p>

<table>
  <thead>
    <tr>
      <th>Storage Class</th>
      <th>내구성 (Durability)</th>
      <th>가용성 (Availability)</th>
      <th>SLA</th>
      <th>가용 영역 (AZ)</th>
      <th>객체당 최소 용량 요금</th>
      <th>최소 Storage 기간 요금</th>
      <th>검색 요금</th>
      <th>검색 지연 시간</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>S3 Standard</strong></td>
      <td>99.999999999% (11 9’s)</td>
      <td>99.99%</td>
      <td>99.9%</td>
      <td>≥3</td>
      <td>-</td>
      <td>-</td>
      <td>-</td>
      <td>밀리초</td>
    </tr>
    <tr>
      <td><strong>S3 Intelligent-Tiering</strong></td>
      <td>99.999999999% (11 9’s)</td>
      <td>99.9%</td>
      <td>99%</td>
      <td>≥3</td>
      <td>128KB</td>
      <td>-</td>
      <td>-</td>
      <td>밀리초</td>
    </tr>
    <tr>
      <td><strong>S3 Express One Zone</strong></td>
      <td>99.999999999% (11 9’s)</td>
      <td>99.95%</td>
      <td>99.9%</td>
      <td>1</td>
      <td>-</td>
      <td>1시간</td>
      <td>GB당 요금</td>
      <td>한 자릿수 밀리초</td>
    </tr>
    <tr>
      <td><strong>S3 Standard-IA</strong></td>
      <td>99.999999999% (11 9’s)</td>
      <td>99.9%</td>
      <td>99%</td>
      <td>≥3</td>
      <td>128KB</td>
      <td>30일</td>
      <td>GB당 요금</td>
      <td>밀리초</td>
    </tr>
    <tr>
      <td><strong>S3 One Zone-IA</strong></td>
      <td>99.999999999% (11 9’s)</td>
      <td>99.5%</td>
      <td>99%</td>
      <td>1</td>
      <td>128KB</td>
      <td>30일</td>
      <td>GB당 요금</td>
      <td>밀리초</td>
    </tr>
    <tr>
      <td><strong>S3 Glacier Instant Retrieval</strong></td>
      <td>99.999999999% (11 9’s)</td>
      <td>99.9%</td>
      <td>99%</td>
      <td>≥3</td>
      <td>128KB</td>
      <td>90일</td>
      <td>GB당 요금</td>
      <td>밀리초</td>
    </tr>
    <tr>
      <td><strong>S3 Glacier Flexible Retrieval</strong></td>
      <td>99.999999999% (11 9’s)</td>
      <td>99.99%</td>
      <td>99.9%</td>
      <td>≥3</td>
      <td>40KB</td>
      <td>90일</td>
      <td>GB당 요금</td>
      <td>분~12시간</td>
    </tr>
    <tr>
      <td><strong>S3 Glacier Deep Archive</strong></td>
      <td>99.999999999% (11 9’s)</td>
      <td>99.99%</td>
      <td>99.9%</td>
      <td>≥3</td>
      <td>40KB</td>
      <td>180일</td>
      <td>GB당 요금</td>
      <td>9~48시간</td>
    </tr>
  </tbody>
</table>

<ul>
  <li>S3 Intelligent-Tiering 은 128KB 미만 객체도 저장 가능하나, 항상 Frequent Access tier 요금으로 과금되며 모니터링 요금은 부과되지 않음</li>
  <li>S3 One Zone-IA는 단일 AZ에 저장되므로 AZ 손실 시 데이터 손실 가능</li>
  <li>S3 Glacier Flexible Retrieval 과 Deep Archive 각 객체당 추가 메타데이터 40KB(S3 Standard 8KB + Glacier 32KB) 요금 발생</li>
</ul>

<h4 id="s3-standard">S3 Standard</h4>

<ul>
  <li>이름 그대로 가장 일반적인 Storage Class</li>
  <li>높은 내구성과 가용성을 제공하며, 지연 시간이 짧아 빠른 데이터 접근 가능</li>
  <li>자주 접근하는 데이터를 위함</li>
</ul>

<h4 id="s3-intelligent-tiering">S3 Intelligent-Tiering</h4>

<ul>
  <li>3개의 Access tier (Frequent, Infrequent, Archive) 로 구성</li>
  <li>접근 패턴에 따라 자동으로 Access tier 간 전환</li>
  <li>접근 빈도가 예측 불가능한 데이터를 위한 Storage Class</li>
</ul>

<h4 id="s3-express-one-zone">S3 Express One Zone</h4>

<ul>
  <li>S3 Standard 보다 최대 10배 빠른 데이터 접근 속도 제공</li>
  <li>자주 Access 하는 데이터와 까다로운 애플리케이션 설계에 적합</li>
  <li>단일 가용 영역(AZ)에 저장되어 비용 절감</li>
  <li>자주 접근하는 데이터에 적합하지만, AZ 손실 시 데이터 손실</li>
</ul>

<h4 id="s3-standard-ia">S3 Standard-IA</h4>

<ul>
  <li>자주 접근하지 않는 데이터를 위한 Storage Class</li>
  <li>높은 내구성과 가용성을 제공하며, 지연 시간은 S3 Standard 와 유사</li>
  <li>접근 빈도가 낮은 데이터를 위한 비용 효율적인 옵션</li>
</ul>

<h4 id="s3-one-zone-ia">S3 One Zone-IA</h4>

<ul>
  <li>단일 가용 영역(AZ)에 저장되어 비용 절감</li>
  <li>접근 빈도가 낮은 데이터를 위한 Storage Class</li>
  <li>AZ 손실 시 데이터 손실 가능</li>
  <li>S3 Standard-IA 와 유사한 요금 구조</li>
</ul>

<p><br /></p>

<h2 id="-s3-glacier">🧊 S3 Glacier</h2>

<p>Glacier(빙하) 라는 이름에서 알 수 있듯, 장기 보관을 위한 Storage Class 다.
데이터 접근 빈도가 매우 낮고, 장기간 보관이 필요한 데이터를 위한 옵션이다.
(병원 의료 데이터, 금융 기록 등)</p>

<p>다른 Storage Class 와 달리, Glacier 는 완전히 별개의 저장소에 데이터를 보관한다.
때문에 S3 에서 제공하는 다른 기능들을 이용할 수 없으며, S3 Service 에서 Access 가 불가능하다.
(복원 작업을 통해서만 접근 가능. 분~시간 단위가 소요된다.)
즉 <strong>마음대로 검색이 불가능하며, 마음대로 삭제 또한 불가능</strong>하다.
Glacier 는 장기간 보관을 주 목적으로 설계한 서비스이고, 그 최소한의 조건이 3개월이기 때문이다.
만약 3개월 이전에 삭제할 경우, 잔여 기간에 대한 요금이 청구된다.</p>

<p>대신 그만큼 저장 비용이 매우 저렴하므로, 보관 특성에 따라 활용을 결정하면 된다.</p>

<h4 id="s3-glacier-instant-retrieval">S3 Glacier Instant Retrieval</h4>

<ul>
  <li>Glacier Storage Class 중 유일하게 밀리초 단위의 지연 시간 제공</li>
  <li>자주 접근하지는 않지만, 필요 시 즉시 접근해야 하는 데이터를 위한 옵션</li>
  <li>분기 1회 Access 에 적합</li>
</ul>

<h4 id="s3-glacier-flexible-retrieval">S3 Glacier Flexible Retrieval</h4>

<ul>
  <li>분~시간(최대 12시간) 단위의 지연 시간 제공</li>
  <li>연 1회 Access 에 적합</li>
  <li>장기 보관이 필요하며, 접근 빈도가 매우 낮은 데이터를 위한 옵션</li>
</ul>

<h4 id="s3-glacier-deep-archive">S3 Glacier Deep Archive</h4>

<ul>
  <li>가장 저렴한 저장 비용 제공</li>
  <li>9~48시간 단위의 지연 시간 제공</li>
  <li>연 1회 미만 Access 에 적합</li>
  <li>금융, 의료 등 데이터를 초장기간 보관하는 서비스와 고객을 위해 설계</li>
</ul>

<p><br /></p>

<h2 id="-s3-storage-class-변경하기">🪣 S3 Storage Class 변경하기</h2>

<p>S3 bucket console 에서 대상 객체를 선택한 후, <code class="language-plaintext highlighter-rouge">Actions</code> - <code class="language-plaintext highlighter-rouge">Change storage class</code> 를 선택하면 쉽게 변경할 수 있다.</p>

<p><img width="1258" height="645" alt="Image" src="https://github.com/user-attachments/assets/22931a1a-8dda-473b-b675-27a15777b792" /></p>

<p>보통 S3 에 적게는 수백, 수천에서 많게는 수억, 수십억 개의 객체가 저장하곤 한다.
개발자가 하나하나 수동으로 변경하는 것은 지나치게 비효율적이고, 사실상 불가능에 가깝다.
그리고 S3 는 이러한 문제를 보완하기 위해 Lifecycle(생명주기) 개념을 제공한다.</p>

<p><br /></p>

<h2 id="-s3-lifecycle">🪣 S3 Lifecycle</h2>

<p>S3 bucket 에 저장된 객체의 생명주기(Lifecycle)을 관리하는 개념이다.
지정한 기간이 경과하면, 객체를 자동으로 다른 Storage Class 로 전환하거나 삭제할 수 있다.
즉, 자신의 서비스 특성에 맞게 S3 객체의 저장 정책을 자동화할 수 있다.
S3 Lifecycle 은 크게 2가지 종류가 있다.</p>

<h3 id="storage-class-transition">Storage Class Transition</h3>

<ul>
  <li>지정한 기간이 경과하면, 객체를 자동으로 다른 Storage Class 로 전환</li>
  <li>예: 30일 후 S3 Standard-IA 로 전환, 90일 후 S3 Glacier Flexible Retrieval 로 전환 등</li>
</ul>

<p>단, 전환 대상 Storage Class 에 따라 최소 보관 기간이 존재한다.
예를 들어, S3 Standard-IA 로 전환한 객체는 최소 30일 동안 보관해야 하며, 
S3 Glacier Flexible Retrieval 로 전환한 객체는 최소 90일 동안 보관해야 한다.
따라서 전환 시점과 최소 보관 기간을 고려하여 정책을 설정해야 한다.</p>

<p>또한 Lifecycle 을 통해 Storage Class 를 전환할 땐 Waterfall 형태의 전환만 가능하다.
(역방향 전환 불가능) 아래 그림을 참고하자.</p>

<p><img src="https://docs.aws.amazon.com/images/AmazonS3/latest/userguide/images/lifecycle-transitions-v4.png" /></p>

<h3 id="expiration">Expiration</h3>

<ul>
  <li>지정한 기간이 경과하면, 객체를 자동으로 삭제</li>
  <li>예: 365일 후 객체 삭제</li>
</ul>

<p>2가지 종류를 조합하여, 장기 보관이 필요한 데이터를 저비용으로 관리할 수 있다.
예를 들어, 자주 접근하지 않는 로그 데이터를 S3 Standard-IA 로 전환한 후, 1년 후 삭제하는 정책을 설정할 수 있다.</p>

<p><br /></p>

<h2 id="-s3-lifecycle-rule-설정하기">🪣 S3 Lifecycle rule 설정하기</h2>

<p>S3 bucket console 에서 <code class="language-plaintext highlighter-rouge">Management</code> - <code class="language-plaintext highlighter-rouge">Lifecycle rules</code> - <code class="language-plaintext highlighter-rouge">Create lifecycle rule</code> 을 선택하여 설정할 수 있다.</p>

<p><img width="1252" height="545" alt="Image" src="https://github.com/user-attachments/assets/2f94ce8c-4cb9-4d61-a0ae-616397efd74d" /></p>

<p><code class="language-plaintext highlighter-rouge">Limit the scope of this rule using one or more filters</code> 옵션을 통해 특정 prefix, tag 혹은 객체의 크기에 따라 규칙을 적용 대상을 한정할 수 있다.
<code class="language-plaintext highlighter-rouge">Apply to all objects in the bucket</code> 옵션을 선택하면, 버킷 내 모든 객체에 규칙이 적용된다.</p>

<p><img width="1239" height="708" alt="Image" src="https://github.com/user-attachments/assets/64aa866e-5b2d-4ca6-b925-ee59ce54b3c5" /></p>

<p><code class="language-plaintext highlighter-rouge">Lifecycle rule actions</code> 항목에선 5가지 작업을 설정할 수 있는데, 앞서 알아본 2가지 개념으로 나누어 볼 수 있다.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">Transition current versions of objects between storage classes</code>
    <ul>
      <li>Storage Class Transition</li>
      <li>경과 기간 선택 후, 전환할 Storage Class 선택</li>
    </ul>
  </li>
  <li><code class="language-plaintext highlighter-rouge">Transition noncurrent versions of objects between storage classes</code>
    <ul>
      <li>Storage Class Transition</li>
      <li>버전 관리가 활성화된 버킷에서, 이전 버전 객체에 대해 Storage Class 전환 선택</li>
    </ul>
  </li>
  <li><code class="language-plaintext highlighter-rouge">Expire current versions of objects</code>
    <ul>
      <li>Expiration</li>
      <li>현재 버전 객체에 대해 만료 처리, 추가 기간 경과 후 영구 삭제</li>
    </ul>
  </li>
  <li><code class="language-plaintext highlighter-rouge">Permanently delete noncurrent versions of objects</code>
    <ul>
      <li>Expiration</li>
      <li>객체의 이전 버전 영구 삭제</li>
    </ul>
  </li>
  <li><code class="language-plaintext highlighter-rouge">Delete expired object delete markers or incomplete multipart uploads</code>
    <ul>
      <li>Expiration</li>
      <li>현재 날짜 기준으로 삭제 실행</li>
    </ul>
  </li>
</ul>

<p><img width="1225" height="299" alt="Image" src="https://github.com/user-attachments/assets/89a98102-0b0e-412c-8b90-6d09ca4bdf37" /></p>

<p>각 사용 패턴에 맞게 선정 후 최종 생성을 마치면, 설정이 완료된다.</p>

<p>AWS S3 는 Lifecycle rule 적용 기준을 익일 00:00 UTC 로 정하고 있다.
예를 들어 <code class="language-plaintext highlighter-rouge">2026-01-08 00:05 UTC</code> 에 생성한 객체에 대해 “10일 후 S3 Standard-IA 로 전환” 규칙을 적용하면, <code class="language-plaintext highlighter-rouge">2026-01-19 00:00 UTC</code> 에 전환이 실행된다.
이 점 유의하여 정책을 설계하면 좋겠다.</p>

<p><br /></p>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://aws.amazon.com/s3/storage-classes/">AWS S3 Storage Classes 공식 페이지</a></li>
  <li><a href="https://aws.amazon.com/s3/pricing/">AWS S3 Pricing 공식 페이지</a></li>
  <li><a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/storage-class-intro.html">AWS S3 User Guide - Storage Classes</a></li>
  <li><a href="https://aws.amazon.com/ko/blogs/korea/new-amazon-s3-express-one-zone-high-performance-storage-class/">Amazon S3 Express One Zone 고성능 스토리지 클래스 정식 출시</a></li>
  <li><a href="https://docs.aws.amazon.com/ko_kr/AmazonS3/latest/userguide/intelligent-tiering-overview.html">S3 Intelligent-Tiering 작동 방식</a></li>
  <li><a href="https://aws.amazon.com/ko/blogs/korea/amazon-s3-glacier-is-the-best-place-to-archive-your-data-introducing-the-s3-glacier-instant-retrieval-storage-class/">Amazon S3 Glacier Instant Retrieval 신규 스토리지 클래스 출시</a></li>
  <li><a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/lifecycle-transition-general-considerations.html">Transitioning objects using Amazon S3 Lifecycle</a></li>
</ul>]]></content><author><name>현구막</name><email>jinha3507@gmail.com</email></author><summary type="html"><![CDATA[🪣 S3 Storage Class]]></summary></entry><entry><title type="html">WebFlux 환경에서 BlockHound 를 통해 blocking call 탐지하기</title><link href="https://hyeon9mak.github.io/webflux-blockhound-guide/" rel="alternate" type="text/html" title="WebFlux 환경에서 BlockHound 를 통해 blocking call 탐지하기" /><published>2026-01-06T00:00:00+09:00</published><updated>2026-01-06T00:00:00+09:00</updated><id>https://hyeon9mak.github.io/webflux-blockhound-guide</id><content type="html" xml:base="https://hyeon9mak.github.io/webflux-blockhound-guide/"><![CDATA[<h2 id="-spring-webflux-와-blockhound">🐕 Spring WebFlux 와 BlockHound</h2>

<p>Spring WebFlux 는 Netty 를 기반으로 Non-Blocking 방식의 프로그래밍 모델을 제공한다. 
이 덕분에 적은 수의 thread 로 수 많은 요청을 처리할 수 있다(C10K problem 해소)는 강점이 있다.
이 강점을 살리기 위해서는 WebFlux 프로젝트 전반에 걸쳐 Non-Blocking 방식으로 개발이 진행되어야 하는데,
단 한 곳에서라도 Blocking Call 이 발생하면, 그 플로우 전체가 더 이상 Non-Blocking 하지 않음(=Blocking)과 마찬가지기 때문이다.</p>

<p>개발자의 부단한 노력으로 눈에 보이는 모든 코드가 Non-Blocking 하게 작성되었다고 해도,
사용하는 여러 라이브러리 내부에서 Blocking Call 이 발생할 수 있다.
이런 Blocking Call 을 탐지하기 위해, project-reactor 에서는 Block 지점이 발견되면 예외를 발생시키는 <a href="https://github.com/reactor/BlockHound">BlockHound 라이브러리</a>를 제공하고 있다.</p>

<p><br /></p>

<h2 id="-blockhound-설정-방법">🐕 BlockHound 설정 방법</h2>

<p>gradle 기준, <code class="language-plaintext highlighter-rouge">build.gradle.kts</code> 에 아래와 같이 의존성을 추가한다.</p>

<div class="language-kotlin highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">dependencies</span> <span class="p">{</span>
    <span class="nf">implementation</span><span class="p">(</span><span class="s">"io.projectreactor.tools:blockhound:{최신버전}.RELEASE"</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>이후 애플리케이션 초기화 시점(예: <code class="language-plaintext highlighter-rouge">main</code> 함수 혹은 <code class="language-plaintext highlighter-rouge">@SpringBootApplication</code> 이 붙은 클래스 내부)에서 BlockHound 를 활성화 시키는 방식이 제안되는데,
개인적으로 별개 <code class="language-plaintext highlighter-rouge">Configuration</code> 클래스를 만들어 설정하는 방식을 선호한다.
(main 함수는 개발자들이 의도적으로 확인하는 일이 적기 때문)</p>

<div class="language-kotlin highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Configuration</span>
<span class="kd">class</span> <span class="nc">BlockHoundConfig</span> <span class="p">{</span>

    <span class="nd">@PostConstruct</span>
    <span class="k">fun</span> <span class="nf">installBlockHound</span><span class="p">()</span> <span class="p">{</span>
        <span class="nc">BlockHound</span><span class="p">.</span><span class="nf">install</span><span class="p">()</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>간단하게 설정을 마치면 이제 WebFlux 애플리케이션 시작 시점부터 ByteCode 를 분석해서
Blocking Call 발생 시점에 <code class="language-plaintext highlighter-rouge">BlockingOperationError</code> 예외가 발생하게 된다.</p>

<p>아래는 swagger-ui 를 통해 정적 리소스에 접근하는 과정에서 Blocking Call 이 탐지된 로그 예시이다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ERROR 26-01-06 01:07:53 [parallel-8] [GlobalExceptionHandler:75] - Blocking call! java.io.RandomAccessFile#readBytes
reactor.blockhound.BlockingOperationError: Blocking call! java.io.RandomAccessFile#readBytes
	at java.base/java.io.RandomAccessFile.readBytes(RandomAccessFile.java)
	Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException: 
Assembly trace from producer [reactor.core.publisher.FluxConcatMapNoPrefetch] :
	reactor.core.publisher.Flux.concatMap(Flux.java:4053)
	org.springframework.web.reactive.resource.PathResourceResolver.getResource(PathResourceResolver.java:95)
Error has been observed at the following site(s):
	*______Flux.concatMap ⇢ at org.springframework.web.reactive.resource.PathResourceResolver.getResource(PathResourceResolver.java:95)
	|_          Flux.next ⇢ at org.springframework.web.reactive.resource.PathResourceResolver.getResource(PathResourceResolver.java:96)
	|_ Mono.switchIfEmpty ⇢ at org.springframework.web.reactive.resource.LiteWebJarsResourceResolver.resolveResourceInternal(LiteWebJarsResourceResolver.java:74)
	|_       Mono.flatMap ⇢ at org.springframework.web.reactive.resource.ResourceWebHandler.getResource(ResourceWebHandler.java:499)
	|_ Mono.switchIfEmpty ⇢ at org.springframework.web.reactive.resource.ResourceWebHandler.handle(ResourceWebHandler.java:430)
	|_       Mono.flatMap ⇢ at org.springframework.web.reactive.resource.ResourceWebHandler.handle(ResourceWebHandler.java:434)
	*___________Mono.then ⇢ at org.springframework.web.reactive.result.SimpleHandlerAdapter.handle(SimpleHandlerAdapter.java:46)
Original Stack Trace:
		at java.base/java.io.RandomAccessFile.readBytes(RandomAccessFile.java)
		at java.base/java.io.RandomAccessFile.read(RandomAccessFile.java:405)
		at java.base/java.io.RandomAccessFile.readFully(RandomAccessFile.java:469)
		at java.base/java.util.zip.ZipFile$Source.readFullyAt(ZipFile.java:1516)
		at java.base/java.util.zip.ZipFile$ZipFileInputStream.initDataOffset(ZipFile.java:923)
		at java.base/java.util.zip.ZipFile$ZipFileInputStream.read(ZipFile.java:939)
		at java.base/java.util.zip.ZipFile$ZipFileInflaterInputStream.fill(ZipFile.java:456)
		at java.base/java.util.zip.InflaterInputStream.read(InflaterInputStream.java:158)
		at java.base/java.io.InputStream.readNBytes(InputStream.java:506)
		at java.base/java.util.jar.JarFile.getBytes(JarFile.java:816)
		at java.base/java.util.jar.JarFile.checkForSpecialAttributes(JarFile.java:1006)
		at java.base/java.util.jar.JarFile.isMultiRelease(JarFile.java:388)
		at java.base/java.util.jar.JarFile.getEntry(JarFile.java:510)
		at java.base/sun.net.www.protocol.jar.URLJarFile.getEntry(URLJarFile.java:131)
		at java.base/sun.net.www.protocol.jar.JarURLConnection.connect(JarURLConnection.java:135)
		at java.base/sun.net.www.protocol.jar.JarURLConnection.getJarEntry(JarURLConnection.java:97)
		at org.springframework.core.io.AbstractFileResolvingResource.checkReadable(AbstractFileResolvingResource.java:157)
		at org.springframework.core.io.ClassPathResource.isReadable(ClassPathResource.java:168)
		at org.springframework.web.reactive.resource.PathResourceResolver.getResource(PathResourceResolver.java:110)
		at org.springframework.web.reactive.resource.PathResourceResolver.lambda$getResource$1(PathResourceResolver.java:95)
		at reactor.core.publisher.FluxConcatMapNoPrefetch$FluxConcatMapNoPrefetchSubscriber.onNext(FluxConcatMapNoPrefetch.java:183)
		(생략)
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">reactor.blockhound.BlockingOperationError: Blocking call! java.io.RandomAccessFile#readBytes</code> 가 정확한 Blocking Call 발생 지점이다.</p>

<p><br /></p>

<h2 id="-blockhound-커스텀-예외-추가하기">🐕 BlockHound 커스텀 예외 추가하기</h2>

<p>아무리 Non-Blocking 한 프로그램을 개발한다고 해도, 필수적으로 Blocking Call 이 필요한 경우들이 있다.
최초로 DB connection 을 맺는 과정, 파일 시스템에 접근하는 과정 등이 대표적이다.
이런 경우 BlockHound 가 예외 처리를 할 수 있도록 커스텀 예외를 추가할 수 있다.</p>

<div class="language-kotlin highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Configuration</span>
<span class="kd">class</span> <span class="nc">BlockHoundConfig</span> <span class="p">{</span>
    
    <span class="nd">@PostConstruct</span>
    <span class="k">fun</span> <span class="nf">installBlockHound</span><span class="p">()</span> <span class="p">{</span>
      <span class="nc">BlockHound</span><span class="p">.</span><span class="nf">install</span><span class="p">(</span><span class="nc">BlockHoundIntegration</span> <span class="p">{</span> <span class="n">builder</span> <span class="p">-&gt;</span>
            <span class="n">builder</span><span class="p">.</span><span class="nf">allowBlockingCallsInside</span><span class="p">(</span>
                <span class="s">"com.example.project.util.FileUtils"</span><span class="p">,</span> <span class="c1">// 예외를 허용할 class name</span>
                <span class="s">"readFileContent"</span> <span class="c1">// 예외를 허용할 class's method name</span>
            <span class="p">)</span>
      <span class="p">})</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>필수적으로 Blocking Call 이 필요한 지점이 한 두 곳이 아니라 일일히 커스텀 예외를 추가하는 것이 번거로울 수 있다.
당연히 project-reactor 진영에서도 문제점을 인지하고 있기 때문에, 많은 사용자들이 예외처리하는 지점에 대해서는 기본적인 예외처리를 제공하고 있다.</p>

<p><img width="915" height="545" alt="Image" src="https://github.com/user-attachments/assets/babbfa17-70e1-4ba1-8b45-f0473f2f84e8" /></p>

<p><br /></p>

<h2 id="-blockhound-를-더-잘-사용하기">🐕 BlockHound 를 더 잘 사용하기</h2>

<p>앞서 swagger-ui 정적 리소스 접근 과정에서 Blocking Call 이 탐지된 예시를 다시 살펴보자.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ERROR 26-01-06 01:07:53 [parallel-8] [GlobalExceptionHandler:75] - Blocking call! java.io.RandomAccessFile#readBytes
reactor.blockhound.BlockingOperationError: Blocking call! java.io.RandomAccessFile#readBytes
	at java.base/java.io.RandomAccessFile.readBytes(RandomAccessFile.java)
	Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException: 
Assembly trace from producer [reactor.core.publisher.FluxConcatMapNoPrefetch] :
	reactor.core.publisher.Flux.concatMap(Flux.java:4053)
	org.springframework.web.reactive.resource.PathResourceResolver.getResource(PathResourceResolver.java:95)
Error has been observed at the following site(s):
	*______Flux.concatMap ⇢ at org.springframework.web.reactive.resource.PathResourceResolver.getResource(PathResourceResolver.java:95)
	|_          Flux.next ⇢ at org.springframework.web.reactive.resource.PathResourceResolver.getResource(PathResourceResolver.java:96)
	|_ Mono.switchIfEmpty ⇢ at org.springframework.web.reactive.resource.LiteWebJarsResourceResolver.resolveResourceInternal(LiteWebJarsResourceResolver.java:74)
	|_       Mono.flatMap ⇢ at org.springframework.web.reactive.resource.ResourceWebHandler.getResource(ResourceWebHandler.java:499)
	|_ Mono.switchIfEmpty ⇢ at org.springframework.web.reactive.resource.ResourceWebHandler.handle(ResourceWebHandler.java:430)
	|_       Mono.flatMap ⇢ at org.springframework.web.reactive.resource.ResourceWebHandler.handle(ResourceWebHandler.java:434)
	*___________Mono.then ⇢ at org.springframework.web.reactive.result.SimpleHandlerAdapter.handle(SimpleHandlerAdapter.java:46)
Original Stack Trace:
		at java.base/java.io.RandomAccessFile.readBytes(RandomAccessFile.java)
		at java.base/java.io.RandomAccessFile.read(RandomAccessFile.java:405)
		at java.base/java.io.RandomAccessFile.readFully(RandomAccessFile.java:469)
		at java.base/java.util.zip.ZipFile$Source.readFullyAt(ZipFile.java:1516)
		at java.base/java.util.zip.ZipFile$ZipFileInputStream.initDataOffset(ZipFile.java:923)
		at java.base/java.util.zip.ZipFile$ZipFileInputStream.read(ZipFile.java:939)
		at java.base/java.util.zip.ZipFile$ZipFileInflaterInputStream.fill(ZipFile.java:456)
		at java.base/java.util.zip.InflaterInputStream.read(InflaterInputStream.java:158)
		at java.base/java.io.InputStream.readNBytes(InputStream.java:506)
		at java.base/java.util.jar.JarFile.getBytes(JarFile.java:816)
		at java.base/java.util.jar.JarFile.checkForSpecialAttributes(JarFile.java:1006)
		at java.base/java.util.jar.JarFile.isMultiRelease(JarFile.java:388)
		at java.base/java.util.jar.JarFile.getEntry(JarFile.java:510)
		at java.base/sun.net.www.protocol.jar.URLJarFile.getEntry(URLJarFile.java:131)
		at java.base/sun.net.www.protocol.jar.JarURLConnection.connect(JarURLConnection.java:135)
		at java.base/sun.net.www.protocol.jar.JarURLConnection.getJarEntry(JarURLConnection.java:97)
		at org.springframework.core.io.AbstractFileResolvingResource.checkReadable(AbstractFileResolvingResource.java:157)
		at org.springframework.core.io.ClassPathResource.isReadable(ClassPathResource.java:168)
		at org.springframework.web.reactive.resource.PathResourceResolver.getResource(PathResourceResolver.java:110)
		at org.springframework.web.reactive.resource.PathResourceResolver.lambda$getResource$1(PathResourceResolver.java:95)
		at reactor.core.publisher.FluxConcatMapNoPrefetch$FluxConcatMapNoPrefetchSubscriber.onNext(FluxConcatMapNoPrefetch.java:183)
		(생략)
</code></pre></div></div>

<p>위 로그를 보면 <code class="language-plaintext highlighter-rouge">java.io.RandomAccessFile#readBytes</code> 에서 Blocking Call 이 탐지된 것을 알 수 있다.
단순히 해당 지점에 대해 커스텀 예외를 추가하면 될까?</p>

<div class="language-kotlin highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Configuration</span>
<span class="kd">class</span> <span class="nc">BlockHoundConfig</span> <span class="p">{</span>
    
    <span class="nd">@PostConstruct</span>
    <span class="k">fun</span> <span class="nf">installBlockHound</span><span class="p">()</span> <span class="p">{</span>
      <span class="nc">BlockHound</span><span class="p">.</span><span class="nf">install</span><span class="p">(</span><span class="nc">BlockHoundIntegration</span> <span class="p">{</span> <span class="n">builder</span> <span class="p">-&gt;</span>
            <span class="n">builder</span><span class="p">.</span><span class="nf">allowBlockingCallsInside</span><span class="p">(</span><span class="s">"java.io.RandomAccessFile"</span><span class="p">,</span> <span class="s">"readBytes"</span><span class="p">)</span>
      <span class="p">})</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>당장의 예외는 해소되어 정상적으로 swagger-ui 가 동작할 수 있겠지만, 타 라이브러리나 개발자 실수로 대상 메서드가 호출되는 지점이 생겼을 때 Blocking 탐지가 어렵게 된다.
<code class="language-plaintext highlighter-rouge">RandomAccessFile</code> 클래스는 <code class="language-plaintext highlighter-rouge">java.io</code> 패키지에 속해있는 JDK 기본 클래스이기 때문에, 다른 라이브러리나 개발자 코드에서 해당 클래스를 사용하는 일이 너무나 쉽게 생길 수 있다.
따라서 BlockHound 커스텀 예외 추가 시점에서는 Blocking Call 을 발생시키는 케이스를 파악해서 조심스럽게 예외를 추가하는 것이 좋겠다.</p>

<p>로그를 자세히 살펴보면, resource resolving 시점에 Blocking Call 이 발생하는 것을 추측할 수 있다.
<code class="language-plaintext highlighter-rouge">PathResourceResolver#getResource</code> 에 대해 커스텀 예외를 추가하고 다시 한 번 swagger-ui 에 접근을 시도하면 어떤 변화가 생길까?</p>

<div class="language-kotlin highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Configuration</span>
<span class="kd">class</span> <span class="nc">BlockHoundConfig</span> <span class="p">{</span>

    <span class="nd">@PostConstruct</span>
    <span class="k">fun</span> <span class="nf">installBlockHound</span><span class="p">()</span> <span class="p">{</span>
        <span class="nc">BlockHound</span><span class="p">.</span><span class="nf">install</span><span class="p">(</span><span class="nc">BlockHoundIntegration</span> <span class="p">{</span> <span class="n">builder</span> <span class="p">-&gt;</span>
            <span class="n">builder</span>
                <span class="p">.</span><span class="nf">allowBlockingCallsInside</span><span class="p">(</span><span class="s">"org.springframework.web.reactive.resource.PathResourceResolver"</span><span class="p">,</span> <span class="s">"getResource"</span><span class="p">)</span>
        <span class="p">})</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ERROR 26-01-06 14:11:03 [parallel-1] [GlobalExceptionHandler:75] - Blocking call! java.io.RandomAccessFile#readBytes
reactor.blockhound.BlockingOperationError: Blocking call! java.io.RandomAccessFile#readBytes
	at java.base/java.io.RandomAccessFile.readBytes(RandomAccessFile.java)
	Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException: 
Assembly trace from producer [reactor.core.publisher.MonoFlatMap] :
	reactor.core.publisher.Mono.flatMap(Mono.java:3179)
	org.springframework.web.reactive.resource.ResourceWebHandler.getResource(ResourceWebHandler.java:499)
Error has been observed at the following site(s):
	*________Mono.flatMap ⇢ at org.springframework.web.reactive.resource.ResourceWebHandler.getResource(ResourceWebHandler.java:499)
	|_ Mono.switchIfEmpty ⇢ at org.springframework.web.reactive.resource.ResourceWebHandler.handle(ResourceWebHandler.java:430)
	|_       Mono.flatMap ⇢ at org.springframework.web.reactive.resource.ResourceWebHandler.handle(ResourceWebHandler.java:434)
	*___________Mono.then ⇢ at org.springframework.web.reactive.result.SimpleHandlerAdapter.handle(SimpleHandlerAdapter.java:46)
Original Stack Trace:
		at java.base/java.io.RandomAccessFile.readBytes(RandomAccessFile.java)
		at java.base/java.io.RandomAccessFile.read(RandomAccessFile.java:405)
		at java.base/java.io.RandomAccessFile.readFully(RandomAccessFile.java:469)
		at java.base/java.util.zip.ZipFile$Source.readFullyAt(ZipFile.java:1516)
		at java.base/java.util.zip.ZipFile$ZipFileInputStream.initDataOffset(ZipFile.java:923)
		at java.base/java.util.zip.ZipFile$ZipFileInputStream.read(ZipFile.java:939)
		at java.base/java.util.zip.ZipFile$ZipFileInflaterInputStream.fill(ZipFile.java:456)
		at java.base/java.util.zip.InflaterInputStream.read(InflaterInputStream.java:158)
		at java.base/java.io.FilterInputStream.read(FilterInputStream.java:132)
		at java.base/java.io.FilterInputStream.read(FilterInputStream.java:106)
		at org.springdoc.ui.AbstractSwaggerIndexTransformer.readFullyAsString(AbstractSwaggerIndexTransformer.java:118)
		at org.springdoc.ui.AbstractSwaggerIndexTransformer.defaultTransformations(AbstractSwaggerIndexTransformer.java:153)
		at org.springdoc.webflux.ui.SwaggerIndexPageTransformer.transform(SwaggerIndexPageTransformer.java:82)
		at org.springframework.web.reactive.resource.DefaultResourceTransformerChain.transform(DefaultResourceTransformerChain.java:89)
		at org.springframework.web.reactive.resource.ResourceWebHandler.lambda$getResource$3(ResourceWebHandler.java:499)
		at reactor.core.publisher.MonoFlatMap$FlatMapMain.onNext(MonoFlatMap.java:132)
		(생략)
</code></pre></div></div>

<p>이번엔 resource resolving 이 아닌 swagger index transforming 시점에 Blocking Call 이 발생하는 것을 알 수 있다.
마찬가지로 swagger index transforming 시점에 대해서도 커스텀 예외를 추가해보자.</p>

<div class="language-kotlin highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Configuration</span>
<span class="kd">class</span> <span class="nc">BlockHoundConfig</span> <span class="p">{</span>

    <span class="nd">@PostConstruct</span>
    <span class="k">fun</span> <span class="nf">installBlockHound</span><span class="p">()</span> <span class="p">{</span>
        <span class="nc">BlockHound</span><span class="p">.</span><span class="nf">install</span><span class="p">(</span><span class="nc">BlockHoundIntegration</span> <span class="p">{</span> <span class="n">builder</span> <span class="p">-&gt;</span>
            <span class="n">builder</span>
                <span class="p">.</span><span class="nf">allowBlockingCallsInside</span><span class="p">(</span><span class="s">"org.springframework.web.reactive.resource.PathResourceResolver"</span><span class="p">,</span> <span class="s">"getResource"</span><span class="p">)</span>
                <span class="p">.</span><span class="nf">allowBlockingCallsInside</span><span class="p">(</span><span class="s">"org.springdoc.ui.AbstractSwaggerIndexTransformer"</span><span class="p">,</span> <span class="s">"readFullyAsString"</span><span class="p">)</span>
        <span class="p">})</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ERROR 26-01-06 14:12:53 [parallel-5] [GlobalExceptionHandler:75] - Blocking call! java.io.RandomAccessFile#readBytes
	at java.base/java.io.RandomAccessFile.readBytes(RandomAccessFile.java)
	at java.base/java.io.RandomAccessFile.read(RandomAccessFile.java:405)
	at java.base/java.io.RandomAccessFile.readFully(RandomAccessFile.java:469)
	at java.base/java.util.zip.ZipFile$Source.readFullyAt(ZipFile.java:1516)
	at java.base/java.util.zip.ZipFile$ZipFileInputStream.initDataOffset(ZipFile.java:923)
	at java.base/java.util.zip.ZipFile$ZipFileInputStream.read(ZipFile.java:939)
	at java.base/java.util.zip.ZipFile$ZipFileInflaterInputStream.fill(ZipFile.java:456)
	at java.base/java.util.zip.InflaterInputStream.read(InflaterInputStream.java:158)
	at java.base/java.io.FilterInputStream.read(FilterInputStream.java:132)
	at org.springframework.asm.ClassReader.readStream(ClassReader.java:323)
	at org.springframework.asm.ClassReader.&lt;init&gt;(ClassReader.java:288)
	at org.springframework.core.type.classreading.SimpleMetadataReader.getClassReader(SimpleMetadataReader.java:56)
	at org.springframework.core.type.classreading.SimpleMetadataReader.&lt;init&gt;(SimpleMetadataReader.java:48)
	at org.springframework.core.type.classreading.SimpleMetadataReaderFactory.getMetadataReader(SimpleMetadataReaderFactory.java:103)
	at org.springframework.core.type.classreading.CachingMetadataReaderFactory.getMetadataReader(CachingMetadataReaderFactory.java:131)
	at org.springframework.core.type.classreading.SimpleMetadataReaderFactory.getMetadataReader(SimpleMetadataReaderFactory.java:81)
	at org.springframework.core.type.filter.AbstractTypeHierarchyTraversingFilter.match(AbstractTypeHierarchyTraversingFilter.java:127)
	at org.springframework.core.type.filter.AbstractTypeHierarchyTraversingFilter.match(AbstractTypeHierarchyTraversingFilter.java:83)
	at org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider.isCandidateComponent(ClassPathScanningCandidateComponentProvider.java:546)
	at org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider.scanCandidateComponents(ClassPathScanningCandidateComponentProvider.java:471)
	at org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider.findCandidateComponents(ClassPathScanningCandidateComponentProvider.java:351)
	at org.springdoc.core.service.OpenAPIService.getApiDefClass(OpenAPIService.java:774)
	at org.springdoc.core.service.OpenAPIService.getOpenAPIDefinition(OpenAPIService.java:535)
	at org.springdoc.core.service.OpenAPIService.build(OpenAPIService.java:241)
	at org.springdoc.api.AbstractOpenApiResource.getOpenApi(AbstractOpenApiResource.java:352)
	at org.springdoc.webflux.api.OpenApiResource.openapiJson(OpenApiResource.java:123)
	at org.springdoc.webflux.api.OpenApiWebfluxResource.openapiJson(OpenApiWebfluxResource.java:119)
	at org.springdoc.webflux.api.MultipleOpenApiWebFluxResource.openapiJson(MultipleOpenApiWebFluxResource.java:98)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	(생략)
</code></pre></div></div>

<p>swagger index transforming 시점에 대해서도 커스텀 예외를 추가했더니, 이번엔 OpenAPI 정의를 생성하는 시점에 Blocking Call 이 발생하는 것을 알 수 있다
이 단계까지 도달했다면 <code class="language-plaintext highlighter-rouge">openapiJson</code> 메서드에 대해 커스텀 예외를 추가해도 충분하니, <code class="language-plaintext highlighter-rouge">org.springdoc.webflux.api.MultipleOpenApiWebFluxResource#openapiJson</code> 에 대해 
마지막 커스텀 예외를 추가해보자.</p>

<div class="language-kotlin highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Configuration</span>
<span class="kd">class</span> <span class="nc">BlockHoundConfig</span> <span class="p">{</span>

    <span class="nd">@PostConstruct</span>
    <span class="k">fun</span> <span class="nf">installBlockHound</span><span class="p">()</span> <span class="p">{</span>
        <span class="nc">BlockHound</span><span class="p">.</span><span class="nf">install</span><span class="p">(</span><span class="nc">BlockHoundIntegration</span> <span class="p">{</span> <span class="n">builder</span> <span class="p">-&gt;</span>
            <span class="n">builder</span>
                <span class="p">.</span><span class="nf">allowBlockingCallsInside</span><span class="p">(</span><span class="s">"org.springframework.web.reactive.resource.PathResourceResolver"</span><span class="p">,</span> <span class="s">"getResource"</span><span class="p">)</span>
                <span class="p">.</span><span class="nf">allowBlockingCallsInside</span><span class="p">(</span><span class="s">"org.springdoc.ui.AbstractSwaggerIndexTransformer"</span><span class="p">,</span> <span class="s">"readFullyAsString"</span><span class="p">)</span>
                <span class="p">.</span><span class="nf">allowBlockingCallsInside</span><span class="p">(</span><span class="s">"org.springdoc.webflux.api.MultipleOpenApiWebFluxResource"</span><span class="p">,</span> <span class="s">"openapiJson"</span><span class="p">)</span>
        <span class="p">})</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>비로소 swagger-ui 정적 리소스 접근 시 Blocking Call 이 탐지되지 않고 정상적으로 swagger 문서를 확인할 수 있게 된다.</p>

<p>이와 같은 방식으로 Blocking call 탐지 로그를 자세히 살펴보면서 정말 필요한 부분에만 fit 하게 커스텀 예외를 추가하는게 좋겠다.</p>

<p>마지막으로, BlockHound 는 의도적으로 ‘예외를 발생 시키는’ 라이브러리다. 
때문에 개발 혹은 테스트 환경에서 BlockHound 를 통해 Blocking 지점을 파악 후 코드를 개선하고
운영 환경에서는 BlockHound 를 비활성화 하는 방식을 권장한다.</p>

<div class="language-kotlin highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nd">@Profile</span><span class="p">(</span><span class="s">"local"</span><span class="p">)</span> <span class="c1">// 개발 및 테스트 환경에서만 활성화</span>
<span class="nd">@Configuration</span>
<span class="kd">class</span> <span class="nc">BlockHoundConfig</span> <span class="p">{</span>

    <span class="nd">@PostConstruct</span>
    <span class="k">fun</span> <span class="nf">installBlockHound</span><span class="p">()</span> <span class="p">{</span>
        <span class="nc">BlockHound</span><span class="p">.</span><span class="nf">install</span><span class="p">(</span><span class="nc">BlockHoundIntegration</span> <span class="p">{</span> <span class="n">builder</span> <span class="p">-&gt;</span>
            <span class="n">builder</span>
                <span class="p">.</span><span class="nf">allowBlockingCallsInside</span><span class="p">(</span><span class="s">"org.springframework.web.reactive.resource.PathResourceResolver"</span><span class="p">,</span> <span class="s">"getResource"</span><span class="p">)</span>
                <span class="p">.</span><span class="nf">allowBlockingCallsInside</span><span class="p">(</span><span class="s">"org.springdoc.ui.AbstractSwaggerIndexTransformer"</span><span class="p">,</span> <span class="s">"readFullyAsString"</span><span class="p">)</span>
                <span class="p">.</span><span class="nf">allowBlockingCallsInside</span><span class="p">(</span><span class="s">"org.springdoc.webflux.api.MultipleOpenApiWebFluxResource"</span><span class="p">,</span> <span class="s">"openapiJson"</span><span class="p">)</span>
        <span class="p">})</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><br /></p>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://github.com/reactor/BlockHound">BlockHound GitHub Repository</a></li>
</ul>]]></content><author><name>현구막</name><email>jinha3507@gmail.com</email></author><summary type="html"><![CDATA[🐕 Spring WebFlux 와 BlockHound]]></summary></entry><entry><title type="html">Kafka Producer 는 message 재전송시 어떤 partition 을 선택할까?</title><link href="https://hyeon9mak.github.io/kafka-producer-message-resend-strategy/" rel="alternate" type="text/html" title="Kafka Producer 는 message 재전송시 어떤 partition 을 선택할까?" /><published>2025-12-14T00:00:00+09:00</published><updated>2025-12-14T00:00:00+09:00</updated><id>https://hyeon9mak.github.io/kafka-producer-message-resend-strategy</id><content type="html" xml:base="https://hyeon9mak.github.io/kafka-producer-message-resend-strategy/"><![CDATA[<p>Kafka 스터디 중 Producer 의 message 재전송 동작에 대해 궁금증이 생겨 시작해본 실험.<br />
실험 결과 및 코드는 <a href="https://github.com/Hyeon9mak/lab/tree/master/spring-kafka">https://github.com/Hyeon9mak/lab/tree/master/spring-kafka</a> 에서 확인할 수 있다.</p>

<h2 id="-상황-가정-및-결론">🔗 상황 가정 및 결론</h2>
<ul>
  <li>kafka producer 가 발송한 message 를 broker 가 수신했으나, 네트워크 이슈로 ACK 응답이 유실 되었다.</li>
  <li>producer 는 일정 시간 내에 ACK 응답을 받지 못했으므로, 동일한 message 를 재발송한다.
    <ul>
      <li>이 때, producer 는 동일한 partition(broker) 으로 message 를 재발송 할까?</li>
      <li>혹은 다른 partition(broker) 로 message 를 재발송 할 가능성이 있는가?</li>
    </ul>
  </li>
</ul>

<p>사실 producer 내부 동작을 명확히 이해하고 있다면 큰 고민 없이 답을 알 수 있다.</p>

<ul>
  <li>producer 내부 partitioner 가 message key 를 hash 하여 어떤 partition 으로 보낼지 결정을 진행한다.</li>
  <li><strong>message key 가 동일하다면, 재발송 시에도 동일한 partition 으로 message 를 재발송</strong> 한다.</li>
</ul>

<p>위 과정을 조금 더 자세히 살펴보자.</p>

<p><br /></p>

<h2 id="-kafka-cluster-구조-연상하기">🔗 Kafka cluster 구조 연상하기</h2>

<p>Kafka 는 더 이상 ZooKeeper 에 의존하지 않는 Kraft 모드를 이용하고 있다.<br />
Kraft 모드에서는 controller 들이 메타데이터 관리를 담당하며, broker 들은 controller 들과 주기적으로 통신하여 메타데이터를 갱신한다.<br />
즉, 기존에는 ZooKeeper 가 메타데이터 관리, controller 가 행동대장 역할을 수행했다면 
Kraft 모드에서는 controller 들이 메타데이터 관리와 행동대장 역할을 모두 수행하는 셈.<br />
(active controller 1:N standby controller 구조. 당연히 결정은 active controller 가 내린다.)</p>

<p><img src="https://images.ctfassets.net/gt6dp23g0g38/1zqOqt3czqPKtcTZBcciph/af88c9a1ebefa859cdad5ba2c6399d03/Kafka_Internals_050.png" alt="image" /></p>

<p>더 자세한 내용은 <a href="https://developer.confluent.io/courses/architecture/control-plane/">https://developer.confluent.io/courses/architecture/control-plane/</a>를 참고하면 좋다.</p>

<p>local 에서 kafka cluster 를 구성할 때도 Kraft 모드를 이용할 수 있다.<br />
partition 3개, replication factor 3개로 topic 을 구성한 예시 구조를 살펴보자.</p>

<p><img width="1139" height="457" alt="Image" src="https://github.com/user-attachments/assets/5c006175-37be-4c8c-bb6e-c930c7f01569" /></p>

<p>각 broker 들은 partition-0, partition-1, partition-2 를 각각 leader 로 나눠 갖고 관리하고 있다.<br />
나머지 follower partition 들은 broker 장애에 대비하여 다른 broker 에 분산 배치되어 가용성을 확보중이다.<br />
(Leader 의 Sync 를 잘 따라오는 follower partition 들을 ISR(In-Sync Replica) 로 부르며, 언제든지 Leader 승격이 가능하도록 관리된다.)</p>

<p><br /></p>

<h2 id="-producer-partitioner-이해하기">🔗 Producer partitioner 이해하기</h2>

<p>Kafka producer 는 message 를 보낼 때, 내부적으로 떤 partition 으로 보낼지 결정하는 partitioner 의 도움을 받는다.<br />
kafka 4.1 버전 기준 기본 partitioner 는 message key 를 hash 하여 partition 을 결정한다.</p>

<p><img width="1222" height="454" alt="Image" src="https://github.com/user-attachments/assets/ff4eb550-89ef-4235-b8f7-df14fb9add0d" /></p>

<p>이를 기준으로 생각해보면, ACK 응답이 유실되어 재발송이 발생하는 상황에서도 동일한 message key 로 hash 를 수행하므로,
동일한 partition 으로 message 를 재발송 한다는 것을 예측할 수 있다.</p>

<p><img width="1142" height="757" alt="Image" src="https://github.com/user-attachments/assets/bea1d979-8a29-4ebd-93c3-08699913b323" /></p>

<h2 id="-실제-발송-테스트">🔗 실제 발송 테스트</h2>

<p>실제 동일한 message key 로 여러차례 발송을 진행하며 로그를 확인해보면 아래와 같이 동일한 partition 으로 message 가 발송되는 것을 확인할 수 있다.</p>

<p><img width="1276" height="556" alt="Image" src="https://github.com/user-attachments/assets/ed082dfe-e756-4e15-8225-5d29447466a2" /></p>

<p>message key 를 계속 바꾸면서 발송을 진행하는 경우, 아래와 같이 다양한 partition 으로 message 가 발송된다.</p>

<p><img width="1290" height="548" alt="Image" src="https://github.com/user-attachments/assets/2d386876-c0ee-4124-9314-643e8e5f910c" /></p>

<p><br /></p>

<h2 id="-번외---broker-장애-시-재발송-동작-추론">🔗 번외 - broker 장애 시 재발송 동작 추론</h2>

<p>만약 일시적인 네트워크 이슈로 ACK 를 유실한게 아니라, broker 자체 장애가 발생했다면 어떻게 동작이 달라질까?<br />
(controller 까지 함께 장애가 발생했다고 가정한다.)</p>

<h3 id="1-broker-1-장애-발생">1. broker-1 장애 발생</h3>

<p><img width="1146" height="456" alt="Image" src="https://github.com/user-attachments/assets/867057a4-014b-4ce7-a529-ddbd8598fe3c" /></p>

<ul>
  <li>producer 는 계속해서 같은 partition-0 으로 message 재발송을 진행한다.</li>
  <li>(Kraft mode 기준) standby controller 사이에서 active controller 장애를 감지한다.</li>
</ul>

<h3 id="2-controller-승격">2. controller 승격</h3>

<p><img width="1140" height="458" alt="Image" src="https://github.com/user-attachments/assets/0d27ad0c-4b3b-4c46-9171-4662f0ed13c9" /></p>

<ul>
  <li>standby controller 사이에서 raft 알고리즘을 통해 새로운 active controller 를 선출한다.</li>
</ul>

<h3 id="3-partition-leader-승격">3. partition leader 승격</h3>

<p><img width="1135" height="469" alt="Image" src="https://github.com/user-attachments/assets/d5c36302-a9f6-4be1-a49b-7ce25086c6e4" /></p>

<ul>
  <li>새로운 active controller 가 ISR(In-Sync Replica) partition 들 중 하나를 선택하여 partition-0 leader 로 승격시킨다.</li>
</ul>

<h3 id="4-producer-metadata-갱신">4. producer metadata 갱신</h3>

<p><img width="1140" height="752" alt="Image" src="https://github.com/user-attachments/assets/4039d951-b9cd-4857-9425-51372640659f" /></p>

<ul>
  <li>broker-1 이 응답하지 않음을 이상하게 여긴 producer 는 다른 broker 들에게 metadata 갱신 요청을 보낸다.</li>
  <li>새로운 active controller 로부터 최신 metadata 를 전달받아 내부 정보를 갱신한다.</li>
</ul>

<h3 id="5-재발송-성공">5. 재발송 성공</h3>

<p><img width="1135" height="763" alt="Image" src="https://github.com/user-attachments/assets/a91a8eb0-a99d-495d-b1aa-61171f3de05b" /></p>

<ul>
  <li>이후 같은 partition-0 으로 재발송을 시도하고, 새로운 partition-0 leader 인 broker-2 로부터 ACK 응답을 받게 된다.</li>
</ul>

<p>만약 controller-1 이 살아있는 상태에서 broker-1 이 장애가 발생했다면, 
controller-1 이 ISR 중 하나를 Leader 로 승격 후 producer 에게 알림을 보내는 과정부터 시작되었을 것이다.</p>

<p><br /></p>

<h2 id="references">References</h2>

<ul>
  <li><a href="https://developer.confluent.io/courses/architecture/control-plane/">https://developer.confluent.io/courses/architecture/control-plane/</a></li>
</ul>]]></content><author><name>현구막</name><email>jinha3507@gmail.com</email></author><summary type="html"><![CDATA[Kafka 스터디 중 Producer 의 message 재전송 동작에 대해 궁금증이 생겨 시작해본 실험. 실험 결과 및 코드는 https://github.com/Hyeon9mak/lab/tree/master/spring-kafka 에서 확인할 수 있다.]]></summary></entry><entry><title type="html">홈 서버 구축기 - 웹서버 추가와 DNS 설정</title><link href="https://hyeon9mak.github.io/home-server-web-server-and-dns/" rel="alternate" type="text/html" title="홈 서버 구축기 - 웹서버 추가와 DNS 설정" /><published>2025-10-07T00:00:00+09:00</published><updated>2025-10-07T00:00:00+09:00</updated><id>https://hyeon9mak.github.io/home-server-web-server-and-dns</id><content type="html" xml:base="https://hyeon9mak.github.io/home-server-web-server-and-dns/"><![CDATA[<h2 id="-홈-서버-구축-목표">💾 홈 서버 구축 목표</h2>

<ul>
  <li>서비스보다는 서버환경 구축 자체에 집중한다.</li>
  <li>만들고 싶은 서비스가 있으면 바로 파이프라인 구성 후 배포해서 확인해볼 수 있는 환경을 만든다.</li>
  <li>첫 스텝은 단일 애플리케이션 CI-CD 파이프라이닝</li>
  <li>두 번째 스탭은 컨테이너화(도커라이징)</li>
  <li>세 번째 스탭은 데스크탑 리소스 모니터링 환경 구축</li>
  <li>마지막 스탭으로 컨테이너 오케스트레이션(k8s, argo 등) 도입</li>
</ul>

<p>지난 시간에는 단일 애플리케이션 CI/CD 파이프라인 구축 및 도커라이징까지 진행했다.<br />
이번 시간에는 리소스 모니터링 환경 구축에 앞서, 웹서버(Nginx) 추가와 DNS 설정을 진행해보려고 한다.</p>

<p><br /></p>

<h2 id="-웹-서버를-추가하는-이유">💾 웹 서버를 추가하는 이유</h2>

<p>최근에 음악 스트리밍 링크를 공유하면 타 플랫폼의 공유 링크를 제공하는 슬랙봇을 제작했다.</p>

<p><img width="1534" height="944" alt="Image" src="https://github.com/user-attachments/assets/296772a8-5227-4aea-8917-0ff1908add65" /></p>

<p>구조는 슬랙 내부에서 플랫폼별 공유 링크를 감지하면 홈 서버로 훅을 보내고,
훅을 전달 받은 홈 서버 내부 애플리케이션이 각 플랫폼 별 공유 링크를 탐색하여 슬랙에 메세지를 작성하는 형태다.</p>

<p>이 때 슬랙에 홈 서버 URL 을 등록해야하는데, 홈 서버 public IP 를 등록하는 것도 바람직하지 않고,
대부분의 서비스에서 port 번호까지 등록하는 것은 지원하지 않기 때문에 
별도 서브 도메인을 이용하여 홈 서버에 접근할 수 있도록 구성하는 과정이 필요했다.</p>

<p>이에 따라 가비아에서 도메인을 구매했는데, 홈 서버 내부에도 웹 서버를 추가하여 
각 서브 도메인 별로 내부 애플리케이션으로 포워딩하는 작업이 필요하다.</p>

<p><br /></p>

<h2 id="-왜-nginx-를-채택했지">💾 왜 Nginx 를 채택했지?</h2>

<p>웹 서버는 Apache, Nginx, Caddy 등 여러가지가 있는데, 근래 가장 널리 사용되는 Nginx 를 채택했다.</p>

<p>Nginx 는 Apache 에 비해 가볍고 빠르며, 설정이 간단하다는 장점이 있다.<br />
더 자세한 내용은 아래 링크를 참고하면 된다.</p>

<p><a href="https://hyeon9mak.github.io/nginx-vs-apache/">https://hyeon9mak.github.io/nginx-vs-apache/</a></p>

<p><br /></p>

<h2 id="-웹-서버를-컨테이너화-할-것인가">💾 웹 서버를 컨테이너화 할 것인가</h2>

<p>웹 서버를 컨테이너화 할지 말지는 고민이 되는 포인트였다.
각 방식의 장단점을 직접적으로 비교해보면 아래와 같다.</p>

<h3 id="host-설치-방식">Host 설치 방식</h3>

<ul>
  <li>장점
    <ul>
      <li>Docker 업데이트나 재시작 시에도 서비스 중단 없음</li>
      <li>SSL 인증서 설정이 더 간단함</li>
      <li>Docker 네트워크 설정 불필요</li>
      <li>단일 진입점으로 모든 컨테이너 관리</li>
    </ul>
  </li>
  <li>단점
    <ul>
      <li>호스트 시스템에 의존성 추가</li>
      <li>컨테이너로 완전히 격리되지 않음</li>
      <li>서버 이전 시 nginx 재설정 필요</li>
    </ul>
  </li>
</ul>

<h3 id="container-설치-방식">Container 설치 방식</h3>

<ul>
  <li>장점
    <ul>
      <li>모든 구성요소를 컨테이너로 관리 가능</li>
      <li>버전 관리 용이</li>
    </ul>
  </li>
  <li>단점
    <ul>
      <li><strong>Docker 중단 시 전체 서비스 다운</strong></li>
    </ul>
  </li>
</ul>

<p>사실 Docker 중단 시 전체 서비스 다운 이라는 단점이 가장 크리티컬 하기 때문에 선택의 여지가 없다.
단일 홈 서버 환경에, 컨테이너로 실행 중인 애플리케이션이 이미 여러개 운영중이다.
각 서브 도메인 별로 직관적인 라우팅이 가능한 웹 서버를 운영하는 것이 목표이기 때문에, 
Host 에 직접 Nginx 를 설치하는 것으로 결정했다.</p>

<p><br /></p>

<h2 id="-nginx-설치-및-스크립트-설정">💾 Nginx 설치 및 스크립트 설정</h2>

<blockquote>
  <ul>
    <li>DNS 사이트에서 서브 도메인 설정까지 마쳤다는 가정하에 진행된다.</li>
    <li>또한 공유기에 전달되는 HTTP, HTTPS 요청을 홈 서버로 전달하기 위해 80, 443 포트 포워딩 설정이 완료되었다는 가정하에 진행된다.</li>
  </ul>
</blockquote>

<h3 id="nginx-설치">nginx 설치</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span><span class="nb">sudo </span>apt update
<span class="nv">$ </span><span class="nb">sudo </span>apt <span class="nb">install </span>nginx

<span class="c"># 서비스 상태 확인</span>
<span class="nv">$ </span><span class="nb">sudo </span>systemctl status nginx
</code></pre></div></div>

<h3 id="nginxconf-설정">nginx.conf 설정</h3>

<p>공식적으로 nginx 에서는 conf 설정 파일을 각 애플리케이션 별로 <code class="language-plaintext highlighter-rouge">/etc/nginx/conf.d/</code> 디렉토리 하위 경로에 작성하고,
<code class="language-plaintext highlighter-rouge">/etc/nginx/nginx.conf</code> 를 통해 include 하는 방식을 권장하고 있다.</p>

<p>기본적으로 설치시 <code class="language-plaintext highlighter-rouge">/etc/nginx/nginx.conf</code> 에서 <code class="language-plaintext highlighter-rouge">/etc/nginx/conf.d/</code> 디렉토리를 include 하고 있기 때문에
각 애플리케이션 별로 원하는 nginx 설정 conf 파일을 작성하면 된다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>server {
    listen 80;
    listen [::]:80;
    
    server_name app.domain.com;
    
    # 로그 파일
    access_log /var/log/nginx/app.access.log;
    error_log /var/log/nginx/app.error.log;
    
    location / {
        # 백엔드 프록시 설정
        proxy_pass http://127.0.0.1:8080;
        
        # 프록시 헤더
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
</code></pre></div></div>

<h3 id="ssl-인증서-자동-발급용-certbot-설치">SSL 인증서 자동 발급용 certbot 설치</h3>

<div class="language-shell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>apt <span class="nb">install </span>certbot python3-certbot-nginx

<span class="c"># 인증서 발급 및 자동 설정</span>
<span class="nb">sudo </span>certbot <span class="nt">--nginx</span> <span class="nt">-d</span> app.domain.com
</code></pre></div></div>

<p>인증서 발급 및 자동 설정이 진행되면 아래와 같이 conf 파일이 자동으로 변경된다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>server {
    server_name app.domain.com;

    # 로그 파일
    access_log /var/log/nginx/app.access.log;
    error_log /var/log/nginx/app.error.log;

    location / {
        # 백엔드 프록시 설정
        proxy_pass http://127.0.0.1:8080;

        # 프록시 헤더
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    listen [::]:443 ssl ipv6only=on; # managed by Certbot
    listen 443 ssl; # managed by Certbot
    ssl_certificate /etc/letsencrypt/live/app.domain.com/fullchain.pem; # managed by Certbot
    ssl_certificate_key /etc/letsencrypt/live/app.domain.com/privkey.pem; # managed by Certbot
    include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot

}

server {
    if ($host = app.domain.com) {
        return 301 https://$host$request_uri;
    } # managed by Certbot


    listen 80;
    listen [::]:80;

    server_name app.domain.com;
    return 404; # managed by Certbot
}
</code></pre></div></div>

<p>여기서 <code class="language-plaintext highlighter-rouge">return 301 https://$host$request_uri;</code> 설정은 HTTP 요청을 HTTPS 요청으로 리다이렉트 하는 설정이다.
다만 이 설정은 모든 요청을 GET 요청으로 변경하기 때문에, 301 리다이렉트가 아닌 308 리다이렉트를 사용하도록 변경해주어야 한다.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    if ($host = app.domain.com) {
        return 308 https://$host$request_uri;
    } # managed by Certbot
</code></pre></div></div>

<h3 id="최종-확인">최종 확인</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 설정 테스트</span>
<span class="nb">sudo </span>nginx <span class="nt">-t</span>

<span class="c"># Nginx 재시작</span>
<span class="nb">sudo </span>systemctl reload nginx

<span class="c"># HTTPS 접속 확인</span>
curl <span class="nt">-I</span> https://app.domain.com
</code></pre></div></div>

<p><br /></p>

<h2 id="-마치며">💾 마치며</h2>

<p>이번 시간에는 웹 서버(Nginx) 추가와 DNS 설정을 진행해보았다.
본격적으로 만들고 싶은 애플리케이션을 만들고 배포하면서 나만의 도메인으로 접속 및 공유를 할 수 있게 되었다.</p>

<p>단일 컴퓨팅 홈 서버에는 필연적으로 성능상 한계가 존재하기 때문에,
앞으로는 리소스 모니터링의 중요성이 더 커질 것이다.
서둘러 모니터링 환경 구축에 돌입해보도록 하자.</p>

<p>3화 끝.</p>]]></content><author><name>현구막</name><email>jinha3507@gmail.com</email></author><summary type="html"><![CDATA[💾 홈 서버 구축 목표]]></summary></entry></feed>